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,42 @@
import { asc } from 'drizzle-orm';
import { db } from '@/lib/db';
import { ports } from '@/lib/db/schema/ports';
import { getPortBrandingConfig } from '@/lib/services/port-config';
interface AuthShellBranding {
logoUrl: string | null;
backgroundUrl: string | null;
appName: string | null;
}
/**
* Pre-port-context surfaces (login, forgot-password, set-password,
* the better-auth password-reset email) need branding before the user
* has picked a port. Resolve against the first active port in the
* system — for a single-tenant deploy that's the right port; for a
* multi-tenant deploy the operator should host each tenant on its own
* subdomain so the wrong-tenant logo doesn't surface here.
*
* Returns null only if the system has no ports at all (fresh install
* pre-seed); callers should fall back to neutral defaults in that case.
*/
export async function resolveAuthShellBranding(): Promise<AuthShellBranding | null> {
try {
const [port] = await db
.select({ id: ports.id })
.from(ports)
.orderBy(asc(ports.createdAt))
.limit(1);
if (!port) return null;
const cfg = await getPortBrandingConfig(port.id);
return {
logoUrl: cfg.logoUrl,
backgroundUrl: cfg.emailBackgroundUrl,
appName: cfg.appName,
};
} catch {
return null;
}
}

View File

@@ -3,8 +3,9 @@
*
* Senders that have a portId call this once and pass the result into
* the email template. Senders without a portId (e.g. CRM invite at
* create-time before a port is selected) pass null — the shell
* falls back to the Port Nimara defaults.
* create-time before a port is selected) pass null — the shell then
* falls back to neutral defaults (no logo, plain background, slate
* accent). Configure per-port branding via /admin/branding.
*/
import { getPortBrandingConfig } from '@/lib/services/port-config';

View File

@@ -16,10 +16,13 @@
* function. Templates call `renderShell({ title, body, branding })`.
*/
const DEFAULT_LOGO_URL =
'https://s3.portnimara.com/images/Port%20Nimara%20New%20Logo-Circular%20Frame_250px.png';
const DEFAULT_BACKGROUND_URL = 'https://s3.portnimara.com/images/Overhead_1_blur.png';
const DEFAULT_PRIMARY_COLOR = '#0F4C81';
// Neutral defaults — no tenant-specific imagery leaks across ports.
// When branding hasn't been configured the email renders without a logo
// and on a plain off-white background. Admins upload their own assets via
// /admin/branding which then flow through via getPortBrandingConfig().
const DEFAULT_LOGO_URL: string | null = null;
const DEFAULT_BACKGROUND_URL: string | null = null;
const DEFAULT_PRIMARY_COLOR = '#1e293b';
export interface BrandingShell {
logoUrl: string | null;
@@ -44,6 +47,13 @@ export function renderShell({ title, body, branding }: ShellOpts): string {
const headerHtml = branding?.emailHeaderHtml ?? '';
const footerHtml = branding?.emailFooterHtml ?? '';
const wrapperStyle = backgroundUrl
? `background-image: url('${backgroundUrl}'); background-size: cover; background-position: center; background-color:#f2f2f2;`
: 'background-color:#f2f2f2;';
const logoBlock = logoUrl
? `<center><img src="${logoUrl}" alt="Logo" width="100" style="margin-bottom:20px;" /></center>`
: '';
return `<!DOCTYPE html>
<html>
<head>
@@ -57,15 +67,13 @@ export function renderShell({ title, body, branding }: ShellOpts): string {
</style>
</head>
<body style="margin:0; padding:0; background-color:#f2f2f2;">
<table role="presentation" width="100%" border="0" cellspacing="0" cellpadding="0" style="background-image: url('${backgroundUrl}'); background-size: cover; background-position: center; background-color:#f2f2f2;">
<table role="presentation" width="100%" border="0" cellspacing="0" cellpadding="0" style="${wrapperStyle}">
<tr>
<td align="center" style="padding:30px 16px;">
<table role="presentation" width="600" border="0" cellspacing="0" cellpadding="0" style="width:100%; max-width:600px; background-color:#ffffff; border-radius:8px; overflow:hidden; box-shadow:0 2px 4px rgba(0,0,0,0.1);">
<tr>
<td style="padding:20px; font-family: Arial, sans-serif; color:#333333; word-break:break-word;">
<center>
<img src="${logoUrl}" alt="Port logo" width="100" style="margin-bottom:20px;" />
</center>
${logoBlock}
${headerHtml ? `<div>${headerHtml}</div>` : ''}
${body}
${footerHtml ? `<div style="margin-top:24px;">${footerHtml}</div>` : ''}

View File

@@ -61,7 +61,7 @@ export async function residentialClientConfirmation(
data: ResidentialClientConfirmationData,
overrides?: RenderOpts,
) {
const portName = data.portName ?? 'Port Nimara';
const portName = data.portName ?? 'our team';
const subject = overrides?.subject?.trim()
? overrides.subject
: `Thank you for your interest in ${portName} Residences`;
@@ -181,7 +181,7 @@ export async function residentialSalesAlert(
data: ResidentialSalesAlertData,
overrides?: RenderOpts,
) {
const portName = data.portName ?? 'Port Nimara';
const portName = data.portName ?? 'our team';
const subject = overrides?.subject?.trim()
? overrides.subject
: `New residential enquiry — ${data.fullName}`;

View File

@@ -49,7 +49,7 @@ export function DocumentShell({
<Document
title={pdfTitle ?? docTitle}
author={pdfAuthor ?? portName}
producer="Port Nimara CRM"
producer={`${portName} CRM`}
>
<Page size="A4" style={styles.page}>
<Header portName={portName} docTitle={docTitle} meta={docMeta} logoBuffer={logoBuffer} />

View File

@@ -121,7 +121,11 @@ export interface DocumensoPayloadOptions {
dimensionUnit?: 'ft' | 'm';
}
const DEFAULT_REDIRECT_URL = 'https://portnimara.com';
// Empty string lets Documenso fall back to its own default post-sign
// landing page when the port admin hasn't configured a redirect URL.
// Never hardcode a tenant's marketing-site URL here — that would route
// every other port's signers to the wrong host.
const DEFAULT_REDIRECT_URL = '';
export interface EoiSignerConfig {
developer: { name: string; email: string };