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

@@ -0,0 +1,31 @@
'use client';
import { createContext, useContext, type ReactNode } from 'react';
export interface AuthBranding {
logoUrl: string | null;
backgroundUrl: string | null;
appName: string | null;
}
const AuthBrandingContext = createContext<AuthBranding | null>(null);
/**
* Server-resolved branding injected at the auth route-group layout so
* every BrandedAuthShell (no matter how nested) can pick it up without
* each page re-fetching from system_settings. See `(auth)/layout.tsx`
* and `(portal)/layout.tsx`.
*/
export function AuthBrandingProvider({
branding,
children,
}: {
branding: AuthBranding | null;
children: ReactNode;
}) {
return <AuthBrandingContext.Provider value={branding}>{children}</AuthBrandingContext.Provider>;
}
export function useAuthBranding(): AuthBranding | null {
return useContext(AuthBrandingContext);
}

View File

@@ -1,35 +1,35 @@
const DEFAULT_BG_URL = 'https://s3.portnimara.com/images/Overhead_1_blur.png';
const DEFAULT_LOGO_URL =
'https://s3.portnimara.com/images/Port%20Nimara%20New%20Logo-Circular%20Frame_250px.png';
'use client';
import { useAuthBranding } from '@/components/shared/auth-branding-provider';
interface BrandedAuthShellProps {
children: React.ReactNode;
/** Per-port branding override resolved server-side by the page that
* renders the shell. When omitted, falls back to the Port Nimara
* defaults so single-tenant deployments remain unaffected. Pages
* that know their portId at render time should pass the result of
* `getPortBrandingConfig(portId)`. */
/** Per-port branding override. When omitted, the shell picks up
* branding from the surrounding `<AuthBrandingProvider>` (mounted at
* the route-group layout). When neither is present, falls back to
* neutral defaults (no logo, plain background). */
branding?: {
logoUrl?: string | null;
backgroundUrl?: string | null;
appName?: string | null;
};
}
/**
* Branded shell shared by every auth/form surface - CRM login, portal login,
* password set/reset/activate, forgot-password. Renders the blurred
* background, the logo, and a centered white card that consumers
* populate with their own form/content.
* Branded shell shared by every auth/form surface CRM login, portal login,
* password set/reset/activate, forgot-password. Renders the background,
* the port logo, and a centered white card that consumers populate with
* their own form/content.
*
* Multi-tenant note (R2-H15): the per-port logoUrl from
* /admin/branding is rendered when the parent page passes a `branding`
* prop. The background image stays as the marina default for all
* deployments — admin-authored backgrounds aren't part of the v1
* branding surface.
* Pages that know their portId at render time can pass `branding` as an
* explicit prop; otherwise the surrounding `<AuthBrandingProvider>` is
* the source of truth.
*/
export function BrandedAuthShell({ children, branding }: BrandedAuthShellProps) {
const logoUrl = branding?.logoUrl || DEFAULT_LOGO_URL;
const altText = branding?.appName || 'Port Nimara';
const ctx = useAuthBranding();
const logoUrl = branding?.logoUrl ?? ctx?.logoUrl ?? null;
const backgroundUrl = branding?.backgroundUrl ?? ctx?.backgroundUrl ?? null;
const altText = branding?.appName ?? ctx?.appName ?? 'Sign in';
// fixed inset-0 anchors the auth surface to the viewport directly —
// iOS Safari ignores overflow-hidden on inner divs for body-level
// scrolling, so a regular `h-dvh overflow-hidden` wrapper doesn't
@@ -42,18 +42,22 @@ export function BrandedAuthShell({ children, branding }: BrandedAuthShellProps)
aria-hidden
className="absolute inset-0 -z-10"
style={{
backgroundImage: `url('${DEFAULT_BG_URL}')`,
backgroundImage: backgroundUrl ? `url('${backgroundUrl}')` : undefined,
backgroundSize: 'cover',
backgroundPosition: 'center',
// Neutral slate fallback so we never leak any one port's brand
// imagery when branding hasn't been configured.
backgroundColor: '#f2f2f2',
}}
/>
<div className="w-full max-w-md">
<div className="bg-white rounded-lg shadow-lg p-8">
<div className="flex justify-center mb-6">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={logoUrl} alt={altText} className="w-24 h-auto" />
</div>
{logoUrl ? (
<div className="flex justify-center mb-6">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={logoUrl} alt={altText} className="w-24 h-auto" />
</div>
) : null}
{children}
</div>
</div>