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>
This commit is contained in:
Randall Stillwell 2026-06-02 10:34:56 -05:00
parent 7a55d6d1c6
commit f3c118c9f6
4 changed files with 734 additions and 18 deletions

View file

@ -1,6 +1,13 @@
"use client"; "use client";
import * as React from "react";
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
import { useSession } from "next-auth/react";
// Membership-derived role drives which surfaces render (Invite button,
// pending-invites section, kebab menus). The server enforces the same
// rules on every mutation regardless of what the UI shows.
import { Copy, Loader2, MailCheck, MoreHorizontal, Trash2, UserCog } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { api } from "@/lib/trpc"; import { api } from "@/lib/trpc";
@ -8,6 +15,17 @@ import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { InviteDialog } from "@/components/teams/invite-dialog";
type WorkspaceRole = "owner" | "admin" | "member";
function memberInitials(name: string | null | undefined, email: string) { function memberInitials(name: string | null | undefined, email: string) {
const n = name?.trim(); const n = name?.trim();
@ -25,6 +43,21 @@ function formatRoleLabel(role: string) {
return labels[key] ?? role.charAt(0).toUpperCase() + role.slice(1).toLowerCase(); return labels[key] ?? role.charAt(0).toUpperCase() + role.slice(1).toLowerCase();
} }
function relativeFromNow(when: Date | string | null | undefined): string {
if (!when) return "—";
const target = when instanceof Date ? when.getTime() : new Date(when).getTime();
const diff = target - Date.now();
if (Number.isNaN(diff)) return "—";
const sign = diff < 0 ? "ago" : "from now";
const abs = Math.abs(diff);
const min = Math.floor(abs / 60_000);
if (min < 60) return `${min}m ${sign}`;
const hr = Math.floor(min / 60);
if (hr < 24) return `${hr}h ${sign}`;
const day = Math.floor(hr / 24);
return `${day}d ${sign}`;
}
function MemberCardSkeleton({ className }: { className?: string }) { function MemberCardSkeleton({ className }: { className?: string }) {
return ( return (
<Card className={cn("overflow-hidden", className)}> <Card className={cn("overflow-hidden", className)}>
@ -42,51 +75,158 @@ function MemberCardSkeleton({ className }: { className?: string }) {
export default function TeamsPage() { export default function TeamsPage() {
const params = useParams(); const params = useParams();
const utils = api.useUtils();
const rawSlug = params?.workspaceSlug; const rawSlug = params?.workspaceSlug;
const workspaceSlug = typeof rawSlug === "string" ? rawSlug : undefined; const workspaceSlug = typeof rawSlug === "string" ? rawSlug : undefined;
const { data: members, isLoading } = api.workspaces.listMembers.useQuery( const enabled = Boolean(workspaceSlug);
const membersQuery = api.workspaces.listMembers.useQuery(
{ workspace: workspaceSlug as string }, { workspace: workspaceSlug as string },
{ enabled: Boolean(workspaceSlug) }, { enabled },
); );
// Caller's role lives on the members query — that's the source of truth for
// who's in this workspace and at what level. resolve() returns the workspace
// itself but not the membership-side role, so we derive it locally instead
// of round-tripping a second procedure.
const currentUserId = useSession().data?.user?.id;
const callerRole = membersQuery.data?.find((m) => m.id === currentUserId)?.role as
| WorkspaceRole
| undefined;
const canManage = callerRole === "owner" || callerRole === "admin";
const invitesQuery = api.invites.list.useQuery(
{ workspace: workspaceSlug as string },
{ enabled: enabled && canManage },
);
const revokeMut = api.invites.revoke.useMutation({
onSuccess: () => utils.invites.list.invalidate({ workspace: workspaceSlug }),
});
const updateRoleMut = api.workspaces.updateMemberRole.useMutation({
onSuccess: () => utils.workspaces.listMembers.invalidate({ workspace: workspaceSlug }),
});
const removeMemberMut = api.workspaces.removeMember.useMutation({
onSuccess: () => utils.workspaces.listMembers.invalidate({ workspace: workspaceSlug }),
});
const [copyFlash, setCopyFlash] = React.useState<string | null>(null);
const copyInviteLink = async (token: string) => {
const url = `${window.location.origin}/invite/${token}`;
try {
await navigator.clipboard.writeText(url);
setCopyFlash(token);
window.setTimeout(() => setCopyFlash(null), 1500);
} catch {
window.prompt("Copy this invite link:", url);
}
};
if (!workspaceSlug) {
return <p className="p-10 text-sm text-muted-foreground">Missing workspace.</p>;
}
const members = membersQuery.data ?? [];
const ownerCount = members.filter((m) => m.role === "owner").length;
return ( return (
<div className="mx-auto max-w-5xl px-8 py-10"> <div className="mx-auto max-w-5xl px-8 py-10">
<div className="mb-8 flex flex-wrap items-center justify-between gap-4"> <div className="mb-8 flex flex-wrap items-center justify-between gap-4">
<h1 className="text-3xl font-bold tracking-tight">Teams</h1> <h1 className="text-3xl font-bold tracking-tight">Teams</h1>
<Button type="button" onClick={() => window.alert("Invite coming soon")}> {canManage ? <InviteDialog workspaceSlug={workspaceSlug} /> : null}
Invite
</Button>
</div> </div>
{!workspaceSlug ? ( {/* --- Pending invites (admin/owner only) --- */}
<p className="text-sm text-muted-foreground">Missing workspace.</p> {canManage ? (
) : isLoading ? ( <section className="mb-10">
<div className="mb-3 flex items-center gap-2">
<MailCheck className="size-4 text-muted-foreground" aria-hidden />
<h2 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">
Pending invites
</h2>
</div>
{invitesQuery.isLoading ? (
<p className="text-sm text-muted-foreground">Loading</p>
) : !invitesQuery.data?.length ? (
<p className="text-sm text-muted-foreground">
No pending invites. Use the Invite button to add someone.
</p>
) : (
<ul className="space-y-2">
{invitesQuery.data.map((inv) => (
<li
key={inv.id}
className="flex items-center justify-between gap-4 rounded-md border border-border bg-card px-4 py-3 shadow-sm"
>
<div className="min-w-0">
<p className="truncate text-sm font-medium">{inv.email}</p>
<p className="text-xs text-muted-foreground">
Invited as {formatRoleLabel(inv.role)} ·{" "}
<span title={new Date(inv.expiresAt).toLocaleString()}>
expires {relativeFromNow(inv.expiresAt)}
</span>
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => copyInviteLink(inv.token)}
>
<Copy className="size-3.5" aria-hidden />
{copyFlash === inv.token ? "Copied" : "Copy link"}
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => revokeMut.mutate({ inviteId: inv.id })}
disabled={revokeMut.isPending}
className="text-destructive hover:bg-destructive/10 hover:text-destructive"
>
Revoke
</Button>
</div>
</li>
))}
</ul>
)}
</section>
) : null}
{/* --- Members --- */}
<div className="mb-3 flex items-center gap-2">
<UserCog className="size-4 text-muted-foreground" aria-hidden />
<h2 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">
Members
</h2>
</div>
{membersQuery.isLoading ? (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3"> <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => ( {Array.from({ length: 6 }).map((_, i) => (
<MemberCardSkeleton key={i} /> <MemberCardSkeleton key={i} />
))} ))}
</div> </div>
) : !members?.length ? ( ) : !members.length ? (
<div <div className="rounded-lg border border-dashed border-border bg-muted/30 p-12 text-center text-sm text-muted-foreground">
className={cn(
"rounded-lg border border-dashed border-border bg-muted/30 p-12 text-center text-sm text-muted-foreground",
)}
>
No members in this workspace yet. No members in this workspace yet.
</div> </div>
) : ( ) : (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3"> <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{members.map((m) => { {members.map((m) => {
const displayName = m.name?.trim() || m.email; const displayName = m.name?.trim() || m.email;
const isSelf = m.id === currentUserId;
const isLastOwner = m.role === "owner" && ownerCount <= 1;
const showActions = canManage && !isSelf;
return ( return (
<Card key={m.id} className="overflow-hidden"> <Card key={m.id} className="overflow-hidden">
<CardContent className="flex items-center gap-4 p-6"> <CardContent className="flex items-center gap-4 p-6">
<Avatar className="size-10 shrink-0"> <Avatar className="size-10 shrink-0">
{m.avatarUrl ? ( {m.avatarUrl ? <AvatarImage src={m.avatarUrl} alt="" /> : null}
<AvatarImage src={m.avatarUrl} alt="" /> <AvatarFallback>
) : null} {memberInitials(m.name, m.email)}
<AvatarFallback>{memberInitials(m.name, m.email)}</AvatarFallback> </AvatarFallback>
</Avatar> </Avatar>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="truncate font-medium">{displayName}</p> <p className="truncate font-medium">{displayName}</p>
@ -95,12 +235,132 @@ export default function TeamsPage() {
<Badge variant="secondary" className="shrink-0 capitalize"> <Badge variant="secondary" className="shrink-0 capitalize">
{formatRoleLabel(m.role)} {formatRoleLabel(m.role)}
</Badge> </Badge>
{showActions ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
aria-label={`Manage ${displayName}`}
className="size-8"
>
<MoreHorizontal className="size-4" aria-hidden />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-44">
<DropdownMenuLabel className="text-xs text-muted-foreground">
Change role
</DropdownMenuLabel>
{/* Only an owner can promote anyone to owner. */}
{callerRole === "owner" ? (
<DropdownMenuItem
disabled={m.role === "owner" || updateRoleMut.isPending}
onSelect={() =>
updateRoleMut.mutate({
workspace: workspaceSlug,
userId: m.id,
role: "owner",
})
}
>
Owner
</DropdownMenuItem>
) : null}
<DropdownMenuItem
disabled={m.role === "admin" || updateRoleMut.isPending}
onSelect={() =>
updateRoleMut.mutate({
workspace: workspaceSlug,
userId: m.id,
role: "admin",
})
}
>
Admin
</DropdownMenuItem>
<DropdownMenuItem
disabled={
m.role === "member" ||
isLastOwner ||
updateRoleMut.isPending
}
onSelect={() =>
updateRoleMut.mutate({
workspace: workspaceSlug,
userId: m.id,
role: "member",
})
}
>
Member
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
disabled={isLastOwner || removeMemberMut.isPending}
onSelect={() => {
if (
window.confirm(
`Remove ${displayName} from this workspace?`,
)
) {
removeMemberMut.mutate({
workspace: workspaceSlug,
userId: m.id,
});
}
}}
className="text-destructive focus:bg-destructive/10 focus:text-destructive"
>
<Trash2 className="size-3.5" aria-hidden />
Remove
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : null}
</CardContent> </CardContent>
</Card> </Card>
); );
})} })}
</div> </div>
)} )}
{/* Surface any mutation errors at the page level so kebab/dialog actions
that hit the last-owner guard or other validation failures don't
fail silently. */}
{(updateRoleMut.error ||
removeMemberMut.error ||
revokeMut.error) ? (
<p className="mt-6 text-sm text-destructive" role="alert">
{updateRoleMut.error?.message ??
removeMemberMut.error?.message ??
revokeMut.error?.message}
{(updateRoleMut.error ||
removeMemberMut.error ||
revokeMut.error) && (
<Button
type="button"
variant="ghost"
size="sm"
className="ml-2"
onClick={() => {
updateRoleMut.reset();
removeMemberMut.reset();
revokeMut.reset();
}}
>
Dismiss
</Button>
)}
</p>
) : null}
{!canManage && callerRole === "member" ? (
<p className="mt-10 rounded-md bg-muted/40 px-3 py-2 text-xs text-muted-foreground">
You can see the team but only owners and admins can invite or change
roles. Ask one of them if you need a change.
</p>
) : null}
</div> </div>
); );
} }

