The client portal no longer uses passwordless / magic-link sign-in. Each
client now has a `portal_users` row with a scrypt-hashed password,
created by an admin from the client detail page; the admin's invite
mails an activation link that the client uses to set their own password.
Forgot-password is wired through the same token mechanism.
Schema (migration `0009_outgoing_rumiko_fujikawa.sql`):
- `portal_users` — one per client account, separate from the CRM
`users` table (better-auth) so the auth realms stay isolated. Email
is globally unique, password is null until activation.
- `portal_auth_tokens` — single-use activation / reset tokens. Stores
only the SHA-256 hash so a DB compromise never leaks live tokens.
Services:
- `src/lib/portal/passwords.ts` — scrypt hash/verify (no new deps;
uses node:crypto), token mint+hash helpers.
- `src/lib/services/portal-auth.service.ts` — createPortalUser,
resendActivation, activateAccount, signIn (timing-safe),
requestPasswordReset, resetPassword. Auth failures throw the new
UnauthorizedError (401); enumeration-safe behaviour everywhere.
Routes:
- POST /api/portal/auth/sign-in — sets the existing portal JWT cookie.
- POST /api/portal/auth/forgot-password — always 200.
- POST /api/portal/auth/reset-password — token + new password.
- POST /api/portal/auth/activate — token + initial password.
- POST /api/v1/clients/:id/portal-user — admin invite (and `?action=resend`).
- Removed: /api/portal/auth/request, /api/portal/auth/verify (magic link).
UI:
- /portal/login — replaced email-only magic-link form with email +
password + "forgot password" link.
- /portal/forgot-password, /portal/reset-password, /portal/activate — new.
- New shared `PasswordSetForm` component used by activate + reset.
- New `PortalInviteButton` rendered on the client detail header.
Email send:
- `createTransporter` now wires SMTP auth when SMTP_USER+SMTP_PASS are
set (gmail app-password or marina-server creds, configured via env).
- `SMTP_FROM` env var lets the sender address be overridden without
pinning it to `noreply@${SMTP_HOST}`.
Tests:
- Smoke spec 17 (client-portal) updated to the new flow: 7/7 green.
- Smoke specs 02-crud-spine, 05-invoices, 20-critical-path updated to
match the post-refactor client + invoice forms (drop companyName,
use OwnerPicker + billingEmail).
- Vitest 652/652 still green; type-check clean.
Drops the dead `requestMagicLink` from portal.service.ts.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
assets/
Server-side runtime assets bundled by Next.js (via outputFileTracingIncludes
in next.config.ts). These files are read with fs.readFile from
process.cwd() at runtime, so they are NOT served as public URLs — use
public/ for that.
eoi-template.pdf
The source PDF used by the in-app EOI generation pathway
(src/lib/pdf/fill-eoi-form.ts). It must be the same PDF that the
Documenso EOI template uploads, so both pathways produce equivalent
documents.
The PDF must contain AcroForm fields with these exact names (mirroring the
Documenso template's formValues keys — see
docs/eoi-documenso-field-mapping.md):
| Field name | Type | Filled with |
|---|---|---|
Name |
Text | EoiContext.client.fullName |
Email |
Text | EoiContext.client.primaryEmail |
Address |
Text | street, city, country |
Yacht Name |
Text | EoiContext.yacht.name |
Length |
Text | EoiContext.yacht.lengthFt |
Width |
Text | EoiContext.yacht.widthFt |
Draft |
Text | EoiContext.yacht.draftFt |
Berth Number |
Text | EoiContext.berth.mooringNumber |
Lease_10 |
Checkbox | always false (legacy default — Purchase, not Lease) |
Purchase |
Checkbox | always true |
Form fields stay interactive after generation (not flattened), so the recipient can still tweak values before signing if the in-app pathway is followed by a Documenso send.
Override path
In dev/test, set EOI_TEMPLATE_PDF_PATH=/abs/path/to/your/template.pdf to
point at a different file (e.g. a fixture).
How to extract this PDF
The legacy flow uploads this PDF to Documenso template ID 8. To get the exact bytes:
- In Documenso, open the EOI template.
- Download the source PDF.
- Drop it here as
eoi-template.pdf.