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,52 @@
import { NextResponse, type NextRequest } from 'next/server';
import { eq } from 'drizzle-orm';
import { db } from '@/lib/db';
import { files } from '@/lib/db/schema/documents';
import { getStorageBackend } from '@/lib/storage';
import { errorResponse, NotFoundError } from '@/lib/errors';
/**
* Public, unauthenticated stream-by-id for branding assets only. Used by
* outbound email templates and the branded auth shell — surfaces where
* the consumer can't authenticate (an inbox image fetch has no session
* cookie). The `category = 'branding'` gate ensures only assets the
* admin explicitly uploaded as port branding leak through this surface;
* every other category (eoi, contract, receipt, …) keeps its
* authenticated `/api/v1/files/[id]/preview` path.
*
* Cached for a day at the edge. Admins replacing a logo write a new
* file id (the system_settings URL changes), so a stale CDN entry for
* the old id is harmless.
*/
export async function GET(_req: NextRequest, ctx: { params: Promise<{ id: string }> }) {
try {
const { id } = await ctx.params;
const row = await db.query.files.findFirst({ where: eq(files.id, id) });
if (!row || row.category !== 'branding') {
throw new NotFoundError('File');
}
const backend = await getStorageBackend();
const stream = await backend.get(row.storagePath);
// Convert the Node Readable into a Web ReadableStream so NextResponse
// can hand it back without buffering the whole blob in memory.
const webStream = new ReadableStream<Uint8Array>({
start(controller) {
stream.on('data', (chunk: Buffer) => controller.enqueue(new Uint8Array(chunk)));
stream.on('end', () => controller.close());
stream.on('error', (err) => controller.error(err));
},
});
return new NextResponse(webStream, {
headers: {
'Content-Type': row.mimeType ?? 'application/octet-stream',
'Cache-Control': 'public, max-age=86400, immutable',
},
});
} catch (error) {
return errorResponse(error);
}
}

View File

