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 11:34:56 -04:00
|
|
|
"use client";
|
|
|
|
|
|
|
|
|
|
import * as React from "react";
|
|
|
|
|
import { Check, Copy, Loader2, X } from "lucide-react";
|
|
|
|
|
|
|
|
|
|
import { Button } from "@/components/ui/button";
|
|
|
|
|
import { Input } from "@/components/ui/input";
|
|
|
|
|
import {
|
|
|
|
|
Sheet,
|
|
|
|
|
SheetContent,
|
|
|
|
|
SheetDescription,
|
|
|
|
|
SheetHeader,
|
|
|
|
|
SheetTitle,
|
|
|
|
|
SheetTrigger,
|
|
|
|
|
} from "@/components/ui/sheet";
|
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
|
|
|
import { InviteRecipientCombobox } from "@/components/teams/invite-recipient-combobox";
|
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 11:34:56 -04:00
|
|
|
import { api } from "@/lib/trpc";
|
|
|
|
|
import { cn } from "@/lib/utils";
|
|
|
|
|
|
|
|
|
|
type InviteRole = "admin" | "member";
|
|
|
|
|
|
|
|
|
|
interface InviteDialogProps {
|
|
|
|
|
workspaceSlug: string;
|
|
|
|
|
/** Children render as the trigger; default is a primary "Invite" button. */
|
|
|
|
|
children?: React.ReactNode;
|
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
|
|
|
/**
|
|
|
|
|
* Optional. Called when the inviter picks an "already a member" suggestion
|
|
|
|
|
* from the autocomplete. Lets the teams page close the dialog and scroll
|
|
|
|
|
* the existing row into view. If omitted, the combobox just closes its
|
|
|
|
|
* dropdown and the user has to clear the input manually.
|
|
|
|
|
*/
|
|
|
|
|
onFocusExistingMember?: (userId: string) => void;
|
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 11:34:56 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Owner/admin-only modal for inviting a teammate by email. Returns the
|
|
|
|
|
* accept URL on success so the inviter can copy/paste it into chat / DM
|
|
|
|
|
* until the transactional email send is wired up (filed follow-up).
|
|
|
|
|
*
|
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
|
|
|
* The email input is the `InviteRecipientCombobox` from Task 3 — typing
|
|
|
|
|
* surfaces existing members, pending invites, and known users from the
|
|
|
|
|
* inviter's other workspaces before the "Send invite to <email>" fallback.
|
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 11:34:56 -04:00
|
|
|
*/
|
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
|
|
|
export function InviteDialog({
|
|
|
|
|
workspaceSlug,
|
|
|
|
|
children,
|
|
|
|
|
onFocusExistingMember,
|
|
|
|
|
}: InviteDialogProps) {
|
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 11:34:56 -04:00
|
|
|
const utils = api.useUtils();
|
|
|
|
|
const [open, setOpen] = React.useState(false);
|
|
|
|
|
const [email, setEmail] = React.useState("");
|
|
|
|
|
const [role, setRole] = React.useState<InviteRole>("member");
|
|
|
|
|
const [acceptUrl, setAcceptUrl] = React.useState<string | null>(null);
|
|
|
|
|
const [reused, setReused] = React.useState(false);
|
|
|
|
|
const [copied, setCopied] = React.useState(false);
|
|
|
|
|
const [error, setError] = React.useState<string | null>(null);
|
|
|
|
|
|
|
|
|
|
const createMut = api.invites.create.useMutation({
|
|
|
|
|
onSuccess: async (result) => {
|
|
|
|
|
setError(null);
|
|
|
|
|
setReused(result.reused);
|
|
|
|
|
const fullUrl = result.acceptUrl.startsWith("/")
|
|
|
|
|
? `${window.location.origin}${result.acceptUrl}`
|
|
|
|
|
: result.acceptUrl;
|
|
|
|
|
setAcceptUrl(fullUrl);
|
|
|
|
|
setCopied(false);
|
|
|
|
|
await utils.invites.list.invalidate({ workspace: workspaceSlug });
|
|
|
|
|
},
|
|
|
|
|
onError: (e) => {
|
|
|
|
|
setError(e.message);
|
|
|
|
|
setAcceptUrl(null);
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const reset = () => {
|
|
|
|
|
setEmail("");
|
|
|
|
|
setRole("member");
|
|
|
|
|
setAcceptUrl(null);
|
|
|
|
|
setReused(false);
|
|
|
|
|
setCopied(false);
|
|
|
|
|
setError(null);
|
|
|
|
|
createMut.reset();
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const onSubmit = (e: React.FormEvent) => {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
if (!email.trim()) {
|
|
|
|
|
setError("Email is required");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
createMut.mutate({
|
|
|
|
|
workspace: workspaceSlug,
|
|
|
|
|
email: email.trim().toLowerCase(),
|
|
|
|
|
role,
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const onCopy = async () => {
|
|
|
|
|
if (!acceptUrl) return;
|
|
|
|
|
try {
|
|
|
|
|
await navigator.clipboard.writeText(acceptUrl);
|
|
|
|
|
setCopied(true);
|
|
|
|
|
window.setTimeout(() => setCopied(false), 2000);
|
|
|
|
|
} catch {
|
|
|
|
|
// Clipboard API can fail in non-secure contexts; fall back to selecting
|
|
|
|
|
// the input so the user can copy manually.
|
|
|
|
|
const input = document.getElementById("invite-accept-url") as
|
|
|
|
|
| HTMLInputElement
|
|
|
|
|
| null;
|
|
|
|
|
input?.select();
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<Sheet
|
|
|
|
|
open={open}
|
|
|
|
|
onOpenChange={(next) => {
|
|
|
|
|
setOpen(next);
|
|
|
|
|
if (!next) reset();
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<SheetTrigger asChild>
|
|
|
|
|
{children ?? <Button type="button">Invite</Button>}
|
|
|
|
|
</SheetTrigger>
|
|
|
|
|
<SheetContent side="right" className="w-full sm:max-w-md">
|
|
|
|
|
<SheetHeader>
|
|
|
|
|
<SheetTitle>Invite a teammate</SheetTitle>
|
|
|
|
|
<SheetDescription>
|
|
|
|
|
They'll get a private link to join this workspace. The link
|
|
|
|
|
expires in 14 days.
|
|
|
|
|
</SheetDescription>
|
|
|
|
|
</SheetHeader>
|
|
|
|
|
|
|
|
|
|
{acceptUrl ? (
|
|
|
|
|
<div className="mt-6 space-y-4">
|
|
|
|
|
<div className="rounded-md border border-emerald-200 bg-emerald-50 p-3 text-sm text-emerald-700 dark:border-emerald-900/50 dark:bg-emerald-950/30 dark:text-emerald-300">
|
|
|
|
|
{reused
|
|
|
|
|
? "An invite for this email already exists. Here's the link:"
|
|
|
|
|
: "Invite created. Share this link with them:"}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="space-y-1.5">
|
|
|
|
|
<label
|
|
|
|
|
htmlFor="invite-accept-url"
|
|
|
|
|
className="text-xs font-medium text-muted-foreground"
|
|
|
|
|
>
|
|
|
|
|
Accept link
|
|
|
|
|
</label>
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<Input
|
|
|
|
|
id="invite-accept-url"
|
|
|
|
|
value={acceptUrl}
|
|
|
|
|
readOnly
|
|
|
|
|
onFocus={(e) => e.currentTarget.select()}
|
|
|
|
|
className="font-mono text-xs"
|
|
|
|
|
/>
|
|
|
|
|
<Button
|
|
|
|
|
type="button"
|
|
|
|
|
variant="outline"
|
|
|
|
|
size="icon"
|
|
|
|
|
onClick={onCopy}
|
|
|
|
|
aria-label={copied ? "Copied" : "Copy invite link"}
|
|
|
|
|
>
|
|
|
|
|
{copied ? (
|
|
|
|
|
<Check className="size-4 text-emerald-600" />
|
|
|
|
|
) : (
|
|
|
|
|
<Copy className="size-4" />
|
|
|
|
|
)}
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
<p className="text-[11px] text-muted-foreground">
|
|
|
|
|
Email delivery isn't wired up yet. Paste this link to them
|
|
|
|
|
directly until it is.
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="flex justify-end gap-2 pt-2">
|
|
|
|
|
<Button type="button" variant="ghost" onClick={reset}>
|
|
|
|
|
Invite another
|
|
|
|
|
</Button>
|
|
|
|
|
<Button type="button" onClick={() => setOpen(false)}>
|
|
|
|
|
Done
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
<form onSubmit={onSubmit} className="mt-6 space-y-5">
|
|
|
|
|
<div className="space-y-1.5">
|
|
|
|
|
<label htmlFor="invite-email" className="text-xs font-medium">
|
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
|
|
|
Email or name
|
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 11:34:56 -04:00
|
|
|
</label>
|
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
|
|
|
<InviteRecipientCombobox
|
|
|
|
|
inputId="invite-email"
|
|
|
|
|
workspaceSlug={workspaceSlug}
|
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 11:34:56 -04:00
|
|
|
value={email}
|
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
|
|
|
onChange={setEmail}
|
|
|
|
|
placeholder="alex@example.com"
|
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 11:34:56 -04:00
|
|
|
disabled={createMut.isPending}
|
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
|
|
|
onSelectSuggestion={(s) => {
|
|
|
|
|
if (s.kind === "member") {
|
|
|
|
|
setOpen(false);
|
|
|
|
|
onFocusExistingMember?.(s.userId);
|
|
|
|
|
}
|
|
|
|
|
}}
|
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 11:34:56 -04:00
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="space-y-1.5">
|
|
|
|
|
<label htmlFor="invite-role" className="text-xs font-medium">
|
|
|
|
|
Role
|
|
|
|
|
</label>
|
|
|
|
|
<select
|
|
|
|
|
id="invite-role"
|
|
|
|
|
value={role}
|
|
|
|
|
onChange={(e) => setRole(e.target.value as InviteRole)}
|
|
|
|
|
disabled={createMut.isPending}
|
|
|
|
|
className={cn(
|
|
|
|
|
"h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm",
|
|
|
|
|
"focus:outline-none focus:ring-2 focus:ring-ring",
|
|
|
|
|
)}
|
|
|
|
|
>
|
|
|
|
|
<option value="member">Member — can use the workspace</option>
|
|
|
|
|
<option value="admin">Admin — can also manage people</option>
|
|
|
|
|
</select>
|
|
|
|
|
<p className="text-[11px] text-muted-foreground">
|
|
|
|
|
Ownership transfers happen in a separate flow, not via invite.
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{error ? (
|
|
|
|
|
<p
|
|
|
|
|
className="flex items-start gap-2 text-sm text-destructive"
|
|
|
|
|
role="alert"
|
|
|
|
|
>
|
|
|
|
|
<X className="mt-0.5 size-4 shrink-0" aria-hidden />
|
|
|
|
|
<span>{error}</span>
|
|
|
|
|
</p>
|
|
|
|
|
) : null}
|
|
|
|
|
|
|
|
|
|
<div className="flex justify-end gap-2 pt-2">
|
|
|
|
|
<Button
|
|
|
|
|
type="button"
|
|
|
|
|
variant="ghost"
|
|
|
|
|
onClick={() => setOpen(false)}
|
|
|
|
|
disabled={createMut.isPending}
|
|
|
|
|
>
|
|
|
|
|
Cancel
|
|
|
|
|
</Button>
|
|
|
|
|
<Button type="submit" disabled={createMut.isPending || !email.trim()}>
|
|
|
|
|
{createMut.isPending ? (
|
|
|
|
|
<>
|
|
|
|
|
<Loader2 className="size-4 animate-spin" aria-hidden />
|
|
|
|
|
Sending…
|
|
|
|
|
</>
|
|
|
|
|
) : (
|
|
|
|
|
"Send invite"
|
|
|
|
|
)}
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
</form>
|
|
|
|
|
)}
|
|
|
|
|
</SheetContent>
|
|
|
|
|
</Sheet>
|
|
|
|
|
);
|
|
|
|
|
}
|