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:
59
scripts/dev-trigger-portal-invite.ts
Normal file
59
scripts/dev-trigger-portal-invite.ts
Normal 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);
|
||||||
|
});
|
||||||
@@ -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,9 +49,7 @@ 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="bg-white rounded-lg border p-8 shadow-sm">
|
|
||||||
<div className="text-center mb-6">
|
<div className="text-center mb-6">
|
||||||
<h1 className="text-xl font-semibold text-gray-900">Client Portal</h1>
|
<h1 className="text-xl font-semibold text-gray-900">Client Portal</h1>
|
||||||
<p className="text-sm text-gray-500 mt-1">Sign in to your account</p>
|
<p className="text-sm text-gray-500 mt-1">Sign in to your account</p>
|
||||||
@@ -75,10 +74,7 @@ export default function PortalLoginPage() {
|
|||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Label htmlFor="password">Password</Label>
|
<Label htmlFor="password">Password</Label>
|
||||||
<Link
|
<Link href="/portal/forgot-password" className="text-xs text-[#007bff] hover:underline">
|
||||||
href="/portal/forgot-password"
|
|
||||||
className="text-xs text-[#1e2844] hover:underline"
|
|
||||||
>
|
|
||||||
Forgot password?
|
Forgot password?
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
@@ -97,7 +93,7 @@ export default function PortalLoginPage() {
|
|||||||
|
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="w-full bg-[#1e2844] hover:bg-[#1e2844]/90 text-white"
|
className="w-full bg-[#007bff] hover:bg-[#0069d9] text-white"
|
||||||
disabled={loading || !email || !password}
|
disabled={loading || !email || !password}
|
||||||
>
|
>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
@@ -110,12 +106,10 @@ export default function PortalLoginPage() {
|
|||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="text-center text-xs text-gray-400 mt-4">
|
<p className="text-center text-xs text-gray-400 mt-6">
|
||||||
This portal is for existing clients only.
|
This portal is for existing clients only.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</PortalAuthShell>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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> {
|
||||||
|
|||||||
@@ -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> {
|
||||||
|
|||||||
@@ -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,19 +104,17 @@ 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="bg-white rounded-lg border p-8 shadow-sm">
|
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<h1 className="text-xl font-semibold text-gray-900">{title}</h1>
|
<h1 className="text-xl font-semibold text-gray-900">{title}</h1>
|
||||||
<p className="text-sm text-gray-500 mt-1">{description}</p>
|
<p className="text-sm text-gray-500 mt-1">{description}</p>
|
||||||
@@ -161,7 +160,7 @@ export function PasswordSetForm({
|
|||||||
|
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="w-full bg-[#1e2844] hover:bg-[#1e2844]/90 text-white"
|
className="w-full bg-[#007bff] hover:bg-[#0069d9] text-white"
|
||||||
disabled={!canSubmit}
|
disabled={!canSubmit}
|
||||||
>
|
>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
@@ -174,8 +173,6 @@ export function PasswordSetForm({
|
|||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</PortalAuthShell>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
27
src/components/portal/portal-auth-shell.tsx
Normal file
27
src/components/portal/portal-auth-shell.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
149
src/lib/email/templates/portal-auth.ts
Normal file
149
src/lib/email/templates/portal-auth.ts
Normal 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, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user