--- 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/`. - 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`