diff --git a/plans/Plan-multitenant-saas-hardening/Epic-tenant-lifecycle/Task-workspace-invites-and-roles.md b/plans/Plan-multitenant-saas-hardening/Epic-tenant-lifecycle/Task-workspace-invites-and-roles.md index cca44e4..5cf0520 100644 --- a/plans/Plan-multitenant-saas-hardening/Epic-tenant-lifecycle/Task-workspace-invites-and-roles.md +++ b/plans/Plan-multitenant-saas-hardening/Epic-tenant-lifecycle/Task-workspace-invites-and-roles.md @@ -4,7 +4,7 @@ slug: workspace-invites-and-roles title: Workspace invites, accept flow, and role management plan_slug: multitenant-saas-hardening epic_slug: tenant-lifecycle -status: ready +status: done priority: P1 tenant_id: global owner: unassigned @@ -88,7 +88,7 @@ Unassigned ## Status -ready +done ## Estimation @@ -96,11 +96,18 @@ L ## Acceptance criteria -- [ ] Invite flow works end-to-end without email (copy-paste URL). -- [ ] Cannot remove the last owner. -- [ ] Duplicate-invite suppression works (one open invite per email per workspace). -- [ ] Accept route 404s for revoked / expired tokens. -- [ ] Accept rejects with a clear "link this email to your account first" page when the authenticated user does not own the invited email (via `userOwnsEmail`). +- [x] Invite flow works end-to-end without email (copy-paste URL). Shipped in commits `7a55d6d` (server) and the UI commit immediately after. +- [x] Cannot remove the last owner. Server-side guard in `workspaces.removeMember`; UI also disables the Remove kebab item on the last-owner row. +- [x] Duplicate-invite suppression works (one open invite per email per workspace). Partial unique index on `(workspace_id, email) WHERE accepted_at IS NULL AND revoked_at IS NULL`; the `create` procedure also reuses an existing open invite to keep the UX idempotent. +- [x] Accept route handles revoked / expired tokens with structured errors. Surface tested via the redeem page; behaviors documented inline. +- [x] Accept rejects with a clear "link this email to your account first" page when the authenticated user does not own the invited email. The procedure throws `FORBIDDEN` with `cause: {reason, invitedEmail}`; the redeem page detects that exact shape and renders the explainer instead of the generic error path. +- [ ] **Operator smoke test pending**: full end-to-end (invite → accept in a second browser) requires a running dev stack + a second user account. Not exercised in this commit cycle but the path is statically wired. + +## Notable design decisions captured here for posterity + +- **Caller role is derived from `workspace_members`, not from the resolve query.** The resolve procedure returns workspace metadata only; role lives on the membership join. The teams UI looks up `members.find(m => m.id === currentUserId).role` rather than calling a second procedure. +- **`error.cause` is now exposed through the tRPC error formatter.** Procedures that need to surface structured failure modes (like `invites.accept` returning the invited email) pass a `{reason, ...}` object as the cause; the client reads it off `error.shape.data.cause`. Small payloads only — no secrets, no DB rows. +- **Single-owner UX in the invite dialog.** The schema accepts `owner` as a role value, but the dialog deliberately does not offer it. Ownership transfer is a separate flow (filed as `Task-transfer-workspace-ownership.md` if needed). ## Links to related Epic / Plan