From 4441f1177f438531431bd89363b2eab4d08ee380 Mon Sep 17 00:00:00 2001 From: Matt Ciaccio Date: Mon, 27 Apr 2026 15:04:21 +0200 Subject: [PATCH] feat(portal): branded auth pages + legacy email styling + dev redirect override MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New PortalAuthShell component: blurred Port Nimara overhead background + circular logo + white rounded card, used by /portal/login, /portal/activate, /portal/reset-password - New email/templates/portal-auth.ts: table-based, responsive (max-width 600px / width 100%), matching the existing legacy inquiry templates; replaces the inline templates that lived in portal-auth.service - EMAIL_REDIRECT_TO env override: when set, sendEmail routes every outbound message to that address regardless of recipient and tags the subject with "[redirected from ]". Dev/test safety net only; unset in production - Portal password minimum length 12 → 9 (service + both API routes + client-side form) - Dev helper script scripts/dev-trigger-portal-invite.ts: seeds a portal user against the first port-nimara client and uses EMAIL_REDIRECT_TO as the stored email so the tester can sign in with the address that received the activation mail Co-Authored-By: Claude Opus 4.7 (1M context) --- scripts/dev-trigger-portal-invite.ts | 59 +++++++ src/app/(portal)/portal/login/page.tsx | 126 +++++++-------- src/app/api/portal/auth/activate/route.ts | 2 +- .../api/portal/auth/reset-password/route.ts | 2 +- src/components/portal/password-set-form.tsx | 141 ++++++++--------- src/components/portal/portal-auth-shell.tsx | 27 ++++ src/lib/email/index.ts | 15 +- src/lib/email/templates/portal-auth.ts | 149 ++++++++++++++++++ src/lib/env.ts | 3 + src/lib/services/portal-auth.service.ts | 73 ++------- 10 files changed, 396 insertions(+), 201 deletions(-) create mode 100644 scripts/dev-trigger-portal-invite.ts create mode 100644 src/components/portal/portal-auth-shell.tsx create mode 100644 src/lib/email/templates/portal-auth.ts diff --git a/scripts/dev-trigger-portal-invite.ts b/scripts/dev-trigger-portal-invite.ts new file mode 100644 index 0000000..b3428a4 --- /dev/null +++ b/scripts/dev-trigger-portal-invite.ts @@ -0,0 +1,59 @@ +/** + * Dev-only helper: pick an existing client and trigger a portal-invite email. + * The activation email gets routed to EMAIL_REDIRECT_TO (set in .env) regardless + * of the per-portal-user `email` field — so we can use any throwaway address + * here without conflicting with seed data. + * + * Run: pnpm tsx scripts/dev-trigger-portal-invite.ts + */ + +import 'dotenv/config'; + +import { db } from '@/lib/db'; +import { clients } from '@/lib/db/schema/clients'; +import { portalUsers } from '@/lib/db/schema/portal'; +import { createPortalUser } from '@/lib/services/portal-auth.service'; +import { env } from '@/lib/env'; +import { eq } from 'drizzle-orm'; + +async function main(): Promise { + if (!env.EMAIL_REDIRECT_TO) { + throw new Error( + 'EMAIL_REDIRECT_TO is not set — refusing to send a real activation email to a real client.', + ); + } + console.log(`EMAIL_REDIRECT_TO is set: ${env.EMAIL_REDIRECT_TO}`); + + const client = await db.query.clients.findFirst({ + where: eq(clients.portId, '294c8240-49a7-403e-92e8-fc3a524c00b4'), + }); + if (!client) throw new Error('No client found in port-nimara'); + + // Use the redirect target as the portal user's actual email, so the + // tester can sign in with the same address that received the activation mail. + const portalEmail = env.EMAIL_REDIRECT_TO; + console.log( + `Creating portal user for client ${client.fullName} (${client.id}) with email ${portalEmail}…`, + ); + + // Clear any prior dev-script seed so uniqueness checks don't trip. + await db.delete(portalUsers).where(eq(portalUsers.clientId, client.id)); + await db.delete(portalUsers).where(eq(portalUsers.email, portalEmail)); + + const result = await createPortalUser({ + clientId: client.id, + portId: client.portId, + email: portalEmail, + name: client.fullName, + createdBy: 'dev-script', + }); + + console.log('Portal user created:', result); + console.log(`Activation email enqueued — should arrive at ${portalEmail}.`); + process.exit(0); +} + +main().catch((err) => { + console.error('Script failed:', err); + process.exit(1); +}); diff --git a/src/app/(portal)/portal/login/page.tsx b/src/app/(portal)/portal/login/page.tsx index 95c0f25..985f45c 100644 --- a/src/app/(portal)/portal/login/page.tsx +++ b/src/app/(portal)/portal/login/page.tsx @@ -8,6 +8,7 @@ import { Loader2 } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; +import { PortalAuthShell } from '@/components/portal/portal-auth-shell'; export default function PortalLoginPage() { const router = useRouter(); @@ -48,74 +49,67 @@ export default function PortalLoginPage() { } return ( -
-
-
-
-

Client Portal

-

Sign in to your account

-
+ +
+

Client Portal

+

Sign in to your account

+
-
-
- - setEmail(e.target.value)} - required - autoFocus - autoComplete="email" - disabled={loading} - /> -
- -
-
- - - Forgot password? - -
- setPassword(e.target.value)} - required - autoComplete="current-password" - disabled={loading} - /> -
- - {error &&

{error}

} - - -
+
+
+ + setEmail(e.target.value)} + required + autoFocus + autoComplete="email" + disabled={loading} + />
-

