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). +

+

+ + Primary action button + +

+

+ Adjust the values in the Identity and Email branding cards above, save, then refresh the + preview to see your changes. +

+ `; + 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); + } + }), +); diff --git a/src/app/api/v1/admin/settings/image/route.ts b/src/app/api/v1/admin/settings/image/route.ts index b154edd4..55fafe65 100644 --- a/src/app/api/v1/admin/settings/image/route.ts +++ b/src/app/api/v1/admin/settings/image/route.ts @@ -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) { diff --git a/src/app/api/v1/me/email/route.ts b/src/app/api/v1/me/email/route.ts index 50795f46..e42c6032 100644 --- a/src/app/api/v1/me/email/route.ts +++ b/src/app/api/v1/me/email/route.ts @@ -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 = ` +

Hi,

+

You (or someone using your account) requested to change the sign-in email on your ${appName} account from ${safeOldEmail} to ${safeNewEmail}.

+

Click here to confirm this change — the link expires in ${VERIFY_TOKEN_TTL_MINUTES} minutes.

+

If you didn't request this, ignore this email.

+ `; + const cancelBody = ` +

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.

+ `; + 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}.

Click here to confirm this change — the link expires in ${VERIFY_TOKEN_TTL_MINUTES} minutes.

If you didn't request this, ignore this 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. */} +