ubiquitous-invention/apps/web/components/teams/invite-dialog.tsx
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

265 lines
8.7 KiB
TypeScript

"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";
import { InviteRecipientCombobox } from "@/components/teams/invite-recipient-combobox";
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;
/**
* 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;
}
/**
* 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).
*
* 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.
*/
export function InviteDialog({
workspaceSlug,
children,
onFocusExistingMember,
}: InviteDialogProps) {
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&apos;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&apos;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">
Email or name
</label>
<InviteRecipientCombobox
inputId="invite-email"
workspaceSlug={workspaceSlug}
value={email}
onChange={setEmail}
placeholder="alex@example.com"
disabled={createMut.isPending}
onSelectSuggestion={(s) => {
if (s.kind === "member") {
setOpen(false);
onFocusExistingMember?.(s.userId);
}
}}
/>
</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>
);
}