Cosskit

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-team

Requires the @cosskit namespace in your components.json.

Open full page

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