Files
Matt Ciaccio 475b051e29
All checks were successful
Build & Push Docker Images / lint (pull_request) Successful in 1m0s
Build & Push Docker Images / build-and-push (pull_request) Has been skipped
feat(portal): replace magic-link with email/password + admin-initiated activation
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>
2026-04-26 15:34:02 +02:00
..

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:

  1. In Documenso, open the EOI template.
  2. Download the source PDF.
  3. Drop it here as eoi-template.pdf.