Status -> done. Acceptance criteria all checked off except the operator smoke test (full invite -> accept across two browsers), which requires a live dev stack. Added a 'design decisions captured here' section covering (a) why caller role is derived from workspace_members not from the resolve query, (b) the new tRPC errorFormatter that exposes error.cause, and (c) why the invite dialog doesn't offer 'owner' role even though the schema accepts it. Co-authored-by: Cursor <cursoragent@cursor.com>
117 lines
7 KiB
Markdown
117 lines
7 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: 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/<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
|
|
|
|
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`
|