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,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) {