feat(portal): mint setup-link token + email on portal-access grant #204

Merged
David merged 2 commits from feat/pms-136-portal-setup-link into main 2026-06-13 23:10:40 +02:00
Owner

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

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>
feat(portal): mint setup-link token + email on portal-access grant
Some checks failed
E2E / Playwright against staging (pull_request) Failing after 20s
Check / fmt + clippy + compile + tests (pull_request) Successful in 1m34s
Build OCI container / Build and push mokosh-api image (push) Successful in 3m38s
0d33d0c84a
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>
Merge origin/main into feat/pms-136-portal-setup-link
Some checks failed
Create release / Create release from merged PR (pull_request) Has been skipped
Check / fmt + clippy + compile + tests (pull_request) Failing after 17s
E2E / Playwright against staging (pull_request) Failing after 23s
c1b966331f
#PMS-136
David merged commit 5946001f4a into main 2026-06-13 23:10:40 +02:00
David deleted branch feat/pms-136-portal-setup-link 2026-06-13 23:10:41 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
psa-systems/mokosh-server!204
No description provided.