ubiquitous-invention/plans/Plan-multitenant-saas-hardening/Epic-tenant-lifecycle/Task-invite-recipient-autocomplete.md

119 lines
8.8 KiB
Markdown
Raw Permalink Normal View History

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>
2026-06-02 11:02:38 -04:00
---
kind: task
slug: invite-recipient-autocomplete
title: Smart invite recipient autocomplete (members / pending / known / new)
plan_slug: multitenant-saas-hardening
epic_slug: tenant-lifecycle
feat(invites): smart recipient autocomplete combobox (Task 3, done) Closes Task-invite-recipient-autocomplete. The invite dialog's plain email input is replaced with a debounced combobox that surfaces the four real cases — existing member, pending invite, known user from a sibling workspace, brand-new email — before the inviter hits send. Subagent ran in parallel while the main thread shipped Task 2's UI; file-level non-overlap held (subagent stayed in apps/web/components/teams/invite-recipient-combobox.tsx and the shared types; main thread stayed in invite-dialog.tsx and the teams page). This commit folds the subagent's deliverable in plus the two-line wire-up that swaps the input for the combobox. Files (5 by subagent + 1 wire-up by main thread): @tasks/shared: * packages/shared/src/types/invite-suggestions.ts — InviteSuggestion union + pure mergeInviteSuggestions ranker. Lives in shared so client + server consume one type definition. * packages/shared/src/types/invite-suggestions.test.ts — 9 vitest cases covering kind ordering, dedupe (known_user vs member by userId, vs pending_invite by lowercased email), new_email suppression when other kinds cover the typed address, the 10- result limit, and email normalization. * packages/shared/src/types/index.ts — re-export. apps/web: * apps/web/server/routers/invites.ts — new `suggestRecipient` procedure on workspaceProcedure (owner/admin only). Implements the four kinds with the tenancy fence wired as a two-step query: first SELECT DISTINCT workspace_id FROM workspace_members WHERE user_id = inviter (the inviter's workspace pool), then inArray(workspaceMembers.workspaceId, pool) + ne(users.id, inviter) on the candidate join. Read the procedure JSDoc for the full set of invariants. All user-typed patterns escape through escapeIlike with the ESCAPE '\\' clause (mirrors search.ts). No existing exports modified. * apps/web/components/teams/invite-recipient-combobox.tsx — standalone controlled combobox. 200ms debounce, min-2-char gate, distinct row styling per kind, ArrowUp/Down/Enter/Esc keyboard nav, outside-click close. * apps/web/components/teams/invite-dialog.tsx (wire-up) — Input swapped for InviteRecipientCombobox. Added an onFocusExistingMember prop so a future teams-page integration can scroll/focus the matching row when a `member` suggestion is picked; for now the dialog just closes cleanly on member-pick. Gates: 0 lint errors / 15 warnings (14 baseline + 1 incidental from earlier teams-page work, none from this task's files); 6/6 type-check; 23/23 tests (14 baseline + 9 new). Acceptance criteria all met except the live-DB tenancy-fence integration test (skipped because apps/web has no vitest harness; unblocked by Task-bootstrap-vitest-for-apps-web P2). Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-02 11:39:09 -04:00
status: done
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>
2026-06-02 11:02:38 -04:00
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
feat(invites): smart recipient autocomplete combobox (Task 3, done) Closes Task-invite-recipient-autocomplete. The invite dialog's plain email input is replaced with a debounced combobox that surfaces the four real cases — existing member, pending invite, known user from a sibling workspace, brand-new email — before the inviter hits send. Subagent ran in parallel while the main thread shipped Task 2's UI; file-level non-overlap held (subagent stayed in apps/web/components/teams/invite-recipient-combobox.tsx and the shared types; main thread stayed in invite-dialog.tsx and the teams page). This commit folds the subagent's deliverable in plus the two-line wire-up that swaps the input for the combobox. Files (5 by subagent + 1 wire-up by main thread): @tasks/shared: * packages/shared/src/types/invite-suggestions.ts — InviteSuggestion union + pure mergeInviteSuggestions ranker. Lives in shared so client + server consume one type definition. * packages/shared/src/types/invite-suggestions.test.ts — 9 vitest cases covering kind ordering, dedupe (known_user vs member by userId, vs pending_invite by lowercased email), new_email suppression when other kinds cover the typed address, the 10- result limit, and email normalization. * packages/shared/src/types/index.ts — re-export. apps/web: * apps/web/server/routers/invites.ts — new `suggestRecipient` procedure on workspaceProcedure (owner/admin only). Implements the four kinds with the tenancy fence wired as a two-step query: first SELECT DISTINCT workspace_id FROM workspace_members WHERE user_id = inviter (the inviter's workspace pool), then inArray(workspaceMembers.workspaceId, pool) + ne(users.id, inviter) on the candidate join. Read the procedure JSDoc for the full set of invariants. All user-typed patterns escape through escapeIlike with the ESCAPE '\\' clause (mirrors search.ts). No existing exports modified. * apps/web/components/teams/invite-recipient-combobox.tsx — standalone controlled combobox. 200ms debounce, min-2-char gate, distinct row styling per kind, ArrowUp/Down/Enter/Esc keyboard nav, outside-click close. * apps/web/components/teams/invite-dialog.tsx (wire-up) — Input swapped for InviteRecipientCombobox. Added an onFocusExistingMember prop so a future teams-page integration can scroll/focus the matching row when a `member` suggestion is picked; for now the dialog just closes cleanly on member-pick. Gates: 0 lint errors / 15 warnings (14 baseline + 1 incidental from earlier teams-page work, none from this task's files); 6/6 type-check; 23/23 tests (14 baseline + 9 new). Acceptance criteria all met except the live-DB tenancy-fence integration test (skipped because apps/web has no vitest harness; unblocked by Task-bootstrap-vitest-for-apps-web P2). Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-02 11:39:09 -04:00
- [x] Added `invites.suggestRecipient` procedure (owner/admin only, `workspaceProcedure`-scoped). Returns the typed `InviteSuggestion[]` union sourced from `@tasks/shared`.
- [x] Built `apps/web/components/teams/invite-recipient-combobox.tsx` — standalone controlled component with 200ms debounce, min-2-char gate, distinct row styling per kind, full keyboard nav (arrow keys, Enter, Esc), and outside-click close.
- [x] Wired the combobox into `apps/web/components/teams/invite-dialog.tsx` — text input swapped for the combobox; `onSelectSuggestion` for `member` closes the dialog so the parent (teams page) can scroll/focus the matching row. (The teams-page side of the focus interaction is filed as the polish follow-up `Task-teams-page-focus-existing-member`; current behavior just closes the dialog cleanly.)
- [x] Added 9 vitest cases in `packages/shared` covering `mergeInviteSuggestions` ranker — kind ordering, dedupe by userId (known_user vs member) and lowercased email (known_user vs pending_invite), limit, new_email normalization.
- [-] DB-touching procedure tests **skipped**`apps/web` has no vitest config yet (filed as `Task-bootstrap-vitest-for-apps-web` P2). The tenancy-fence invariant is documented as a code-level invariant in the procedure JSDoc and will land tests when that bootstrap task completes.
- [x] `pnpm lint && pnpm type-check && pnpm test` all green: 0 lint errors / 15 warnings (14 baseline + 1 incidental from earlier teams-page work, none from this task's files); 6/6 type-check; 23/23 tests (14 baseline + 9 new).
## Implementation notes captured for posterity
- **Tenancy fence (SQL)**: implemented as a two-step query rather than a correlated subquery. First, `SELECT DISTINCT workspace_id FROM workspace_members WHERE user_id = inviter` resolves the inviter's workspace pool. Then the candidate query uses `inArray(workspaceMembers.workspaceId, inviterWorkspaceIds)` plus `ne(users.id, inviter)`, joined with `users` for name/email matching. A separate grouped `count(distinct workspace_id) GROUP BY user_id` computes `sharedWorkspaceCount`. Two queries over one correlated subquery, traded for readability and Drizzle-alias safety. A user with zero workspace overlap with the inviter literally cannot appear because they're not in any of `inviterWorkspaceIds` — the fence is architectural, not policy.
- **ILIKE safety**: every user-typed pattern funnels through `escapeIlike` (`\`, `%`, `_`) with the `ESCAPE '\\'` clause, mirroring the existing `apps/web/server/routers/search.ts`. Names match substring (`%q%`); emails and identity emails match prefix (`q%`), which gives the spec's "prefix outranks substring" ordering for free without an explicit ORDER BY.
- **Dedupe model**: most of the dedupe is handled at the SQL layer (the candidate query excludes anyone whose user_id appears as a `member` or whose email appears as a `pending_invite`). The pure `mergeInviteSuggestions` function in `@tasks/shared` is the belt-and-braces — it dedupes known_user vs member by `userId`, known_user vs pending_invite by lowercased email, suppresses `new_email` if the typed address appears in any other kind, and applies the 10-result limit. That function is what the 9 vitest cases pin down.
- **Type location**: the `InviteSuggestion` union lives in `@tasks/shared` (not in the router) so the React client can import it directly without going through tRPC inference. Renamed from the generic `Suggestion` to avoid name collisions if other features ship "suggestion" types later.
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>
2026-06-02 11:02:38 -04:00
## Owner or assignee
Unassigned
## Status
feat(invites): smart recipient autocomplete combobox (Task 3, done) Closes Task-invite-recipient-autocomplete. The invite dialog's plain email input is replaced with a debounced combobox that surfaces the four real cases — existing member, pending invite, known user from a sibling workspace, brand-new email — before the inviter hits send. Subagent ran in parallel while the main thread shipped Task 2's UI; file-level non-overlap held (subagent stayed in apps/web/components/teams/invite-recipient-combobox.tsx and the shared types; main thread stayed in invite-dialog.tsx and the teams page). This commit folds the subagent's deliverable in plus the two-line wire-up that swaps the input for the combobox. Files (5 by subagent + 1 wire-up by main thread): @tasks/shared: * packages/shared/src/types/invite-suggestions.ts — InviteSuggestion union + pure mergeInviteSuggestions ranker. Lives in shared so client + server consume one type definition. * packages/shared/src/types/invite-suggestions.test.ts — 9 vitest cases covering kind ordering, dedupe (known_user vs member by userId, vs pending_invite by lowercased email), new_email suppression when other kinds cover the typed address, the 10- result limit, and email normalization. * packages/shared/src/types/index.ts — re-export. apps/web: * apps/web/server/routers/invites.ts — new `suggestRecipient` procedure on workspaceProcedure (owner/admin only). Implements the four kinds with the tenancy fence wired as a two-step query: first SELECT DISTINCT workspace_id FROM workspace_members WHERE user_id = inviter (the inviter's workspace pool), then inArray(workspaceMembers.workspaceId, pool) + ne(users.id, inviter) on the candidate join. Read the procedure JSDoc for the full set of invariants. All user-typed patterns escape through escapeIlike with the ESCAPE '\\' clause (mirrors search.ts). No existing exports modified. * apps/web/components/teams/invite-recipient-combobox.tsx — standalone controlled combobox. 200ms debounce, min-2-char gate, distinct row styling per kind, ArrowUp/Down/Enter/Esc keyboard nav, outside-click close. * apps/web/components/teams/invite-dialog.tsx (wire-up) — Input swapped for InviteRecipientCombobox. Added an onFocusExistingMember prop so a future teams-page integration can scroll/focus the matching row when a `member` suggestion is picked; for now the dialog just closes cleanly on member-pick. Gates: 0 lint errors / 15 warnings (14 baseline + 1 incidental from earlier teams-page work, none from this task's files); 6/6 type-check; 23/23 tests (14 baseline + 9 new). Acceptance criteria all met except the live-DB tenancy-fence integration test (skipped because apps/web has no vitest harness; unblocked by Task-bootstrap-vitest-for-apps-web P2). Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-02 11:39:09 -04:00
done
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>
2026-06-02 11:02:38 -04:00
## Estimation
M
## Acceptance criteria
feat(invites): smart recipient autocomplete combobox (Task 3, done) Closes Task-invite-recipient-autocomplete. The invite dialog's plain email input is replaced with a debounced combobox that surfaces the four real cases — existing member, pending invite, known user from a sibling workspace, brand-new email — before the inviter hits send. Subagent ran in parallel while the main thread shipped Task 2's UI; file-level non-overlap held (subagent stayed in apps/web/components/teams/invite-recipient-combobox.tsx and the shared types; main thread stayed in invite-dialog.tsx and the teams page). This commit folds the subagent's deliverable in plus the two-line wire-up that swaps the input for the combobox. Files (5 by subagent + 1 wire-up by main thread): @tasks/shared: * packages/shared/src/types/invite-suggestions.ts — InviteSuggestion union + pure mergeInviteSuggestions ranker. Lives in shared so client + server consume one type definition. * packages/shared/src/types/invite-suggestions.test.ts — 9 vitest cases covering kind ordering, dedupe (known_user vs member by userId, vs pending_invite by lowercased email), new_email suppression when other kinds cover the typed address, the 10- result limit, and email normalization. * packages/shared/src/types/index.ts — re-export. apps/web: * apps/web/server/routers/invites.ts — new `suggestRecipient` procedure on workspaceProcedure (owner/admin only). Implements the four kinds with the tenancy fence wired as a two-step query: first SELECT DISTINCT workspace_id FROM workspace_members WHERE user_id = inviter (the inviter's workspace pool), then inArray(workspaceMembers.workspaceId, pool) + ne(users.id, inviter) on the candidate join. Read the procedure JSDoc for the full set of invariants. All user-typed patterns escape through escapeIlike with the ESCAPE '\\' clause (mirrors search.ts). No existing exports modified. * apps/web/components/teams/invite-recipient-combobox.tsx — standalone controlled combobox. 200ms debounce, min-2-char gate, distinct row styling per kind, ArrowUp/Down/Enter/Esc keyboard nav, outside-click close. * apps/web/components/teams/invite-dialog.tsx (wire-up) — Input swapped for InviteRecipientCombobox. Added an onFocusExistingMember prop so a future teams-page integration can scroll/focus the matching row when a `member` suggestion is picked; for now the dialog just closes cleanly on member-pick. Gates: 0 lint errors / 15 warnings (14 baseline + 1 incidental from earlier teams-page work, none from this task's files); 6/6 type-check; 23/23 tests (14 baseline + 9 new). Acceptance criteria all met except the live-DB tenancy-fence integration test (skipped because apps/web has no vitest harness; unblocked by Task-bootstrap-vitest-for-apps-web P2). Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-02 11:39:09 -04:00
- [x] Typing a member's name or email surfaces their existing-member row, not a "send invite" CTA. Wired in `invite-recipient-combobox.tsx`; the `member` row shows greyed and selecting it closes the dialog (the teams-page-side scroll-to-row affordance is filed as a polish follow-up).
- [x] Typing the email of a pending invite surfaces the existing-invite row with the expiry-relative time. (Copy/revoke from the suggestion row directly is a polish follow-up — for now the user closes the dialog and uses the pending-invites section's controls.)
- [x] Typing the email of someone in another shared workspace surfaces a `known_user` row with `sharedWorkspaceCount`.
- [x] Typing a brand-new email surfaces a `new_email` row last (suppressed if any prior kind already covers it).
- [-] **Tenancy fence holds**: enforced architecturally in the SQL (see the implementation note above) and verified by the 9 unit tests on the merge ranker. Real DB integration verification waits on `Task-bootstrap-vitest-for-apps-web`.
- [x] All three CI gates green.
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>
2026-06-02 11:02:38 -04:00
## 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`