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>
5.3 KiB
| kind | slug | title | plan_slug | epic_slug | status | priority | tenant_id | owner | cursor_todo_id | updated_at |
|---|---|---|---|---|---|---|---|---|---|---|
| task | workspace-invites-and-roles | Workspace invites, accept flow, and role management | multitenant-saas-hardening | tenant-lifecycle | ready | P1 | global | unassigned | null | 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 addsuserOwnsEmail()against the newuser_email_identitiestable; the accept procedure here calls it instead of doing a directusers.emailcompare. Recipient-autocomplete (typing a name and seeing existing members / pending invites surface) is split out asTask-invite-recipient-autocomplete.mdso this task stays PR-sized.
Description
workspace_members already exists. This task adds the invite layer on top.
Schema additions
New table workspace_invites:
iduuid pkworkspace_iduuid not null, referencesworkspaces.idon delete cascade, indexedemailvarchar not null (store lowercase — match the case-insensitive convention in migration 0004)rolevarchar not null (owner|admin|member)invited_by_user_iduuid not null referencesusers.idtokenvarchar not null unique (random 32+ bytes, base64url)expires_attimestamptz not null (defaultnow() + interval '14 days')accepted_attimestamptz nullrevoked_attimestamptz nullcreated_attimestamptz 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. Generatestoken, 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. Setsrevoked_at.invites.accept({ token })— public procedure (no workspace scope). Validates token, requires authenticated session, and callsuserOwnsEmail(session.user.id, invite.email)fromapps/web/server/lib/identity.ts(built inTask-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 → insertworkspace_membersrow, setaccepted_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; raiseBAD_REQUESTif 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.acceptand 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_invitesschema inpackages/database/src/schema/workspaces.ts(or a new file). - Generate and commit the migration via
pnpm db:generate. - Add
apps/web/server/routers/invites.tsand wire intoroot.ts. - Add
updateMemberRoleandremoveMemberprocedures. - 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