feat(branding): multi-tenant brand naming + per-port email shell + auth UI continuity

Removes the last hardcoded "Port Nimara" references so a tenant cloning
the deploy with a fresh slug sees their own brand throughout.

Browser + native chrome:
- `generateMetadata` reads `branding_app_name` from the first port row
  so the browser tab title, apple-web-app title, and template literal
  reflect the tenant (fallback "CRM" until DB is seeded).
- Mobile topbar derives the brand-mark initials from the port slug
  ("port-nimara" → "PN", "marina-alpha" → "MA") — no code edit on clone.
- `documenso-payload` default redirect URL is `""` so Documenso falls
  back to its own post-sign page instead of routing every tenant's
  signers to portnimara.com; per-port `redirectUrl` setting still wins.
- Server-startup log uses generic "CRM server listening".

Email + auth shell:
- New `auth-shell-branding.ts` resolves logo / background / appName once
  per request from `system_settings`; used by both the email shell and
  the auth-pages SSR layout.
- `auth-branding-provider` wraps `/login`, `/reset-password`, `/set-password`,
  portal `/portal/*` so the branded shell hydrates with the same assets
  the inbox sees.
- `me/email` change email uses the branded shell instead of inline HTML
  with "Port Nimara CRM" baked into copy.
- Admin branding page adds an email-preview card (POSTs to
  `/api/v1/admin/branding/email-preview`) so an admin can spot-check
  their templates before going live.
- `/api/public/files/[id]` exposes branding-category files anonymously
  so inbox images (no session cookie) can render; any other category
  still flows through authenticated `/api/v1/files/[id]/preview`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-20 15:54:10 +02:00
parent bac253b360
commit b4bf9cca3f
24 changed files with 583 additions and 89 deletions

View File

@@ -5,6 +5,9 @@ import { getPortalDashboard } from '@/lib/services/portal.service';
import { isPortalDisabledGlobally } from '@/lib/services/portal-auth.service';
import { PortalHeader } from '@/components/portal/portal-header';
import { PortalNav } from '@/components/portal/portal-nav';
import { AuthBrandingProvider } from '@/components/shared/auth-branding-provider';
import { resolveAuthShellBranding } from '@/lib/email/auth-shell-branding';
import { getPortBrandingConfig } from '@/lib/services/port-config';
export const metadata: Metadata = {
title: {
@@ -54,15 +57,31 @@ export default async function PortalLayout({ children }: { children: React.React
}
}
// Branding for the auth-shell pages (login, forgot-password, reset).
// When the visitor has a session, use that port's branding so they
// stay inside one tenant's look. Otherwise pick up the first-port
// default — the same path the CRM auth pages take.
const branding = session
? await getPortBrandingConfig(session.portId)
.then((cfg) => ({
logoUrl: cfg.logoUrl,
backgroundUrl: cfg.emailBackgroundUrl,
appName: cfg.appName,
}))
.catch(() => null)
: await resolveAuthShellBranding();
return (
<div className="min-h-screen bg-gray-50">
{session && (
<>
<PortalHeader portName={portName} portLogoUrl={portLogoUrl} clientName={clientName} />
<PortalNav />
</>
)}
<main className={session ? 'max-w-5xl mx-auto px-4 sm:px-6 py-8' : ''}>{children}</main>
</div>
<AuthBrandingProvider branding={branding}>
<div className="min-h-screen bg-gray-50">
{session && (
<>
<PortalHeader portName={portName} portLogoUrl={portLogoUrl} clientName={clientName} />
<PortalNav />
</>
)}
<main className={session ? 'max-w-5xl mx-auto px-4 sm:px-6 py-8' : ''}>{children}</main>
</div>
</AuthBrandingProvider>
);
}