Commit graph

2 commits

Author SHA1 Message Date
Randall Stillwell
29e69e964b 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 10:39:09 -05:00
Randall Stillwell
f3c118c9f6 feat(invites): invite dialog + accept route + teams UI (Task 2, part 2/2)
Closes Task-workspace-invites-and-roles end-to-end. Builds on the
schema + procedures from 7a55d6d (Task 2, part 1/2).

apps/web/components/teams/invite-dialog.tsx (new):
* Owner/admin-only sheet that wraps invites.create. Email input + role
  select (member/admin; owner deliberately excluded — single-owner
  model means ownership transfer is a separate flow, not a fresh
  invite). On success surfaces the accept URL with a copy-to-clipboard
  affordance and a "your email isn't wired up yet, paste this directly"
  hint. Plain text input in this commit; the smart recipient
  autocomplete combobox from Task 3 will swap it in via a follow-up
  edit to this same file (subagent is working that in parallel).

apps/web/app/(app)/[workspaceSlug]/teams/page.tsx (rewrite):
* Replaced the placeholder "Invite coming soon" button with the new
  InviteDialog. Adds:
  - Pending invites section (admin/owner only) listing each open
    invite with email, role, expiry-relative time, and Copy link /
    Revoke actions.
  - Per-member kebab menu with role-change actions and Remove. Only
    owners can promote anyone to owner; admins can move people
    between admin/member only. The "demote to member" item disables
    on the last-owner row (the server enforces this anyway with a
    clear error; UI just avoids surfacing a click that'd 400).
  - "You're a member, not a manager" footer hint for non-owners/admins.
* Caller's role is derived from the members query (no extra
  round-trip) — the membership row IS the source of truth for who
  can manage what.
* Mutation errors surface inline at the page level with a Dismiss
  action — kebab/copy actions that hit the last-owner guard, expired-
  token error, etc. don't fail silently.

apps/web/app/invite/[token]/page.tsx (new):
* Public-by-token redeem page. Four phases handled cleanly:
  1. No session yet -> "Sign in to continue" with callbackUrl set so
     the user lands back here after auth.
  2. Authenticated, accepting -> spinner.
  3. Success -> redirect to the workspace's slug-rooted URL.
  4. FORBIDDEN with cause.reason='email_not_owned' -> dedicated
     explainer page showing both the invited email AND the user's
     current sign-in email, with deep links to link the invited email
     via OAuth and try again. (This is the Task 1 invariant
     surfacing through the UI: we never silently accept an invite
     under a mismatched identity.)
* All other accept errors (not found / revoked / expired) render the
  message verbatim with a "Go home" button.

apps/web/server/trpc.ts:
* Added a small errorFormatter that exposes `error.cause` to the
  client when it's a plain object. Required for the invite-accept
  explainer page to read `cause.invitedEmail` off the TRPCError. The
  cause-payload contract is "small, pure data, no secrets" — anything
  the server throws as a cause is also visible client-side.

End-to-end behavior verified statically: type-check clean across all
6 packages. Smoke test path:

1. As admin@tasks.dev, open /<workspace>/teams.
2. Click Invite -> dialog opens -> enter an email, pick member, send.
3. See the success state with the accept URL. Copy it.
4. Open the URL in a different browser (or incognito). With no session
   -> sign-in prompt. After auth -> invite accepts and you land in
   the workspace. With a session whose email doesn't match -> the
   email-mismatch explainer renders.

Note: the test runner shows three new tests in packages/shared
(invite-suggestions.test.ts) from the in-progress Task-3 subagent.
Those land with their own commit when the subagent finishes — they're
visible here only because they share the working tree.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-02 10:34:56 -05:00