docs(plans): split workspace-invites convoy into identity + invites + autocomplete
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>
This commit is contained in:
parent
48defb2ffd
commit
820dae6510
7 changed files with 418 additions and 6 deletions
|
|
@ -7,7 +7,7 @@ status: ready
|
||||||
priority: P1
|
priority: P1
|
||||||
tenant_id: global
|
tenant_id: global
|
||||||
cursor_epic_id: null
|
cursor_epic_id: null
|
||||||
updated_at: "2026-06-01"
|
updated_at: "2026-06-02"
|
||||||
---
|
---
|
||||||
|
|
||||||
# Epic objective
|
# Epic objective
|
||||||
|
|
@ -18,7 +18,9 @@ Cover the operational edges of running a multitenant app: who's in the workspace
|
||||||
|
|
||||||
**In scope**
|
**In scope**
|
||||||
|
|
||||||
|
- **Multi-email identity** on user profile (foundation — one user can own multiple verified emails).
|
||||||
- Invite + role management (`workspace_members` already exists; add invite flow on top).
|
- Invite + role management (`workspace_members` already exists; add invite flow on top).
|
||||||
|
- Smart invite-recipient autocomplete (members / pending / known users / new email).
|
||||||
- Append-only `audit_log` table with write hooks.
|
- Append-only `audit_log` table with write hooks.
|
||||||
- Soft-delete on workspaces and core tenant-scoped tables.
|
- Soft-delete on workspaces and core tenant-scoped tables.
|
||||||
- Rate-limiting middleware on auth + mutation routes.
|
- Rate-limiting middleware on auth + mutation routes.
|
||||||
|
|
@ -32,10 +34,20 @@ Cover the operational edges of running a multitenant app: who's in the workspace
|
||||||
|
|
||||||
| Task | Link |
|
| Task | Link |
|
||||||
|------|------|
|
|------|------|
|
||||||
|
| Multi-email identity on user profile | `./Task-multi-email-identity.md` |
|
||||||
| Workspace invites and roles | `./Task-workspace-invites-and-roles.md` |
|
| Workspace invites and roles | `./Task-workspace-invites-and-roles.md` |
|
||||||
|
| Invite recipient autocomplete | `./Task-invite-recipient-autocomplete.md` |
|
||||||
| Workspace soft-delete and audit | `./Task-workspace-soft-delete-and-audit.md` |
|
| Workspace soft-delete and audit | `./Task-workspace-soft-delete-and-audit.md` |
|
||||||
| Rate limit and abuse guardrails | `./Task-rate-limit-and-abuse-guardrails.md` |
|
| Rate limit and abuse guardrails | `./Task-rate-limit-and-abuse-guardrails.md` |
|
||||||
|
|
||||||
|
### Follow-ups (filed, not in this convoy)
|
||||||
|
|
||||||
|
| Task | Link | Why deferred |
|
||||||
|
|------|------|--------------|
|
||||||
|
| Manual email verification | `./Task-manual-email-verification.md` | Add an email outside an OAuth provider; needs verification token + send + entry UI. |
|
||||||
|
| Disconnect linked email | `./Task-disconnect-linked-email.md` | Has destructive edge cases (last verified email, primary swap). |
|
||||||
|
| Account merge | `./Task-account-merge.md` | Merge two existing users who turn out to share an email — security-sensitive, not v1. |
|
||||||
|
|
||||||
## Dependencies
|
## Dependencies
|
||||||
|
|
||||||
- Depends on: `Plan-daily-driver-finish` (don't harden surfaces that are still stubs).
|
- Depends on: `Plan-daily-driver-finish` (don't harden surfaces that are still stubs).
|
||||||
|
|
@ -52,6 +64,8 @@ Cover the operational edges of running a multitenant app: who's in the workspace
|
||||||
|
|
||||||
| Phase | Window | Notes |
|
| Phase | Window | Notes |
|
||||||
|-------|--------|-------|
|
|-------|--------|-------|
|
||||||
| Invites | Week 1 | Owner can add a teammate end-to-end. |
|
| Identity foundation | Week 1 (first half) | `Task-multi-email-identity` lands before any invite work touches the sign-in path. |
|
||||||
| Audit + soft-delete | Week 2 | Schema + middleware. |
|
| Invites | Week 1 (second half) | `Task-workspace-invites-and-roles` end-to-end with copy-paste URL. |
|
||||||
|
| Invite polish | Week 2 (first half) | `Task-invite-recipient-autocomplete`. Can parallelize with audit/soft-delete. |
|
||||||
|
| Audit + soft-delete | Week 2 (second half) | Schema + middleware. |
|
||||||
| Guardrails | Week 3 | Rate limits + abuse signals. |
|
| Guardrails | Week 3 | Rate limits + abuse signals. |
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
---
|
||||||
|
kind: task
|
||||||
|
slug: account-merge
|
||||||
|
title: Merge two existing user accounts that turn out to share an email
|
||||||
|
plan_slug: multitenant-saas-hardening
|
||||||
|
epic_slug: tenant-lifecycle
|
||||||
|
status: draft
|
||||||
|
priority: P2
|
||||||
|
tenant_id: global
|
||||||
|
owner: unassigned
|
||||||
|
cursor_todo_id: null
|
||||||
|
updated_at: "2026-06-02"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Task summary
|
||||||
|
|
||||||
|
Handle the case where a user has, historically, created two separate `users` rows (one via GitHub OAuth, one via Credentials, etc.) and now wants them merged into a single identity. The current behavior in `Task-multi-email-identity` is to *reject* the OAuth sign-in when the email collides with another user — this task is the operator-facing fix.
|
||||||
|
|
||||||
|
## Why deferred from the invites convoy
|
||||||
|
|
||||||
|
Account merge is security-sensitive and has cascading data implications:
|
||||||
|
|
||||||
|
- Both users may have authored `objects`, posted comments, owned workspaces, accepted invites.
|
||||||
|
- After merge, all FKs on the losing user's id need to point to the winning user's id, in one transaction.
|
||||||
|
- Some FKs have unique constraints (e.g., `workspace_members(workspace_id, user_id)`) — merging two users who are both in the same workspace creates a conflict that has to be resolved by keeping the higher-role row.
|
||||||
|
- Requires both accounts to prove ownership (re-sign-in to the losing account, then OAuth-verify the winning account's email).
|
||||||
|
|
||||||
|
This is its own multi-day effort. Out of scope for the invites convoy.
|
||||||
|
|
||||||
|
## Scope (sketch — refine when scheduled)
|
||||||
|
|
||||||
|
- Detection: when `ensureUserIdByVerifiedEmail` would reject an OAuth sign-in because the email is verified on another user, instead surface a "we found another account with this email — merge?" affordance.
|
||||||
|
- Merge procedure: a transactional operator that rewires FKs from the losing user to the winning user, handles unique-constraint collisions per table, and writes a comprehensive audit-log entry.
|
||||||
|
- UI: profile-side "Merge another account into this one" flow that requires authenticating to both.
|
||||||
|
|
||||||
|
## Acceptance criteria (draft)
|
||||||
|
|
||||||
|
- [ ] All FKs that referenced the losing user now reference the winning user; no orphans.
|
||||||
|
- [ ] Workspace-membership conflicts resolved with the higher-role row kept.
|
||||||
|
- [ ] Audit-log entry includes a snapshot of every rewired FK count.
|
||||||
|
- [ ] Losing user row is hard-deleted only after a 30-day window during which the merge can be reverted (soft-delete first; reuses `Task-workspace-soft-delete-and-audit` semantics).
|
||||||
|
|
||||||
|
## Links
|
||||||
|
|
||||||
|
- Epic: `./Epic-tenant-lifecycle.md`
|
||||||
|
- Depends on: `./Task-multi-email-identity.md`, `./Task-workspace-soft-delete-and-audit.md`.
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
---
|
||||||
|
kind: task
|
||||||
|
slug: disconnect-linked-email
|
||||||
|
title: Disconnect a linked email from a user's profile
|
||||||
|
plan_slug: multitenant-saas-hardening
|
||||||
|
epic_slug: tenant-lifecycle
|
||||||
|
status: draft
|
||||||
|
priority: P2
|
||||||
|
tenant_id: global
|
||||||
|
owner: unassigned
|
||||||
|
cursor_todo_id: null
|
||||||
|
updated_at: "2026-06-02"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Task summary
|
||||||
|
|
||||||
|
Let a user remove a linked email from their profile. Sounds simple; isn't. Removing the wrong row can either lock the user out (no verified email = can't be matched on next sign-in) or strand an invite that was bound to that email.
|
||||||
|
|
||||||
|
## Why deferred from the invites convoy
|
||||||
|
|
||||||
|
`Task-multi-email-identity` ships the "Linked emails" list as **read-only**. Adding a remove action requires:
|
||||||
|
|
||||||
|
- Server-side guard: cannot remove the user's last verified identity. Cannot remove the `source='primary'` identity unless another verified identity is promoted to primary in the same transaction.
|
||||||
|
- UX for the "this email has 2 pending workspace invites — accept or revoke them first" case.
|
||||||
|
- Audit-log entry (overlaps with `Task-workspace-soft-delete-and-audit`).
|
||||||
|
- Confirmation modal with clear "this will affect: pending invites X, Y" copy.
|
||||||
|
|
||||||
|
## Scope (sketch — refine when scheduled)
|
||||||
|
|
||||||
|
- `identity.disconnectEmail({ identityId })` procedure with the guards above.
|
||||||
|
- Profile UI: confirmation modal that lists pending-invite impact before the destructive action.
|
||||||
|
- If the disconnected identity was `source='primary'`, prompt to choose a new primary from the remaining verified set.
|
||||||
|
|
||||||
|
## Acceptance criteria (draft)
|
||||||
|
|
||||||
|
- [ ] Cannot disconnect the only verified identity on an account.
|
||||||
|
- [ ] Disconnecting an email that has pending invites surfaces those invites in the confirmation flow.
|
||||||
|
- [ ] Audit-log row written for every disconnect.
|
||||||
|
|
||||||
|
## Links
|
||||||
|
|
||||||
|
- Epic: `./Epic-tenant-lifecycle.md`
|
||||||
|
- Depends on: `./Task-multi-email-identity.md`, `./Task-workspace-soft-delete-and-audit.md` (for audit log).
|
||||||
|
|
@ -0,0 +1,111 @@
|
||||||
|
---
|
||||||
|
kind: task
|
||||||
|
slug: invite-recipient-autocomplete
|
||||||
|
title: Smart invite recipient autocomplete (members / pending / known / new)
|
||||||
|
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
|
||||||
|
|
||||||
|
Replace the plain "type an email" field in the invite dialog with a debounced combobox that surfaces the four real cases (already a member / pending invite / known user in your other workspaces / brand-new email) before the inviter even hits submit. Reduces the "wait, I already invited them" UX bug and the "I can't remember if she's in this workspace yet" papercut.
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
This is the polish task in the invites convoy. Depends on `Task-multi-email-identity` (Task 1) for identity-aware matching and `Task-workspace-invites-and-roles` (Task 2) for the invites table.
|
||||||
|
|
||||||
|
### tRPC procedure
|
||||||
|
|
||||||
|
Add to `apps/web/server/routers/invites.ts` (created in Task 2):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
invites.suggestRecipient({ workspaceSlug, query })
|
||||||
|
```
|
||||||
|
|
||||||
|
Permission: owner/admin only (same as `invites.create`).
|
||||||
|
|
||||||
|
Returns a typed result array. Each suggestion is one of:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type Suggestion =
|
||||||
|
| { kind: "member"; userId: string; name: string; email: string; role: Role }
|
||||||
|
| { kind: "pending_invite"; inviteId: string; email: string; role: Role; expiresAt: string }
|
||||||
|
| { kind: "known_user"; userId: string; name: string; email: string; sharedWorkspaceCount: number }
|
||||||
|
| { kind: "new_email"; email: string; valid: boolean };
|
||||||
|
```
|
||||||
|
|
||||||
|
Search rules (each scoped so tenancy can't leak):
|
||||||
|
|
||||||
|
1. **member** — search `workspace_members` JOIN `users` where workspace = current and (`users.name ilike '%q%'` OR `email ilike 'q%'`). Identity emails included via the new identities table from Task 1.
|
||||||
|
2. **pending_invite** — open invites on this workspace whose lowercased email starts with `q`.
|
||||||
|
3. **known_user** — users in *any* workspace the **inviter** is also in. Match name or any of their verified identity emails. Excludes anyone already returned in (1) or (2). Returns `sharedWorkspaceCount` so the UI can say *"in 3 of your workspaces."*
|
||||||
|
|
||||||
|
**Tenancy fence**: this MUST be scoped to workspaces the inviter shares with the candidate. A global user-search procedure would leak existence cross-tenant. Test for this explicitly.
|
||||||
|
|
||||||
|
4. **new_email** — if `query` parses as a valid email and isn't covered by (1)/(2)/(3), return it once.
|
||||||
|
|
||||||
|
Order suggestions by kind (member → pending_invite → known_user → new_email). Limit to ~10 total. Empty/<2-char query returns `[]`.
|
||||||
|
|
||||||
|
### UI changes
|
||||||
|
|
||||||
|
Replace the email text input in the existing invite dialog (built in Task 2) with a combobox. Debounce 200ms. Min 2 chars.
|
||||||
|
|
||||||
|
Render each suggestion type differently:
|
||||||
|
|
||||||
|
- **member** — greyed-out row with subtle "Already a member · click to scroll to their row." Selecting closes the dialog and scrolls/highlights the matching row in the team list.
|
||||||
|
- **pending_invite** — row with "Invite already sent · expires <relative time>." Buttons: `Copy link`, `Revoke`.
|
||||||
|
- **known_user** — primary "Invite as member" action, footer text "in N of your workspaces." Default role from the role select stays.
|
||||||
|
- **new_email** — "Send invite to alice@gmail.com" with the role select alongside. This is the existing behavior, just now framed as one option among many.
|
||||||
|
|
||||||
|
If `query` is unambiguously an email (matches regex) but matches a member or pending invite, still show the member/pending row *above* the "Send new invite" affordance — don't hide the canonical case.
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
Vitest, in `apps/web` (or wherever the invites router tests live after Task 2):
|
||||||
|
|
||||||
|
- One unit test per suggestion `kind`.
|
||||||
|
- One tenancy-fence test: inviter is in workspace X, target user is in workspace Y, no overlap → target does NOT appear under `known_user`. (This is the security-relevant assertion.)
|
||||||
|
- One ordering test: typing a string that matches all four kinds returns them in `member → pending_invite → known_user → new_email` order.
|
||||||
|
- One ranking test: prefix matches outrank substring matches for email.
|
||||||
|
|
||||||
|
## Subtasks
|
||||||
|
|
||||||
|
- [ ] Add `invites.suggestRecipient` procedure to `apps/web/server/routers/invites.ts`.
|
||||||
|
- [ ] Build the combobox UI in `apps/web/components/teams/invite-dialog.tsx` (or wherever Task 2 placed it).
|
||||||
|
- [ ] Render each `kind` with its own row styling and action.
|
||||||
|
- [ ] Wire the "scroll to existing member" interaction when a `member` suggestion is selected.
|
||||||
|
- [ ] Vitest: per-kind unit tests + tenancy fence test + ordering test.
|
||||||
|
- [ ] Run `pnpm lint && pnpm type-check && pnpm test` clean.
|
||||||
|
|
||||||
|
## Owner or assignee
|
||||||
|
|
||||||
|
Unassigned
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
ready
|
||||||
|
|
||||||
|
## Estimation
|
||||||
|
|
||||||
|
M
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [ ] Typing a member's name or email surfaces their existing-member row, not a "send invite" CTA.
|
||||||
|
- [ ] Typing the email of a pending invite surfaces the existing-invite row with copy/revoke.
|
||||||
|
- [ ] Typing the email of someone in another shared workspace surfaces a `known_user` row.
|
||||||
|
- [ ] Typing a brand-new email surfaces a `new_email` row last.
|
||||||
|
- [ ] **Tenancy fence holds**: a user with no shared workspace overlap with the inviter does not appear under any kind, even by exact email match.
|
||||||
|
- [ ] All three CI gates green.
|
||||||
|
|
||||||
|
## Links to related Epic / Plan
|
||||||
|
|
||||||
|
- Epic: `./Epic-tenant-lifecycle.md`
|
||||||
|
- Plan: `../Plan-multitenant-saas-hardening.md`
|
||||||
|
- Depends on: `./Task-multi-email-identity.md`, `./Task-workspace-invites-and-roles.md`
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
---
|
||||||
|
kind: task
|
||||||
|
slug: manual-email-verification
|
||||||
|
title: Manual email verification — add an email outside an OAuth provider
|
||||||
|
plan_slug: multitenant-saas-hardening
|
||||||
|
epic_slug: tenant-lifecycle
|
||||||
|
status: draft
|
||||||
|
priority: P2
|
||||||
|
tenant_id: global
|
||||||
|
owner: unassigned
|
||||||
|
cursor_todo_id: null
|
||||||
|
updated_at: "2026-06-02"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Task summary
|
||||||
|
|
||||||
|
Let a user add a new email to their profile (one they don't have an OAuth account for) by typing it, receiving a one-time verification code, and entering the code. Required so people can accept invites sent to emails they only use for forwarding / aliases / etc.
|
||||||
|
|
||||||
|
## Why deferred from the invites convoy
|
||||||
|
|
||||||
|
`Task-multi-email-identity` populates the identities table from OAuth provider claims only. That covers the common case (sign in via GitHub, GitHub knows your verified GitHub email). It does NOT cover: "I have a personal Gmail I never use to log in, and someone invited me at that address."
|
||||||
|
|
||||||
|
This is real product surface but requires:
|
||||||
|
|
||||||
|
- Verification token table (or reuse `verification_tokens` which NextAuth already provides).
|
||||||
|
- An outbound email send — needs a chosen provider (Resend / Postmark / SES). The invites convoy explicitly deferred email-send to a follow-up because picking a provider is its own discussion.
|
||||||
|
- Rate-limit on code requests (3/hour/email) — overlaps with `Task-rate-limit-and-abuse-guardrails`.
|
||||||
|
- A code-entry UI on the profile page.
|
||||||
|
|
||||||
|
## Scope (sketch — refine when scheduled)
|
||||||
|
|
||||||
|
- Profile UI: "Add email" button → input → "Send code" → 6-digit code entry.
|
||||||
|
- Backend: `identity.requestEmailVerification({ email })` (rate-limited), `identity.confirmEmailVerification({ email, code })`.
|
||||||
|
- On confirm, insert `user_email_identities` with `source='manual'`, `verified_at=now()`.
|
||||||
|
- Reject if the email is already a verified identity on another user (same rule as OAuth case).
|
||||||
|
|
||||||
|
## Acceptance criteria (draft)
|
||||||
|
|
||||||
|
- [ ] User can add a verified email to their profile without going through OAuth.
|
||||||
|
- [ ] Codes expire in 15 minutes; max 3 sends per email per hour.
|
||||||
|
- [ ] Confirmed emails appear in the "Linked emails" section with `source='manual'`.
|
||||||
|
|
||||||
|
## Links
|
||||||
|
|
||||||
|
- Epic: `./Epic-tenant-lifecycle.md`
|
||||||
|
- Depends on: `./Task-multi-email-identity.md`, transactional email provider decision.
|
||||||
|
|
@ -0,0 +1,147 @@
|
||||||
|
---
|
||||||
|
kind: task
|
||||||
|
slug: multi-email-identity
|
||||||
|
title: Multi-email identity on user profile (foundation for invite-by-email)
|
||||||
|
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
|
||||||
|
|
||||||
|
Decouple application identity from a single `users.email` column. Add a `user_email_identities` table so one user can own multiple verified emails (primary + OAuth-claimed + later manual). Required so the invite-acceptance flow can correctly resolve "the human at alice@gmail.com" even when they're signed in via GitHub as alice@personal.
|
||||||
|
|
||||||
|
This is the *foundation* task for the invites convoy. Lands before `Task-workspace-invites-and-roles` because it changes the sign-in path that invite-accept depends on.
|
||||||
|
|
||||||
|
## Why now (and why not later)
|
||||||
|
|
||||||
|
The current `ensureUserIdByEmail` in `apps/web/lib/auth.ts` collapses sign-in to a case-insensitive lookup on `users.email`. Concrete consequence: a user who signs up with `alice@personal.com` via GitHub today and then signs in with `alice@gmail.com` via Google tomorrow ends up as **two separate `users` rows**, neither of which is "the user." Every multitenant feature we layer on top — invites, audit log attribution, billing — would inherit this confusion.
|
||||||
|
|
||||||
|
Doing it right once is cheaper than dragging it for the next year.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
### Schema additions
|
||||||
|
|
||||||
|
New table in `packages/database/src/schema/users.ts` (kept colocated with `users` since they're logically the same identity surface):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export const userEmailIdentities = pgTable(
|
||||||
|
"user_email_identities",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
userId: uuid("user_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id, { onDelete: "cascade" }),
|
||||||
|
email: varchar("email", { length: 255 }).notNull(), // stored lowercase
|
||||||
|
verifiedAt: timestamp("verified_at", { withTimezone: true }),
|
||||||
|
source: varchar("source", { length: 30 }).notNull(),
|
||||||
|
// 'primary' | 'oauth:github' | 'oauth:google' | 'oauth:authentik' | 'manual'
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
lastUsedAt: timestamp("last_used_at", { withTimezone: true }),
|
||||||
|
},
|
||||||
|
(table) => ({
|
||||||
|
userIdx: index("user_email_identities_user_id_idx").on(table.userId),
|
||||||
|
emailIdx: index("user_email_identities_email_idx").on(table.email),
|
||||||
|
userEmailUnique: uniqueIndex("user_email_identities_user_id_email_unique").on(
|
||||||
|
table.userId,
|
||||||
|
table.email,
|
||||||
|
),
|
||||||
|
// A verified email belongs to exactly one user globally.
|
||||||
|
verifiedEmailUnique: uniqueIndex("user_email_identities_verified_email_unique").on(
|
||||||
|
table.email,
|
||||||
|
).where(sql`verified_at IS NOT NULL`),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Migration
|
||||||
|
|
||||||
|
Generate with `pnpm db:generate`. The migration must include a **backfill** step: insert one row per existing `users` row with `source='primary'`, `email=lower(users.email)`, `verified_at=users.created_at` (we trust existing rows because we minted them).
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- inside the generated migration, after CREATE TABLE
|
||||||
|
INSERT INTO user_email_identities (user_id, email, verified_at, source, created_at)
|
||||||
|
SELECT id, lower(email), created_at, 'primary', created_at FROM users
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Auth wiring
|
||||||
|
|
||||||
|
Refactor `apps/web/lib/auth.ts`:
|
||||||
|
|
||||||
|
- Rename `ensureUserIdByEmail` → `ensureUserIdByVerifiedEmail`. Lookup hits `user_email_identities WHERE email = lower(?) AND verified_at IS NOT NULL` first. On miss, create a new user + a `source='primary'` identity in one transaction.
|
||||||
|
- Add a callback hook (see research subagent recommendation — likely `signIn` or `jwt`) that, on OAuth sign-in where `profile.email_verified === true`, upserts a `source='oauth:<provider>'` identity for that user. If the email is already a verified identity belonging to a *different* user → reject the sign-in with a clear error.
|
||||||
|
- Bump `last_used_at` on the identity row that actually authenticated this session.
|
||||||
|
|
||||||
|
### Server-side helper
|
||||||
|
|
||||||
|
Export `userOwnsEmail(userId: string, emailLower: string): Promise<boolean>` from `apps/web/server/lib/identity.ts` (new file). Returns true iff `user_email_identities` has a row with that user + lowercased email + `verified_at IS NOT NULL`. This is the function `Task-workspace-invites-and-roles` will call.
|
||||||
|
|
||||||
|
### Profile UI
|
||||||
|
|
||||||
|
Add a "Linked emails" section to the profile/settings page. Lists each identity with:
|
||||||
|
|
||||||
|
- Email (lowercased display)
|
||||||
|
- Source badge (`Primary`, `GitHub`, `Google`, `Authentik`, `Manual`)
|
||||||
|
- Verified state (icon if verified, "Pending verification" otherwise)
|
||||||
|
- Last used (relative time)
|
||||||
|
|
||||||
|
**Read-only in this task.** Adding a "Disconnect" button is `Task-disconnect-linked-email` (filed separately because it has destructive edge cases — disconnecting your last verified email locks you out).
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
Vitest is wired now, so add real assertions:
|
||||||
|
|
||||||
|
- `packages/database` or `apps/web` (test runner can land in either): identity-table CRUD round-trip.
|
||||||
|
- Sign-in callback writes a new identity on OAuth login.
|
||||||
|
- Sign-in callback rejects when the OAuth email is verified for another user.
|
||||||
|
- `userOwnsEmail` returns the right boolean across all four identity states (primary/oauth/manual/missing).
|
||||||
|
|
||||||
|
## Subtasks
|
||||||
|
|
||||||
|
- [ ] Add `user_email_identities` schema in `packages/database/src/schema/users.ts`.
|
||||||
|
- [ ] Generate migration via `pnpm db:generate`, hand-augment with the backfill `INSERT`.
|
||||||
|
- [ ] Refactor `apps/web/lib/auth.ts`: rename to `ensureUserIdByVerifiedEmail`, add OAuth identity upsert, add cross-user conflict rejection.
|
||||||
|
- [ ] Create `apps/web/server/lib/identity.ts` with `userOwnsEmail`.
|
||||||
|
- [ ] Add "Linked emails" read-only section on the profile/settings page.
|
||||||
|
- [ ] Add vitest tests for `userOwnsEmail` + callback behavior.
|
||||||
|
- [ ] Run `pnpm lint && pnpm type-check && pnpm test` clean.
|
||||||
|
- [ ] Smoke test: sign out, sign back in via the same provider, verify only one identity row (no duplicates).
|
||||||
|
|
||||||
|
## Owner or assignee
|
||||||
|
|
||||||
|
Unassigned
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
ready
|
||||||
|
|
||||||
|
## Estimation
|
||||||
|
|
||||||
|
M-L
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [ ] An existing user can sign out, sign back in via OAuth with a different verified email than their `users.email`, and end up resolved to the same `users.id`. Profile page lists both emails.
|
||||||
|
- [ ] A second user attempting to sign in via OAuth with an email that's already verified on another user is rejected with a clear error.
|
||||||
|
- [ ] `userOwnsEmail(userId, emailLower)` returns the correct boolean for all four cases (primary owned, oauth owned, manual owned, not owned).
|
||||||
|
- [ ] Existing users have one `primary` identity row each after migration.
|
||||||
|
- [ ] All three CI gates green.
|
||||||
|
|
||||||
|
## Follow-ups explicitly NOT in scope
|
||||||
|
|
||||||
|
- `Task-manual-email-verification.md` — type a new email, get a one-time code, verify.
|
||||||
|
- `Task-disconnect-linked-email.md` — unlink with safety checks (last-verified protection).
|
||||||
|
- `Task-account-merge.md` — merge two existing users who turn out to share an email.
|
||||||
|
|
||||||
|
## Links to related Epic / Plan
|
||||||
|
|
||||||
|
- Epic: `./Epic-tenant-lifecycle.md`
|
||||||
|
- Plan: `../Plan-multitenant-saas-hardening.md`
|
||||||
|
- Blocks: `./Task-workspace-invites-and-roles.md`, `./Task-invite-recipient-autocomplete.md`
|
||||||
|
|
@ -9,13 +9,15 @@ priority: P1
|
||||||
tenant_id: global
|
tenant_id: global
|
||||||
owner: unassigned
|
owner: unassigned
|
||||||
cursor_todo_id: null
|
cursor_todo_id: null
|
||||||
updated_at: "2026-06-01"
|
updated_at: "2026-06-02"
|
||||||
---
|
---
|
||||||
|
|
||||||
# Task summary
|
# 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.
|
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
|
## Description
|
||||||
|
|
||||||
`workspace_members` already exists. This task adds the *invite* layer on top.
|
`workspace_members` already exists. This task adds the *invite* layer on top.
|
||||||
|
|
@ -43,7 +45,7 @@ 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.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.list({ workspaceSlug })` — admin/owner only. Lists pending invites.
|
||||||
- `invites.revoke({ inviteId })` — admin/owner only. Sets `revoked_at`.
|
- `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`.
|
- `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
|
### Membership procedures
|
||||||
|
|
||||||
|
|
@ -57,7 +59,7 @@ Extend the existing `workspaces` router (`apps/web/server/routers/workspaces.ts`
|
||||||
|
|
||||||
Extend `apps/web/app/(app)/[workspaceSlug]/teams/page.tsx`:
|
Extend `apps/web/app/(app)/[workspaceSlug]/teams/page.tsx`:
|
||||||
|
|
||||||
- Add "Invite teammate" button → dialog with email + role select.
|
- 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".
|
- 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.
|
- Per-member kebab menu: change role, remove. Hide for the current user; hide remove for the last owner.
|
||||||
|
|
||||||
|
|
@ -98,8 +100,11 @@ L
|
||||||
- [ ] Cannot remove the last owner.
|
- [ ] Cannot remove the last owner.
|
||||||
- [ ] Duplicate-invite suppression works (one open invite per email per workspace).
|
- [ ] Duplicate-invite suppression works (one open invite per email per workspace).
|
||||||
- [ ] Accept route 404s for revoked / expired tokens.
|
- [ ] 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
|
## Links to related Epic / Plan
|
||||||
|
|
||||||
- Epic: `./Epic-tenant-lifecycle.md`
|
- Epic: `./Epic-tenant-lifecycle.md`
|
||||||
- Plan: `../Plan-multitenant-saas-hardening.md`
|
- Plan: `../Plan-multitenant-saas-hardening.md`
|
||||||
|
- Depends on: `./Task-multi-email-identity.md`
|
||||||
|
- Followed by: `./Task-invite-recipient-autocomplete.md`
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue