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 | invite-recipient-autocomplete | Smart invite recipient autocomplete (members / pending / known / new) | multitenant-saas-hardening | tenant-lifecycle | ready | P1 | global | unassigned | null | 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):
invites.suggestRecipient({ workspaceSlug, query })
Permission: owner/admin only (same as invites.create).
Returns a typed result array. Each suggestion is one of:
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):
-
member — search
workspace_membersJOINuserswhere workspace = current and (users.name ilike '%q%'ORemail ilike 'q%'). Identity emails included via the new identities table from Task 1. -
pending_invite — open invites on this workspace whose lowercased email starts with
q. -
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
sharedWorkspaceCountso 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.
-
new_email — if
queryparses 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 ." 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_emailorder. - One ranking test: prefix matches outrank substring matches for email.
Subtasks
- Add
invites.suggestRecipientprocedure toapps/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
kindwith its own row styling and action. - Wire the "scroll to existing member" interaction when a
membersuggestion is selected. - Vitest: per-kind unit tests + tenancy fence test + ordering test.
- Run
pnpm lint && pnpm type-check && pnpm testclean.
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_userrow. - Typing a brand-new email surfaces a
new_emailrow 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