5 Commits

Author SHA1 Message Date
Matt Ciaccio
ea8181d108 test(visual): regression baselines for stable list/landing pages
All checks were successful
Build & Push Docker Images / lint (pull_request) Successful in 1m7s
Build & Push Docker Images / build-and-push (pull_request) Has been skipped
New `visual` project covers six low-volatility screens — portal login,
dashboard, and the four core lists (clients/yachts/berths/invoices) —
with full-page screenshots that diff to a 2% pixel-ratio tolerance.
Animations and the cursor caret are disabled inline so transient
rendering doesn't trigger flaky diffs.

Detail screens (yacht detail, EOI dialog, invoice form steps) are
intentionally deferred until we have stable per-id fixtures so
snapshots don't drift with seed data.

Regenerate with: pnpm exec playwright test --project=visual --update-snapshots

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 15:42:40 +02:00
Matt Ciaccio
65b241805e test(portal): IMAP full-lifecycle activation E2E + dev probe helper
New realapi spec walks the entire portal activation loop over real
network: invite via the admin endpoint → wait for the activation email
to land in the IMAP mailbox → extract the token from the body link →
activate the portal user via the public API → sign in with the new
password.

The match logic deliberately doesn't filter on the TO header — the
combination of EMAIL_REDIRECT_TO rewriting and +addressing made TO
matching brittle. Instead we discriminate by sender (noreply@…),
subject keyword, and body link pattern, which is unique enough to find
exactly the email this test triggered.

Companion script scripts/dev-imap-probe.ts dumps the most recent ~10
messages with from/to/subject/date — useful for debugging when an IMAP
match goes wrong.

Skips when IMAP_HOST / IMAP_USER / IMAP_PASS are absent so the suite
stays portable.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 15:40:28 +02:00
Matt Ciaccio
4a859245b7 test(documenso): real-API E2E spec + 2.x response normalization
The documenso-template pathway was returning 201 with documensoId=null
because Documenso 2.x renamed `id` → `documentId` and recipient `id` →
`recipientId` in its API responses. Our DocumensoDocument interface
still expected the legacy v1.13 shape, so destructuring silently yielded
undefined and the documents row got NULL'd.

- Add normalizeDocument() in documenso-client that reads either field
  name and surfaces the legacy `id` form downstream consumers expect
- Apply normalization at every callsite that returns DocumensoDocument
  (createDocument, generateDocumentFromTemplate, sendDocument, getDocument)
- New realapi Playwright project (opt-in: --project=realapi) targeting
  tests/e2e/realapi/, with 2-min timeout for real-network calls
- New spec: documenso-real-api.spec.ts seeds client/yacht/berth/interest
  via the v1 API, fires generate-and-sign through the documenso-template
  pathway, asserts the response carries a documensoId, then GETs the
  document directly from Documenso to confirm it exists with PENDING
  status and recipients populated

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 15:25:06 +02:00
Matt Ciaccio
4441f1177f 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>
2026-04-27 15:04:21 +02:00
Matt Ciaccio
c4085265ff fix(documenso): align webhook receiver with Documenso v1.13 + 2.x protocol
Documenso authenticates outbound webhooks via the X-Documenso-Secret
header carrying the plaintext secret (no HMAC). The previous receiver
verified an HMAC against a non-existent x-documenso-signature header
and switched on parsed.type, neither of which Documenso emits — so
every real delivery was being silently rejected.

- Read X-Documenso-Secret, compare timing-safe to env secret
- Switch on parsed.event with uppercase normalization for both v1.13
  (DOCUMENT_SIGNED) and 2.x (lowercase-dotted UI labels) wire formats
- Alias DOCUMENT_RECIPIENT_COMPLETED to DOCUMENT_SIGNED (same
  semantics across versions)
- Handle DOCUMENT_OPENED / DOCUMENT_REJECTED / DOCUMENT_CANCELLED in
  addition to the existing DOCUMENT_SIGNED + DOCUMENT_COMPLETED paths
