106 lines
4.1 KiB
Markdown
106 lines
4.1 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-01"
|
||
|
|
---
|
||
|
|
|
||
|
|
# 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.
|
||
|
|
|
||
|
|
## 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, inserts `workspace_members` row, sets `accepted_at`.
|
||
|
|
|
||
|
|
### 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.
|
||
|
|
- 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.
|
||
|
|
|
||
|
|
## Links to related Epic / Plan
|
||
|
|
|
||
|
|
- Epic: `./Epic-tenant-lifecycle.md`
|
||
|
|
- Plan: `../Plan-multitenant-saas-hardening.md`
|