@@ -0,0 +1,86 @@
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { errorResponse, ValidationError } from '@/lib/errors';
import { parseBody } from '@/lib/api/route-helpers';
import { getPortBrandingConfig } from '@/lib/services/port-config';
import { renderShell } from '@/lib/email/shell';
import { sendEmail } from '@/lib/email';
const SAMPLE_SUBJECT_SUFFIX = ' — branding preview';
function buildSampleEmail(branding: {
logoUrl: string | null;
emailBackgroundUrl: string | null;
primaryColor: string;
appName: string;
emailHeaderHtml: string | null;
emailFooterHtml: string | null;
}): { subject: string; html: string } {
const subject = `${branding.appName}${SAMPLE_SUBJECT_SUFFIX}`;
const body = `
<h1 style="font-size:20px;margin:0 0 12px;color:${branding.primaryColor};">A sample notification</h1>
<p style="margin:0 0 12px;color:#334155;">Hi there,</p>
<p style="margin:0 0 12px;color:#334155;">
This is a preview of how transactional emails from <strong>${branding.appName}</strong>
will look using the current branding settings (logo, background image, primary color,
and any custom header/footer HTML you've added).
</p>
<p style="margin:0 0 12px;">
<a href="https://example.com" style="display:inline-block;padding:10px 18px;background-color:${branding.primaryColor};color:#ffffff;text-decoration:none;border-radius:6px;font-weight:600;">
Primary action button
</a>
</p>
<p style="margin:0;color:#64748b;font-size:13px;">
Adjust the values in the Identity and Email branding cards above, save, then refresh the
preview to see your changes.
</p>
`;
const html = renderShell({
title: subject,
body,
branding: {
logoUrl: branding.logoUrl,
backgroundUrl: branding.emailBackgroundUrl,
primaryColor: branding.primaryColor,
emailHeaderHtml: branding.emailHeaderHtml,
emailFooterHtml: branding.emailFooterHtml,
},
});
return { subject, html };
}
// GET — return the sample email rendered with the current port's branding.
export const GET = withAuth(
withPermission('admin', 'manage_settings', async (_req, ctx) => {
try {
if (!ctx.portId) throw new ValidationError('No active port');
const branding = await getPortBrandingConfig(ctx.portId);
const { subject, html } = buildSampleEmail(branding);
return NextResponse.json({ data: { subject, html } });
} catch (error) {
return errorResponse(error);
}
}),
);
const sendTestSchema = z.object({
recipient: z.string().email('Enter a valid email address'),
});
// POST — actually send the sample email to a single recipient.
export const POST = withAuth(
withPermission('admin', 'manage_settings', async (req, ctx) => {
try {
if (!ctx.portId) throw new ValidationError('No active port');
const { recipient } = await parseBody(req, sendTestSchema);
const branding = await getPortBrandingConfig(ctx.portId);
const { subject, html } = buildSampleEmail(branding);
await sendEmail(recipient, subject, html);
return NextResponse.json({ data: { sent: true, recipient } });
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -64,7 +64,10 @@ export const POST = withAuth(
);
const baseUrl = env.APP_URL.replace(/\/+$/, '');
const url = `${baseUrl}/api/v1/files/${record.id}/preview`;
// Branding assets must survive in email-inbox land where no session
// cookie travels — route through the public-by-id surface gated on
// `category='branding'` rather than the authenticated preview path.
const url = `${baseUrl}/api/public/files/${record.id}`;
return NextResponse.json({ data: { fileId: record.id, url } });
} catch (error) {

View File

@@ -89,19 +89,49 @@ export const PATCH = withAuth(async (req, ctx) => {
const cancelUrl = `${baseUrl}/api/v1/me/email/cancel/${rawToken}`;
try {
const { sendEmail } = await import('@/lib/email');
const [{ sendEmail }, { renderShell, safeUrl }, { resolveAuthShellBranding }] =
await Promise.all([
import('@/lib/email'),
import('@/lib/email/shell'),
import('@/lib/email/auth-shell-branding'),
]);
const branding = await resolveAuthShellBranding();
const appName = branding?.appName?.trim() || 'CRM';
const brandingShell = branding
? {
logoUrl: branding.logoUrl,
backgroundUrl: branding.backgroundUrl,
primaryColor: null,
emailHeaderHtml: null,
emailFooterHtml: null,
}
: null;
const safeOldEmail = ctx.user.email.replace(/[<>&]/g, '');
const safeNewEmail = email.replace(/[<>&]/g, '');
const confirmBody = `
<p style="margin-bottom:16px;">Hi,</p>
<p style="margin-bottom:16px;">You (or someone using your account) requested to change the sign-in email on your ${appName} account from <strong>${safeOldEmail}</strong> to <strong>${safeNewEmail}</strong>.</p>
<p style="margin-bottom:16px;"><a href="${safeUrl(confirmUrl)}" style="color:#2563eb;font-weight:600;">Click here to confirm this change</a> — the link expires in ${VERIFY_TOKEN_TTL_MINUTES} minutes.</p>
<p style="color:#64748b;">If you didn't request this, ignore this email.</p>
`;
const cancelBody = `
<p style="margin-bottom:16px;">Hi,</p>
<p style="margin-bottom:16px;">A change to your sign-in email was requested. If this wasn't you, <a href="${safeUrl(cancelUrl)}" style="color:#2563eb;font-weight:600;">click here to cancel the change</a> immediately and consider rotating your password.</p>
`;
const confirmSubject = `Confirm your new ${appName} email address`;
const noticeSubject = `A change to your ${appName} email was requested`;
await Promise.allSettled([
sendEmail(
email,
'Confirm your new Port Nimara CRM email address',
`<p>Hi,</p><p>You (or someone using your account) requested to change the sign-in email on your Port Nimara CRM account from <strong>${ctx.user.email}</strong> to <strong>${email}</strong>.</p><p><a href="${confirmUrl}">Click here to confirm this change</a> — the link expires in ${VERIFY_TOKEN_TTL_MINUTES} minutes.</p><p>If you didn't request this, ignore this email.</p>`,
confirmSubject,
renderShell({ title: confirmSubject, body: confirmBody, branding: brandingShell }),
undefined,
`Confirm new email: ${confirmUrl}`,
),
sendEmail(
ctx.user.email,
'A change to your Port Nimara CRM email was requested',
`<p>Hi,</p><p>A change to your sign-in email was requested. If this wasn't you, <a href="${cancelUrl}">click here to cancel the change</a> immediately and consider rotating your password.</p>`,
noticeSubject,
renderShell({ title: noticeSubject, body: cancelBody, branding: brandingShell }),
undefined,
`Cancel email change: ${cancelUrl}`,
),