ubiquitous-invention/plans/Plan-multitenant-saas-hardening/Epic-tenant-lifecycle/Task-workspace-invites-and-roles.md
Randall Stillwell 820dae6510 docs(plans): split workspace-invites convoy into identity + invites + autocomplete
User pushed back on "strict email match in v1" — the right architectural
answer is multi-email identity (one users row owning multiple verified
emails), not a stopgap. Scaling the convoy accordingly:

1. Task-multi-email-identity (NEW, P1, foundation)
   - user_email_identities table (user_id, email lowercased, verified_at,
     source: primary | oauth:<provider> | manual)
   - Refactor ensureUserIdByEmail -> ensureUserIdByVerifiedEmail against
     the new table.
   - OAuth callback writes a source='oauth:<provider>' identity when the
     provider returns email_verified=true. Cross-user conflict rejects.
   - Profile UI: "Linked emails" section, read-only in v1.
   - Exports userOwnsEmail(userId, emailLower) for invite accept to call.

2. Task-workspace-invites-and-roles (existing, narrowed)
   - All the original spec.
   - Accept procedure calls userOwnsEmail() instead of comparing
     users.email directly. Mismatch renders an explainer page, not a
     silent accept.

3. Task-invite-recipient-autocomplete (NEW, P1, polish)
   - invites.suggestRecipient returns typed suggestions across four
     kinds: member / pending_invite / known_user / new_email.
   - Tenancy fence on known_user is the security-relevant assertion;
     test for it explicitly.
   - Combobox UI renders each kind with its own affordance.

Three follow-ups filed explicitly to keep this convoy PR-sized:
- Task-manual-email-verification (add an email outside OAuth)
- Task-disconnect-linked-email (destructive, needs last-verified guard)
- Task-account-merge (handle the legacy duplicate-users case)

Epic file refreshed with the new task table, follow-up table, and a
phase ordering note. Identity lands first because it touches the
sign-in path; invites and autocomplete can ship in their own PRs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-02 10:02:38 -05:00

110 lines
5.3 KiB
Markdown

---
kind: task
slug: workspace-invites-and-roles
title: Workspace invites, accept flow, and role management
plan_slug: multitenant-saas-hardening
epic_slug: tenant-lifecycle
status: ready
priority: P1
tenant_id: global
owner: unassigned
cursor_todo_id: null
updated_at: "2026-06-02"
---
# Task summary
Owners can invite an email to a workspace, the recipient accepts via a link (or via a "pending invites" UI on first sign-in), and lands in the workspace as a member. Owners and admins can change roles and remove members.
> **Depends on `Task-multi-email-identity.md`.** That task adds `userOwnsEmail()` against the new `user_email_identities` table; the accept procedure here calls it instead of doing a direct `users.email` compare. Recipient-autocomplete (typing a name and seeing existing members / pending invites surface) is split out as `Task-invite-recipient-autocomplete.md` so this task stays PR-sized.
## Description
`workspace_members` already exists. This task adds the *invite* layer on top.
### Schema additions
New table `workspace_invites`:
- `id` uuid pk
- `workspace_id` uuid not null, references `workspaces.id` on delete cascade, indexed
- `email` varchar not null (store lowercase — match the case-insensitive convention in migration 0004)
- `role` varchar not null (`owner` | `admin` | `member`)
- `invited_by_user_id` uuid not null references `users.id`
- `token` varchar not null unique (random 32+ bytes, base64url)
- `expires_at` timestamptz not null (default `now() + interval '14 days'`)
- `accepted_at` timestamptz null
- `revoked_at` timestamptz null
- `created_at` timestamptz default now
- Unique partial index on `(workspace_id, lower(email)) where accepted_at is null and revoked_at is null` — prevents two open invites for the same email.
### tRPC procedures
In a new router `apps/web/server/routers/invites.ts`:
- `invites.create({ workspaceSlug, email, role })` — admin/owner only. Generates `token`, sends an invite email (later — for now just return the accept URL so an operator can paste it). Idempotent: if there's an open invite for that email/workspace, return it.
- `invites.list({ workspaceSlug })` — admin/owner only. Lists pending invites.
- `invites.revoke({ inviteId })` — admin/owner only. Sets `revoked_at`.
- `invites.accept({ token })`*public* procedure (no workspace scope). Validates token, requires authenticated session, and calls `userOwnsEmail(session.user.id, invite.email)` from `apps/web/server/lib/identity.ts` (built in `Task-multi-email-identity`). If the user does not own the invited email, render an explainer page directing them to link the email from their profile and try again — do NOT silently accept the invite under a mismatched identity. Owned → insert `workspace_members` row, set `accepted_at`, redirect to the workspace.
### Membership procedures
Extend the existing `workspaces` router (`apps/web/server/routers/workspaces.ts`):
- `workspaces.listMembers({ workspaceSlug })` — already exists per the teams page; verify.
- `workspaces.updateMemberRole({ workspaceSlug, userId, role })` — admin/owner only.
- `workspaces.removeMember({ workspaceSlug, userId })` — admin/owner only. Can't remove the last owner; raise `BAD_REQUEST` if attempted.
### UI
Extend `apps/web/app/(app)/[workspaceSlug]/teams/page.tsx`:
- Add "Invite teammate" button → dialog with email + role select. (A smart autocomplete combobox replaces the plain email input in `Task-invite-recipient-autocomplete`; this task ships the plain text input only.)
- Show pending invites in a separate section with "Copy invite link" and "Revoke".
- Per-member kebab menu: change role, remove. Hide for the current user; hide remove for the last owner.
Add a new route `apps/web/app/invite/[token]/page.tsx`:
- If not signed in, send to `/sign-in?callbackUrl=/invite/<token>`.
- If signed in, call `invites.accept` and redirect to the workspace.
### Email (optional first pass)
Don't block on actual email sending. Return the accept URL from `invites.create` and let the operator paste it. Add a follow-up task ("send invite emails via Resend/Postmark") once a provider is chosen.
## Subtasks
- [ ] Add `workspace_invites` schema in `packages/database/src/schema/workspaces.ts` (or a new file).
- [ ] Generate and commit the migration via `pnpm db:generate`.
- [ ] Add `apps/web/server/routers/invites.ts` and wire into `root.ts`.
- [ ] Add `updateMemberRole` and `removeMember` procedures.
- [ ] Add invite dialog and pending-invites section to teams page.
- [ ] Add `/invite/[token]` accept route.
- [ ] Verify end-to-end: owner A invites email B, B signs up with that email, lands in the workspace as member.
## Owner or assignee
Unassigned
## Status
ready
## Estimation
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`).
## Links to related Epic / Plan
- Epic: `./Epic-tenant-lifecycle.md`
- Plan: `../Plan-multitenant-saas-hardening.md`
- Depends on: `./Task-multi-email-identity.md`
- Followed by: `./Task-invite-recipient-autocomplete.md`