Compare commits
5 Commits
475b051e29
...
ea8181d108
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ea8181d108 | ||
|
|
65b241805e | ||
|
|
4a859245b7 | ||
|
|
4441f1177f | ||
|
|
c4085265ff |
@@ -51,6 +51,30 @@ export default defineConfig({
|
||||
viewport: { width: 1440, height: 900 },
|
||||
},
|
||||
},
|
||||
{
|
||||
// Real-API tests hit live external services (Documenso, IMAP, etc.).
|
||||
// Opt-in only: pnpm exec playwright test --project=realapi
|
||||
name: 'realapi',
|
||||
testMatch: /realapi\/.*\.spec\.ts/,
|
||||
dependencies: ['setup'],
|
||||
timeout: 120_000,
|
||||
use: {
|
||||
...devices['Desktop Chrome'],
|
||||
viewport: { width: 1440, height: 900 },
|
||||
},
|
||||
},
|
||||
{
|
||||
// Visual regression baselines. Regenerate with --update-snapshots after
|
||||
// intentional UI changes; otherwise pnpm exec playwright test --project=visual
|
||||
// diffs against the committed PNGs.
|
||||
name: 'visual',
|
||||
testMatch: /visual\/.*\.spec\.ts/,
|
||||
dependencies: ['setup'],
|
||||
use: {
|
||||
...devices['Desktop Chrome'],
|
||||
viewport: { width: 1440, height: 900 },
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
// Don't start the dev server — we expect it to already be running
|
||||
|
||||
66
scripts/dev-imap-probe.ts
Normal file
66
scripts/dev-imap-probe.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* Dev diagnostic: connect to IMAP and print the most recent ~10 messages,
|
||||
* showing TO/FROM/subject/date so we can see what the dev mailbox is
|
||||
* actually receiving.
|
||||
*
|
||||
* Run: pnpm tsx scripts/dev-imap-probe.ts
|
||||
*/
|
||||
|
||||
import 'dotenv/config';
|
||||
import { ImapFlow } from 'imapflow';
|
||||
import { simpleParser } from 'mailparser';
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const host = process.env.IMAP_HOST!;
|
||||
const port = Number(process.env.IMAP_PORT ?? 993);
|
||||
const user = process.env.IMAP_USER!;
|
||||
const pass = process.env.IMAP_PASS!;
|
||||
|
||||
if (!host || !user || !pass) {
|
||||
throw new Error('IMAP_HOST / IMAP_USER / IMAP_PASS not set');
|
||||
}
|
||||
|
||||
console.log(`Connecting to ${user}@${host}:${port}…`);
|
||||
const client = new ImapFlow({
|
||||
host,
|
||||
port,
|
||||
secure: port === 993,
|
||||
auth: { user, pass },
|
||||
logger: false,
|
||||
});
|
||||
|
||||
await client.connect();
|
||||
console.log('Connected. Inbox status:');
|
||||
const lock = await client.getMailboxLock('INBOX');
|
||||
try {
|
||||
const status = await client.status('INBOX', { messages: true, recent: true });
|
||||
console.log(' total:', status.messages, '| recent:', status.recent);
|
||||
|
||||
// Pull the last 10 by UID
|
||||
const since = new Date(Date.now() - 30 * 60 * 1000); // last 30 min
|
||||
const result = await client.search({ since });
|
||||
const uids = Array.isArray(result) ? result.slice(-10).reverse() : [];
|
||||
console.log(`Found ${uids.length} messages in last 30min:`);
|
||||
for (const uid of uids) {
|
||||
const msg = await client.fetchOne(String(uid), { source: true, envelope: true });
|
||||
if (!msg || !msg.source) continue;
|
||||
const parsed = await simpleParser(msg.source);
|
||||
const tos = (Array.isArray(parsed.to) ? parsed.to : parsed.to ? [parsed.to] : [])
|
||||
.flatMap((a) => a.value.map((v) => v.address ?? ''))
|
||||
.join(', ');
|
||||
console.log(
|
||||
` uid=${uid} date=${parsed.date?.toISOString()} from=${parsed.from?.text} to=${tos} subject=${parsed.subject}`,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
lock.release();
|
||||
}
|
||||
await client.logout();
|
||||
console.log('Done.');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('Probe failed:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
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 { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { PortalAuthShell } from '@/components/portal/portal-auth-shell';
|
||||
|
||||
export default function PortalLoginPage() {
|
||||
const router = useRouter();
|
||||
@@ -48,74 +49,67 @@ export default function PortalLoginPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 px-4">
|
||||
<div className="w-full max-w-sm">
|
||||
<div className="bg-white rounded-lg border p-8 shadow-sm">
|
||||
<div className="text-center mb-6">
|
||||
<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>
|
||||
</div>
|
||||
<PortalAuthShell>
|
||||
<div className="text-center mb-6">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="email">Email address</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="you@example.com"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
autoFocus
|
||||
autoComplete="email"
|
||||
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>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="email">Email address</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="you@example.com"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
autoFocus
|
||||
autoComplete="email"
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className="text-center text-xs text-gray-400 mt-4">
|
||||
This portal is for existing clients only.
|
||||
</p>
|
||||
</div>
|
||||
</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-[#007bff] 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-[#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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { activateAccount } from '@/lib/services/portal-auth.service';
|
||||
|
||||
const bodySchema = z.object({
|
||||
token: z.string().min(1),
|
||||
password: z.string().min(12),
|
||||
password: z.string().min(9),
|
||||
});
|
||||
|
||||
export async function POST(req: NextRequest): Promise<NextResponse> {
|
||||
|
||||
@@ -6,7 +6,7 @@ import { resetPassword } from '@/lib/services/portal-auth.service';
|
||||
|
||||
const bodySchema = z.object({
|
||||
token: z.string().min(1),
|
||||
password: z.string().min(12),
|
||||
password: z.string().min(9),
|
||||
});
|
||||
|
||||
export async function POST(req: NextRequest): Promise<NextResponse> {
|
||||
|
||||
@@ -2,11 +2,13 @@ import { NextRequest, NextResponse } from 'next/server';
|
||||
import { createHash } from 'crypto';
|
||||
|
||||
import { db } from '@/lib/db';
|
||||
import { verifyDocumensoSignature } from '@/lib/services/documenso-webhook';
|
||||
import { verifyDocumensoSecret } from '@/lib/services/documenso-webhook';
|
||||
import {
|
||||
handleRecipientSigned,
|
||||
handleDocumentCompleted,
|
||||
handleDocumentExpired,
|
||||
handleDocumentOpened,
|
||||
handleDocumentRejected,
|
||||
handleDocumentCancelled,
|
||||
} from '@/lib/services/documents.service';
|
||||
import { env } from '@/lib/env';
|
||||
import { logger } from '@/lib/logger';
|
||||
@@ -14,39 +16,58 @@ import { logger } from '@/lib/logger';
|
||||
// BR-024: Dedup via signatureHash unique index on documentEvents
|
||||
// Always return 200 from webhook (webhook best practice)
|
||||
|
||||
// Documenso emits Prisma enum names on the wire (e.g. "DOCUMENT_SIGNED").
|
||||
// The UI displays them as lowercase-dotted ("document.signed") but the JSON
|
||||
// body uses the enum value as-is. Normalize both forms in case 2.x ever flips.
|
||||
function canonicalizeEvent(event: string): string {
|
||||
return event.toUpperCase().replace(/\./g, '_');
|
||||
}
|
||||
|
||||
type DocumensoRecipient = {
|
||||
email: string;
|
||||
signingStatus?: string;
|
||||
readStatus?: string;
|
||||
signedAt?: string | null;
|
||||
};
|
||||
|
||||
type DocumensoWebhookBody = {
|
||||
event: string;
|
||||
payload: {
|
||||
id: number | string;
|
||||
recipients?: DocumensoRecipient[];
|
||||
};
|
||||
};
|
||||
|
||||
export async function POST(req: NextRequest): Promise<NextResponse> {
|
||||
let payload: string;
|
||||
let rawBody: string;
|
||||
|
||||
try {
|
||||
payload = await req.text();
|
||||
rawBody = await req.text();
|
||||
} catch {
|
||||
return NextResponse.json({ ok: false }, { status: 200 });
|
||||
}
|
||||
|
||||
// Verify HMAC signature
|
||||
const signature = req.headers.get('x-documenso-signature') ?? '';
|
||||
// Documenso v1.13 + 2.x send the secret in plaintext via X-Documenso-Secret.
|
||||
const providedSecret = req.headers.get('x-documenso-secret') ?? '';
|
||||
|
||||
if (!verifyDocumensoSignature(payload, signature, env.DOCUMENSO_WEBHOOK_SECRET)) {
|
||||
logger.warn({ signature }, 'Invalid Documenso webhook signature');
|
||||
return NextResponse.json({ ok: false, error: 'Invalid signature' }, { status: 200 });
|
||||
if (!verifyDocumensoSecret(providedSecret, env.DOCUMENSO_WEBHOOK_SECRET)) {
|
||||
logger.warn({ providedLen: providedSecret.length }, 'Invalid Documenso webhook secret');
|
||||
return NextResponse.json({ ok: false, error: 'Invalid secret' }, { status: 200 });
|
||||
}
|
||||
|
||||
// Compute deduplication hash
|
||||
const signatureHash = createHash('sha256').update(payload).digest('hex');
|
||||
const signatureHash = createHash('sha256').update(rawBody).digest('hex');
|
||||
|
||||
let parsed: { type: string; payload: Record<string, unknown> };
|
||||
let parsed: DocumensoWebhookBody;
|
||||
|
||||
try {
|
||||
parsed = JSON.parse(payload) as typeof parsed;
|
||||
parsed = JSON.parse(rawBody) as DocumensoWebhookBody;
|
||||
} catch {
|
||||
logger.warn('Failed to parse Documenso webhook payload');
|
||||
return NextResponse.json({ ok: false }, { status: 200 });
|
||||
}
|
||||
|
||||
// Dedup: try to insert a sentinel documentEvent with signatureHash
|
||||
// We need a documentId — if dedup fails at this stage we can't easily check.
|
||||
// Instead, store the hash lookup on the first real documentEvent insert in handlers.
|
||||
// Here we just check if this hash was already seen in any event.
|
||||
// Replay guard: if any event with this hash already exists, skip.
|
||||
try {
|
||||
const existing = await db.query.documentEvents.findFirst({
|
||||
where: (de, { eq }) => eq(de.signatureHash, signatureHash),
|
||||
@@ -60,33 +81,69 @@ export async function POST(req: NextRequest): Promise<NextResponse> {
|
||||
logger.error({ err }, 'Failed to check duplicate webhook');
|
||||
}
|
||||
|
||||
const event = canonicalizeEvent(parsed.event);
|
||||
const documensoId = String(parsed.payload?.id ?? '');
|
||||
const recipients = parsed.payload?.recipients ?? [];
|
||||
|
||||
if (!documensoId) {
|
||||
logger.warn({ event }, 'Documenso webhook missing payload.id');
|
||||
return NextResponse.json({ ok: true }, { status: 200 });
|
||||
}
|
||||
|
||||
try {
|
||||
switch (parsed.type) {
|
||||
case 'RECIPIENT_SIGNED':
|
||||
await handleRecipientSigned({
|
||||
documentId: parsed.payload.documentId as string,
|
||||
recipientEmail: parsed.payload.recipientEmail as string,
|
||||
switch (event) {
|
||||
case 'DOCUMENT_SIGNED':
|
||||
case 'DOCUMENT_RECIPIENT_COMPLETED': {
|
||||
// v1.13 fires DOCUMENT_SIGNED per recipient sign;
|
||||
// 2.x fires DOCUMENT_RECIPIENT_COMPLETED for the same semantics.
|
||||
const signedRecipients = recipients.filter(
|
||||
(r) => r.signingStatus === 'SIGNED' || Boolean(r.signedAt),
|
||||
);
|
||||
for (const r of signedRecipients) {
|
||||
await handleRecipientSigned({
|
||||
documentId: documensoId,
|
||||
recipientEmail: r.email,
|
||||
signatureHash: `${signatureHash}:signed:${r.email}`,
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'DOCUMENT_OPENED': {
|
||||
const openedRecipients = recipients.filter((r) => r.readStatus === 'OPENED');
|
||||
for (const r of openedRecipients) {
|
||||
await handleDocumentOpened({
|
||||
documentId: documensoId,
|
||||
recipientEmail: r.email,
|
||||
signatureHash: `${signatureHash}:opened:${r.email}`,
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'DOCUMENT_COMPLETED':
|
||||
await handleDocumentCompleted({ documentId: documensoId });
|
||||
break;
|
||||
|
||||
case 'DOCUMENT_REJECTED': {
|
||||
const rejecting = recipients.find((r) => r.signingStatus === 'REJECTED');
|
||||
await handleDocumentRejected({
|
||||
documentId: documensoId,
|
||||
recipientEmail: rejecting?.email,
|
||||
signatureHash,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case 'DOCUMENT_COMPLETED':
|
||||
await handleDocumentCompleted({
|
||||
documentId: parsed.payload.documentId as string,
|
||||
});
|
||||
break;
|
||||
|
||||
case 'DOCUMENT_EXPIRED':
|
||||
await handleDocumentExpired({
|
||||
documentId: parsed.payload.documentId as string,
|
||||
});
|
||||
case 'DOCUMENT_CANCELLED':
|
||||
await handleDocumentCancelled({ documentId: documensoId, signatureHash });
|
||||
break;
|
||||
|
||||
default:
|
||||
logger.info({ type: parsed.type }, 'Unhandled Documenso webhook event type');
|
||||
logger.info({ event }, 'Unhandled Documenso webhook event type');
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error({ err, type: parsed.type }, 'Error processing Documenso webhook');
|
||||
logger.error({ err, event }, 'Error processing Documenso webhook');
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true }, { status: 200 });
|
||||
|
||||
@@ -8,6 +8,7 @@ import { CheckCircle2, Loader2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { PortalAuthShell } from '@/components/portal/portal-auth-shell';
|
||||
|
||||
interface PasswordSetFormProps {
|
||||
/** API endpoint that accepts `{ token, password }` and sets / resets the password. */
|
||||
@@ -19,7 +20,7 @@ interface PasswordSetFormProps {
|
||||
submitLabel: string;
|
||||
}
|
||||
|
||||
const MIN_LENGTH = 12;
|
||||
const MIN_LENGTH = 9;
|
||||
|
||||
/**
|
||||
* Shared form used by both the activation and password-reset flows. The
|
||||
@@ -74,8 +75,8 @@ export function PasswordSetForm({
|
||||
|
||||
if (!token) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 px-4">
|
||||
<div className="w-full max-w-md text-center space-y-3">
|
||||
<PortalAuthShell>
|
||||
<div className="text-center space-y-3">
|
||||
<h1 className="text-xl font-semibold text-gray-900">Link is missing or invalid</h1>
|
||||
<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
|
||||
@@ -83,19 +84,19 @@ export function PasswordSetForm({
|
||||
</p>
|
||||
<Link
|
||||
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
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</PortalAuthShell>
|
||||
);
|
||||
}
|
||||
|
||||
if (done) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 px-4">
|
||||
<div className="w-full max-w-md text-center">
|
||||
<PortalAuthShell>
|
||||
<div className="text-center">
|
||||
<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" />
|
||||
</div>
|
||||
@@ -103,79 +104,75 @@ export function PasswordSetForm({
|
||||
<p className="text-gray-500 text-sm">{successDescription}</p>
|
||||
<Link
|
||||
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
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</PortalAuthShell>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 px-4">
|
||||
<div className="w-full max-w-sm">
|
||||
<div className="bg-white rounded-lg border p-8 shadow-sm">
|
||||
<div className="mb-6">
|
||||
<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'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>
|
||||
<PortalAuthShell>
|
||||
<div className="mb-6">
|
||||
<h1 className="text-xl font-semibold text-gray-900">{title}</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">{description}</p>
|
||||
</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'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>
|
||||
);
|
||||
}
|
||||
|
||||
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> {
|
||||
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;
|
||||
}
|
||||
|
||||
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_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
|
||||
|
||||
@@ -23,6 +23,27 @@ async function documensoFetch(path: string, options?: RequestInit): Promise<unkn
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// Documenso 2.x renamed top-level `id` → `documentId` and recipient `id` →
|
||||
// `recipientId`; v1.13 still uses `id`. Normalize both shapes to the legacy
|
||||
// `id` form that this codebase consumes everywhere downstream.
|
||||
function normalizeDocument(raw: unknown): DocumensoDocument {
|
||||
const r = (raw ?? {}) as Record<string, unknown>;
|
||||
const id = String(r.documentId ?? r.id ?? '');
|
||||
const status = String(r.status ?? 'PENDING');
|
||||
const recipientsRaw = (r.recipients as Array<Record<string, unknown>> | undefined) ?? [];
|
||||
const recipients = recipientsRaw.map((rec) => ({
|
||||
id: String(rec.recipientId ?? rec.id ?? ''),
|
||||
name: String(rec.name ?? ''),
|
||||
email: String(rec.email ?? ''),
|
||||
role: String(rec.role ?? ''),
|
||||
signingOrder: Number(rec.signingOrder ?? 0),
|
||||
status: String(rec.signingStatus ?? rec.status ?? 'PENDING'),
|
||||
signingUrl: typeof rec.signingUrl === 'string' ? rec.signingUrl : undefined,
|
||||
embeddedUrl: typeof rec.embeddedUrl === 'string' ? rec.embeddedUrl : undefined,
|
||||
}));
|
||||
return { id, status, recipients };
|
||||
}
|
||||
|
||||
export interface DocumensoRecipient {
|
||||
name: string;
|
||||
email: string;
|
||||
@@ -53,7 +74,7 @@ export async function createDocument(
|
||||
return documensoFetch('/api/v1/documents', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ title, document: pdfBase64, recipients }),
|
||||
}) as Promise<DocumensoDocument>;
|
||||
}).then(normalizeDocument);
|
||||
}
|
||||
|
||||
export async function generateDocumentFromTemplate(
|
||||
@@ -63,17 +84,17 @@ export async function generateDocumentFromTemplate(
|
||||
return documensoFetch(`/api/v1/templates/${templateId}/generate-document`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
}) as Promise<DocumensoDocument>;
|
||||
}).then(normalizeDocument);
|
||||
}
|
||||
|
||||
export async function sendDocument(docId: string): Promise<DocumensoDocument> {
|
||||
return documensoFetch(`/api/v1/documents/${docId}/send`, {
|
||||
method: 'POST',
|
||||
}) as Promise<DocumensoDocument>;
|
||||
}).then(normalizeDocument);
|
||||
}
|
||||
|
||||
export async function getDocument(docId: string): Promise<DocumensoDocument> {
|
||||
return documensoFetch(`/api/v1/documents/${docId}`) as Promise<DocumensoDocument>;
|
||||
return documensoFetch(`/api/v1/documents/${docId}`).then(normalizeDocument);
|
||||
}
|
||||
|
||||
export async function sendReminder(docId: string, signerId: string): Promise<void> {
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
import { createHmac } from 'crypto';
|
||||
import { timingSafeEqual } from 'crypto';
|
||||
|
||||
export function verifyDocumensoSignature(
|
||||
payload: string,
|
||||
signature: string,
|
||||
secret: string,
|
||||
): boolean {
|
||||
const hmac = createHmac('sha256', secret).update(payload).digest('hex');
|
||||
// Documenso (v1.13 + 2.x) authenticates outbound webhooks by sending the
|
||||
// configured secret in plaintext via the `X-Documenso-Secret` header.
|
||||
// There is no HMAC. Compare the provided value timing-safely to the env secret.
|
||||
export function verifyDocumensoSecret(provided: string, expected: string): boolean {
|
||||
if (!provided || provided.length !== expected.length) return false;
|
||||
try {
|
||||
return timingSafeEqual(Buffer.from(hmac), Buffer.from(signature));
|
||||
return timingSafeEqual(Buffer.from(provided), Buffer.from(expected));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -635,3 +635,128 @@ export async function handleDocumentExpired(eventData: { documentId: string }) {
|
||||
|
||||
emitToRoom(`port:${doc.portId}`, 'document:expired', { documentId: doc.id });
|
||||
}
|
||||
|
||||
export async function handleDocumentOpened(eventData: {
|
||||
documentId: string;
|
||||
recipientEmail: string;
|
||||
signatureHash?: string;
|
||||
}) {
|
||||
const doc = await db.query.documents.findFirst({
|
||||
where: eq(documents.documensoId, eventData.documentId),
|
||||
});
|
||||
if (!doc) {
|
||||
logger.warn({ documensoId: eventData.documentId }, 'Document not found for webhook');
|
||||
return;
|
||||
}
|
||||
|
||||
const [signer] = await db
|
||||
.select()
|
||||
.from(documentSigners)
|
||||
.where(
|
||||
and(
|
||||
eq(documentSigners.documentId, doc.id),
|
||||
eq(documentSigners.signerEmail, eventData.recipientEmail),
|
||||
),
|
||||
);
|
||||
|
||||
await db.insert(documentEvents).values({
|
||||
documentId: doc.id,
|
||||
eventType: 'viewed',
|
||||
signerId: signer?.id ?? null,
|
||||
signatureHash: eventData.signatureHash ?? null,
|
||||
eventData: { recipientEmail: eventData.recipientEmail },
|
||||
});
|
||||
|
||||
emitToRoom(`port:${doc.portId}`, 'document:signer:opened', {
|
||||
documentId: doc.id,
|
||||
signerEmail: eventData.recipientEmail,
|
||||
});
|
||||
}
|
||||
|
||||
export async function handleDocumentRejected(eventData: {
|
||||
documentId: string;
|
||||
recipientEmail?: string;
|
||||
signatureHash?: string;
|
||||
}) {
|
||||
const doc = await db.query.documents.findFirst({
|
||||
where: eq(documents.documensoId, eventData.documentId),
|
||||
});
|
||||
if (!doc) {
|
||||
logger.warn({ documensoId: eventData.documentId }, 'Document not found for webhook');
|
||||
return;
|
||||
}
|
||||
|
||||
let signerId: string | null = null;
|
||||
if (eventData.recipientEmail) {
|
||||
const [signer] = await db
|
||||
.update(documentSigners)
|
||||
.set({ status: 'declined' })
|
||||
.where(
|
||||
and(
|
||||
eq(documentSigners.documentId, doc.id),
|
||||
eq(documentSigners.signerEmail, eventData.recipientEmail),
|
||||
),
|
||||
)
|
||||
.returning();
|
||||
signerId = signer?.id ?? null;
|
||||
}
|
||||
|
||||
await db
|
||||
.update(documents)
|
||||
.set({ status: 'rejected', updatedAt: new Date() })
|
||||
.where(eq(documents.id, doc.id));
|
||||
|
||||
if (doc.interestId && doc.documentType === 'eoi') {
|
||||
await db
|
||||
.update(interests)
|
||||
.set({ eoiStatus: 'rejected', updatedAt: new Date() })
|
||||
.where(eq(interests.id, doc.interestId));
|
||||
}
|
||||
|
||||
await db.insert(documentEvents).values({
|
||||
documentId: doc.id,
|
||||
eventType: 'rejected',
|
||||
signerId,
|
||||
signatureHash: eventData.signatureHash ?? null,
|
||||
eventData: { recipientEmail: eventData.recipientEmail ?? null },
|
||||
});
|
||||
|
||||
emitToRoom(`port:${doc.portId}`, 'document:rejected', {
|
||||
documentId: doc.id,
|
||||
signerEmail: eventData.recipientEmail ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
export async function handleDocumentCancelled(eventData: {
|
||||
documentId: string;
|
||||
signatureHash?: string;
|
||||
}) {
|
||||
const doc = await db.query.documents.findFirst({
|
||||
where: eq(documents.documensoId, eventData.documentId),
|
||||
});
|
||||
if (!doc) {
|
||||
logger.warn({ documensoId: eventData.documentId }, 'Document not found for webhook');
|
||||
return;
|
||||
}
|
||||
|
||||
await db
|
||||
.update(documents)
|
||||
.set({ status: 'cancelled', updatedAt: new Date() })
|
||||
.where(eq(documents.id, doc.id));
|
||||
|
||||
if (doc.interestId && doc.documentType === 'eoi') {
|
||||
await db
|
||||
.update(interests)
|
||||
.set({ eoiStatus: 'cancelled', updatedAt: new Date() })
|
||||
.where(eq(interests.id, doc.interestId));
|
||||
}
|
||||
|
||||
await db.insert(documentEvents).values({
|
||||
documentId: doc.id,
|
||||
eventType: 'cancelled',
|
||||
signatureHash: eventData.signatureHash ?? null,
|
||||
eventData: { documensoId: eventData.documentId },
|
||||
});
|
||||
|
||||
emitToRoom(`port:${doc.portId}`, 'document:cancelled', { documentId: doc.id });
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -143,6 +143,9 @@ export interface ServerToClientEvents {
|
||||
clientName?: string;
|
||||
}) => void;
|
||||
'document:expired': (payload: { documentId: string }) => void;
|
||||
'document:cancelled': (payload: { documentId: string }) => void;
|
||||
'document:rejected': (payload: { documentId: string; signerEmail?: string | null }) => void;
|
||||
'document:signer:opened': (payload: { documentId: string; signerEmail?: string }) => void;
|
||||
'document:reminderSent': (payload: { documentId: string; recipientEmail: string }) => void;
|
||||
|
||||
// Document template events
|
||||
|
||||
@@ -11,6 +11,7 @@ const PUBLIC_PATHS: string[] = [
|
||||
'/api/auth/',
|
||||
'/api/public/',
|
||||
'/api/health',
|
||||
'/api/webhooks/',
|
||||
'/scan',
|
||||
'/portal/',
|
||||
'/api/portal/',
|
||||
|
||||
139
tests/e2e/realapi/documenso-real-api.spec.ts
Normal file
139
tests/e2e/realapi/documenso-real-api.spec.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import 'dotenv/config';
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
import { login, apiHeaders } from '../smoke/helpers';
|
||||
|
||||
/**
|
||||
* Real-API end-to-end test for the Documenso documenso-template pathway.
|
||||
*
|
||||
* This spec exercises the SEND side of the integration only — it creates a
|
||||
* fully-loaded interest, fires generate-and-sign, and asserts both
|
||||
* (a) the CRM persisted a documensoId on the documents row, and
|
||||
* (b) Documenso itself returns 200 for the freshly-created document.
|
||||
*
|
||||
* The receive side (webhook → state update) is validated separately by
|
||||
* triggering a real signing event in the Documenso UI and watching
|
||||
* /tmp/dev-server.log; it isn't automated here because Documenso has no
|
||||
* machine-friendly "sign on behalf of recipient" endpoint we can drive
|
||||
* deterministically from CI.
|
||||
*
|
||||
* Requires DOCUMENSO_API_URL + DOCUMENSO_API_KEY + DOCUMENSO_TEMPLATE_ID_EOI
|
||||
* in the environment (loaded via dotenv).
|
||||
*/
|
||||
|
||||
const DOCUMENSO_BASE = process.env.DOCUMENSO_API_URL;
|
||||
const DOCUMENSO_API_KEY = process.env.DOCUMENSO_API_KEY;
|
||||
|
||||
test.describe('Documenso real-API: documenso-template pathway', () => {
|
||||
test.skip(!DOCUMENSO_BASE || !DOCUMENSO_API_KEY, 'DOCUMENSO_API_URL / DOCUMENSO_API_KEY not set');
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await login(page, 'super_admin');
|
||||
});
|
||||
|
||||
test('generate-and-sign creates a real Documenso document', async ({ page }) => {
|
||||
const stamp = Date.now();
|
||||
const headers = await apiHeaders(page);
|
||||
|
||||
// ─── 1. Seed client (with a contact email so Documenso has a recipient) ──
|
||||
const clientRes = await page.request.post('/api/v1/clients', {
|
||||
headers,
|
||||
data: {
|
||||
fullName: `Documenso E2E Client ${stamp}`,
|
||||
contacts: [
|
||||
{ channel: 'email', value: `documenso-e2e-${stamp}@example.test`, isPrimary: true },
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(clientRes.ok(), `client create: ${clientRes.status()} ${await clientRes.text()}`).toBe(
|
||||
true,
|
||||
);
|
||||
const clientId = (await clientRes.json()).data.id as string;
|
||||
|
||||
// ─── 2. Seed yacht owned by that client ──────────────────────────────────
|
||||
const yachtRes = await page.request.post('/api/v1/yachts', {
|
||||
headers,
|
||||
data: {
|
||||
name: `E2E Yacht ${stamp}`,
|
||||
owner: { type: 'client', id: clientId },
|
||||
},
|
||||
});
|
||||
expect(yachtRes.ok(), `yacht create: ${yachtRes.status()} ${await yachtRes.text()}`).toBe(true);
|
||||
const yachtId = (await yachtRes.json()).data.id as string;
|
||||
|
||||
// ─── 3. Seed berth ───────────────────────────────────────────────────────
|
||||
const berthRes = await page.request.post('/api/v1/berths', {
|
||||
headers,
|
||||
data: {
|
||||
mooringNumber: `E2E-${stamp}`,
|
||||
area: 'Test Area',
|
||||
},
|
||||
});
|
||||
expect(berthRes.ok(), `berth create: ${berthRes.status()} ${await berthRes.text()}`).toBe(true);
|
||||
const berthId = (await berthRes.json()).data.id as string;
|
||||
|
||||
// ─── 4. Create the interest linking all three ────────────────────────────
|
||||
const interestRes = await page.request.post('/api/v1/interests', {
|
||||
headers,
|
||||
data: { clientId, yachtId, berthId, pipelineStage: 'open' },
|
||||
});
|
||||
expect(
|
||||
interestRes.ok(),
|
||||
`interest create: ${interestRes.status()} ${await interestRes.text()}`,
|
||||
).toBe(true);
|
||||
const interestId = (await interestRes.json()).data.id as string;
|
||||
|
||||
// ─── 5. Fire generate-and-sign through the documenso-template pathway ────
|
||||
const signRes = await page.request.post(
|
||||
'/api/v1/document-templates/documenso-template/generate-and-sign',
|
||||
{
|
||||
headers,
|
||||
data: {
|
||||
interestId,
|
||||
clientId,
|
||||
berthId,
|
||||
pathway: 'documenso-template',
|
||||
signers: [],
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(signRes.ok(), `generate-and-sign: ${signRes.status()} ${await signRes.text()}`).toBe(
|
||||
true,
|
||||
);
|
||||
|
||||
const body = (await signRes.json()) as {
|
||||
data: { document: { id: string; documensoId: string | null; status: string } };
|
||||
};
|
||||
expect(body.data.document.documensoId, 'documensoId on response').toBeTruthy();
|
||||
expect(body.data.document.status).toBe('sent');
|
||||
|
||||
const documensoId = body.data.document.documensoId!;
|
||||
|
||||
// ─── 6. Verify the document exists on the Documenso side ─────────────────
|
||||
const documensoRes = await page.request.get(
|
||||
`${DOCUMENSO_BASE}/api/v1/documents/${documensoId}`,
|
||||
{
|
||||
headers: { Authorization: `Bearer ${DOCUMENSO_API_KEY}` },
|
||||
},
|
||||
);
|
||||
expect(
|
||||
documensoRes.ok(),
|
||||
`documenso GET: ${documensoRes.status()} ${await documensoRes.text()}`,
|
||||
).toBe(true);
|
||||
|
||||
const documensoDoc = (await documensoRes.json()) as {
|
||||
id: number;
|
||||
status: string;
|
||||
recipients?: Array<{ email: string; signingStatus: string }>;
|
||||
};
|
||||
|
||||
expect(String(documensoDoc.id)).toBe(documensoId);
|
||||
// Freshly-sent doc should be PENDING (or DRAFT if Documenso queued it).
|
||||
expect(['PENDING', 'DRAFT'], `unexpected status ${documensoDoc.status}`).toContain(
|
||||
documensoDoc.status,
|
||||
);
|
||||
// The template is configured with ≥1 recipient role, so we should have
|
||||
// at least the client's email populated.
|
||||
expect(documensoDoc.recipients?.length ?? 0).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
155
tests/e2e/realapi/portal-imap-activation.spec.ts
Normal file
155
tests/e2e/realapi/portal-imap-activation.spec.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import 'dotenv/config';
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { ImapFlow } from 'imapflow';
|
||||
import { simpleParser, type ParsedMail } from 'mailparser';
|
||||
|
||||
import { login, apiHeaders } from '../smoke/helpers';
|
||||
|
||||
/**
|
||||
* Real-API end-to-end test for the portal activation flow over real SMTP+IMAP.
|
||||
*
|
||||
* Walks the full loop:
|
||||
* 1. Create a fresh client with a +addressed test email.
|
||||
* 2. Admin invites the client to the portal, triggering the activation email.
|
||||
* 3. Connect to IMAP and poll until the activation email lands.
|
||||
* 4. Parse the email, extract the activation token from the link.
|
||||
* 5. POST /api/portal/auth/activate with the token + new password.
|
||||
* 6. POST /api/portal/auth/sign-in with the same email + password.
|
||||
* 7. Assert the sign-in succeeds.
|
||||
*
|
||||
* Requires IMAP_HOST + IMAP_USER + IMAP_PASS in the environment to read the
|
||||
* destination mailbox. The test sends to <IMAP_USER>+imap-test-<ts> which
|
||||
* routes back to the same inbox via standard +addressing.
|
||||
*
|
||||
* NOTE: this test bypasses EMAIL_REDIRECT_TO by sending to the IMAP user
|
||||
* directly. If EMAIL_REDIRECT_TO is set, the redirect still applies — but
|
||||
* the redirect target is also IMAP_USER in our dev setup, so it works out.
|
||||
*/
|
||||
|
||||
const IMAP_HOST = process.env.IMAP_HOST;
|
||||
const IMAP_PORT = Number(process.env.IMAP_PORT ?? 993);
|
||||
const IMAP_USER = process.env.IMAP_USER;
|
||||
const IMAP_PASS = process.env.IMAP_PASS;
|
||||
|
||||
const MAILBOX = 'INBOX';
|
||||
const POLL_TIMEOUT_MS = 60_000;
|
||||
const POLL_INTERVAL_MS = 5_000;
|
||||
|
||||
function plusAddress(base: string, tag: string): string {
|
||||
const [local, domain] = base.split('@');
|
||||
return `${local}+${tag}@${domain}`;
|
||||
}
|
||||
|
||||
async function fetchActivationToken(args: {
|
||||
recipientEmail: string;
|
||||
notBefore: Date;
|
||||
}): Promise<string> {
|
||||
if (!IMAP_HOST || !IMAP_USER || !IMAP_PASS) {
|
||||
throw new Error('IMAP credentials not configured');
|
||||
}
|
||||
|
||||
const client = new ImapFlow({
|
||||
host: IMAP_HOST,
|
||||
port: IMAP_PORT,
|
||||
secure: IMAP_PORT === 993,
|
||||
auth: { user: IMAP_USER, pass: IMAP_PASS },
|
||||
logger: false,
|
||||
});
|
||||
|
||||
await client.connect();
|
||||
try {
|
||||
const lock = await client.getMailboxLock(MAILBOX);
|
||||
try {
|
||||
const deadline = Date.now() + POLL_TIMEOUT_MS;
|
||||
while (Date.now() < deadline) {
|
||||
// Walk recent messages; first one whose body carries an
|
||||
// /portal/activate?token=... link wins. We don't filter on TO header
|
||||
// because EMAIL_REDIRECT_TO may rewrite it and +addressing may or may
|
||||
// not be preserved depending on the server config.
|
||||
const searchResult = await client.search({ since: args.notBefore });
|
||||
const uids = Array.isArray(searchResult) ? [...searchResult].reverse() : [];
|
||||
for (const uid of uids) {
|
||||
const msg = await client.fetchOne(String(uid), { source: true });
|
||||
if (!msg || !msg.source) continue;
|
||||
const parsed: ParsedMail = await simpleParser(msg.source);
|
||||
// Skip anything that isn't our send (sender + subject discriminator).
|
||||
const fromAddr = parsed.from?.value?.[0]?.address?.toLowerCase() ?? '';
|
||||
const subject = (parsed.subject ?? '').toLowerCase();
|
||||
if (!fromAddr.includes('noreply@letsbe.solutions') || !subject.includes('activate')) {
|
||||
continue;
|
||||
}
|
||||
const body = parsed.html || parsed.text || '';
|
||||
const match = body.match(/\/portal\/activate\?token=([A-Za-z0-9_\-]+)/);
|
||||
if (match?.[1]) {
|
||||
return match[1];
|
||||
}
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
|
||||
}
|
||||
throw new Error(
|
||||
`Activation email for ${args.recipientEmail} not found within ${POLL_TIMEOUT_MS}ms`,
|
||||
);
|
||||
} finally {
|
||||
lock.release();
|
||||
}
|
||||
} finally {
|
||||
await client.logout();
|
||||
}
|
||||
}
|
||||
|
||||
test.describe('Portal activation: SMTP + IMAP round-trip', () => {
|
||||
test.skip(!IMAP_HOST || !IMAP_USER || !IMAP_PASS, 'IMAP_HOST / IMAP_USER / IMAP_PASS not set');
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await login(page, 'super_admin');
|
||||
});
|
||||
|
||||
test('full activation loop via real SMTP delivery + IMAP retrieval', async ({ page }) => {
|
||||
const stamp = Date.now();
|
||||
const headers = await apiHeaders(page);
|
||||
const recipientEmail = plusAddress(IMAP_USER!, `imap-test-${stamp}`);
|
||||
const notBefore = new Date(Date.now() - 60_000); // 1-min skew tolerance
|
||||
|
||||
// ─── 1. Create a fresh client ────────────────────────────────────────────
|
||||
const clientRes = await page.request.post('/api/v1/clients', {
|
||||
headers,
|
||||
data: {
|
||||
fullName: `IMAP Test Client ${stamp}`,
|
||||
contacts: [{ channel: 'email', value: recipientEmail, isPrimary: true }],
|
||||
},
|
||||
});
|
||||
expect(clientRes.ok(), `client create: ${clientRes.status()}`).toBe(true);
|
||||
const clientId = (await clientRes.json()).data.id as string;
|
||||
|
||||
// ─── 2. Admin invites the client ─────────────────────────────────────────
|
||||
const inviteRes = await page.request.post(`/api/v1/clients/${clientId}/portal-user`, {
|
||||
headers,
|
||||
data: { email: recipientEmail, name: `IMAP Test Client ${stamp}` },
|
||||
});
|
||||
expect(inviteRes.ok(), `invite: ${inviteRes.status()} ${await inviteRes.text()}`).toBe(true);
|
||||
|
||||
// ─── 3-4. Poll IMAP and extract the activation token ─────────────────────
|
||||
// Match on the +addressed recipient — that's what's preserved in the TO
|
||||
// header even after the mailserver delivers it back to IMAP_USER's inbox.
|
||||
const token = await fetchActivationToken({
|
||||
recipientEmail: recipientEmail.toLowerCase(),
|
||||
notBefore,
|
||||
});
|
||||
expect(token, 'activation token extracted from email').toBeTruthy();
|
||||
|
||||
// ─── 5. Activate the account ─────────────────────────────────────────────
|
||||
const password = `Imap-Test-${stamp}!`;
|
||||
const activateRes = await page.request.post('/api/portal/auth/activate', {
|
||||
data: { token, password },
|
||||
});
|
||||
expect(activateRes.ok(), `activate: ${activateRes.status()} ${await activateRes.text()}`).toBe(
|
||||
true,
|
||||
);
|
||||
|
||||
// ─── 6-7. Sign in with the new credentials ───────────────────────────────
|
||||
const signInRes = await page.request.post('/api/portal/auth/sign-in', {
|
||||
data: { email: recipientEmail, password },
|
||||
});
|
||||
expect(signInRes.ok(), `sign-in: ${signInRes.status()} ${await signInRes.text()}`).toBe(true);
|
||||
});
|
||||
});
|
||||
67
tests/e2e/visual/snapshots.spec.ts
Normal file
67
tests/e2e/visual/snapshots.spec.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { test, expect, type Page } from '@playwright/test';
|
||||
|
||||
import { login, navigateTo } from '../smoke/helpers';
|
||||
|
||||
/**
|
||||
* Visual regression baselines for stable list/landing pages.
|
||||
*
|
||||
* On first run (or after intentional UI changes), regenerate:
|
||||
* pnpm exec playwright test --project=visual --update-snapshots
|
||||
*
|
||||
* Subsequent runs diff against the committed PNGs under
|
||||
* tests/e2e/visual/snapshots.spec.ts-snapshots/.
|
||||
*
|
||||
* Pages chosen are list/landing screens that don't depend on per-row
|
||||
* fixture data — they tolerate seed drift between runs. Detail screens
|
||||
* (yacht detail, EOI dialog, invoice form review) are intentionally
|
||||
* deferred until we have stable fixtures wired up.
|
||||
*/
|
||||
|
||||
const PAGES = [
|
||||
{ name: 'portal-login', path: '/portal/login', requireAuth: false },
|
||||
{ name: 'dashboard', path: '/dashboard', requireAuth: true },
|
||||
{ name: 'clients-list', path: '/clients', requireAuth: true },
|
||||
{ name: 'yachts-list', path: '/yachts', requireAuth: true },
|
||||
{ name: 'berths-list', path: '/berths', requireAuth: true },
|
||||
{ name: 'invoices-list', path: '/invoices', requireAuth: true },
|
||||
] as const;
|
||||
|
||||
async function settle(page: Page) {
|
||||
// Quiet the page so dynamic content (timers, spinners, blinking cursors)
|
||||
// doesn't cause flaky pixel diffs.
|
||||
await page.addStyleTag({
|
||||
content: `
|
||||
*, *::before, *::after {
|
||||
animation-duration: 0s !important;
|
||||
animation-delay: 0s !important;
|
||||
transition-duration: 0s !important;
|
||||
transition-delay: 0s !important;
|
||||
caret-color: transparent !important;
|
||||
}
|
||||
`,
|
||||
});
|
||||
await page.waitForLoadState('networkidle');
|
||||
// Tiny pause to let TanStack Query flush
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
|
||||
test.describe('Visual regression', () => {
|
||||
for (const p of PAGES) {
|
||||
test(`${p.name} matches baseline`, async ({ page }) => {
|
||||
if (p.requireAuth) {
|
||||
await login(page, 'super_admin');
|
||||
await navigateTo(page, p.path);
|
||||
} else {
|
||||
await page.goto(p.path);
|
||||
}
|
||||
|
||||
await settle(page);
|
||||
|
||||
await expect(page).toHaveScreenshot(`${p.name}.png`, {
|
||||
fullPage: true,
|
||||
// Tolerate small text-rendering differences across machines/runs.
|
||||
maxDiffPixelRatio: 0.02,
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 150 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 153 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 21 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 204 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.7 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 170 KiB |
Reference in New Issue
Block a user