# Team Access Panel

A member list with per-row role assignment, a shareable invite link, and guarded access revocation.

## Installation

```bash
npx shadcn@latest add https://mwui.vercel.app/r/team-access-panel.json
```

[Registry JSON](https://mwui.vercel.app/r/team-access-panel.json)

## Preview

```tsx
import { TeamAccessPanel } from "@/components/team-access-panel";

export function Preview() {
  return <TeamAccessPanel />;
}
```


## Source

### components/team-access-panel.tsx

```tsx
"use client";

import { IconLink, IconTrash } from "@tabler/icons-react";
import * as React from "react";

import { Avatar, AvatarGroup } from "@/components/ui/avatar-group";
import { Button } from "@/components/ui/button";
import {
  Card,
  CardAction,
  CardContent,
  CardDescription,
  CardHeader,
  CardTitle,
} from "@/components/ui/card";
import { ConfirmDialogProvider, useConfirm } from "@/components/ui/confirm-dialog";
import { CopyButton } from "@/components/ui/copy-button";
import { InputGroup, InputGroupAddon, InputGroupInput } from "@/components/ui/input-group";
import { MultiSelect } from "@/components/ui/multi-select";
import { Separator } from "@/components/ui/separator";
import { cn } from "@/lib/utils";

import { defaultInviteLink, defaultMembers, roleOptions, type TeamMember } from "@/lib/team-data";

type TeamAccessPanelProps = React.ComponentProps<"div"> & {
  members?: TeamMember[];
  inviteLink?: string;
  onMembersChange?: (members: TeamMember[]) => void;
};

function TeamAccessPanel({ ...props }: TeamAccessPanelProps) {
  return (
    <ConfirmDialogProvider>
      <TeamAccessPanelCard {...props} />
    </ConfirmDialogProvider>
  );
}

function TeamAccessPanelCard({
  members: membersProp,
  inviteLink = defaultInviteLink,
  onMembersChange,
  className,
  ...props
}: TeamAccessPanelProps) {
  const confirm = useConfirm();
  const [members, setMembers] = React.useState<TeamMember[]>(membersProp ?? defaultMembers);

  const updateMembers = (next: TeamMember[]) => {
    setMembers(next);
    onMembersChange?.(next);
  };

  const handleRolesChange = (id: string, roles: string[]) => {
    updateMembers(members.map((member) => (member.id === id ? { ...member, roles } : member)));
  };

  const handleRevoke = async (member: TeamMember) => {
    const accepted = await confirm({
      title: `Revoke access for ${member.name}?`,
      description:
        "They lose access to every project in this workspace immediately. Type the name to confirm.",
      confirmLabel: "Revoke access",
      confirmText: member.name,
      destructive: true,
    });

    if (accepted) {
      updateMembers(members.filter((item) => item.id !== member.id));
    }
  };

  return (
    <Card className={cn("w-full max-w-2xl [--avatar-ring:var(--card)]", className)} {...props}>
      <CardHeader>
        <CardTitle>Team access</CardTitle>
        <CardDescription>
          {members.length} {members.length === 1 ? "person has" : "people have"} access to this
          workspace.
        </CardDescription>
        <CardAction>
          <AvatarGroup aria-label="Workspace members" people={members} size="sm" />
        </CardAction>
      </CardHeader>

      <CardContent className="space-y-6">
        <InputGroup>
          <InputGroupAddon>
            <IconLink />
          </InputGroupAddon>
          <InputGroupInput aria-label="Invite link" readOnly value={inviteLink} />
          <InputGroupAddon align="inline-end">
            <CopyButton copiedLabel="Copied" copyLabel="Copy link" size="sm" value={inviteLink} />
          </InputGroupAddon>
        </InputGroup>

        <Separator />

        <ul className="divide-y">
          {members.map((member) => (
            <li className="flex flex-wrap items-center gap-4 py-4 first:pt-0" key={member.id}>
              <Avatar name={member.name} src={member.src} />
              <div className="min-w-0 flex-1">
                <p className="truncate text-sm font-medium">{member.name}</p>
                <p className="truncate text-sm text-muted-foreground">{member.email}</p>
              </div>
              <MultiSelect
                aria-label={`Roles for ${member.name}`}
                chipsLayout="scroll"
                className="w-full sm:w-56"
                onValueChange={(roles) => {
                  handleRolesChange(member.id, roles);
                }}
                options={roleOptions}
                placeholder="No roles"
                value={member.roles}
              />
              <Button
                aria-label={`Revoke access for ${member.name}`}
                onClick={() => {
                  void handleRevoke(member);
                }}
                size="icon"
                variant="ghost"
              >
                <IconTrash />
              </Button>
            </li>
          ))}
        </ul>

        {members.length === 0 ? (
          <p className="py-6 text-center text-sm text-muted-foreground">
            Nobody has access yet. Share the invite link to add people.
          </p>
        ) : null}
      </CardContent>
    </Card>
  );
}

export { TeamAccessPanel, type TeamAccessPanelProps };
```


### lib/team-data.ts

```ts
import type { MultiSelectOption } from "@/components/ui/multi-select";

type TeamMember = {
  id: string;
  name: string;
  email: string;
  src?: string;
  roles: string[];
};

const roleOptions: MultiSelectOption[] = [
  { value: "owner", label: "Owner", group: "Administration" },
  { value: "admin", label: "Admin", group: "Administration" },
  { value: "billing", label: "Billing", group: "Administration" },
  { value: "editor", label: "Editor", group: "Workspace" },
  { value: "reviewer", label: "Reviewer", group: "Workspace" },
  { value: "viewer", label: "Viewer", group: "Workspace" },
];

const defaultMembers: TeamMember[] = [
  {
    id: "ana",
    name: "Ana Ribeiro",
    email: "ana@example.com",
    roles: ["owner", "admin"],
  },
  {
    id: "tomas",
    name: "Tomás Lima",
    email: "tomas@example.com",
    roles: ["editor"],
  },
  {
    id: "wei",
    name: "Wei Zhang",
    email: "wei@example.com",
    roles: ["reviewer", "viewer"],
  },
  {
    id: "marta",
    name: "Marta Sousa",
    email: "marta@example.com",
    roles: ["viewer"],
  },
];

const defaultInviteLink = "https://app.example.com/invite/9f3c-24ab-71de";

export { defaultInviteLink, defaultMembers, roleOptions, type TeamMember };
```



## Usage

A workspace access panel. The header summarises members with an avatar group, the invite link sits
in a copyable input group, and each row carries a grouped multi-select for roles plus a destructive
revoke action.

Revoking runs through `confirmText`, so the person's name has to be typed before the action is
allowed. The block renders its own `ConfirmDialogProvider`.

```tsx
import { TeamAccessPanel } from "@/components/team-access-panel";

<TeamAccessPanel
  inviteLink="https://app.example.com/invite/9f3c-24ab-71de"
  onMembersChange={(members) => {
    console.log(members);
  }}
/>;
```

Members and role options ship as a separate data module, so the sample fixture can be swapped for
real data without touching the component.

