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);
}
}