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

@@ -7,6 +7,7 @@ import { getLocale, getMessages } from 'next-intl/server';
import { Toaster } from 'sonner';
import { classifyFormFactor } from '@/lib/form-factor';
import { ReactGrabViewportSync } from '@/components/dev/react-grab-viewport-sync';
import { resolveAuthShellBranding } from '@/lib/email/auth-shell-branding';
import './globals.css';
const inter = Inter({
@@ -28,26 +29,37 @@ export const viewport: Viewport = {
themeColor: '#1e2844',
};
export const metadata: Metadata = {
title: {
default: 'Port Nimara CRM',
template: '%s | Port Nimara CRM',
},
description: 'Marina management system for Port Nimara',
appleWebApp: {
capable: true,
statusBarStyle: 'black-translucent',
title: 'Port Nimara',
},
icons: {
icon: [
{ url: '/icon-192.png', sizes: '192x192', type: 'image/png' },
{ url: '/icon-512.png', sizes: '512x512', type: 'image/png' },
],
apple: '/apple-touch-icon.png',
},
manifest: '/manifest.json',
};
/**
* Resolve the browser tab title from the first-port `branding_app_name`
* setting so a tenant's deploy sees their own brand in the title bar
* (and in `Cmd+T` browser history). Falls back to a generic label when
* the DB hasn't been seeded yet (e.g. fresh `pnpm dev` against an empty
* database during onboarding).
*/
export async function generateMetadata(): Promise<Metadata> {
const branding = await resolveAuthShellBranding();
const appName = branding?.appName?.trim() || 'CRM';
return {
title: {
default: appName,
template: `%s | ${appName}`,
},
description: `${appName} — marina management system`,
appleWebApp: {
capable: true,
statusBarStyle: 'black-translucent',
title: appName,
},
icons: {
icon: [
{ url: '/icon-192.png', sizes: '192x192', type: 'image/png' },
{ url: '/icon-512.png', sizes: '512x512', type: 'image/png' },
],
apple: '/apple-touch-icon.png',
},
manifest: '/manifest.json',
};
}
export default async function RootLayout({ children }: { children: React.ReactNode }) {
const headerList = await headers();