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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user