"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 = { 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 (
); } 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(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

Missing workspace.

; } const members = membersQuery.data ?? []; const ownerCount = members.filter((m) => m.role === "owner").length; return (

Teams

{canManage ? : null}
{/* --- Pending invites (admin/owner only) --- */} {canManage ? (

Pending invites

{invitesQuery.isLoading ? (

Loading…

) : !invitesQuery.data?.length ? (

No pending invites. Use the Invite button to add someone.

) : (
    {invitesQuery.data.map((inv) => (
  • {inv.email}

    Invited as {formatRoleLabel(inv.role)} ·{" "} expires {relativeFromNow(inv.expiresAt)}

  • ))}
)}
) : null} {/* --- Members --- */}

Members

{membersQuery.isLoading ? (
{Array.from({ length: 6 }).map((_, i) => ( ))}
) : !members.length ? (
No members in this workspace yet.
) : (
{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 ( {m.avatarUrl ? : null} {memberInitials(m.name, m.email)}

{displayName}

{m.email}

{formatRoleLabel(m.role)} {showActions ? ( Change role {/* Only an owner can promote anyone to owner. */} {callerRole === "owner" ? ( updateRoleMut.mutate({ workspace: workspaceSlug, userId: m.id, role: "owner", }) } > Owner ) : null} updateRoleMut.mutate({ workspace: workspaceSlug, userId: m.id, role: "admin", }) } > Admin updateRoleMut.mutate({ workspace: workspaceSlug, userId: m.id, role: "member", }) } > Member { 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" > Remove ) : null}
); })}
)} {/* 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) ? (

{updateRoleMut.error?.message ?? removeMemberMut.error?.message ?? revokeMut.error?.message} {(updateRoleMut.error || removeMemberMut.error || revokeMut.error) && ( )}

) : null} {!canManage && callerRole === "member" ? (

You can see the team but only owners and admins can invite or change roles. Ask one of them if you need a change.

) : null}
); }