diff --git a/src/app/(auth)/layout.tsx b/src/app/(auth)/layout.tsx
index 541ab101..bda8c200 100644
--- a/src/app/(auth)/layout.tsx
+++ b/src/app/(auth)/layout.tsx
@@ -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 {children};
}
diff --git a/src/app/(auth)/login/page.tsx b/src/app/(auth)/login/page.tsx
index 7f0e1abc..2c639ef7 100644
--- a/src/app/(auth)/login/page.tsx
+++ b/src/app/(auth)/login/page.tsx
@@ -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 (
-
Port Nimara CRM
+
{appName}
Sign in to continue
diff --git a/src/app/(auth)/reset-password/page.tsx b/src/app/(auth)/reset-password/page.tsx
index 3dc602e9..ce65132a 100644
--- a/src/app/(auth)/reset-password/page.tsx
+++ b/src/app/(auth)/reset-password/page.tsx
@@ -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;
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.');
diff --git a/src/app/(auth)/setup/page.tsx b/src/app/(auth)/setup/page.tsx
index 0b642cb1..6fd399da 100644
--- a/src/app/(auth)/setup/page.tsx
+++ b/src/app/(auth)/setup/page.tsx
@@ -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() {
-
Welcome to Port Nimara CRM
+
Welcome to {appName}
No administrator account exists yet. Create one to get started — you’ll be the
super-administrator for this installation.
diff --git a/src/app/(dashboard)/[portSlug]/admin/branding/page.tsx b/src/app/(dashboard)/[portSlug]/admin/branding/page.tsx
index ff700a65..2aedd4cb 100644
--- a/src/app/(dashboard)/[portSlug]/admin/branding/page.tsx
+++ b/src/app/(dashboard)/[portSlug]/admin/branding/page.tsx
@@ -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 = `
@@ -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)}
/>
+
);
diff --git a/src/app/(dashboard)/layout.tsx b/src/app/(dashboard)/layout.tsx
index 0f8192dd..ce39c26f 100644
--- a/src/app/(dashboard)/layout.tsx
+++ b/src/app/(dashboard)/layout.tsx
@@ -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 = Object.fromEntries(portBrandingEntries);
+
return (
@@ -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}
diff --git a/src/app/(portal)/layout.tsx b/src/app/(portal)/layout.tsx
index 56b1078c..6a9e7e10 100644
--- a/src/app/(portal)/layout.tsx
+++ b/src/app/(portal)/layout.tsx
@@ -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 (
-
- {session && (
- <>
-
-
- >
- )}
- {children}
-
+
+
+ {session && (
+ <>
+
+
+ >
+ )}
+ {children}
+
+
);
}
diff --git a/src/app/api/public/files/[id]/route.ts b/src/app/api/public/files/[id]/route.ts
new file mode 100644
index 00000000..37794384
--- /dev/null
+++ b/src/app/api/public/files/[id]/route.ts
@@ -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({
+ 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);
+ }
+}
diff --git a/src/app/api/v1/admin/branding/email-preview/route.ts b/src/app/api/v1/admin/branding/email-preview/route.ts
new file mode 100644
index 00000000..0270055d
--- /dev/null
+++ b/src/app/api/v1/admin/branding/email-preview/route.ts
@@ -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 = `
+
A sample notification
+
Hi there,
+
+ This is a preview of how transactional emails from ${branding.appName}
+ will look using the current branding settings (logo, background image, primary color,
+ and any custom header/footer HTML you've added).
+
A change to your sign-in email was requested. If this wasn't you, click here to cancel the change immediately and consider rotating your password.
+ `;
+ 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',
- `
Hi,
You (or someone using your account) requested to change the sign-in email on your Port Nimara CRM account from ${ctx.user.email} to ${email}.
`,
+ 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',
- `
Hi,
A change to your sign-in email was requested. If this wasn't you, click here to cancel the change immediately and consider rotating your password.
`,
+ noticeSubject,
+ renderShell({ title: noticeSubject, body: cancelBody, branding: brandingShell }),
undefined,
`Cancel email change: ${cancelUrl}`,
),
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index 30281172..1482a3d4 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -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 {
+ 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();
diff --git a/src/components/admin/branding/email-preview-card.tsx b/src/components/admin/branding/email-preview-card.tsx
new file mode 100644
index 00000000..a90b65a7
--- /dev/null
+++ b/src/components/admin/branding/email-preview-card.tsx
@@ -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(null);
+ const [subject, setSubject] = useState(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('/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 (
+
+
+
+
+ Preview & test
+
+ Renders a sample transactional email with the current port's branding. Save
+ changes first, then refresh the preview to see them.
+
+
+
+
+
+
+ {html ? (
+
+
+ Subject: {subject}
+
+
+ {/* 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. */}
+
+
diff --git a/src/lib/email/auth-shell-branding.ts b/src/lib/email/auth-shell-branding.ts
new file mode 100644
index 00000000..efbbd41c
--- /dev/null
+++ b/src/lib/email/auth-shell-branding.ts
@@ -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 {
+ 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;
+ }
+}
diff --git a/src/lib/email/branding-resolver.ts b/src/lib/email/branding-resolver.ts
index 76831808..6bd7c9b8 100644
--- a/src/lib/email/branding-resolver.ts
+++ b/src/lib/email/branding-resolver.ts
@@ -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';
diff --git a/src/lib/email/shell.ts b/src/lib/email/shell.ts
index c06a4a12..44f162f8 100644
--- a/src/lib/email/shell.ts
+++ b/src/lib/email/shell.ts
@@ -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
+ ? `