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:
86
src/app/api/v1/admin/branding/email-preview/route.ts
Normal file
86
src/app/api/v1/admin/branding/email-preview/route.ts
Normal 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);
|
||||
}
|
||||
}),
|
||||
);
|
||||
Reference in New Issue
Block a user