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

@@ -4,6 +4,7 @@ import {
} from '@/components/admin/shared/settings-form-card';
import { PageHeader } from '@/components/shared/page-header';
import { PdfLogoUploader } from '@/components/admin/branding/pdf-logo-uploader';
import { EmailPreviewCard } from '@/components/admin/branding/email-preview-card';
const DEFAULT_EMAIL_HEADER_HTML = `<!-- Optional pre-body header -->
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="border-collapse:collapse;">
@@ -49,7 +50,7 @@ const FIELDS: SettingFieldDef[] = [
key: 'branding_email_background_url',
label: 'Email background image',
description:
'Optional blurred photo shown behind the white email card. Leave blank to use the built-in Port Nimara overhead. Recommended: 1920x1080 JPG, pre-blurred to ~20px gaussian so it reads as a soft background even on small clients.',
'Blurred photo shown behind the white email card and the auth-shell (login / reset password) pages. Leave blank to render a plain off-white backdrop. Recommended: 1920x1080 JPG, pre-blurred to ~20px gaussian so it reads as a soft background even on small clients.',
type: 'image-upload',
defaultValue: '',
},
@@ -96,6 +97,7 @@ export default function BrandingSettingsPage() {
description="HTML fragments rendered around every transactional email."
fields={FIELDS.slice(3)}
/>
<EmailPreviewCard />
<PdfLogoUploader />
</div>
);

View File

@@ -15,6 +15,7 @@ import { DevModeBanner } from '@/components/shared/dev-mode-banner';
import { RealtimeToasts } from '@/components/shared/realtime-toasts';
import { WebVitalsReporter } from '@/components/shared/web-vitals-reporter';
import { classifyFormFactor } from '@/lib/form-factor';
import { getPortBrandingConfig } from '@/lib/services/port-config';
export default async function DashboardLayout({ children }: { children: React.ReactNode }) {
const headerList = await headers();
@@ -42,6 +43,22 @@ export default async function DashboardLayout({ children }: { children: React.Re
email: session.user.email,
};
// Per-port logo map for the sidebar. Resolved server-side so the
// sidebar can swap brand on port switch without an extra round-trip.
// Falls back to null per port when no logo is configured — the
// sidebar surfaces nothing rather than leaking a generic placeholder.
const portBrandingEntries = await Promise.all(
ports.map(async (p) => {
try {
const cfg = await getPortBrandingConfig(p.id);
return [p.id, cfg.logoUrl] as const;
} catch {
return [p.id, null] as const;
}
}),
);
const portLogoUrls: Record<string, string | null> = Object.fromEntries(portBrandingEntries);
return (
<QueryProvider>
<PortProvider ports={ports} defaultPortId={ports[0]?.id ?? null}>
@@ -62,6 +79,7 @@ export default async function DashboardLayout({ children }: { children: React.Re
isSuperAdmin={profile?.isSuperAdmin ?? false}
user={user}
ports={ports}
portLogoUrls={portLogoUrls}
initialFormFactor={initialFormFactor}
>
{children}