- This portal is for existing clients only. -

-
-
+
+
+ + + Forgot password? + +
+ setPassword(e.target.value)} + required + autoComplete="current-password" + disabled={loading} + /> +
+ + {error &&

{error}

} + + + + +

+ This portal is for existing clients only. +

+ ); } diff --git a/src/app/api/portal/auth/activate/route.ts b/src/app/api/portal/auth/activate/route.ts index f674058..e3ea429 100644 --- a/src/app/api/portal/auth/activate/route.ts +++ b/src/app/api/portal/auth/activate/route.ts @@ -6,7 +6,7 @@ import { activateAccount } from '@/lib/services/portal-auth.service'; const bodySchema = z.object({ token: z.string().min(1), - password: z.string().min(12), + password: z.string().min(9), }); export async function POST(req: NextRequest): Promise { diff --git a/src/app/api/portal/auth/reset-password/route.ts b/src/app/api/portal/auth/reset-password/route.ts index 71deef7..2c5542d 100644 --- a/src/app/api/portal/auth/reset-password/route.ts +++ b/src/app/api/portal/auth/reset-password/route.ts @@ -6,7 +6,7 @@ import { resetPassword } from '@/lib/services/portal-auth.service'; const bodySchema = z.object({ token: z.string().min(1), - password: z.string().min(12), + password: z.string().min(9), }); export async function POST(req: NextRequest): Promise { diff --git a/src/components/portal/password-set-form.tsx b/src/components/portal/password-set-form.tsx index 510afa7..1d9e727 100644 --- a/src/components/portal/password-set-form.tsx +++ b/src/components/portal/password-set-form.tsx @@ -8,6 +8,7 @@ import { CheckCircle2, Loader2 } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; +import { PortalAuthShell } from '@/components/portal/portal-auth-shell'; interface PasswordSetFormProps { /** API endpoint that accepts `{ token, password }` and sets / resets the password. */ @@ -19,7 +20,7 @@ interface PasswordSetFormProps { submitLabel: string; } -const MIN_LENGTH = 12; +const MIN_LENGTH = 9; /** * Shared form used by both the activation and password-reset flows. The @@ -74,8 +75,8 @@ export function PasswordSetForm({ if (!token) { return ( -
-
+ +

Link is missing or invalid

Please use the link from the email we sent you. If the link is broken, request a new @@ -83,19 +84,19 @@ export function PasswordSetForm({

Request a new link
-
+ ); } if (done) { return ( -
-
+ +
@@ -103,79 +104,75 @@ export function PasswordSetForm({

{successDescription}

Sign in
-
+ ); } return ( -
-
-
-
-

{title}

-

{description}

-
- -
-
- - setPassword(e.target.value)} - required - autoFocus - autoComplete="new-password" - minLength={MIN_LENGTH} - disabled={loading} - /> -

At least {MIN_LENGTH} characters.

- {tooShort && ( -

- Password must be at least {MIN_LENGTH} characters. -

- )} -
- -
- - setConfirm(e.target.value)} - required - autoComplete="new-password" - disabled={loading} - /> - {mismatch &&

Passwords don't match.

} -
- - {error &&

{error}

} - - -
-
+ +
+

{title}

+

{description}

-
+ +
+
+ + setPassword(e.target.value)} + required + autoFocus + autoComplete="new-password" + minLength={MIN_LENGTH} + disabled={loading} + /> +

At least {MIN_LENGTH} characters.

+ {tooShort && ( +

+ Password must be at least {MIN_LENGTH} characters. +

+ )} +
+ +
+ + setConfirm(e.target.value)} + required + autoComplete="new-password" + disabled={loading} + /> + {mismatch &&

Passwords don't match.

} +
+ + {error &&

{error}

} + + +
+ ); } diff --git a/src/components/portal/portal-auth-shell.tsx b/src/components/portal/portal-auth-shell.tsx new file mode 100644 index 0000000..2783300 --- /dev/null +++ b/src/components/portal/portal-auth-shell.tsx @@ -0,0 +1,27 @@ +const BG_URL = 'https://s3.portnimara.com/images/Overhead_1_blur.png'; +const LOGO_URL = + 'https://s3.portnimara.com/images/Port%20Nimara%20New%20Logo-Circular%20Frame_250px.png'; + +export function PortalAuthShell({ children }: { children: React.ReactNode }) { + return ( +
+
+
+
+ {/* eslint-disable-next-line @next/next/no-img-element */} + Port Nimara +
+ {children} +
+
+
+ ); +} diff --git a/src/lib/email/index.ts b/src/lib/email/index.ts index 076a812..74b1370 100644 --- a/src/lib/email/index.ts +++ b/src/lib/email/index.ts @@ -45,15 +45,24 @@ export async function sendEmail( ): Promise { const transporter = createTransporter(); + const requestedTo = Array.isArray(to) ? to.join(', ') : to; + const effectiveTo = env.EMAIL_REDIRECT_TO ?? requestedTo; + const effectiveSubject = env.EMAIL_REDIRECT_TO + ? `[redirected from ${requestedTo}] ${subject}` + : subject; + const info = await transporter.sendMail({ from: from ?? env.SMTP_FROM ?? `Port Nimara CRM `, - to: Array.isArray(to) ? to.join(', ') : to, - subject, + to: effectiveTo, + subject: effectiveSubject, html, ...(text ? { text } : {}), }); - logger.debug({ messageId: info.messageId, to, subject }, 'Email sent'); + logger.debug( + { messageId: info.messageId, to: effectiveTo, originalTo: requestedTo, subject }, + env.EMAIL_REDIRECT_TO ? 'Email sent (redirected)' : 'Email sent', + ); return info; } diff --git a/src/lib/email/templates/portal-auth.ts b/src/lib/email/templates/portal-auth.ts new file mode 100644 index 0000000..6565707 --- /dev/null +++ b/src/lib/email/templates/portal-auth.ts @@ -0,0 +1,149 @@ +interface ActivationData { + portName: string; + link: string; + ttlHours: number; + recipientName?: string; +} + +interface ResetData { + portName: string; + link: string; + ttlMinutes: number; + recipientName?: string; +} + +const LOGO_URL = + 'https://s3.portnimara.com/images/Port%20Nimara%20New%20Logo-Circular%20Frame_250px.png'; +const BACKGROUND_URL = 'https://s3.portnimara.com/images/Overhead_1_blur.png'; + +function shell(opts: { title: string; body: string }): string { + return ` + + + + + ${opts.title} + + + + + + + +
+ + + + +
+
+ Port Nimara Logo +
+ ${opts.body} +
+
+ +`; +} + +export function activationEmail(data: ActivationData): { + subject: string; + html: string; + text: string; +} { + const subject = `Activate your ${data.portName} client portal account`; + const greeting = data.recipientName ? `Dear ${escapeHtml(data.recipientName)},` : 'Welcome,'; + + const body = ` +

+ Welcome to ${escapeHtml(data.portName)} +

+

${greeting}

+

+ You've been invited to access the ${escapeHtml(data.portName)} client portal. + Click the button below to set your password and activate your account. + The link expires in ${data.ttlHours} hours. +

+

+ + Activate account + +

+

+ If the button doesn't work, paste this link into your browser:
+ ${data.link} +

+

+ Thank you,
+ ${escapeHtml(data.portName)} CRM +

`; + + const text = [ + `Welcome to ${data.portName}`, + '', + `You've been invited to access the ${data.portName} client portal.`, + `Activate your account by visiting: ${data.link}`, + '', + `The link expires in ${data.ttlHours} hours.`, + '', + `Thank you,`, + `${data.portName} CRM`, + ].join('\n'); + + return { subject, html: shell({ title: subject, body }), text }; +} + +export function resetEmail(data: ResetData): { subject: string; html: string; text: string } { + const subject = `Reset your ${data.portName} client portal password`; + const greeting = data.recipientName ? `Dear ${escapeHtml(data.recipientName)},` : 'Hello,'; + + const body = ` +

+ Password reset +

+

${greeting}

+

+ We received a request to reset the password on your ${escapeHtml(data.portName)} + client portal account. Click the button below to choose a new one. + The link expires in ${data.ttlMinutes} minutes. +

+

+ + Reset password + +

+

+ If you didn't request this, you can safely ignore this email — your password will remain unchanged. +

+

+ Thank you,
+ ${escapeHtml(data.portName)} CRM +

`; + + const text = [ + `Password reset for ${data.portName}`, + '', + `Reset your password by visiting: ${data.link}`, + `The link expires in ${data.ttlMinutes} minutes.`, + '', + `If you didn't request this, you can safely ignore this email.`, + '', + `Thank you,`, + `${data.portName} CRM`, + ].join('\n'); + + return { subject, html: shell({ title: subject, body }), text }; +} + +function escapeHtml(str: string): string { + return str + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} diff --git a/src/lib/env.ts b/src/lib/env.ts index ebde487..b51639b 100644 --- a/src/lib/env.ts +++ b/src/lib/env.ts @@ -35,6 +35,9 @@ const envSchema = z.object({ SMTP_USER: z.string().optional(), SMTP_PASS: z.string().optional(), SMTP_FROM: z.string().optional(), + // Dev/test safety net: when set, sendEmail redirects every outbound message + // to this address regardless of the requested recipient. Leave empty in prod. + EMAIL_REDIRECT_TO: z.string().email().optional(), // Encryption EMAIL_CREDENTIAL_KEY: z diff --git a/src/lib/services/portal-auth.service.ts b/src/lib/services/portal-auth.service.ts index a37d00c..98c8bc9 100644 --- a/src/lib/services/portal-auth.service.ts +++ b/src/lib/services/portal-auth.service.ts @@ -6,6 +6,7 @@ import { ports } from '@/lib/db/schema/ports'; import { portalAuthTokens, portalUsers } from '@/lib/db/schema/portal'; import { env } from '@/lib/env'; import { sendEmail } from '@/lib/email'; +import { activationEmail, resetEmail } from '@/lib/email/templates/portal-auth'; import { ConflictError, NotFoundError, UnauthorizedError, ValidationError } from '@/lib/errors'; import { logger } from '@/lib/logger'; import { createPortalToken } from '@/lib/portal/auth'; @@ -13,7 +14,7 @@ import { hashPassword, hashToken, mintToken, verifyPassword } from '@/lib/portal const ACTIVATION_TOKEN_TTL_HOURS = 72; const RESET_TOKEN_TTL_MINUTES = 30; -const MIN_PASSWORD_LENGTH = 12; +const MIN_PASSWORD_LENGTH = 9; // ─── Admin-side: invite a client to the portal ─────────────────────────────── @@ -79,11 +80,14 @@ async function issueActivationToken( const portName = port?.name ?? 'Port Nimara'; const link = `${env.APP_URL}/portal/activate?token=${encodeURIComponent(raw)}`; - const subject = `Activate your ${portName} client portal account`; - const html = activationEmailHtml({ portName, link, ttlHours: ACTIVATION_TOKEN_TTL_HOURS }); + const { subject, html, text } = activationEmail({ + portName, + link, + ttlHours: ACTIVATION_TOKEN_TTL_HOURS, + }); try { - await sendEmail(email, subject, html); + await sendEmail(email, subject, html, undefined, text); } catch (err) { logger.error({ err, email }, 'Failed to send portal activation email'); // Re-throw — the admin should know if their invite mail bounced. @@ -183,13 +187,14 @@ export async function requestPasswordReset(email: string): Promise { const port = await db.query.ports.findFirst({ where: eq(ports.id, user.portId) }); const portName = port?.name ?? 'Port Nimara'; const link = `${env.APP_URL}/portal/reset-password?token=${encodeURIComponent(raw)}`; + const { subject, html, text } = resetEmail({ + portName, + link, + ttlMinutes: RESET_TOKEN_TTL_MINUTES, + }); try { - await sendEmail( - user.email, - `Reset your ${portName} client portal password`, - resetEmailHtml({ portName, link, ttlMinutes: RESET_TOKEN_TTL_MINUTES }), - ); + await sendEmail(user.email, subject, html, undefined, text); } catch (err) { logger.error({ err, email: user.email }, 'Failed to send password-reset email'); // Don't propagate — the public route returns 200 either way. @@ -235,52 +240,4 @@ async function consumeToken( return { portalUserId: row.portalUserId }; } -// ─── Email templates ───────────────────────────────────────────────────────── - -function activationEmailHtml(args: { portName: string; link: string; ttlHours: number }): string { - return ` - - -
-
-

${args.portName}

-

Client Portal

-
-
-

Welcome,

-

- You've been invited to access the ${args.portName} client portal. Click the button below to set your password and activate your account. The link expires in ${args.ttlHours} hours. -

- -

If the button doesn't work, paste this link into your browser:
${args.link}

-
-
- - `; -} - -function resetEmailHtml(args: { portName: string; link: string; ttlMinutes: number }): string { - return ` - - -
-
-

${args.portName}

-

Password reset

-
-
-

Hello,

-

- We received a request to reset your client portal password. Click the button below to choose a new one. The link expires in ${args.ttlMinutes} minutes. If you didn't request this, you can ignore this email. -

- -

If the button doesn't work, paste this link into your browser:
${args.link}

-
-
- - `; -} +// Activation + reset email templates live in src/lib/email/templates/portal-auth.ts