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:
42
src/lib/email/auth-shell-branding.ts
Normal file
42
src/lib/email/auth-shell-branding.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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>` : ''}
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user