{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "access-1",
  "title": "Access 1: access review",
  "author": "Ballmac <https://ui.ballmac.com>",
  "description": "A periodic access review: each person's role and last use, Keep, Reduce or Remove with lower roles only, people idle for months flagged in words, a keep-everyone-active shortcut, progress, and a confirmation before anyone loses access.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "utils",
    "https://ui.ballmac.com/r/alert-dialog.json",
    "https://ui.ballmac.com/r/button.json",
    "https://ui.ballmac.com/r/skeleton.json",
    "https://ui.ballmac.com/r/state-view.json",
    "https://ui.ballmac.com/r/return-focus.json"
  ],
  "files": [
    {
      "path": "registry/ballmac/components/blocks/access-1/access-1.tsx",
      "content": "// Ballmac UI: Access 1. https://ui.ballmac.com/blocks/access-1\n\"use client\"\n\nimport * as React from \"react\"\nimport { CheckCheck, Clock, Loader2, ShieldAlert } from \"lucide-react\"\n\nimport { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from \"@/components/ballmac/alert-dialog\"\nimport { Button } from \"@/components/ballmac/button\"\nimport { Skeleton } from \"@/components/ballmac/skeleton\"\nimport { frameProps, StateView, type StateProps } from \"@/components/ballmac/state-view\"\nimport { useReturnFocus } from \"@/lib/ballmac/return-focus\"\nimport { cn } from \"@/lib/utils\"\n\ntype Access1Person = {\n  id: string\n  name: string\n  email: string\n  /** The role they hold now. */\n  role: string\n  /** When they last used the product, already formatted, such as \"3 days ago\" or \"Never\". */\n  lastUsed: string\n  /** The same, in days, used to flag people who have not been seen for a long time. */\n  idleDays: number\n}\n\ntype Access1Decision = { action: \"keep\" | \"reduce\" | \"remove\"; to?: string }\n\ntype Access1Props = Omit<React.ComponentProps<\"section\">, \"title\"> &\n  StateProps & {\n    people?: Access1Person[]\n    title?: string\n    /** Roles from most to least access. Reducing offers the roles below the person's own. */\n    roles?: string[]\n    /** Idle for this many days or more is flagged. */\n    flagAfterDays?: number\n    /** Text about the deadline, such as \"Due 31 Oct\". */\n    due?: string\n    /** Called with every decision when the review is submitted. Reject and the message says it was not saved. */\n    onSubmit?: (decisions: Record<string, Access1Decision>) => void | Promise<void>\n  }\n\nconst sample: Access1Person[] = [\n  { id: \"u1\", name: \"Priya Shah\", email: \"priya@acme.example\", role: \"Owner\", lastUsed: \"Today\", idleDays: 0 },\n  { id: \"u2\", name: \"Theo Nakamura\", email: \"theo@acme.example\", role: \"Admin\", lastUsed: \"Yesterday\", idleDays: 1 },\n  { id: \"u3\", name: \"Noor Haddad\", email: \"noor@acme.example\", role: \"Admin\", lastUsed: \"112 days ago\", idleDays: 112 },\n  { id: \"u4\", name: \"Kofi Mensah\", email: \"kofi@acme.example\", role: \"Member\", lastUsed: \"5 days ago\", idleDays: 5 },\n  { id: \"u5\", name: \"Ana Souza\", email: \"ana@acme.example\", role: \"Member\", lastUsed: \"2 weeks ago\", idleDays: 14 },\n  { id: \"u6\", name: \"Oliver Reed\", email: \"oliver@acme.example\", role: \"Member\", lastUsed: \"Never\", idleDays: 999 },\n  { id: \"u7\", name: \"Mei Tanaka\", email: \"mei@acme.example\", role: \"Viewer\", lastUsed: \"3 days ago\", idleDays: 3 },\n]\n\nfunction Skel() {\n  return (\n    <div aria-hidden=\"true\" className=\"grid gap-3\">\n      <Skeleton className=\"h-16 rounded-xl\" />\n      {[0, 1, 2, 3, 4].map((i) => (\n        <Skeleton key={i} className=\"h-20 rounded-xl\" />\n      ))}\n    </div>\n  )\n}\n\n/**\n * A periodic access review: every person with their role and when they last used the product, and a Keep, Reduce or Remove decision each. People who\n * have not been seen for months are flagged in words, a button keeps everyone who is active, progress counts what is left, and submitting asks first when\n * anyone will lose access.\n */\nfunction Access1({ people: peopleProp = sample, title = \"Access review\", roles = [\"Owner\", \"Admin\", \"Member\", \"Viewer\"], flagAfterDays = 90, due, onSubmit, state = \"ready\", onRetry, emptyState, errorState, className, ...props }: Access1Props) {\n  const id = React.useId()\n  const [decisions, setDecisions] = React.useState<Record<string, Access1Decision>>({})\n  const [filter, setFilter] = React.useState<\"all\" | \"todo\" | \"flagged\">(\"all\")\n  const [confirm, setConfirm] = React.useState(false)\n  const [busy, setBusy] = React.useState(false)\n  const [result, setResult] = React.useState<{ ok: boolean; text: string } | null>(null)\n  const ret = useReturnFocus(confirm)\n\n  const view = state === \"ready\" && peopleProp.length === 0 ? \"empty\" : state\n  const flagged = (p: Access1Person) => p.idleDays >= flagAfterDays\n  const done = peopleProp.filter((p) => decisions[p.id]).length\n  const left = peopleProp.length - done\n  const count = (a: Access1Decision[\"action\"]) => Object.values(decisions).filter((d) => d.action === a).length\n  const shown = peopleProp.filter((p) => (filter === \"all\" ? true : filter === \"todo\" ? !decisions[p.id] : flagged(p)))\n  const below = (role: string) => roles.slice(roles.indexOf(role) + 1)\n\n  function decide(p: Access1Person, action: Access1Decision[\"action\"]) {\n    setResult(null)\n    setDecisions((d) => ({ ...d, [p.id]: action === \"reduce\" ? { action, to: d[p.id]?.to ?? below(p.role)[0] } : { action } }))\n  }\n  function keepActive() {\n    setResult(null)\n    setDecisions((d) => ({ ...d, ...Object.fromEntries(peopleProp.filter((p) => !d[p.id] && !flagged(p)).map((p) => [p.id, { action: \"keep\" as const }])) }))\n  }\n  async function submit() {\n    setBusy(true)\n    setResult(null)\n    try {\n      await onSubmit?.(decisions)\n      setResult({ ok: true, text: `Review submitted. ${count(\"keep\")} kept, ${count(\"reduce\")} reduced, ${count(\"remove\")} removed.` })\n    } catch (e) {\n      setResult({ ok: false, text: e instanceof Error ? e.message : \"The review was not saved. Nothing was changed.\" })\n    } finally {\n      setBusy(false)\n    }\n  }\n  const start = () => (count(\"remove\") > 0 ? setConfirm(true) : void submit())\n\n  return (\n    <section data-slot=\"access-1\" data-state={state} aria-labelledby={`${id}-title`} className={cn(\"mx-auto w-full max-w-4xl px-4 py-10 sm:px-6\", className)} {...frameProps(props)}>\n      <header className=\"mb-8\">\n        <h1 id={`${id}-title`} className=\"text-3xl font-semibold tracking-[-0.035em] text-balance\">\n          {title}\n        </h1>\n        <p className=\"text-muted-foreground mt-2 max-w-xl text-pretty\">Check who still needs the access they have. Anything you do not decide stays as it is when you submit.{due ? ` ${due}.` : \"\"}</p>\n      </header>\n      <StateView state={view} onRetry={onRetry} label=\"Loading people\" loading={<Skel />} empty={{ title: \"Nobody to review\", description: \"Everyone with access will appear here when a review starts.\", ...emptyState }} error={{ title: \"The review could not be loaded\", description: \"No decisions were lost. Check your connection and try again.\", ...errorState }}>\n        <div className=\"bg-card grid gap-3 rounded-xl border p-4 sm:p-5\">\n          <div className=\"flex flex-wrap items-baseline justify-between gap-2\">\n            <p className=\"text-sm font-medium tabular-nums\">{done} of {peopleProp.length} reviewed</p>\n            <p className=\"text-muted-foreground text-xs tabular-nums\">{count(\"keep\")} keep · {count(\"reduce\")} reduce · {count(\"remove\")} remove</p>\n          </div>\n          <div role=\"meter\" aria-label=\"Review progress\" aria-valuemin={0} aria-valuemax={peopleProp.length} aria-valuenow={done} aria-valuetext={`${done} of ${peopleProp.length} reviewed`} className=\"bg-muted h-2 overflow-hidden rounded-full\">\n            <div className=\"bg-foreground h-full rounded-full transition-[width] duration-300 motion-reduce:transition-none\" style={{ width: `${peopleProp.length ? (done / peopleProp.length) * 100 : 0}%` }} />\n          </div>\n          <div className=\"flex flex-wrap items-center justify-between gap-3\">\n            <div role=\"group\" aria-label=\"Show\" className=\"flex flex-wrap gap-2\">\n              {([[\"all\", \"Everyone\", peopleProp.length], [\"todo\", \"To review\", left], [\"flagged\", \"Not used lately\", peopleProp.filter(flagged).length]] as const).map(([k, l, n]) => (\n                <button key={k} type=\"button\" aria-pressed={filter === k} onClick={() => setFilter(k)} className={cn(\"focus-visible:ring-ring/50 rounded-full border px-3 py-1 text-sm font-medium outline-none transition-colors focus-visible:ring-[3px] motion-reduce:transition-none\", filter === k ? \"bg-foreground text-background border-foreground\" : \"hover:bg-accent\")}>\n                  {l} <span className={cn(\"tabular-nums\", filter === k ? \"opacity-70\" : \"text-muted-foreground\")}>{n}</span>\n                </button>\n              ))}\n            </div>\n            <Button variant=\"outline\" size=\"sm\" onClick={keepActive}>\n              <CheckCheck aria-hidden=\"true\" /> Keep everyone who is active\n            </Button>\n          </div>\n        </div>\n\n        <ul className=\"mt-4 grid gap-3\">\n          {shown.map((p) => {\n            const d = decisions[p.id]\n            const isFlagged = flagged(p)\n            const options = below(p.role)\n            return (\n              <li key={p.id} data-decision={d?.action} className={cn(\"bg-card rounded-xl border p-4\", d?.action === \"remove\" && \"border-destructive/50\")}>\n                <div className=\"flex flex-wrap items-start justify-between gap-x-6 gap-y-3\">\n                  <div className=\"min-w-0\">\n                    <p className=\"flex flex-wrap items-center gap-x-2 gap-y-1 font-medium\">\n                      {p.name}\n                      <span className=\"text-muted-foreground rounded-full border px-2 py-0.5 text-xs font-normal\">{p.role}</span>\n                    </p>\n                    <p className=\"text-muted-foreground text-xs\" dir=\"ltr\">{p.email}</p>\n                    <p className={cn(\"mt-1.5 flex items-center gap-1.5 text-xs\", isFlagged ? \"text-foreground font-medium\" : \"text-muted-foreground\")}>\n                      {isFlagged ? <ShieldAlert aria-hidden=\"true\" className=\"text-chart-3 size-3.5\" /> : <Clock aria-hidden=\"true\" className=\"size-3.5\" />}\n                      Last used {p.lastUsed.toLowerCase()}\n                      {isFlagged && \" · not used lately\"}\n                    </p>\n                  </div>\n                  <fieldset className=\"flex flex-wrap items-center gap-2\">\n                    <legend className=\"sr-only\">Decision for {p.name}</legend>\n                    <div className=\"bg-muted/60 inline-flex rounded-full p-0.5\">\n                      {([[\"keep\", \"Keep\"], [\"reduce\", \"Reduce\"], [\"remove\", \"Remove\"]] as const).map(([k, l]) => (\n                        <label key={k} className={cn(\"relative cursor-pointer\", k === \"reduce\" && options.length === 0 && \"hidden\")}>\n                          <input type=\"radio\" name={`${id}-${p.id}`} value={k} checked={d?.action === k} onChange={() => decide(p, k)} className=\"peer sr-only\" />\n                          <span className={cn(\"peer-focus-visible:ring-ring/50 block rounded-full px-3.5 py-1.5 text-sm font-medium transition-colors peer-focus-visible:ring-[3px] motion-reduce:transition-none\", d?.action === k ? (k === \"remove\" ? \"bg-destructive text-white\" : \"bg-foreground text-background\") : \"text-muted-foreground hover:text-foreground\")}>{l}</span>\n                        </label>\n                      ))}\n                    </div>\n                    {d?.action === \"reduce\" && options.length > 0 && (\n                      <label className=\"flex items-center gap-2 text-sm\">\n                        <span className=\"text-muted-foreground\">to</span>\n                        <select aria-label={`New role for ${p.name}`} value={d.to} onChange={(e) => setDecisions((x) => ({ ...x, [p.id]: { action: \"reduce\", to: e.target.value } }))} className=\"bg-background focus-visible:ring-ring/50 h-9 rounded-md border px-2 text-sm outline-none focus-visible:ring-[3px]\">\n                          {options.map((r) => (\n                            <option key={r}>{r}</option>\n                          ))}\n                        </select>\n                      </label>\n                    )}\n                  </fieldset>\n                </div>\n              </li>\n            )\n          })}\n        </ul>\n        {shown.length === 0 && <p className=\"text-muted-foreground mt-4 rounded-xl border border-dashed px-4 py-10 text-center text-sm\">{filter === \"todo\" ? \"Everyone has a decision.\" : \"Nobody here.\"}</p>}\n\n        <div className=\"bg-background/90 sticky bottom-3 mt-6 flex flex-wrap items-center justify-between gap-3 rounded-2xl border p-3 shadow-lg backdrop-blur\">\n          <p aria-live=\"polite\" className={cn(\"text-sm\", result && !result.ok && \"text-destructive font-medium\")} role={result && !result.ok ? \"alert\" : undefined}>\n            {result ? result.text : left > 0 ? `${left} still to review.` : \"Everyone has a decision.\"}\n          </p>\n          <Button disabled={left > 0 || busy} onClick={start}>\n            {busy && <Loader2 aria-hidden=\"true\" className=\"animate-spin\" />} Submit review\n          </Button>\n        </div>\n      </StateView>\n\n      <AlertDialog open={confirm} onOpenChange={setConfirm}>\n        <AlertDialogContent {...ret}>\n          <AlertDialogHeader>\n            <AlertDialogTitle>Remove {count(\"remove\")} {count(\"remove\") === 1 ? \"person\" : \"people\"}?</AlertDialogTitle>\n            <AlertDialogDescription>They lose access as soon as you submit: {peopleProp.filter((p) => decisions[p.id]?.action === \"remove\").map((p) => p.name).join(\", \")}.</AlertDialogDescription>\n          </AlertDialogHeader>\n          <AlertDialogFooter>\n            <AlertDialogCancel>Go back</AlertDialogCancel>\n            <AlertDialogAction destructive onClick={() => void submit()}>Submit and remove</AlertDialogAction>\n          </AlertDialogFooter>\n        </AlertDialogContent>\n      </AlertDialog>\n    </section>\n  )\n}\n\nexport { Access1, type Access1Props, type Access1Person, type Access1Decision }\n",
      "type": "registry:component",
      "target": "@components/ballmac/blocks/access-1/access-1.tsx"
    }
  ],
  "meta": {
    "schema": 1,
    "tier": "free",
    "tags": [
      "access review",
      "permissions",
      "roles",
      "security",
      "compliance",
      "admin",
      "least privilege"
    ],
    "version": "1.0.0",
    "updated": "2026-10-24",
    "tests": true,
    "ai": {
      "summary": "A quarterly access review. <Access1 people={[{ id, name, email, role, lastUsed: '3 days ago', idleDays: 3 }]} roles={['Owner','Admin','Member','Viewer']} flagAfterDays={90} due='Due 31 Oct' onSubmit={(decisions) => Promise} />. Each person gets Keep, Reduce (to a lower role) or Remove; undecided people stay as they are. Submit is off until everyone has a decision and asks first when anyone will be removed.",
      "whenToUse": [
        "A workspace admin's quarterly check of who has access",
        "A compliance task that needs a record of every keep, reduce and remove"
      ],
      "whenNotToUse": [
        "Inviting or changing one person's role; use settings or team blocks",
        "A log of past changes; use audit-1"
      ],
      "composesWith": [
        "audit-1",
        "settings-1",
        "invite-1",
        "app-shell-1"
      ],
      "a11y": [
        {
          "keys": "Decisions",
          "action": "Each person's Keep, Reduce and Remove are a native radio group in a fieldset named for them; the arrow keys move between them"
        },
        {
          "keys": "Progress",
          "action": "A meter reading '3 of 7 reviewed'; what is left is also written under the list"
        },
        {
          "keys": "Flagged people",
          "action": "Marked 'not used lately' in words with an icon, not colour alone"
        },
        {
          "keys": "Removing",
          "action": "An alert dialog names exactly who loses access and returns focus when closed"
        }
      ],
      "customization": [
        "roles and flagAfterDays",
        "due text",
        "onSubmit receives { [id]: { action, to? } }; reject it and the message says nothing was saved"
      ]
    },
    "states": [
      {
        "id": "loading",
        "title": "Loading",
        "example": "access-1-loading",
        "trigger": "state=\"loading\"",
        "note": "Skeleton cards under the real heading, announced as loading."
      },
      {
        "id": "empty",
        "title": "Nobody to review",
        "example": "access-1-empty",
        "trigger": "state=\"empty\"",
        "note": "Says nobody is to review; emptyState sets the copy."
      },
      {
        "id": "error",
        "title": "Could not load",
        "example": "access-1-error",
        "trigger": "state=\"error\"",
        "note": "An alert saying no decisions were lost, with Try again (onRetry)."
      }
    ],
    "examples": [
      "access-1-demo",
      "access-1-flagged",
      "access-1-loading",
      "access-1-empty",
      "access-1-error"
    ],
    "url": "https://ui.ballmac.com/components/access-1"
  },
  "categories": [
    "blocks",
    "access"
  ],
  "type": "registry:block"
}