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,123 @@
'use client';
import { useState } from 'react';
import { Eye, Send } from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { apiFetch } from '@/lib/api/client';
interface PreviewResponse {
data: { subject: string; html: string };
}
/**
* Live preview of the branded transactional email shell plus a
* "send a test" affordance. Both use the current port's branding so
* admins can sanity-check uploads + colour + header/footer HTML
* without firing one of the real flows.
*/
export function EmailPreviewCard() {
const [html, setHtml] = useState<string | null>(null);
const [subject, setSubject] = useState<string | null>(null);
const [loadingPreview, setLoadingPreview] = useState(false);
const [testEmail, setTestEmail] = useState('');
const [sending, setSending] = useState(false);
async function refreshPreview() {
setLoadingPreview(true);
try {
const res = await apiFetch<PreviewResponse>('/api/v1/admin/branding/email-preview');
setSubject(res.data.subject);
setHtml(res.data.html);
} catch (err: unknown) {
toast.error(err instanceof Error ? err.message : 'Preview failed');
} finally {
setLoadingPreview(false);
}
}
async function sendTest() {
if (!testEmail) return;
setSending(true);
try {
await apiFetch('/api/v1/admin/branding/email-preview', {
method: 'POST',
body: { recipient: testEmail },
});
toast.success(`Test email queued to ${testEmail}`);
} catch (err: unknown) {
toast.error(err instanceof Error ? err.message : 'Send failed');
} finally {
setSending(false);
}
}
return (
<Card>
<CardHeader>
<div className="flex items-start justify-between gap-3">
<div>
<CardTitle>Preview & test</CardTitle>
<CardDescription>
Renders a sample transactional email with the current port&apos;s branding. Save
changes first, then refresh the preview to see them.
</CardDescription>
</div>
<Button variant="outline" size="sm" onClick={refreshPreview} disabled={loadingPreview}>
<Eye className="mr-1.5 h-4 w-4" />
{loadingPreview ? 'Loading…' : html ? 'Refresh preview' : 'Show preview'}
</Button>
</div>
</CardHeader>
<CardContent className="space-y-4">
{html ? (
<div className="space-y-2">
<div className="text-xs text-muted-foreground">
Subject: <span className="font-medium text-foreground">{subject}</span>
</div>
<div className="rounded-md border bg-white">
{/* Sandboxed so the rendered HTML can't execute scripts or
steal the admin's session. Same-origin would let it call
/api/* with the admin's cookies. */}
<iframe
title="Email preview"
srcDoc={html}
sandbox=""
className="h-[640px] w-full rounded-md"
/>
</div>
</div>
) : (
<p className="text-sm text-muted-foreground">
Click <span className="font-medium">Show preview</span> to render a sample email.
</p>
)}
<div className="space-y-2 border-t pt-4">
<Label htmlFor="test-email-input">Send a test</Label>
<div className="flex flex-wrap items-center gap-2">
<Input
id="test-email-input"
type="email"
value={testEmail}
onChange={(e) => setTestEmail(e.target.value)}
placeholder="you@example.com"
className="flex-1 min-w-[240px]"
/>
<Button onClick={sendTest} disabled={sending || !testEmail}>
<Send className="mr-1.5 h-4 w-4" />
{sending ? 'Sending…' : 'Send test email'}
</Button>
</div>
<p className="text-xs text-muted-foreground">
Sends the same sample email to the address you enter. Useful for checking how it lands
in Gmail, Outlook, Apple Mail, etc.
</p>
</div>
</CardContent>
</Card>
);
}