feat(portal): branded auth pages + legacy email styling + dev redirect override

- 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 <original>]". 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) <noreply@anthropic.com>
This commit is contained in:
Matt Ciaccio
2026-04-27 15:04:21 +02:00
parent c4085265ff
commit 4441f1177f
10 changed files with 396 additions and 201 deletions

View File

@@ -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<void> {
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);
});

View File

@@ -8,6 +8,7 @@ import { Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { PortalAuthShell } from '@/components/portal/portal-auth-shell';
export default function PortalLoginPage() { export default function PortalLoginPage() {
const router = useRouter(); const router = useRouter();
@@ -48,74 +49,67 @@ export default function PortalLoginPage() {
} }
return ( return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 px-4"> <PortalAuthShell>
<div className="w-full max-w-sm"> <div className="text-center mb-6">
<div className="bg-white rounded-lg border p-8 shadow-sm"> <h1 className="text-xl font-semibold text-gray-900">Client Portal</h1>
<div className="text-center mb-6"> <p className="text-sm text-gray-500 mt-1">Sign in to your account</p>
<h1 className="text-xl font-semibold text-gray-900">Client Portal</h1> </div>
<p className="text-sm text-gray-500 mt-1">Sign in to your account</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label htmlFor="email">Email address</Label> <Label htmlFor="email">Email address</Label>
<Input <Input
id="email" id="email"
type="email" type="email"
placeholder="you@example.com" placeholder="you@example.com"
value={email} value={email}
onChange={(e) => setEmail(e.target.value)} onChange={(e) => setEmail(e.target.value)}
required required
autoFocus autoFocus
autoComplete="email" autoComplete="email"
disabled={loading} disabled={loading}
/> />
</div>
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<Label htmlFor="password">Password</Label>
<Link
href="/portal/forgot-password"
className="text-xs text-[#1e2844] hover:underline"
>
Forgot password?
</Link>
</div>
<Input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
autoComplete="current-password"
disabled={loading}
/>
</div>
{error && <p className="text-sm text-red-600">{error}</p>}
<Button
type="submit"
className="w-full bg-[#1e2844] hover:bg-[#1e2844]/90 text-white"
disabled={loading || !email || !password}
>
{loading ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Signing in
</>
) : (
'Sign in'
)}
</Button>
</form>
</div> </div>
<p className="text-center text-xs text-gray-400 mt-4"> <div className="space-y-1.5">
This portal is for existing clients only. <div className="flex items-center justify-between">
</p> <Label htmlFor="password">Password</Label>
</div> <Link href="/portal/forgot-password" className="text-xs text-[#007bff] hover:underline">
</div> Forgot password?
</Link>
</div>
<Input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
autoComplete="current-password"
disabled={loading}
/>
</div>
{error && <p className="text-sm text-red-600">{error}</p>}
<Button
type="submit"
className="w-full bg-[#007bff] hover:bg-[#0069d9] text-white"
disabled={loading || !email || !password}
>
{loading ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Signing in
</>
) : (
'Sign in'
)}
</Button>
</form>
<p className="text-center text-xs text-gray-400 mt-6">
This portal is for existing clients only.
</p>
</PortalAuthShell>
); );
} }

View File

