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:
@@ -1,12 +1,16 @@
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
import { AuthBrandingProvider } from '@/components/shared/auth-branding-provider';
|
||||
import { resolveAuthShellBranding } from '@/lib/email/auth-shell-branding';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: {
|
||||
default: 'Sign In',
|
||||
template: '%s | Port Nimara CRM',
|
||||
template: '%s',
|
||||
},
|
||||
};
|
||||
|
||||
export default function AuthLayout({ children }: { children: React.ReactNode }) {
|
||||
return <>{children}</>;
|
||||
export default async function AuthLayout({ children }: { children: React.ReactNode }) {
|
||||
const branding = await resolveAuthShellBranding();
|
||||
return <AuthBrandingProvider branding={branding}>{children}</AuthBrandingProvider>;
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { BrandedAuthShell } from '@/components/shared/branded-auth-shell';
|
||||
import { useAuthBranding } from '@/components/shared/auth-branding-provider';
|
||||
|
||||
// `identifier` accepts either an email address or a username (3–30 lowercase
|
||||
// letters / digits / dot / underscore / hyphen). The server endpoint
|
||||
@@ -43,6 +44,8 @@ function safeRedirectTarget(raw: string | null): string {
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter();
|
||||
const branding = useAuthBranding();
|
||||
const appName = branding?.appName?.trim() || 'CRM';
|
||||
const searchParams = useSearchParams();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
@@ -105,7 +108,7 @@ export default function LoginPage() {
|
||||
return (
|
||||
<BrandedAuthShell>
|
||||
<div className="text-center mb-6">
|
||||
<h1 className="text-xl font-semibold text-gray-900">Port Nimara CRM</h1>
|
||||
<h1 className="text-xl font-semibold text-gray-900">{appName}</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">Sign in to continue</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
@@ -19,6 +20,8 @@ const resetSchema = z.object({
|
||||
type ResetFormData = z.infer<typeof resetSchema>;
|
||||
|
||||
export default function ResetPasswordPage() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
@@ -30,16 +33,39 @@ export default function ResetPasswordPage() {
|
||||
resolver: zodResolver(resetSchema),
|
||||
});
|
||||
|
||||
// If the user landed here from a stale email link that points to
|
||||
// `/reset-password?token=…` instead of `/set-password?token=…`, hand
|
||||
// them off to the set-password form (the one that actually knows how
|
||||
// to consume the token). New emails should point straight at
|
||||
// `/set-password`, but old links live in inboxes for a long time.
|
||||
useEffect(() => {
|
||||
const token = searchParams.get('token');
|
||||
if (token) {
|
||||
router.replace(`/set-password?token=${encodeURIComponent(token)}`);
|
||||
}
|
||||
}, [router, searchParams]);
|
||||
|
||||
async function onSubmit(data: ResetFormData) {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
// Always show the same success message regardless of whether the email exists.
|
||||
await fetch('/api/auth/reset-password', {
|
||||
// Better-auth's request-link endpoint is `/api/auth/request-password-reset`.
|
||||
// `/api/auth/reset-password` is the *consume-token* endpoint and silently
|
||||
// rejects an email-only payload, which is why the old code appeared to
|
||||
// "succeed" without ever sending mail.
|
||||
const response = await fetch('/api/auth/request-password-reset', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email: data.email }),
|
||||
body: JSON.stringify({ email: data.email, redirectTo: '/set-password' }),
|
||||
});
|
||||
|
||||
// Treat 400 "user not found" as success so we don't leak whether the
|
||||
// account exists — the success copy says "if an account exists…".
|
||||
// Anything else (5xx, network) surfaces as a real error.
|
||||
if (!response.ok && response.status !== 400) {
|
||||
toast.error('Something went wrong. Please try again.');
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitted(true);
|
||||
} catch {
|
||||
toast.error('Something went wrong. Please try again.');
|
||||
|
||||
@@ -11,6 +11,7 @@ import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { BrandedAuthShell } from '@/components/shared/branded-auth-shell';
|
||||
import { useAuthBranding } from '@/components/shared/auth-branding-provider';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
@@ -36,6 +37,8 @@ interface StatusResp {
|
||||
*/
|
||||
export default function SetupPage() {
|
||||
const router = useRouter();
|
||||
const branding = useAuthBranding();
|
||||
const appName = branding?.appName?.trim() || 'this CRM';
|
||||
const [checking, setChecking] = useState(true);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
@@ -109,7 +112,7 @@ export default function SetupPage() {
|
||||
<BrandedAuthShell>
|
||||
<div className="space-y-6">
|
||||
<div className="text-center space-y-1">
|
||||
<h1 className="text-xl font-semibold">Welcome to Port Nimara CRM</h1>
|
||||
<h1 className="text-xl font-semibold">Welcome to {appName}</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No administrator account exists yet. Create one to get started — you’ll be the
|
||||
super-administrator for this installation.
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
} from '@/components/admin/shared/settings-form-card';
|
||||
import { PageHeader } from '@/components/shared/page-header';
|
||||
import { PdfLogoUploader } from '@/components/admin/branding/pdf-logo-uploader';
|
||||
import { EmailPreviewCard } from '@/components/admin/branding/email-preview-card';
|
||||
|
||||
const DEFAULT_EMAIL_HEADER_HTML = `<!-- Optional pre-body header -->
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="border-collapse:collapse;">
|
||||
@@ -49,7 +50,7 @@ const FIELDS: SettingFieldDef[] = [
|
||||
key: 'branding_email_background_url',
|
||||
label: 'Email background image',
|
||||
description:
|
||||
'Optional blurred photo shown behind the white email card. Leave blank to use the built-in Port Nimara overhead. Recommended: 1920x1080 JPG, pre-blurred to ~20px gaussian so it reads as a soft background even on small clients.',
|
||||
'Blurred photo shown behind the white email card and the auth-shell (login / reset password) pages. Leave blank to render a plain off-white backdrop. Recommended: 1920x1080 JPG, pre-blurred to ~20px gaussian so it reads as a soft background even on small clients.',
|
||||
type: 'image-upload',
|
||||
defaultValue: '',
|
||||
},
|
||||
@@ -96,6 +97,7 @@ export default function BrandingSettingsPage() {
|
||||
description="HTML fragments rendered around every transactional email."
|
||||
fields={FIELDS.slice(3)}
|
||||
/>
|
||||
<EmailPreviewCard />
|
||||
<PdfLogoUploader />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -15,6 +15,7 @@ import { DevModeBanner } from '@/components/shared/dev-mode-banner';
|
||||
import { RealtimeToasts } from '@/components/shared/realtime-toasts';
|
||||
import { WebVitalsReporter } from '@/components/shared/web-vitals-reporter';
|
||||
import { classifyFormFactor } from '@/lib/form-factor';
|
||||
import { getPortBrandingConfig } from '@/lib/services/port-config';
|
||||
|
||||
export default async function DashboardLayout({ children }: { children: React.ReactNode }) {
|
||||
const headerList = await headers();
|
||||
@@ -42,6 +43,22 @@ export default async function DashboardLayout({ children }: { children: React.Re
|
||||
email: session.user.email,
|
||||
};
|
||||
|
||||
// Per-port logo map for the sidebar. Resolved server-side so the
|
||||
// sidebar can swap brand on port switch without an extra round-trip.
|
||||
// Falls back to null per port when no logo is configured — the
|
||||
// sidebar surfaces nothing rather than leaking a generic placeholder.
|
||||
const portBrandingEntries = await Promise.all(
|
||||
ports.map(async (p) => {
|
||||
try {
|
||||
const cfg = await getPortBrandingConfig(p.id);
|
||||
return [p.id, cfg.logoUrl] as const;
|
||||
} catch {
|
||||
return [p.id, null] as const;
|
||||
}
|
||||
}),
|
||||
);
|
||||
const portLogoUrls: Record<string, string | null> = Object.fromEntries(portBrandingEntries);
|
||||
|
||||
return (
|
||||
<QueryProvider>
|
||||
<PortProvider ports={ports} defaultPortId={ports[0]?.id ?? null}>
|
||||
@@ -62,6 +79,7 @@ export default async function DashboardLayout({ children }: { children: React.Re
|
||||
isSuperAdmin={profile?.isSuperAdmin ?? false}
|
||||
user={user}
|
||||
ports={ports}
|
||||
portLogoUrls={portLogoUrls}
|
||||
initialFormFactor={initialFormFactor}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -5,6 +5,9 @@ import { getPortalDashboard } from '@/lib/services/portal.service';
|
||||
import { isPortalDisabledGlobally } from '@/lib/services/portal-auth.service';
|
||||
import { PortalHeader } from '@/components/portal/portal-header';
|
||||
import { PortalNav } from '@/components/portal/portal-nav';
|
||||
import { AuthBrandingProvider } from '@/components/shared/auth-branding-provider';
|
||||
import { resolveAuthShellBranding } from '@/lib/email/auth-shell-branding';
|
||||
import { getPortBrandingConfig } from '@/lib/services/port-config';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: {
|
||||
@@ -54,15 +57,31 @@ export default async function PortalLayout({ children }: { children: React.React
|
||||
}
|
||||
}
|
||||
|
||||
// Branding for the auth-shell pages (login, forgot-password, reset).
|
||||
// When the visitor has a session, use that port's branding so they
|
||||
// stay inside one tenant's look. Otherwise pick up the first-port
|
||||
// default — the same path the CRM auth pages take.
|
||||
const branding = session
|
||||
? await getPortBrandingConfig(session.portId)
|
||||
.then((cfg) => ({
|
||||
logoUrl: cfg.logoUrl,
|
||||
backgroundUrl: cfg.emailBackgroundUrl,
|
||||
appName: cfg.appName,
|
||||
}))
|
||||
.catch(() => null)
|
||||
: await resolveAuthShellBranding();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
{session && (
|
||||
<>
|
||||
<PortalHeader portName={portName} portLogoUrl={portLogoUrl} clientName={clientName} />
|
||||
<PortalNav />
|
||||
</>
|
||||
)}
|
||||
<main className={session ? 'max-w-5xl mx-auto px-4 sm:px-6 py-8' : ''}>{children}</main>
|
||||
</div>
|
||||
<AuthBrandingProvider branding={branding}>
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
{session && (
|
||||
<>
|
||||
<PortalHeader portName={portName} portLogoUrl={portLogoUrl} clientName={clientName} />
|
||||
<PortalNav />
|
||||
</>
|
||||
)}
|
||||
<main className={session ? 'max-w-5xl mx-auto px-4 sm:px-6 py-8' : ''}>{children}</main>
|
||||
</div>
|
||||
</AuthBrandingProvider>
|
||||
);
|
||||
}
|
||||
|
||||
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}`,
|
||||
),
|
||||
|
||||
@@ -7,6 +7,7 @@ import { getLocale, getMessages } from 'next-intl/server';
|
||||
import { Toaster } from 'sonner';
|
||||
import { classifyFormFactor } from '@/lib/form-factor';
|
||||
import { ReactGrabViewportSync } from '@/components/dev/react-grab-viewport-sync';
|
||||
import { resolveAuthShellBranding } from '@/lib/email/auth-shell-branding';
|
||||
import './globals.css';
|
||||
|
||||
const inter = Inter({
|
||||
@@ -28,26 +29,37 @@ export const viewport: Viewport = {
|
||||
themeColor: '#1e2844',
|
||||
};
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: {
|
||||
default: 'Port Nimara CRM',
|
||||
template: '%s | Port Nimara CRM',
|
||||
},
|
||||
description: 'Marina management system for Port Nimara',
|
||||
appleWebApp: {
|
||||
capable: true,
|
||||
statusBarStyle: 'black-translucent',
|
||||
title: 'Port Nimara',
|
||||
},
|
||||
icons: {
|
||||
icon: [
|
||||
{ url: '/icon-192.png', sizes: '192x192', type: 'image/png' },
|
||||
{ url: '/icon-512.png', sizes: '512x512', type: 'image/png' },
|
||||
],
|
||||
apple: '/apple-touch-icon.png',
|
||||
},
|
||||
manifest: '/manifest.json',
|
||||
};
|
||||
/**
|
||||
* Resolve the browser tab title from the first-port `branding_app_name`
|
||||
* setting so a tenant's deploy sees their own brand in the title bar
|
||||
* (and in `Cmd+T` browser history). Falls back to a generic label when
|
||||
* the DB hasn't been seeded yet (e.g. fresh `pnpm dev` against an empty
|
||||
* database during onboarding).
|
||||
*/
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const branding = await resolveAuthShellBranding();
|
||||
const appName = branding?.appName?.trim() || 'CRM';
|
||||
return {
|
||||
title: {
|
||||
default: appName,
|
||||
template: `%s | ${appName}`,
|
||||
},
|
||||
description: `${appName} — marina management system`,
|
||||
appleWebApp: {
|
||||
capable: true,
|
||||
statusBarStyle: 'black-translucent',
|
||||
title: appName,
|
||||
},
|
||||
icons: {
|
||||
icon: [
|
||||
{ url: '/icon-192.png', sizes: '192x192', type: 'image/png' },
|
||||
{ url: '/icon-512.png', sizes: '512x512', type: 'image/png' },
|
||||
],
|
||||
apple: '/apple-touch-icon.png',
|
||||
},
|
||||
manifest: '/manifest.json',
|
||||
};
|
||||
}
|
||||
|
||||
export default async function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
const headerList = await headers();
|
||||
|
||||
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>
|
||||
|
||||
42
src/lib/email/auth-shell-branding.ts
Normal file
42
src/lib/email/auth-shell-branding.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { asc } from 'drizzle-orm';
|
||||
|
||||
import { db } from '@/lib/db';
|
||||
import { ports } from '@/lib/db/schema/ports';
|
||||
import { getPortBrandingConfig } from '@/lib/services/port-config';
|
||||
|
||||
interface AuthShellBranding {
|
||||
logoUrl: string | null;
|
||||
backgroundUrl: string | null;
|
||||
appName: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-port-context surfaces (login, forgot-password, set-password,
|
||||
* the better-auth password-reset email) need branding before the user
|
||||
* has picked a port. Resolve against the first active port in the
|
||||
* system — for a single-tenant deploy that's the right port; for a
|
||||
* multi-tenant deploy the operator should host each tenant on its own
|
||||
* subdomain so the wrong-tenant logo doesn't surface here.
|
||||
*
|
||||
* Returns null only if the system has no ports at all (fresh install
|
||||
* pre-seed); callers should fall back to neutral defaults in that case.
|
||||
*/
|
||||
export async function resolveAuthShellBranding(): Promise<AuthShellBranding | null> {
|
||||
try {
|
||||
const [port] = await db
|
||||
.select({ id: ports.id })
|
||||
.from(ports)
|
||||
.orderBy(asc(ports.createdAt))
|
||||
.limit(1);
|
||||
if (!port) return null;
|
||||
|
||||
const cfg = await getPortBrandingConfig(port.id);
|
||||
return {
|
||||
logoUrl: cfg.logoUrl,
|
||||
backgroundUrl: cfg.emailBackgroundUrl,
|
||||
appName: cfg.appName,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -3,8 +3,9 @@
|
||||
*
|
||||
* Senders that have a portId call this once and pass the result into
|
||||
* the email template. Senders without a portId (e.g. CRM invite at
|
||||
* create-time before a port is selected) pass null — the shell
|
||||
* falls back to the Port Nimara defaults.
|
||||
* create-time before a port is selected) pass null — the shell then
|
||||
* falls back to neutral defaults (no logo, plain background, slate
|
||||
* accent). Configure per-port branding via /admin/branding.
|
||||
*/
|
||||
|
||||
import { getPortBrandingConfig } from '@/lib/services/port-config';
|
||||
|
||||
@@ -16,10 +16,13 @@
|
||||
* function. Templates call `renderShell({ title, body, branding })`.
|
||||
*/
|
||||
|
||||
const DEFAULT_LOGO_URL =
|
||||
'https://s3.portnimara.com/images/Port%20Nimara%20New%20Logo-Circular%20Frame_250px.png';
|
||||
const DEFAULT_BACKGROUND_URL = 'https://s3.portnimara.com/images/Overhead_1_blur.png';
|
||||
const DEFAULT_PRIMARY_COLOR = '#0F4C81';
|
||||
// Neutral defaults — no tenant-specific imagery leaks across ports.
|
||||
// When branding hasn't been configured the email renders without a logo
|
||||
// and on a plain off-white background. Admins upload their own assets via
|
||||
// /admin/branding which then flow through via getPortBrandingConfig().
|
||||
const DEFAULT_LOGO_URL: string | null = null;
|
||||
const DEFAULT_BACKGROUND_URL: string | null = null;
|
||||
const DEFAULT_PRIMARY_COLOR = '#1e293b';
|
||||
|
||||
export interface BrandingShell {
|
||||
logoUrl: string | null;
|
||||
@@ -44,6 +47,13 @@ export function renderShell({ title, body, branding }: ShellOpts): string {
|
||||
const headerHtml = branding?.emailHeaderHtml ?? '';
|
||||
const footerHtml = branding?.emailFooterHtml ?? '';
|
||||
|
||||
const wrapperStyle = backgroundUrl
|
||||
? `background-image: url('${backgroundUrl}'); background-size: cover; background-position: center; background-color:#f2f2f2;`
|
||||
: 'background-color:#f2f2f2;';
|
||||
const logoBlock = logoUrl
|
||||
? `<center><img src="${logoUrl}" alt="Logo" width="100" style="margin-bottom:20px;" /></center>`
|
||||
: '';
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
@@ -57,15 +67,13 @@ export function renderShell({ title, body, branding }: ShellOpts): string {
|
||||
</style>
|
||||
</head>
|
||||
<body style="margin:0; padding:0; background-color:#f2f2f2;">
|
||||
<table role="presentation" width="100%" border="0" cellspacing="0" cellpadding="0" style="background-image: url('${backgroundUrl}'); background-size: cover; background-position: center; background-color:#f2f2f2;">
|
||||
<table role="presentation" width="100%" border="0" cellspacing="0" cellpadding="0" style="${wrapperStyle}">
|
||||
<tr>
|
||||
<td align="center" style="padding:30px 16px;">
|
||||
<table role="presentation" width="600" border="0" cellspacing="0" cellpadding="0" style="width:100%; max-width:600px; background-color:#ffffff; border-radius:8px; overflow:hidden; box-shadow:0 2px 4px rgba(0,0,0,0.1);">
|
||||
<tr>
|
||||
<td style="padding:20px; font-family: Arial, sans-serif; color:#333333; word-break:break-word;">
|
||||
<center>
|
||||
<img src="${logoUrl}" alt="Port logo" width="100" style="margin-bottom:20px;" />
|
||||
</center>
|
||||
${logoBlock}
|
||||
${headerHtml ? `<div>${headerHtml}</div>` : ''}
|
||||
${body}
|
||||
${footerHtml ? `<div style="margin-top:24px;">${footerHtml}</div>` : ''}
|
||||
|
||||
@@ -61,7 +61,7 @@ export async function residentialClientConfirmation(
|
||||
data: ResidentialClientConfirmationData,
|
||||
overrides?: RenderOpts,
|
||||
) {
|
||||
const portName = data.portName ?? 'Port Nimara';
|
||||
const portName = data.portName ?? 'our team';
|
||||
const subject = overrides?.subject?.trim()
|
||||
? overrides.subject
|
||||
: `Thank you for your interest in ${portName} Residences`;
|
||||
@@ -181,7 +181,7 @@ export async function residentialSalesAlert(
|
||||
data: ResidentialSalesAlertData,
|
||||
overrides?: RenderOpts,
|
||||
) {
|
||||
const portName = data.portName ?? 'Port Nimara';
|
||||
const portName = data.portName ?? 'our team';
|
||||
const subject = overrides?.subject?.trim()
|
||||
? overrides.subject
|
||||
: `New residential enquiry — ${data.fullName}`;
|
||||
|
||||
@@ -49,7 +49,7 @@ export function DocumentShell({
|
||||
<Document
|
||||
title={pdfTitle ?? docTitle}
|
||||
author={pdfAuthor ?? portName}
|
||||
producer="Port Nimara CRM"
|
||||
producer={`${portName} CRM`}
|
||||
>
|
||||
<Page size="A4" style={styles.page}>
|
||||
<Header portName={portName} docTitle={docTitle} meta={docMeta} logoBuffer={logoBuffer} />
|
||||
|
||||
@@ -121,7 +121,11 @@ export interface DocumensoPayloadOptions {
|
||||
dimensionUnit?: 'ft' | 'm';
|
||||
}
|
||||
|
||||
const DEFAULT_REDIRECT_URL = 'https://portnimara.com';
|
||||
// Empty string lets Documenso fall back to its own default post-sign
|
||||
// landing page when the port admin hasn't configured a redirect URL.
|
||||
// Never hardcode a tenant's marketing-site URL here — that would route
|
||||
// every other port's signers to the wrong host.
|
||||
const DEFAULT_REDIRECT_URL = '';
|
||||
|
||||
export interface EoiSignerConfig {
|
||||
developer: { name: string; email: string };
|
||||
|
||||
@@ -99,7 +99,7 @@ async function main(): Promise<void> {
|
||||
}
|
||||
|
||||
httpServer.listen(env.PORT, () => {
|
||||
logger.info({ port: env.PORT, env: env.NODE_ENV }, 'Port Nimara CRM server listening');
|
||||
logger.info({ port: env.PORT, env: env.NODE_ENV }, 'CRM server listening');
|
||||
});
|
||||
|
||||
// Graceful stop on container restart / deploy. Without this, every
|
||||
|
||||
@@ -250,9 +250,13 @@ describe('buildDocumensoPayload', () => {
|
||||
expect(payload.meta.message).toContain('On behalf of Blue Seas');
|
||||
});
|
||||
|
||||
it('uses default redirect URL when not provided', () => {
|
||||
it('uses empty default redirect URL when not provided (Documenso falls back to its own page)', () => {
|
||||
// Multi-tenant: never hardcode a single port's marketing-site URL
|
||||
// here. When the admin hasn't configured a redirect, send empty
|
||||
// string so Documenso uses its built-in post-sign page. Per-port
|
||||
// override flows through getPortDocumensoConfig → redirectUrl.
|
||||
const payload = buildDocumensoPayload(makeContext(), OPTIONS);
|
||||
expect(payload.meta.redirectUrl).toBe('https://portnimara.com');
|
||||
expect(payload.meta.redirectUrl).toBe('');
|
||||
});
|
||||
|
||||
it('uses custom redirect URL when provided', () => {
|
||||
|
||||
Reference in New Issue
Block a user