- Bypass session middleware for /api/webhooks/* (signature is the auth)

Verified end-to-end against signatures.letsbe.solutions: real
DOCUMENT_RECIPIENT_COMPLETED + DOCUMENT_COMPLETED deliveries now pass
secret verification, dispatch correctly, and the handler updates
state (or warns gracefully when the documensoId is unknown).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 13:46:48 +02:00
27 changed files with 1097 additions and 246 deletions

View File

@@ -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
View 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);
});

View 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);
});

View File

@@ -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>
);
}

View File

@@ -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> {

View File

@@ -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> {

View File

@@ -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 });

View File

@@ -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&apos;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&apos;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>
);
}

View 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>
);
}

View File

@@ -45,15 +45,24 @@ export async function sendEmail(
): Promise<nodemailer.SentMessageInfo> {
const transporter = createTransporter();
const requestedTo = Array.isArray(to) ? to.join(', ') : to;
const effectiveTo = env.EMAIL_REDIRECT_TO ?? requestedTo;
const effectiveSubject = env.EMAIL_REDIRECT_TO
? `[redirected from ${requestedTo}] ${subject}`
: subject;
const info = await transporter.sendMail({
from: from ?? env.SMTP_FROM ?? `Port Nimara CRM <noreply@${env.SMTP_HOST}>`,
to: Array.isArray(to) ? to.join(', ') : to,
subject,
to: effectiveTo,
subject: effectiveSubject,
html,
...(text ? { text } : {}),
});
logger.debug({ messageId: info.messageId, to, subject }, 'Email sent');
logger.debug(
{ messageId: info.messageId, to: effectiveTo, originalTo: requestedTo, subject },
env.EMAIL_REDIRECT_TO ? 'Email sent (redirected)' : 'Email sent',
);
return info;
}

View File

@@ -0,0 +1,149 @@
interface ActivationData {
portName: string;
link: string;
ttlHours: number;
recipientName?: string;
}
interface ResetData {
portName: string;
link: string;
ttlMinutes: number;
recipientName?: string;
}
const LOGO_URL =
'https://s3.portnimara.com/images/Port%20Nimara%20New%20Logo-Circular%20Frame_250px.png';
const BACKGROUND_URL = 'https://s3.portnimara.com/images/Overhead_1_blur.png';
function shell(opts: { title: string; body: string }): string {
return `<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<title>${opts.title}</title>
<style type="text/css">
table, td { mso-table-lspace: 0pt; mso-table-rspace: 0pt; }
img { border: 0; display: block; }
p { margin: 0; padding: 0; }
</style>
</head>
<body style="margin:0; padding:0; background-color:#f2f2f2;">
<table role="presentation" width="100%" border="0" cellspacing="0" cellpadding="0" style="background-image: url('${BACKGROUND_URL}'); background-size: cover; background-position: center; background-color:#f2f2f2;">
<tr>
<td align="center" style="padding:30px 16px;">
<table role="presentation" width="600" border="0" cellspacing="0" cellpadding="0" style="width:100%; max-width:600px; background-color:#ffffff; border-radius:8px; overflow:hidden; box-shadow:0 2px 4px rgba(0,0,0,0.1);">
<tr>
<td style="padding:20px; font-family: Arial, sans-serif; color:#333333; word-break:break-word;">
<center>
<img src="${LOGO_URL}" alt="Port Nimara Logo" width="100" style="margin-bottom:20px;" />
</center>
${opts.body}
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>`;
}
export function activationEmail(data: ActivationData): {
subject: string;
html: string;
text: string;
} {
const subject = `Activate your ${data.portName} client portal account`;
const greeting = data.recipientName ? `Dear ${escapeHtml(data.recipientName)},` : 'Welcome,';
const body = `
<p style="margin-bottom:10px; font-size:18px; font-weight:bold; color:#007bff;">
Welcome to ${escapeHtml(data.portName)}
</p>
<p style="margin-bottom:10px; font-size:16px; line-height:1.5;">${greeting}</p>
<p style="margin-bottom:20px; font-size:16px; line-height:1.5;">
You've been invited to access the ${escapeHtml(data.portName)} client portal.
Click the button below to set your password and activate your account.
The link expires in ${data.ttlHours} hours.
</p>
<p style="text-align:center; margin:30px 0;">
<a href="${data.link}" style="display:inline-block; background-color:#007bff; color:#ffffff; text-decoration:none; padding:14px 35px; border-radius:5px; font-weight:bold; font-size:16px;">
Activate account
</a>
</p>
<p style="font-size:14px; color:#666; line-height:1.5; padding:15px 0; border-top:1px solid #eee; margin-top:20px;">
If the button doesn't work, paste this link into your browser:<br />
<a href="${data.link}" style="color:#007bff; text-decoration:underline; word-break:break-all;">${data.link}</a>
</p>
<p style="font-size:16px; margin-top:30px;">
Thank you,<br />
<strong>${escapeHtml(data.portName)} CRM</strong>
</p>`;
const text = [
`Welcome to ${data.portName}`,
'',
`You've been invited to access the ${data.portName} client portal.`,
`Activate your account by visiting: ${data.link}`,
'',
`The link expires in ${data.ttlHours} hours.`,
'',
`Thank you,`,
`${data.portName} CRM`,
].join('\n');
return { subject, html: shell({ title: subject, body }), text };
}
export function resetEmail(data: ResetData): { subject: string; html: string; text: string } {
const subject = `Reset your ${data.portName} client portal password`;
const greeting = data.recipientName ? `Dear ${escapeHtml(data.recipientName)},` : 'Hello,';
const body = `
<p style="margin-bottom:10px; font-size:18px; font-weight:bold; color:#007bff;">
Password reset
</p>
<p style="margin-bottom:10px; font-size:16px; line-height:1.5;">${greeting}</p>
<p style="margin-bottom:20px; font-size:16px; line-height:1.5;">
We received a request to reset the password on your ${escapeHtml(data.portName)}
client portal account. Click the button below to choose a new one.
The link expires in ${data.ttlMinutes} minutes.
</p>
<p style="text-align:center; margin:30px 0;">
<a href="${data.link}" style="display:inline-block; background-color:#007bff; color:#ffffff; text-decoration:none; padding:14px 35px; border-radius:5px; font-weight:bold; font-size:16px;">
Reset password
</a>
</p>
<p style="font-size:14px; color:#666; line-height:1.5; padding:15px 0; border-top:1px solid #eee; margin-top:20px;">
If you didn't request this, you can safely ignore this email — your password will remain unchanged.
</p>
<p style="font-size:16px; margin-top:30px;">
Thank you,<br />
<strong>${escapeHtml(data.portName)} CRM</strong>
</p>`;
const text = [
`Password reset for ${data.portName}`,
'',
`Reset your password by visiting: ${data.link}`,
`The link expires in ${data.ttlMinutes} minutes.`,
'',
`If you didn't request this, you can safely ignore this email.`,
'',
`Thank you,`,
`${data.portName} CRM`,
].join('\n');
return { subject, html: shell({ title: subject, body }), text };
}
function escapeHtml(str: string): string {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}

View File

@@ -35,6 +35,9 @@ const envSchema = z.object({
SMTP_USER: z.string().optional(),
SMTP_PASS: z.string().optional(),
SMTP_FROM: z.string().optional(),
// Dev/test safety net: when set, sendEmail redirects every outbound message
// to this address regardless of the requested recipient. Leave empty in prod.
EMAIL_REDIRECT_TO: z.string().email().optional(),
// Encryption
EMAIL_CREDENTIAL_KEY: z

View File

@@ -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> {

View File

@@ -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;
}

View File

@@ -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 });
}

View File

@@ -6,6 +6,7 @@ import { ports } from '@/lib/db/schema/ports';
import { portalAuthTokens, portalUsers } from '@/lib/db/schema/portal';
import { env } from '@/lib/env';
import { sendEmail } from '@/lib/email';
import { activationEmail, resetEmail } from '@/lib/email/templates/portal-auth';
import { ConflictError, NotFoundError, UnauthorizedError, ValidationError } from '@/lib/errors';
import { logger } from '@/lib/logger';
import { createPortalToken } from '@/lib/portal/auth';
@@ -13,7 +14,7 @@ import { hashPassword, hashToken, mintToken, verifyPassword } from '@/lib/portal
const ACTIVATION_TOKEN_TTL_HOURS = 72;
const RESET_TOKEN_TTL_MINUTES = 30;
const MIN_PASSWORD_LENGTH = 12;
const MIN_PASSWORD_LENGTH = 9;
// ─── Admin-side: invite a client to the portal ───────────────────────────────
@@ -79,11 +80,14 @@ async function issueActivationToken(
const portName = port?.name ?? 'Port Nimara';
const link = `${env.APP_URL}/portal/activate?token=${encodeURIComponent(raw)}`;
const subject = `Activate your ${portName} client portal account`;
const html = activationEmailHtml({ portName, link, ttlHours: ACTIVATION_TOKEN_TTL_HOURS });
const { subject, html, text } = activationEmail({
portName,
link,
ttlHours: ACTIVATION_TOKEN_TTL_HOURS,
});
try {
await sendEmail(email, subject, html);
await sendEmail(email, subject, html, undefined, text);
} catch (err) {
logger.error({ err, email }, 'Failed to send portal activation email');
// Re-throw — the admin should know if their invite mail bounced.
@@ -183,13 +187,14 @@ export async function requestPasswordReset(email: string): Promise<void> {
const port = await db.query.ports.findFirst({ where: eq(ports.id, user.portId) });
const portName = port?.name ?? 'Port Nimara';
const link = `${env.APP_URL}/portal/reset-password?token=${encodeURIComponent(raw)}`;
const { subject, html, text } = resetEmail({
portName,
link,
ttlMinutes: RESET_TOKEN_TTL_MINUTES,
});
try {
await sendEmail(
user.email,
`Reset your ${portName} client portal password`,
resetEmailHtml({ portName, link, ttlMinutes: RESET_TOKEN_TTL_MINUTES }),
);
await sendEmail(user.email, subject, html, undefined, text);
} catch (err) {
logger.error({ err, email: user.email }, 'Failed to send password-reset email');
// Don't propagate — the public route returns 200 either way.
@@ -235,52 +240,4 @@ async function consumeToken(
return { portalUserId: row.portalUserId };
}
// ─── Email templates ─────────────────────────────────────────────────────────
function activationEmailHtml(args: { portName: string; link: string; ttlHours: number }): string {
return `
<!DOCTYPE html>
<html><body style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;background:#f5f5f5;padding:40px 0;margin:0;">
<div style="max-width:480px;margin:0 auto;background:#fff;border-radius:8px;overflow:hidden;box-shadow:0 2px 8px rgba(0,0,0,0.08);">
<div style="background:#1e2844;padding:32px 40px;text-align:center;">
<h1 style="color:#fff;margin:0;font-size:22px;font-weight:600;">${args.portName}</h1>
<p style="color:#9ca3af;margin:6px 0 0;font-size:14px;">Client Portal</p>
</div>
<div style="padding:40px;">
<p style="color:#374151;font-size:16px;margin:0 0 16px;">Welcome,</p>
<p style="color:#6b7280;font-size:15px;line-height:1.6;margin:0 0 32px;">
You've been invited to access the ${args.portName} client portal. Click the button below to set your password and activate your account. The link expires in ${args.ttlHours} hours.
</p>
<div style="text-align:center;margin:0 0 32px;">
<a href="${args.link}" style="display:inline-block;background:#1e2844;color:#fff;text-decoration:none;padding:14px 32px;border-radius:6px;font-size:15px;font-weight:500;">Activate account</a>
</div>
<p style="color:#9ca3af;font-size:13px;margin:0;line-height:1.5;">If the button doesn't work, paste this link into your browser:<br/><a href="${args.link}" style="color:#1e2844;word-break:break-all;">${args.link}</a></p>
</div>
</div>
</body></html>
`;
}
function resetEmailHtml(args: { portName: string; link: string; ttlMinutes: number }): string {
return `
<!DOCTYPE html>
<html><body style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;background:#f5f5f5;padding:40px 0;margin:0;">
<div style="max-width:480px;margin:0 auto;background:#fff;border-radius:8px;overflow:hidden;box-shadow:0 2px 8px rgba(0,0,0,0.08);">
<div style="background:#1e2844;padding:32px 40px;text-align:center;">
<h1 style="color:#fff;margin:0;font-size:22px;font-weight:600;">${args.portName}</h1>
<p style="color:#9ca3af;margin:6px 0 0;font-size:14px;">Password reset</p>
</div>
<div style="padding:40px;">
<p style="color:#374151;font-size:16px;margin:0 0 16px;">Hello,</p>
<p style="color:#6b7280;font-size:15px;line-height:1.6;margin:0 0 32px;">
We received a request to reset your client portal password. Click the button below to choose a new one. The link expires in ${args.ttlMinutes} minutes. If you didn't request this, you can ignore this email.
</p>
<div style="text-align:center;margin:0 0 32px;">
<a href="${args.link}" style="display:inline-block;background:#1e2844;color:#fff;text-decoration:none;padding:14px 32px;border-radius:6px;font-size:15px;font-weight:500;">Reset password</a>
</div>
<p style="color:#9ca3af;font-size:13px;margin:0;line-height:1.5;">If the button doesn't work, paste this link into your browser:<br/><a href="${args.link}" style="color:#1e2844;word-break:break-all;">${args.link}</a></p>
</div>
</div>
</body></html>
`;
}
// Activation + reset email templates live in src/lib/email/templates/portal-auth.ts

View File

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

View File

@@ -11,6 +11,7 @@ const PUBLIC_PATHS: string[] = [
'/api/auth/',
'/api/public/',
'/api/health',
'/api/webhooks/',
'/scan',
'/portal/',
'/api/portal/',

View 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);
});
});

View 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);
});
});

View 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