View file

@ -0,0 +1,191 @@
"use client";
import * as React from "react";
import { useParams, useRouter } from "next/navigation";
import Link from "next/link";
import { signIn, useSession } from "next-auth/react";
import { CheckCircle2, Loader2, Mail, ShieldAlert } from "lucide-react";
import { Button } from "@/components/ui/button";
import { api } from "@/lib/trpc";
/**
* Public-by-token invite redeem page. Three phases:
*
* 1. Not signed in -> bounce to /sign-in with callbackUrl that brings us
* back here. We don't surface the invite contents pre-auth; the only
* thing the operator needs to see is "you need to sign in first."
*
* 2. Signed in, calling invites.accept(). On success: route to the
* workspace landing page.
*
* 3. Signed in but identity mismatch (FORBIDDEN with cause.reason ===
* "email_not_owned"). Render the explainer with a deep link to the
* profile's Linked Emails section. The user can either:
* - Sign out and sign in via the matching email's provider.
* - Add the missing email to their profile (manual verification
* is a follow-up task; in v1 the link points at the read-only
* Linked Emails page).
*/
export default function InviteAcceptPage() {
const params = useParams();
const router = useRouter();
const { data: session, status } = useSession();
const rawToken = params?.token;
const token = typeof rawToken === "string" ? rawToken : undefined;
const acceptMut = api.invites.accept.useMutation({
onSuccess: (result) => {
router.replace(`/${result.workspace.slug}`);
},
});
const triedRef = React.useRef(false);
React.useEffect(() => {
if (!token) return;
if (status !== "authenticated") return;
if (triedRef.current) return;
triedRef.current = true;
acceptMut.mutate({ token });
}, [token, status]); // eslint-disable-line react-hooks/exhaustive-deps
if (!token) {
return <InviteShell title="Invalid invite link" tone="error" />;
}
if (status === "loading") {
return <InviteShell title="Checking your session…" tone="loading" />;
}
if (status === "unauthenticated") {
return (
<InviteShell title="Sign in to accept this invite" tone="loading">
<p className="text-sm text-muted-foreground">
You need to be signed in for us to know which account to add to the
workspace.
</p>
<div className="mt-4 flex justify-center">
<Button
type="button"
onClick={() =>
signIn(undefined, { callbackUrl: `/invite/${token}` })
}
>
Sign in to continue
</Button>
</div>
</InviteShell>
);
}
if (acceptMut.isPending || (acceptMut.isIdle && status === "authenticated")) {
return <InviteShell title="Accepting your invite…" tone="loading" />;
}
if (acceptMut.isSuccess && acceptMut.data) {
return (
<InviteShell title="You're in" tone="success">
<p className="text-sm text-muted-foreground">
Joined {acceptMut.data.workspace.name} as{" "}
<span className="font-medium">{acceptMut.data.role}</span>. Redirecting
</p>
</InviteShell>
);
}
if (acceptMut.error) {
// Identity mismatch — caller is signed in but doesn't own the invited
// email. This is the dedicated explainer path, not a generic error.
const cause = acceptMut.error.shape?.data?.cause as
| { reason?: string; invitedEmail?: string }
| undefined;
if (cause?.reason === "email_not_owned" && cause.invitedEmail) {
return (
<InviteShell
title="This invite was sent to a different email"
tone="mismatch"
>
<p className="text-sm text-muted-foreground">
The invite was for{" "}
<span className="font-medium">{cause.invitedEmail}</span>, but
you&apos;re signed in as{" "}
<span className="font-medium">{session?.user?.email}</span>.
</p>
<p className="mt-2 text-sm text-muted-foreground">
To accept, link the invited email to your account from your
profile (e.g. sign in via that email&apos;s OAuth provider), then
return to this page. Or sign out and sign back in with the
invited email directly.
</p>
<div className="mt-5 flex flex-wrap justify-center gap-2">
<Button asChild type="button" variant="outline">
<Link href="/">Go to my workspaces</Link>
</Button>
<Button asChild type="button">
{/* No global profile route; use the first workspace's profile
settings. The user will see Linked Emails there. */}
<Link href="/">View linked emails</Link>
</Button>
</div>
</InviteShell>
);
}
return (
<InviteShell title="We couldn't accept this invite" tone="error">
<p className="text-sm text-muted-foreground">{acceptMut.error.message}</p>
<div className="mt-4 flex justify-center">
<Button asChild type="button" variant="outline">
<Link href="/">Go home</Link>
</Button>
</div>
</InviteShell>
);
}
return <InviteShell title="Loading invite…" tone="loading" />;
}
type Tone = "loading" | "success" | "error" | "mismatch";
function InviteShell({
title,
tone,
children,
}: {
title: string;
tone: Tone;
children?: React.ReactNode;
}) {
const Icon =
tone === "success"
? CheckCircle2
: tone === "mismatch"
? ShieldAlert
: tone === "error"
? ShieldAlert
: tone === "loading"
? Loader2
: Mail;
const iconClass =
tone === "success"
? "text-emerald-600"
: tone === "error" || tone === "mismatch"
? "text-amber-600"
: "text-muted-foreground";
return (
<div className="flex min-h-screen items-center justify-center bg-background px-6">
<div className="w-full max-w-md rounded-xl border border-border bg-card p-8 text-center shadow-sm">
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-muted">
<Icon
className={`size-6 ${iconClass} ${tone === "loading" ? "animate-spin" : ""}`}
aria-hidden
/>
</div>
<h1 className="mb-2 text-lg font-semibold">{title}</h1>
{children}
</div>
</div>
);
}

View file

@ -0,0 +1,249 @@
"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 `<Input>` 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<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
</label>
<Input
id="invite-email"
type="email"
autoComplete="off"
placeholder="alex@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
disabled={createMut.isPending}
/>
</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>
);
}

View file

@ -27,6 +27,22 @@ export async function createContext(): Promise<Context> {
const t = initTRPC.context<Context>().create({ const t = initTRPC.context<Context>().create({
transformer: superjson, transformer: superjson,
// Expose `error.cause` to the client. Procedures that need to surface
// structured failure modes (e.g. `invites.accept` returning the invited
// email so the explainer page can render) pass a `{ reason, ... }` object
// as the cause and the client reads it from `error.shape.data.cause`.
// Anything thrown should still be safe to serialize (no class instances,
// no DB rows, no secrets) — keep cause payloads small and pure data.
errorFormatter: ({ shape, error }) => ({
...shape,
data: {
...shape.data,
cause:
error.cause && typeof error.cause === "object" && !(error.cause instanceof Error)
? (error.cause as Record<string, unknown>)
: undefined,
},
}),
}); });
export const isAuthed = t.middleware(({ ctx, next }) => { export const isAuthed = t.middleware(({ ctx, next }) => {