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

@@ -45,15 +45,24 @@ export async function sendEmail(
): Promise<nodemailer.SentMessageInfo> {
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 <noreply@${env.SMTP_HOST}>`,
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;
}

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_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

View File

@@ -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<void> {
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 `
<!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>
`;
}
// Activation + reset email templates live in src/lib/email/templates/portal-auth.ts