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>
111 lines
5.3 KiB
Markdown
111 lines
5.3 KiB
Markdown
---
|
|
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`
|