@@ -6,7 +6,7 @@ import { activateAccount } from '@/lib/services/portal-auth.service';
const bodySchema = z.object({ const bodySchema = z.object({
token: z.string().min(1), token: z.string().min(1),
password: z.string().min(12), password: z.string().min(9),
}); });
export async function POST(req: NextRequest): Promise<NextResponse> { export async function POST(req: NextRequest): Promise<NextResponse> {

View File

@@ -6,7 +6,7 @@ import { resetPassword } from '@/lib/services/portal-auth.service';
const bodySchema = z.object({ const bodySchema = z.object({
token: z.string().min(1), token: z.string().min(1),
password: z.string().min(12), password: z.string().min(9),
}); });
export async function POST(req: NextRequest): Promise<NextResponse> { export async function POST(req: NextRequest): Promise<NextResponse> {

View File

@@ -8,6 +8,7 @@ import { CheckCircle2, Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { PortalAuthShell } from '@/components/portal/portal-auth-shell';
interface PasswordSetFormProps { interface PasswordSetFormProps {
/** API endpoint that accepts `{ token, password }` and sets / resets the password. */ /** API endpoint that accepts `{ token, password }` and sets / resets the password. */
@@ -19,7 +20,7 @@ interface PasswordSetFormProps {
submitLabel: string; submitLabel: string;
} }
const MIN_LENGTH = 12; const MIN_LENGTH = 9;
/** /**
* Shared form used by both the activation and password-reset flows. The * Shared form used by both the activation and password-reset flows. The
@@ -74,8 +75,8 @@ export function PasswordSetForm({
if (!token) { if (!token) {
return ( return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 px-4"> <PortalAuthShell>
<div className="w-full max-w-md text-center space-y-3"> <div className="text-center space-y-3">
<h1 className="text-xl font-semibold text-gray-900">Link is missing or invalid</h1> <h1 className="text-xl font-semibold text-gray-900">Link is missing or invalid</h1>
<p className="text-sm text-gray-500"> <p className="text-sm text-gray-500">
Please use the link from the email we sent you. If the link is broken, request a new 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({
</p> </p>
<Link <Link
href="/portal/forgot-password" href="/portal/forgot-password"
className="inline-block text-sm text-[#1e2844] hover:underline" className="inline-block text-sm text-[#007bff] hover:underline"
> >
Request a new link Request a new link
</Link> </Link>
</div> </div>
</div> </PortalAuthShell>
); );
} }
if (done) { if (done) {
return ( return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 px-4"> <PortalAuthShell>
<div className="w-full max-w-md text-center"> <div className="text-center">
<div className="inline-flex items-center justify-center w-14 h-14 rounded-full bg-green-50 mb-4"> <div className="inline-flex items-center justify-center w-14 h-14 rounded-full bg-green-50 mb-4">
<CheckCircle2 className="h-7 w-7 text-green-600" /> <CheckCircle2 className="h-7 w-7 text-green-600" />
</div> </div>
@@ -103,79 +104,75 @@ export function PasswordSetForm({
<p className="text-gray-500 text-sm">{successDescription}</p> <p className="text-gray-500 text-sm">{successDescription}</p>
<Link <Link
href="/portal/login" href="/portal/login"
className="mt-6 inline-block text-sm text-[#1e2844] hover:underline" className="mt-6 inline-block text-sm text-[#007bff] hover:underline"
> >
Sign in Sign in
</Link> </Link>
</div> </div>
</div> </PortalAuthShell>
); );
} }
return ( return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 px-4"> <PortalAuthShell>
<div className="w-full max-w-sm"> <div className="mb-6">
<div className="bg-white rounded-lg border p-8 shadow-sm"> <h1 className="text-xl font-semibold text-gray-900">{title}</h1>
<div className="mb-6"> <p className="text-sm text-gray-500 mt-1">{description}</p>
<h1 className="text-xl font-semibold text-gray-900">{title}</h1>
<p className="text-sm text-gray-500 mt-1">{description}</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="password">New password</Label>
<Input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
autoFocus
autoComplete="new-password"
minLength={MIN_LENGTH}
disabled={loading}
/>
<p className="text-xs text-gray-500">At least {MIN_LENGTH} characters.</p>
{tooShort && (
<p className="text-xs text-red-600">
Password must be at least {MIN_LENGTH} characters.
</p>
)}
</div>
<div className="space-y-1.5">
<Label htmlFor="confirm">Confirm password</Label>
<Input
id="confirm"
type="password"
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
required
autoComplete="new-password"
disabled={loading}
/>
{mismatch && <p className="text-xs text-red-600">Passwords don&apos;t match.</p>}
</div>
{error && <p className="text-sm text-red-600">{error}</p>}
<Button
type="submit"
className="w-full bg-[#1e2844] hover:bg-[#1e2844]/90 text-white"
disabled={!canSubmit}
>
{loading ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Saving
</>
) : (
submitLabel
)}
</Button>
</form>
</div>
</div> </div>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="password">New password</Label>
<Input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
autoFocus
autoComplete="new-password"
minLength={MIN_LENGTH}
disabled={loading}
/>
<p className="text-xs text-gray-500">At least {MIN_LENGTH} characters.</p>
{tooShort && (
<p className="text-xs text-red-600">
Password must be at least {MIN_LENGTH} characters.
</p>
)}
</div>
<div className="space-y-1.5">
<Label htmlFor="confirm">Confirm password</Label>
<Input
id="confirm"
type="password"
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
required
autoComplete="new-password"
disabled={loading}
/>
{mismatch && <p className="text-xs text-red-600">Passwords don&apos;t match.</p>}
</div>
{error && <p className="text-sm text-red-600">{error}</p>}
<Button
type="submit"
className="w-full bg-[#007bff] hover:bg-[#0069d9] text-white"
disabled={!canSubmit}
>
{loading ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Saving
</>
) : (
submitLabel
)}
</Button>
</form>
</PortalAuthShell>
); );
} }

View File

@@ -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 (
<div
className="min-h-screen flex items-center justify-center px-4 py-8"
style={{
backgroundImage: `url('${BG_URL}')`,
backgroundSize: 'cover',
backgroundPosition: 'center',
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={LOGO_URL} alt="Port Nimara" className="w-24 h-auto" />
</div>
{children}
</div>
</div>
</div>
);
}

View File

@@ -45,15 +45,24 @@ export async function sendEmail(
): Promise<nodemailer.SentMessageInfo> { ): Promise<nodemailer.SentMessageInfo> {
const transporter = createTransporter(); 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({ const info = await transporter.sendMail({
from: from ?? env.SMTP_FROM ?? `Port Nimara CRM <noreply@${env.SMTP_HOST}>`, from: from ?? env.SMTP_FROM ?? `Port Nimara CRM <noreply@${env.SMTP_HOST}>`,
to: Array.isArray(to) ? to.join(', ') : to, to: effectiveTo,
subject, subject: effectiveSubject,
html, html,
...(text ? { text } : {}), ...(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; return info;
} }

View File

@@ -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 `<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<title>${opts.title}</title>
<style type="text/css">
table, td { mso-table-lspace: 0pt; mso-table-rspace: 0pt; }
img { border: 0; display: block; }
p { margin: 0; padding: 0; }
</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('${BACKGROUND_URL}'); background-size: cover; background-position: center; background-color:#f2f2f2;">
<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="${LOGO_URL}" alt="Port Nimara Logo" width="100" style="margin-bottom:20px;" />
</center>
${opts.body}
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>`;
}
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 = `
<p style="margin-bottom:10px; font-size:18px; font-weight:bold; color:#007bff;">
Welcome to ${escapeHtml(data.portName)}
</p>
<p style="margin-bottom:10px; font-size:16px; line-height:1.5;">${greeting}</p>
<p style="margin-bottom:20px; font-size:16px; line-height:1.5;">
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.
</p>
<p style="text-align:center; margin:30px 0;">
<a href="${data.link}" style="display:inline-block; background-color:#007bff; color:#ffffff; text-decoration:none; padding:14px 35px; border-radius:5px; font-weight:bold; font-size:16px;">
Activate account
</a>
</p>
<p style="font-size:14px; color:#666; line-height:1.5; padding:15px 0; border-top:1px solid #eee; margin-top:20px;">
If the button doesn't work, paste this link into your browser:<br />
<a href="${data.link}" style="color:#007bff; text-decoration:underline; word-break:break-all;">${data.link}</a>
</p>
<p style="font-size:16px; margin-top:30px;">
Thank you,<br />
<strong>${escapeHtml(data.portName)} CRM</strong>
</p>`;
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 = `
<p style="margin-bottom:10px; font-size:18px; font-weight:bold; color:#007bff;">
Password reset
</p>
<p style="margin-bottom:10px; font-size:16px; line-height:1.5;">${greeting}</p>
<p style="margin-bottom:20px; font-size:16px; line-height:1.5;">
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.
</p>
<p style="text-align:center; margin:30px 0;">
<a href="${data.link}" style="display:inline-block; background-color:#007bff; color:#ffffff; text-decoration:none; padding:14px 35px; border-radius:5px; font-weight:bold; font-size:16px;">
Reset password
</a>
</p>
<p style="font-size:14px; color:#666; line-height:1.5; padding:15px 0; border-top:1px solid #eee; margin-top:20px;">
If you didn't request this, you can safely ignore this email — your password will remain unchanged.
</p>
<p style="font-size:16px; margin-top:30px;">
Thank you,<br />
<strong>${escapeHtml(data.portName)} CRM</strong>
</p>`;
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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}

View File

@@ -35,6 +35,9 @@ const envSchema = z.object({
SMTP_USER: z.string().optional(), SMTP_USER: z.string().optional(),
SMTP_PASS: z.string().optional(), SMTP_PASS: z.string().optional(),
SMTP_FROM: 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 // Encryption
EMAIL_CREDENTIAL_KEY: z EMAIL_CREDENTIAL_KEY: z

View File

@@ -6,6 +6,7 @@ import { ports } from '@/lib/db/schema/ports';
import { portalAuthTokens, portalUsers } from '@/lib/db/schema/portal'; import { portalAuthTokens, portalUsers } from '@/lib/db/schema/portal';
import { env } from '@/lib/env'; import { env } from '@/lib/env';
import { sendEmail } from '@/lib/email'; import { sendEmail } from '@/lib/email';
import { activationEmail, resetEmail } from '@/lib/email/templates/portal-auth';
import { ConflictError, NotFoundError, UnauthorizedError, ValidationError } from '@/lib/errors'; import { ConflictError, NotFoundError, UnauthorizedError, ValidationError } from '@/lib/errors';
import { logger } from '@/lib/logger'; import { logger } from '@/lib/logger';
import { createPortalToken } from '@/lib/portal/auth'; 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 ACTIVATION_TOKEN_TTL_HOURS = 72;
const RESET_TOKEN_TTL_MINUTES = 30; const RESET_TOKEN_TTL_MINUTES = 30;
const MIN_PASSWORD_LENGTH = 12; const MIN_PASSWORD_LENGTH = 9;
// ─── Admin-side: invite a client to the portal ─────────────────────────────── // ─── Admin-side: invite a client to the portal ───────────────────────────────
@@ -79,11 +80,14 @@ async function issueActivationToken(
const portName = port?.name ?? 'Port Nimara'; const portName = port?.name ?? 'Port Nimara';
const link = `${env.APP_URL}/portal/activate?token=${encodeURIComponent(raw)}`; const link = `${env.APP_URL}/portal/activate?token=${encodeURIComponent(raw)}`;
const subject = `Activate your ${portName} client portal account`; const { subject, html, text } = activationEmail({
const html = activationEmailHtml({ portName, link, ttlHours: ACTIVATION_TOKEN_TTL_HOURS }); portName,
link,
ttlHours: ACTIVATION_TOKEN_TTL_HOURS,
});
try { try {
await sendEmail(email, subject, html); await sendEmail(email, subject, html, undefined, text);
} catch (err) { } catch (err) {
logger.error({ err, email }, 'Failed to send portal activation email'); logger.error({ err, email }, 'Failed to send portal activation email');
// Re-throw — the admin should know if their invite mail bounced. // Re-throw — the admin should know if their invite mail bounced.
@@ -183,13 +187,14 @@ export async function requestPasswordReset(email: string): Promise<void> {
const port = await db.query.ports.findFirst({ where: eq(ports.id, user.portId) }); const port = await db.query.ports.findFirst({ where: eq(ports.id, user.portId) });
const portName = port?.name ?? 'Port Nimara'; const portName = port?.name ?? 'Port Nimara';
const link = `${env.APP_URL}/portal/reset-password?token=${encodeURIComponent(raw)}`; const link = `${env.APP_URL}/portal/reset-password?token=${encodeURIComponent(raw)}`;
const { subject, html, text } = resetEmail({
portName,
link,
ttlMinutes: RESET_TOKEN_TTL_MINUTES,
});
try { try {
await sendEmail( await sendEmail(user.email, subject, html, undefined, text);
user.email,
`Reset your ${portName} client portal password`,
resetEmailHtml({ portName, link, ttlMinutes: RESET_TOKEN_TTL_MINUTES }),
);
} catch (err) { } catch (err) {
logger.error({ err, email: user.email }, 'Failed to send password-reset email'); logger.error({ err, email: user.email }, 'Failed to send password-reset email');
// Don't propagate — the public route returns 200 either way. // Don't propagate — the public route returns 200 either way.
@@ -235,52 +240,4 @@ async function consumeToken(
return { portalUserId: row.portalUserId }; return { portalUserId: row.portalUserId };
} }
// ─── Email templates ───────────────────────────────────────────────────────── // Activation + reset email templates live in src/lib/email/templates/portal-auth.ts
function activationEmailHtml(args: { portName: string; link: string; ttlHours: number }): string {
return `
<!DOCTYPE html>
<html><body style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;background:#f5f5f5;padding:40px 0;margin:0;">
<div style="max-width:480px;margin:0 auto;background:#fff;border-radius:8px;overflow:hidden;box-shadow:0 2px 8px rgba(0,0,0,0.08);">
<div style="background:#1e2844;padding:32px 40px;text-align:center;">
<h1 style="color:#fff;margin:0;font-size:22px;font-weight:600;">${args.portName}</h1>
<p style="color:#9ca3af;margin:6px 0 0;font-size:14px;">Client Portal</p>
</div>
<div style="padding:40px;">
<p style="color:#374151;font-size:16px;margin:0 0 16px;">Welcome,</p>
<p style="color:#6b7280;font-size:15px;line-height:1.6;margin:0 0 32px;">
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.
</p>
<div style="text-align:center;margin:0 0 32px;">
<a href="${args.link}" style="display:inline-block;background:#1e2844;color:#fff;text-decoration:none;padding:14px 32px;border-radius:6px;font-size:15px;font-weight:500;">Activate account</a>
</div>
<p style="color:#9ca3af;font-size:13px;margin:0;line-height:1.5;">If the button doesn't work, paste this link into your browser:<br/><a href="${args.link}" style="color:#1e2844;word-break:break-all;">${args.link}</a></p>
</div>
</div>
</body></html>
`;
}
function resetEmailHtml(args: { portName: string; link: string; ttlMinutes: number }): string {
return `
<!DOCTYPE html>
<html><body style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;background:#f5f5f5;padding:40px 0;margin:0;">
<div style="max-width:480px;margin:0 auto;background:#fff;border-radius:8px;overflow:hidden;box-shadow:0 2px 8px rgba(0,0,0,0.08);">
<div style="background:#1e2844;padding:32px 40px;text-align:center;">
<h1 style="color:#fff;margin:0;font-size:22px;font-weight:600;">${args.portName}</h1>
<p style="color:#9ca3af;margin:6px 0 0;font-size:14px;">Password reset</p>
</div>
<div style="padding:40px;">
<p style="color:#374151;font-size:16px;margin:0 0 16px;">Hello,</p>
<p style="color:#6b7280;font-size:15px;line-height:1.6;margin:0 0 32px;">
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.
</p>
<div style="text-align:center;margin:0 0 32px;">
<a href="${args.link}" style="display:inline-block;background:#1e2844;color:#fff;text-decoration:none;padding:14px 32px;border-radius:6px;font-size:15px;font-weight:500;">Reset password</a>
</div>
<p style="color:#9ca3af;font-size:13px;margin:0;line-height:1.5;">If the button doesn't work, paste this link into your browser:<br/><a href="${args.link}" style="color:#1e2844;word-break:break-all;">${args.link}</a></p>
</div>
</div>
</body></html>
`;
}