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:
52
src/app/api/public/files/[id]/route.ts
Normal file
52
src/app/api/public/files/[id]/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
}),
|
||||
);
|
||||
@@ -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) {
|
||||
|
||||
@@ -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}`,
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user