- 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>
150 lines
6.1 KiB
TypeScript
150 lines
6.1 KiB
TypeScript
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, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''');
|
|
}
|