--- 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: done 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 done ## Estimation L ## Acceptance criteria - [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 - 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`