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:
123
src/components/admin/branding/email-preview-card.tsx
Normal file
123
src/components/admin/branding/email-preview-card.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
||||
@@ -31,8 +31,27 @@ export function MobileTopbar() {
|
||||
const last = segments[segments.length - 1] ?? '';
|
||||
const isUuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(last);
|
||||
const fallbackSegment = isUuid ? segments[segments.length - 2] : last;
|
||||
// Derive a sensible title from the current path slug when no
|
||||
// page-level title is set. Avoids hardcoding a specific tenant name —
|
||||
// a fresh deploy with port slug `marina-alpha` reads as "Marina Alpha"
|
||||
// here without code edits.
|
||||
const portSlug = segments[0] ?? '';
|
||||
const portTitle = portSlug.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
const fallbackTitle =
|
||||
fallbackSegment?.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()) ?? 'Port Nimara';
|
||||
fallbackSegment?.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()) ||
|
||||
portTitle ||
|
||||
'CRM';
|
||||
|
||||
// Brand-mark initials derived from the port slug
|
||||
// ("port-nimara" → "PN", "marina-alpha" → "MA"). Cheap, self-contained,
|
||||
// no extra DB round-trip.
|
||||
const initials = portSlug
|
||||
? portSlug
|
||||
.split('-')
|
||||
.map((part) => part[0]?.toUpperCase() ?? '')
|
||||
.join('')
|
||||
.slice(0, 2)
|
||||
: 'CR';
|
||||
|
||||
return (
|
||||
<header
|
||||
@@ -58,13 +77,13 @@ export function MobileTopbar() {
|
||||
</button>
|
||||
) : (
|
||||
<div
|
||||
aria-label="Port Nimara"
|
||||
aria-label={portTitle || 'Home'}
|
||||
className={cn(
|
||||
'size-9 shrink-0 rounded-lg flex items-center justify-center',
|
||||
'bg-[#3a7bc8] shadow-[inset_0_1px_0_rgba(255,255,255,0.18),0_1px_2px_rgba(0,0,0,0.25)]',
|
||||
)}
|
||||
>
|
||||
<span className="text-white font-bold text-[13px] tracking-tight">PN</span>
|
||||
<span className="text-white font-bold text-[13px] tracking-tight">{initials}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
31
src/components/shared/auth-branding-provider.tsx
Normal file
31
src/components/shared/auth-branding-provider.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
'use client';
|
||||
|
||||
import { createContext, useContext, type ReactNode } from 'react';
|
||||
|
||||
export interface AuthBranding {
|
||||
logoUrl: string | null;
|
||||
backgroundUrl: string | null;
|
||||
appName: string | null;
|
||||
}
|
||||
|
||||
const AuthBrandingContext = createContext<AuthBranding | null>(null);
|
||||
|
||||
/**
|
||||
* Server-resolved branding injected at the auth route-group layout so
|
||||
* every BrandedAuthShell (no matter how nested) can pick it up without
|
||||
* each page re-fetching from system_settings. See `(auth)/layout.tsx`
|
||||
* and `(portal)/layout.tsx`.
|
||||
*/
|
||||
export function AuthBrandingProvider({
|
||||
branding,
|
||||
children,
|
||||
}: {
|
||||
branding: AuthBranding | null;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return <AuthBrandingContext.Provider value={branding}>{children}</AuthBrandingContext.Provider>;
|
||||
}
|
||||
|
||||
export function useAuthBranding(): AuthBranding | null {
|
||||
return useContext(AuthBrandingContext);
|
||||
}
|
||||
@@ -1,35 +1,35 @@
|
||||
const DEFAULT_BG_URL = 'https://s3.portnimara.com/images/Overhead_1_blur.png';
|
||||
const DEFAULT_LOGO_URL =
|
||||
'https://s3.portnimara.com/images/Port%20Nimara%20New%20Logo-Circular%20Frame_250px.png';
|
||||
'use client';
|
||||
|
||||
import { useAuthBranding } from '@/components/shared/auth-branding-provider';
|
||||
|
||||
interface BrandedAuthShellProps {
|
||||
children: React.ReactNode;
|
||||
/** Per-port branding override resolved server-side by the page that
|
||||
* renders the shell. When omitted, falls back to the Port Nimara
|
||||
* defaults so single-tenant deployments remain unaffected. Pages
|
||||
* that know their portId at render time should pass the result of
|
||||
* `getPortBrandingConfig(portId)`. */
|
||||
/** Per-port branding override. When omitted, the shell picks up
|
||||
* branding from the surrounding `<AuthBrandingProvider>` (mounted at
|
||||
* the route-group layout). When neither is present, falls back to
|
||||
* neutral defaults (no logo, plain background). */
|
||||
branding?: {
|
||||
logoUrl?: string | null;
|
||||
backgroundUrl?: string | null;
|
||||
appName?: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Branded shell shared by every auth/form surface - CRM login, portal login,
|
||||
* password set/reset/activate, forgot-password. Renders the blurred
|
||||
* background, the logo, and a centered white card that consumers
|
||||
* populate with their own form/content.
|
||||
* Branded shell shared by every auth/form surface — CRM login, portal login,
|
||||
* password set/reset/activate, forgot-password. Renders the background,
|
||||
* the port logo, and a centered white card that consumers populate with
|
||||
* their own form/content.
|
||||
*
|
||||
* Multi-tenant note (R2-H15): the per-port logoUrl from
|
||||
* /admin/branding is rendered when the parent page passes a `branding`
|
||||
* prop. The background image stays as the marina default for all
|
||||
* deployments — admin-authored backgrounds aren't part of the v1
|
||||
* branding surface.
|
||||
* Pages that know their portId at render time can pass `branding` as an
|
||||
* explicit prop; otherwise the surrounding `<AuthBrandingProvider>` is
|
||||
* the source of truth.
|
||||
*/
|
||||
export function BrandedAuthShell({ children, branding }: BrandedAuthShellProps) {
|
||||
const logoUrl = branding?.logoUrl || DEFAULT_LOGO_URL;
|
||||
const altText = branding?.appName || 'Port Nimara';
|
||||
const ctx = useAuthBranding();
|
||||
const logoUrl = branding?.logoUrl ?? ctx?.logoUrl ?? null;
|
||||
const backgroundUrl = branding?.backgroundUrl ?? ctx?.backgroundUrl ?? null;
|
||||
const altText = branding?.appName ?? ctx?.appName ?? 'Sign in';
|
||||
// fixed inset-0 anchors the auth surface to the viewport directly —
|
||||
// iOS Safari ignores overflow-hidden on inner divs for body-level
|
||||
// scrolling, so a regular `h-dvh overflow-hidden` wrapper doesn't
|
||||
@@ -42,18 +42,22 @@ export function BrandedAuthShell({ children, branding }: BrandedAuthShellProps)
|
||||
aria-hidden
|
||||
className="absolute inset-0 -z-10"
|
||||
style={{
|
||||
backgroundImage: `url('${DEFAULT_BG_URL}')`,
|
||||
backgroundImage: backgroundUrl ? `url('${backgroundUrl}')` : undefined,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center',
|
||||
// Neutral slate fallback so we never leak any one port's brand
|
||||
// imagery when branding hasn't been configured.
|
||||
backgroundColor: '#f2f2f2',
|
||||
}}
|
||||
/>
|
||||
<div className="w-full max-w-md">
|
||||
<div className="bg-white rounded-lg shadow-lg p-8">
|
||||
<div className="flex justify-center mb-6">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={logoUrl} alt={altText} className="w-24 h-auto" />
|
||||
</div>
|
||||
{logoUrl ? (
|
||||
<div className="flex justify-center mb-6">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={logoUrl} alt={altText} className="w-24 h-auto" />
|
||||
</div>
|
||||
) : null}
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user