feat(portal): mint setup-link token + email on portal-access grant #204
Loading…
Reference in a new issue
No description provided.
Delete branch "feat/pms-136-portal-setup-link"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
When an MSP agent grants a contact portal access, the contact previously got
is_portal_user = TRUEwith no credential and no way to obtain one (PMS-17 AC2 deliberately deferred the credential mint to this issue). This wires the customer-facing onboarding handshake end to end.ContactServicenow refactors the bare flag flip into a setup-token mint.create_contactwithcreate_portal_access: true, andupdate_contactwith afalse -> trueis_portal_usertransition, each insert oneportal_setup_tokensrow inside the same transaction as the grant and, after commit, email the contact a/portal/set-password?token=...link via the sharedMailer(best-effort: a failed send leaves the token persisted for resend). A re-grant of an already-portal contact mints no second token; revoking viais_portal_user: falsejust flips the flag back.UpdateContactRequestgainsis_portal_user: Option<bool>so the grant/revoke path has an input.POST /api/v1/portal/auth/setup-password(public; the single-use token is the credential) redeems{contact_id}.{secret}: it setscontacts.portal_password_hash, marks the token used, and returns 204. A replayed (used) token returns 410 Gone; an expired or unknown token returns 400. After redemption the contact signs in immediately via the existing PMS-26/portal/auth/loginendpoint.New migration
041_portal_setup_tokens.sqlmirrorspassword_reset_tokens(contact-bound, 72h TTL, only the Argon2 hash of the secret stored). The 024/038 RLS DO-block loops already ran, so the table attaches the fail-closed FORCE'dtenant_isolationpolicy explicitly (PMS-257 posture). Adds anAppError::Gone(410) variant.Integration tests assert: the token row appears on create-grant and update-grant and not on the negative control or a re-grant; redeem sets the hash and returns 204; replay is 410; expired is 400; and the new credential is accepted by the portal login endpoint.
#PMS-136
Co-Authored-By: Claude Opus 4.8 (1M context) noreply@anthropic.com
When an MSP agent grants a contact portal access, the contact previously got `is_portal_user = TRUE` with no credential and no way to obtain one (PMS-17 AC2 deliberately deferred the credential mint to this issue). This wires the customer-facing onboarding handshake end to end. `ContactService` now refactors the bare flag flip into a setup-token mint. `create_contact` with `create_portal_access: true`, and `update_contact` with a `false -> true` `is_portal_user` transition, each insert one `portal_setup_tokens` row inside the same transaction as the grant and, after commit, email the contact a `/portal/set-password?token=...` link via the shared `Mailer` (best-effort: a failed send leaves the token persisted for resend). A re-grant of an already-portal contact mints no second token; revoking via `is_portal_user: false` just flips the flag back. `UpdateContactRequest` gains `is_portal_user: Option<bool>` so the grant/revoke path has an input. `POST /api/v1/portal/auth/setup-password` (public; the single-use token is the credential) redeems `{contact_id}.{secret}`: it sets `contacts.portal_password_hash`, marks the token used, and returns 204. A replayed (used) token returns 410 Gone; an expired or unknown token returns 400. After redemption the contact signs in immediately via the existing PMS-26 `/portal/auth/login` endpoint. New migration `041_portal_setup_tokens.sql` mirrors `password_reset_tokens` (contact-bound, 72h TTL, only the Argon2 hash of the secret stored). The 024/038 RLS DO-block loops already ran, so the table attaches the fail-closed FORCE'd `tenant_isolation` policy explicitly (PMS-257 posture). Adds an `AppError::Gone` (410) variant. Integration tests assert: the token row appears on create-grant and update-grant and not on the negative control or a re-grant; redeem sets the hash and returns 204; replay is 410; expired is 400; and the new credential is accepted by the portal login endpoint. #PMS-136 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>