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

@@ -31,8 +31,27 @@ export function MobileTopbar() {
const last = segments[segments.length - 1] ?? '';
const isUuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(last);
const fallbackSegment = isUuid ? segments[segments.length - 2] : last;
// Derive a sensible title from the current path slug when no
// page-level title is set. Avoids hardcoding a specific tenant name —
// a fresh deploy with port slug `marina-alpha` reads as "Marina Alpha"
// here without code edits.
const portSlug = segments[0] ?? '';
const portTitle = portSlug.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
const fallbackTitle =
fallbackSegment?.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()) ?? 'Port Nimara';
fallbackSegment?.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()) ||
portTitle ||
'CRM';
// Brand-mark initials derived from the port slug
// ("port-nimara" → "PN", "marina-alpha" → "MA"). Cheap, self-contained,
// no extra DB round-trip.
const initials = portSlug
? portSlug
.split('-')
.map((part) => part[0]?.toUpperCase() ?? '')
.join('')
.slice(0, 2)
: 'CR';
return (
<header
@@ -58,13 +77,13 @@ export function MobileTopbar() {
</button>
) : (
<div
aria-label="Port Nimara"
aria-label={portTitle || 'Home'}
className={cn(
'size-9 shrink-0 rounded-lg flex items-center justify-center',
'bg-[#3a7bc8] shadow-[inset_0_1px_0_rgba(255,255,255,0.18),0_1px_2px_rgba(0,0,0,0.25)]',
)}
>
<span className="text-white font-bold text-[13px] tracking-tight">PN</span>
<span className="text-white font-bold text-[13px] tracking-tight">{initials}</span>
</div>
)}