Team Members & Invitations
Team settings screen with seat usage, an invite form, a member roster with inline role changes, and a pending-invitation list.
pnpm dlx shadcn@latest add @cosskit/settings-teamRequires the @cosskit namespace in your components.json.
Code
"use client";
import { useState } from "react";
import {
MailIcon,
MailPlusIcon,
MoreHorizontalIcon,
SendIcon,
ShieldIcon,
Trash2Icon,
UserMinusIcon,
UserRoundIcon,
} from "lucide-react";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardDescription,
CardHeader,
CardPanel,
CardTitle,
} from "@/components/ui/card";
import {
Empty,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from "@/components/ui/empty";
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
} from "@/components/ui/input-group";
import { Meter, MeterIndicator, MeterLabel, MeterTrack } from "@/components/ui/meter";
import {
Menu,
MenuItem,
MenuPopup,
MenuSeparator,
MenuTrigger,
} from "@/components/ui/menu";
import {
Select,
SelectItem,
SelectPopup,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
// ---------------------------------------------------------------------------
// Data
//
// Swap `initialMembers` / `initialInvites` for your own. Role changes, invites,
// and revocations below are local state only — wire them to your API.
// ---------------------------------------------------------------------------
/** Owner is intentionally not assignable — ownership transfer is its own flow. */
const ASSIGNABLE_ROLES = [
{ label: "Admin", value: "admin" },
{ label: "Member", value: "member" },
{ label: "Viewer", value: "viewer" },
];
const ROLE_LABEL: Record<string, string> = {
owner: "Owner",
admin: "Admin",
member: "Member",
viewer: "Viewer",
};
const SEAT_LIMIT = 10;
interface Member {
id: string;
name: string;
initials: string;
email: string;
role: string;
isYou?: boolean;
deactivated?: boolean;
joined: string;
}
const initialMembers: Member[] = [
{ id: "1", name: "Nadia Rahim", initials: "NR", email: "nadia@kestrel.io", role: "owner", isYou: true, joined: "Mar 2, 2024" },
{ id: "2", name: "Marcus Bell", initials: "MB", email: "marcus@kestrel.io", role: "admin", joined: "Apr 18, 2024" },
{ id: "3", name: "Amara Osei", initials: "AO", email: "amara@kestrel.io", role: "admin", joined: "Jun 9, 2024" },
{ id: "4", name: "Priya Raman", initials: "PR", email: "priya@kestrel.io", role: "member", joined: "Sep 1, 2024" },
{ id: "5", name: "Theo Laurent", initials: "TL", email: "theo@kestrel.io", role: "member", joined: "Nov 22, 2024" },
{ id: "6", name: "Greta Hoffmann", initials: "GH", email: "greta@kestrel.io", role: "member", joined: "Feb 14, 2025" },
{ id: "7", name: "Yusuf Kaya", initials: "YK", email: "yusuf@kestrel.io", role: "viewer", joined: "May 6, 2025" },
{ id: "8", name: "Owen Brady", initials: "OB", email: "owen@kestrel.io", role: "member", deactivated: true, joined: "Jan 30, 2025" },
];
interface Invite {
id: string;
email: string;
role: string;
invitedBy: string;
expires: string;
}
const initialInvites: Invite[] = [
{ id: "i1", email: "dmitri@quanta.sh", role: "member", invitedBy: "Nadia Rahim", expires: "in 5 days" },
{ id: "i2", email: "lena@fieldnotes.app", role: "viewer", invitedBy: "Marcus Bell", expires: "in 2 days" },
];
// ---------------------------------------------------------------------------
// Page
// ---------------------------------------------------------------------------
export default function SettingsTeam() {
const [members, setMembers] = useState(initialMembers);
const [invites, setInvites] = useState(initialInvites);
const [email, setEmail] = useState("");
const [inviteRole, setInviteRole] = useState("member");
// Deactivated members keep their row but release their seat; pending invites
// hold one. That is why the roster count and the seat count differ.
const activeCount = members.filter((m) => !m.deactivated).length;
const deactivatedCount = members.length - activeCount;
const seatsUsed = activeCount + invites.length;
const seatsFull = seatsUsed >= SEAT_LIMIT;
function handleInvite(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
const address = email.trim();
if (address === "" || seatsFull) return;
setInvites((current) => [
...current,
{
id: `i${current.length + 1}-${address}`,
email: address,
role: inviteRole,
invitedBy: "Nadia Rahim",
expires: "in 7 days",
},
]);
setEmail("");
}
return (
<div className="mx-auto flex w-full max-w-4xl flex-col gap-8 p-4 sm:p-6 md:p-8 [&_[data-slot=button]]:transition-[box-shadow,scale] [&_[data-slot=button]:not(.w-full)]:motion-safe:hover:scale-[1.05] [&_[data-slot=button].w-full]:motion-safe:hover:scale-[1.03]">
{/* Page heading */}
<div className="flex flex-col gap-1">
<h1 className="font-semibold text-2xl tracking-tight">Team</h1>
<p className="text-muted-foreground text-sm">
Manage who has access to the Kestrel workspace and what they can do.
</p>
</div>
{/* Invite */}
<Card>
<CardHeader>
<CardTitle>Invite people</CardTitle>
<CardDescription>
Invitations are emailed immediately and expire after 7 days.
</CardDescription>
</CardHeader>
<CardPanel className="flex flex-col gap-5">
<form className="flex flex-col gap-3 sm:flex-row" onSubmit={handleInvite}>
<InputGroup className="sm:flex-1">
<InputGroupAddon>
<MailIcon />
</InputGroupAddon>
<InputGroupInput
aria-label="Email address to invite"
disabled={seatsFull}
onValueChange={setEmail}
placeholder="name@company.com"
type="email"
value={email}
/>
</InputGroup>
<Select
disabled={seatsFull}
items={ASSIGNABLE_ROLES}
onValueChange={(value) => setInviteRole(value ?? "member")}
value={inviteRole}
>
{/* Pointer cursor is a house rule; coss ships `cursor-default`
here by native-menu convention. It goes on the popup — which
portals out of this block, so no ancestor rule can reach it —
and never by editing coss source. Safe on a trigger that can be
disabled: coss pairs disabled with `pointer-events-none`. */}
<SelectTrigger
aria-label="Role for invitee"
className="cursor-pointer sm:w-36"
>
<SelectValue />
</SelectTrigger>
<SelectPopup className="[&_[data-slot$='-item']]:cursor-pointer">
{ASSIGNABLE_ROLES.map((role) => (
<SelectItem key={role.value} value={role.value}>
{role.label}
</SelectItem>
))}
</SelectPopup>
</Select>
<Button disabled={seatsFull} type="submit">
<SendIcon />
Send invite
</Button>
</form>
{/* Seat usage. Pending invites hold a seat, which is why they count. */}
<Meter className="gap-2" max={SEAT_LIMIT} value={seatsUsed}>
<div className="flex w-full items-baseline justify-between gap-2">
<MeterLabel>Seats used</MeterLabel>
<span className="text-muted-foreground text-sm tabular-nums">
{seatsUsed} of {SEAT_LIMIT}
</span>
</div>
<MeterTrack className="rounded-full">
<MeterIndicator />
</MeterTrack>
</Meter>
{seatsFull && (
<p className="text-sm text-warning-foreground">
All seats are in use. Remove a member or upgrade your plan to invite
more people.
</p>
)}
</CardPanel>
</Card>
{/* Members */}
<Card>
<CardHeader>
<CardTitle>Members</CardTitle>
<CardDescription>
{activeCount} {activeCount === 1 ? "person has" : "people have"} access
{deactivatedCount > 0 && `, ${deactivatedCount} deactivated`}.
</CardDescription>
</CardHeader>
<CardPanel className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-full ps-6">Member</TableHead>
<TableHead className="max-sm:hidden">Role</TableHead>
<TableHead className="max-lg:hidden">Joined</TableHead>
<TableHead className="w-px pe-6">
<span className="sr-only">Actions</span>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{members.map((member) => {
const isOwner = member.role === "owner";
return (
<TableRow key={member.id}>
<TableCell className="w-full ps-6">
<div className="flex items-center gap-3">
<Avatar className="size-8 shrink-0 rounded-full">
<AvatarFallback className="text-xs">
{member.initials}
</AvatarFallback>
</Avatar>
<div className="flex min-w-0 flex-col gap-1">
<span className="flex items-center gap-2 font-medium leading-none">
<span className="truncate">{member.name}</span>
{member.isYou && (
<Badge size="sm" variant="outline">
You
</Badge>
)}
{member.deactivated && (
<Badge size="sm" variant="secondary">
Deactivated
</Badge>
)}
</span>
<span className="truncate text-muted-foreground text-xs leading-none">
{member.email}
</span>
{/* Role is a column on sm+; inline it on mobile so it
is never lost when the column is hidden. */}
<span className="text-muted-foreground text-xs leading-none sm:hidden">
{ROLE_LABEL[member.role]}
</span>
</div>
</div>
</TableCell>
<TableCell className="max-sm:hidden">
{isOwner ? (
// Ownership transfer is a separate, confirmed flow --
// deliberately not a dropdown.
<span className="flex items-center gap-1.5 text-muted-foreground text-sm">
<ShieldIcon className="size-3.5" />
Owner
</span>
) : (
<Select
disabled={member.deactivated}
items={ASSIGNABLE_ROLES}
onValueChange={(value) =>
setMembers((current) =>
current.map((m) =>
m.id === member.id
? { ...m, role: value ?? m.role }
: m,
),
)
}
value={member.role}
>
<SelectTrigger
aria-label={`Role for ${member.name}`}
className="w-32 cursor-pointer"
size="sm"
>
<SelectValue />
</SelectTrigger>
<SelectPopup className="[&_[data-slot$='-item']]:cursor-pointer">
{ASSIGNABLE_ROLES.map((role) => (
<SelectItem key={role.value} value={role.value}>
{role.label}
</SelectItem>
))}
</SelectPopup>
</Select>
)}
</TableCell>
<TableCell className="max-lg:hidden text-muted-foreground">
{member.joined}
</TableCell>
<TableCell className="pe-6">
<Menu>
<MenuTrigger
render={
<Button
aria-label={`Actions for ${member.name}`}
disabled={isOwner}
size="icon-sm"
variant="ghost"
/>
}
>
<MoreHorizontalIcon />
</MenuTrigger>
<MenuPopup
align="end"
className="min-w-44 [&_[data-slot$='-item']]:cursor-pointer"
>
<MenuItem>
<UserRoundIcon />
View profile
</MenuItem>
<MenuItem>
<MailIcon />
Email member
</MenuItem>
<MenuSeparator />
<MenuItem>
<UserMinusIcon />
{member.deactivated ? "Reactivate" : "Deactivate"}
</MenuItem>
<MenuItem variant="destructive">
<Trash2Icon />
Remove from team
</MenuItem>
</MenuPopup>
</Menu>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</CardPanel>
</Card>
{/* Pending invitations */}
<Card>
<CardHeader>
<CardTitle>Pending invitations</CardTitle>
<CardDescription>
People who have been invited but have not joined yet.
</CardDescription>
</CardHeader>
<CardPanel className={invites.length > 0 ? "p-0" : undefined}>
{invites.length === 0 ? (
<Empty className="py-8">
<EmptyHeader>
<EmptyMedia variant="icon">
<MailPlusIcon />
</EmptyMedia>
<EmptyTitle>No pending invitations</EmptyTitle>
<EmptyDescription>
Everyone you have invited has joined. Send another invitation
above to add someone new.
</EmptyDescription>
</EmptyHeader>
</Empty>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-full ps-6">Email</TableHead>
<TableHead className="max-sm:hidden">Role</TableHead>
<TableHead className="max-lg:hidden">Invited by</TableHead>
<TableHead className="max-md:hidden">Expires</TableHead>
<TableHead className="w-px pe-6">
<span className="sr-only">Actions</span>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{invites.map((invite) => (
<TableRow key={invite.id}>
<TableCell className="w-full ps-6">
<div className="flex min-w-0 flex-col gap-1">
<span className="truncate font-medium leading-none">
{invite.email}
</span>
<span className="text-muted-foreground text-xs leading-none sm:hidden">
{ROLE_LABEL[invite.role]} · expires {invite.expires}
</span>
</div>
</TableCell>
<TableCell className="max-sm:hidden">
<Badge variant="secondary">{ROLE_LABEL[invite.role]}</Badge>
</TableCell>
<TableCell className="max-lg:hidden text-muted-foreground">
{invite.invitedBy}
</TableCell>
<TableCell className="max-md:hidden text-muted-foreground">
{invite.expires}
</TableCell>
<TableCell className="pe-6">
<div className="flex items-center justify-end gap-1">
<Button className="max-sm:hidden" size="sm" variant="ghost">
Resend
</Button>
<Button
aria-label={`Revoke invitation for ${invite.email}`}
onClick={() =>
setInvites((current) =>
current.filter((i) => i.id !== invite.id),
)
}
size="icon-sm"
variant="ghost"
>
<Trash2Icon />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardPanel>
</Card>
</div>
);
}
Dependencies
- @coss/ui