"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 { 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; } /** * 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 a plain `` in this task; the smart recipient * autocomplete from `Task-invite-recipient-autocomplete` will swap it for * a combobox in a separate commit. */ export function InviteDialog({ workspaceSlug, children }: InviteDialogProps) { const utils = api.useUtils(); const [open, setOpen] = React.useState(false); const [email, setEmail] = React.useState(""); const [role, setRole] = React.useState("member"); const [acceptUrl, setAcceptUrl] = React.useState(null); const [reused, setReused] = React.useState(false); const [copied, setCopied] = React.useState(false); const [error, setError] = React.useState(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 ( { setOpen(next); if (!next) reset(); }} > {children ?? Invite} Invite a teammate They'll get a private link to join this workspace. The link expires in 14 days. {acceptUrl ? ( {reused ? "An invite for this email already exists. Here's the link:" : "Invite created. Share this link with them:"} Accept link e.currentTarget.select()} className="font-mono text-xs" /> {copied ? ( ) : ( )} Email delivery isn't wired up yet. Paste this link to them directly until it is. Invite another setOpen(false)}> Done ) : ( Email setEmail(e.target.value)} required disabled={createMut.isPending} /> Role 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", )} > Member — can use the workspace Admin — can also manage people Ownership transfers happen in a separate flow, not via invite. {error ? ( {error} ) : null} setOpen(false)} disabled={createMut.isPending} > Cancel {createMut.isPending ? ( <> Sending… > ) : ( "Send invite" )} )} ); }
Email delivery isn't wired up yet. Paste this link to them directly until it is.
Ownership transfers happen in a separate flow, not via invite.
{error}