ubiquitous-invention/apps/web/app/(app)/[workspaceSlug]/teams/page.tsx
Randall Stillwell f3c118c9f6 feat(invites): invite dialog + accept route + teams UI (Task 2, part 2/2)
Closes Task-workspace-invites-and-roles end-to-end. Builds on the
schema + procedures from 7a55d6d (Task 2, part 1/2).

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

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

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

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

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

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

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

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

366 lines
14 KiB
TypeScript

"use client";
import * as React from "react";
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 { api } from "@/lib/trpc";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
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) {
const n = name?.trim();
if (n) return n.slice(0, 1).toUpperCase();
return email.trim().slice(0, 1).toUpperCase();
}
function formatRoleLabel(role: string) {
const key = role.toLowerCase();
const labels: Record<string, string> = {
owner: "Owner",
admin: "Admin",
member: "Member",
};
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 }) {
return (
<Card className={cn("overflow-hidden", className)}>
<CardContent className="flex items-center gap-4 p-6">
<div className="size-10 shrink-0 animate-pulse rounded-full bg-muted" />
<div className="min-w-0 flex-1 space-y-2">
<div className="h-4 w-32 animate-pulse rounded-md bg-muted" />
<div className="h-3 w-48 max-w-full animate-pulse rounded-md bg-muted" />
</div>
<div className="h-5 w-16 shrink-0 animate-pulse rounded-full bg-muted" />
</CardContent>
</Card>
);
}
export default function TeamsPage() {
const params = useParams();
const utils = api.useUtils();
const rawSlug = params?.workspaceSlug;
const workspaceSlug = typeof rawSlug === "string" ? rawSlug : undefined;
const enabled = Boolean(workspaceSlug);
const membersQuery = api.workspaces.listMembers.useQuery(
{ workspace: workspaceSlug as string },
{ 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 (
<div className="mx-auto max-w-5xl px-8 py-10">
<div className="mb-8 flex flex-wrap items-center justify-between gap-4">
<h1 className="text-3xl font-bold tracking-tight">Teams</h1>
{canManage ? <InviteDialog workspaceSlug={workspaceSlug} /> : null}
</div>
{/* --- Pending invites (admin/owner only) --- */}
{canManage ? (
<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">
{Array.from({ length: 6 }).map((_, i) => (
<MemberCardSkeleton key={i} />
))}
</div>
) : !members.length ? (
<div className="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.
</div>
) : (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{members.map((m) => {
const displayName = m.name?.trim() || m.email;
const isSelf = m.id === currentUserId;
const isLastOwner = m.role === "owner" && ownerCount <= 1;
const showActions = canManage && !isSelf;
return (
<Card key={m.id} className="overflow-hidden">
<CardContent className="flex items-center gap-4 p-6">
<Avatar className="size-10 shrink-0">
{m.avatarUrl ? <AvatarImage src={m.avatarUrl} alt="" /> : null}
<AvatarFallback>
{memberInitials(m.name, m.email)}
</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<p className="truncate font-medium">{displayName}</p>
<p className="truncate text-sm text-muted-foreground">{m.email}</p>
</div>
<Badge variant="secondary" className="shrink-0 capitalize">
{formatRoleLabel(m.role)}
</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>
</Card>
);
})}
</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>
);
}