{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "settings-5",
  "title": "Settings 5: audit log",
  "author": "Ballmac <https://ui.ballmac.com>",
  "description": "An audit log of who changed what, by people, AI agents, API keys and the system: grouped by day, filtered by who, area and time, searched, each event opening to its IP, a before-and-after diff and raw details, exported as CSV.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "utils",
    "https://ui.ballmac.com/r/button.json",
    "https://ui.ballmac.com/r/diff-viewer.json",
    "https://ui.ballmac.com/r/json-viewer.json",
    "https://ui.ballmac.com/r/segmented-control.json",
    "https://ui.ballmac.com/r/select.json",
    "https://ui.ballmac.com/r/skeleton.json",
    "https://ui.ballmac.com/r/state-view.json"
  ],
  "files": [
    {
      "path": "registry/ballmac/components/blocks/settings-5/settings-5.tsx",
      "content": "// Ballmac UI: Settings 5. https://ui.ballmac.com/blocks/settings-5\n\"use client\"\n\nimport * as React from \"react\"\nimport { Bot, ChevronRight, Download, KeyRound, Loader2, ScrollText, Search, Server, TriangleAlert } from \"lucide-react\"\n\nimport { Button } from \"@/components/ballmac/button\"\nimport { DiffViewer } from \"@/components/ballmac/diff-viewer\"\nimport { JsonViewer } from \"@/components/ballmac/json-viewer\"\nimport { SegmentedControl, SegmentedControlItem } from \"@/components/ballmac/segmented-control\"\nimport { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from \"@/components/ballmac/select\"\nimport { Skeleton } from \"@/components/ballmac/skeleton\"\nimport { frameProps, StateView, type StateProps } from \"@/components/ballmac/state-view\"\nimport { cn } from \"@/lib/utils\"\n\ntype Settings5ActorKind = \"user\" | \"agent\" | \"api\" | \"system\"\n\ntype Settings5Event = {\n  id: string\n  actor: { name: string; email?: string; kind?: Settings5ActorKind }\n  /** A machine name such as \"member.role_changed\". The part before the dot is its area, used by the area filter. */\n  action: string\n  /** What happened, as a sentence people can read. */\n  summary: string\n  /** The group heading, already written for people (\"Today\", \"14 October\"). Events with the same day are grouped. */\n  day: string\n  /** Already written for people (\"09:42\"). */\n  time: string\n  target?: string\n  ip?: string\n  location?: string\n  severity?: \"info\" | \"warning\" | \"critical\"\n  /** A setting's value before and after, shown as a diff. */\n  changes?: { before: string; after: string }\n  /** Anything else recorded, shown as JSON. */\n  metadata?: unknown\n}\n\ntype Settings5Range = \"24h\" | \"7d\" | \"30d\" | \"90d\"\n\ntype Settings5Props = Omit<React.ComponentProps<\"section\">, \"title\"> &\n  StateProps & {\n    /** Events, newest first. */\n    events?: Settings5Event[]\n    title?: string\n    description?: string\n    /** The time range (controlled). Fetch the events for it in onRangeChange. */\n    range?: Settings5Range\n    onRangeChange?: (range: Settings5Range) => void\n    /** Whether older events can be loaded. */\n    hasMore?: boolean\n    /** Loads the next page. */\n    onLoadMore?: () => void | Promise<void>\n    /** Called by Export with the events that match the filters. Defaults to downloading them as a CSV file. */\n    onExport?: (events: Settings5Event[]) => void\n  }\n\nconst sample: Settings5Event[] = [\n  { id: \"a1\", actor: { name: \"Priya Shah\", email: \"priya@acme.com\", kind: \"user\" }, action: \"member.role_changed\", summary: \"Changed Alex Kim’s role from Member to Admin\", day: \"Today\", time: \"09:42\", target: \"alex@acme.com\", ip: \"203.0.113.24\", location: \"Lisbon, PT\", severity: \"warning\", changes: { before: \"role: member\", after: \"role: admin\" } },\n  { id: \"a2\", actor: { name: \"Support agent\", kind: \"agent\" }, action: \"billing.refund_issued\", summary: \"Refunded $129.00 on order 4821, approved by Priya Shah\", day: \"Today\", time: \"09:15\", target: \"ch_2\", metadata: { approvedBy: \"priya@acme.com\", amount: 12900, currency: \"usd\" } },\n  { id: \"a3\", actor: { name: \"Deploy key\", kind: \"api\" }, action: \"integration.connected\", summary: \"Connected the Code hosting integration\", day: \"Today\", time: \"08:03\", ip: \"198.51.100.7\" },\n  { id: \"a4\", actor: { name: \"Jordan Lee\", email: \"jordan@acme.com\", kind: \"user\" }, action: \"security.sso_enforced\", summary: \"Required single sign-on for everyone\", day: \"Yesterday\", time: \"17:20\", ip: \"203.0.113.51\", location: \"New York, US\", severity: \"critical\", changes: { before: \"sso:\\n  required: false\", after: \"sso:\\n  required: true\\n  provider: okta\" } },\n  { id: \"a5\", actor: { name: \"Jordan Lee\", email: \"jordan@acme.com\", kind: \"user\" }, action: \"member.invited\", summary: \"Invited sam@acme.com as a Member\", day: \"Yesterday\", time: \"11:02\", target: \"sam@acme.com\" },\n  { id: \"a6\", actor: { name: \"System\", kind: \"system\" }, action: \"security.session_revoked\", summary: \"Signed out 3 sessions after a password change\", day: \"14 October\", time: \"22:47\", target: \"alex@acme.com\" },\n]\n\nconst kindIcon = { agent: Bot, api: KeyRound, system: Server } as const\nconst rangeLabel: Record<Settings5Range, string> = { \"24h\": \"Last 24 hours\", \"7d\": \"Last 7 days\", \"30d\": \"Last 30 days\", \"90d\": \"Last 90 days\" }\n\nfunction csv(events: Settings5Event[]) {\n  const cell = (v: unknown) => `\"${String(v ?? \"\").replace(/\"/g, '\"\"')}\"`\n  const head = [\"day\", \"time\", \"actor\", \"email\", \"kind\", \"action\", \"summary\", \"target\", \"ip\", \"location\"]\n  const rows = events.map((e) => [e.day, e.time, e.actor.name, e.actor.email, e.actor.kind ?? \"user\", e.action, e.summary, e.target, e.ip, e.location].map(cell).join(\",\"))\n  return [head.join(\",\"), ...rows].join(\"\\n\")\n}\n\nfunction LogSkeleton() {\n  return (\n    <div className=\"grid gap-2\" aria-hidden=\"true\">\n      <Skeleton className=\"h-3 w-16\" />\n      {[0, 1, 2, 3, 4].map((i) => (\n        <div key={i} className=\"flex items-center gap-3 rounded-lg border p-3\">\n          <Skeleton className=\"h-3 w-10\" />\n          <Skeleton className=\"size-7 rounded-full\" />\n          <Skeleton className=\"h-3.5 flex-1\" />\n          <Skeleton className=\"hidden h-5 w-32 rounded-md sm:block\" />\n        </div>\n      ))}\n    </div>\n  )\n}\n\nfunction Row({ e }: { e: Settings5Event }) {\n  const [open, setOpen] = React.useState(false)\n  const id = React.useId()\n  const kind = e.actor.kind ?? \"user\"\n  const KindIcon = kind === \"user\" ? null : kindIcon[kind]\n  const initials = e.actor.name.split(\" \").map((w) => w[0]).join(\"\").slice(0, 2)\n  const hasDetail = !!(e.target || e.ip || e.location || e.changes || e.metadata !== undefined)\n  // a row with nothing more to show is plain text, not a button that does nothing\n  const Head = hasDetail ? \"button\" : \"div\"\n  return (\n    <li className={cn(\"bg-card rounded-lg border\", e.severity === \"critical\" && \"border-destructive/40\")}>\n      <Head\n        {...(hasDetail ? { type: \"button\" as const, \"aria-expanded\": open, \"aria-controls\": id, onClick: () => setOpen((o) => !o) } : {})}\n        className={cn(\"flex w-full items-center gap-3 rounded-lg p-3 text-start\", hasDetail && \"outline-none hover:bg-accent/40 focus-visible:ring-[3px] focus-visible:ring-ring/50\")}\n      >\n        <span className=\"text-muted-foreground w-10 shrink-0 text-xs tabular-nums\">{e.time}</span>\n        <span aria-hidden=\"true\" className=\"bg-muted relative flex size-7 shrink-0 items-center justify-center rounded-full border text-[11px] font-semibold\">\n          {KindIcon ? <KindIcon className=\"size-3.5\" /> : initials}\n        </span>\n        <span className=\"min-w-0 flex-1\">\n          <span className=\"block text-sm text-pretty\">\n            {e.severity && e.severity !== \"info\" ? (\n              <TriangleAlert aria-label={e.severity === \"critical\" ? \"Critical\" : \"Warning\"} className={cn(\"me-1.5 inline size-3.5 align-[-2px]\", e.severity === \"critical\" ? \"text-destructive\" : \"text-chart-3\")} />\n            ) : null}\n            <span className=\"font-medium\">{e.actor.name}</span>\n            {kind !== \"user\" ? <span className=\"text-muted-foreground text-xs\"> ({kind === \"agent\" ? \"agent\" : kind === \"api\" ? \"API key\" : \"system\"})</span> : null}{\" \"}\n            <span className=\"text-muted-foreground\">·</span> {e.summary}\n          </span>\n        </span>\n        <code dir=\"ltr\" className=\"bg-muted text-muted-foreground hidden shrink-0 rounded px-1.5 py-0.5 font-mono text-[11px] md:block\">\n          {e.action}\n        </code>\n        {hasDetail ? <ChevronRight aria-hidden=\"true\" className={cn(\"text-muted-foreground size-4 shrink-0 transition-transform duration-150\", open ? \"rotate-90\" : \"rtl:rotate-180\")} /> : <span className=\"w-4 shrink-0\" />}\n      </Head>\n      {open && hasDetail ? (\n        <div id={id} className=\"grid gap-3 border-t p-3 ps-16\">\n          <dl className=\"grid gap-x-6 gap-y-1.5 text-xs sm:grid-cols-[auto_1fr]\">\n            <dt className=\"text-muted-foreground\">Action</dt>\n            <dd dir=\"ltr\" className=\"text-start font-mono\">{e.action}</dd>\n            {e.actor.email ? (\n              <>\n                <dt className=\"text-muted-foreground\">By</dt>\n                <dd className=\"break-all\">{e.actor.email}</dd>\n              </>\n            ) : null}\n            {e.target ? (\n              <>\n                <dt className=\"text-muted-foreground\">Target</dt>\n                <dd className=\"break-all\">{e.target}</dd>\n              </>\n            ) : null}\n            {e.ip ? (\n              <>\n                <dt className=\"text-muted-foreground\">IP address</dt>\n                <dd dir=\"ltr\" className=\"text-start font-mono\">{e.ip}{e.location ? <span className=\"text-muted-foreground font-sans\"> · {e.location}</span> : null}</dd>\n              </>\n            ) : null}\n          </dl>\n          {e.changes ? <DiffViewer before={e.changes.before} after={e.changes.after} label={`Change made by ${e.actor.name}`} /> : null}\n          {e.metadata !== undefined ? <JsonViewer value={e.metadata} label=\"Details\" /> : null}\n        </div>\n      ) : null}\n    </li>\n  )\n}\n\n/**\n * An audit log: who did what and when, by people, agents, API keys and the system, grouped by day, filtered by who and by area,\n * searched, each event expandable to its target, IP address, a before-and-after diff and raw details, exported as CSV.\n */\nfunction Settings5({\n  events = sample,\n  title = \"Audit log\",\n  description = \"Every change to this workspace, by people, agents and API keys.\",\n  range: rangeProp,\n  onRangeChange,\n  hasMore = false,\n  onLoadMore,\n  onExport,\n  state = \"ready\",\n  onRetry,\n  emptyState,\n  errorState,\n  className,\n  ...props\n}: Settings5Props) {\n  const id = React.useId()\n  const [rangeLocal, setRangeLocal] = React.useState<Settings5Range>(\"7d\")\n  const range = rangeProp ?? rangeLocal\n  const [who, setWho] = React.useState<\"all\" | \"user\" | \"agent\" | \"api\">(\"all\")\n  const [area, setArea] = React.useState(\"all\")\n  const [query, setQuery] = React.useState(\"\")\n  const [loadingMore, setLoadingMore] = React.useState(false)\n  const areas = [...new Set(events.map((e) => e.action.split(\".\")[0]!))].sort()\n  const q = query.trim().toLowerCase()\n  const visible = events.filter(\n    (e) =>\n      (who === \"all\" || (e.actor.kind ?? \"user\") === who) &&\n      (area === \"all\" || e.action.split(\".\")[0] === area) &&\n      (!q || `${e.summary} ${e.actor.name} ${e.actor.email ?? \"\"} ${e.action} ${e.target ?? \"\"} ${e.ip ?? \"\"}`.toLowerCase().includes(q))\n  )\n  const days = visible.reduce<{ day: string; events: Settings5Event[] }[]>((acc, e) => {\n    const last = acc[acc.length - 1]\n    if (last && last.day === e.day) last.events.push(e)\n    else acc.push({ day: e.day, events: [e] })\n    return acc\n  }, [])\n  const ready = state === \"ready\"\n\n  function exportCsv() {\n    if (onExport) return onExport(visible)\n    const url = URL.createObjectURL(new Blob([csv(visible)], { type: \"text/csv\" }))\n    const a = document.createElement(\"a\")\n    a.href = url\n    a.download = \"audit-log.csv\"\n    a.click()\n    setTimeout(() => URL.revokeObjectURL(url), 0)\n  }\n\n  return (\n    <section data-slot=\"settings-5\" data-state={state} aria-labelledby={`${id}-title`} className={cn(\"mx-auto w-full max-w-5xl px-4 py-8 sm:px-6\", className)} {...frameProps(props)}>\n      <div className=\"flex flex-wrap items-end gap-4\">\n        <div className=\"min-w-0 flex-1\">\n          <h2 id={`${id}-title`} className=\"text-2xl font-semibold tracking-[-0.03em]\">\n            {title}\n          </h2>\n          <p className=\"text-muted-foreground mt-1 text-sm\">{description}</p>\n        </div>\n        <Button variant=\"outline\" size=\"sm\" onClick={exportCsv} disabled={!ready || !visible.length}>\n          <Download aria-hidden=\"true\" />\n          Export CSV\n        </Button>\n      </div>\n\n      <div className=\"mt-5 flex flex-wrap items-center gap-2\">\n        <div className=\"bg-background focus-within:ring-ring/50 flex h-9 min-w-0 flex-1 basis-56 items-center gap-2 rounded-md border px-2.5 focus-within:ring-[3px]\">\n          <Search aria-hidden=\"true\" className=\"text-muted-foreground size-4\" />\n          <input type=\"search\" aria-label=\"Search the audit log\" placeholder=\"Search people, actions, IP addresses\" value={query} onChange={(e) => setQuery(e.target.value)} disabled={!ready} className=\"placeholder:text-muted-foreground min-w-0 flex-1 bg-transparent text-sm outline-none\" />\n        </div>\n        <SegmentedControl value={who} onValueChange={(v) => setWho(v as typeof who)} aria-label=\"Who\" size=\"sm\" disabled={!ready}>\n          <SegmentedControlItem value=\"all\">Everyone</SegmentedControlItem>\n          <SegmentedControlItem value=\"user\">People</SegmentedControlItem>\n          <SegmentedControlItem value=\"agent\">Agents</SegmentedControlItem>\n          <SegmentedControlItem value=\"api\">API</SegmentedControlItem>\n        </SegmentedControl>\n        <Select value={area} onValueChange={setArea} disabled={!ready}>\n          <SelectTrigger size=\"sm\" className=\"w-36\" aria-label=\"Area\">\n            <SelectValue />\n          </SelectTrigger>\n          <SelectContent>\n            <SelectItem value=\"all\">All areas</SelectItem>\n            {areas.map((a) => (\n              <SelectItem key={a} value={a}>\n                {a.charAt(0).toUpperCase() + a.slice(1)}\n              </SelectItem>\n            ))}\n          </SelectContent>\n        </Select>\n        <Select\n          value={range}\n          onValueChange={(r) => {\n            setRangeLocal(r as Settings5Range)\n            onRangeChange?.(r as Settings5Range)\n          }}\n          disabled={!ready}\n        >\n          <SelectTrigger size=\"sm\" className=\"w-40\" aria-label=\"Time range\">\n            <SelectValue />\n          </SelectTrigger>\n          <SelectContent>\n            {(Object.keys(rangeLabel) as Settings5Range[]).map((r) => (\n              <SelectItem key={r} value={r}>\n                {rangeLabel[r]}\n              </SelectItem>\n            ))}\n          </SelectContent>\n        </Select>\n      </div>\n\n      <StateView\n        state={ready && !events.length ? \"empty\" : state}\n        onRetry={onRetry}\n        label=\"Loading the audit log\"\n        loading={<LogSkeleton />}\n        empty={{ title: emptyState?.title ?? \"Nothing recorded yet\", description: emptyState?.description ?? `No changes in the ${rangeLabel[range].toLowerCase()}.`, icon: emptyState?.icon ?? <ScrollText />, action: emptyState?.action }}\n        error={{ title: errorState?.title ?? \"The audit log could not be loaded\", ...errorState }}\n        className=\"mt-6\"\n      >\n        <div className=\"grid gap-6\">\n          {days.map((d) => (\n            <section key={d.day} aria-label={d.day} className=\"grid gap-2\">\n              <h3 className=\"text-muted-foreground text-xs font-medium\">{d.day}</h3>\n              <ul className=\"grid gap-1.5\">\n                {d.events.map((e) => (\n                  <Row key={e.id} e={e} />\n                ))}\n              </ul>\n            </section>\n          ))}\n          {!visible.length ? <p className=\"text-muted-foreground py-10 text-center text-sm\">No events match these filters.</p> : null}\n          {hasMore && onLoadMore ? (\n            <div className=\"flex justify-center\">\n              <Button\n                variant=\"outline\"\n                size=\"sm\"\n                disabled={loadingMore}\n                onClick={async () => {\n                  setLoadingMore(true)\n                  try {\n                    await onLoadMore()\n                  } finally {\n                    setLoadingMore(false)\n                  }\n                }}\n              >\n                {loadingMore ? <Loader2 aria-hidden=\"true\" className=\"animate-spin motion-reduce:animate-none\" /> : null}\n                {loadingMore ? \"Loading…\" : \"Load older events\"}\n              </Button>\n            </div>\n          ) : null}\n        </div>\n      </StateView>\n    </section>\n  )\n}\n\nexport { Settings5, type Settings5Props, type Settings5Event, type Settings5ActorKind, type Settings5Range }\n",
      "type": "registry:component",
      "target": "@components/ballmac/blocks/settings-5/settings-5.tsx"
    }
  ],
  "meta": {
    "schema": 1,
    "tier": "free",
    "tags": [
      "settings",
      "audit log",
      "activity",
      "security",
      "compliance",
      "history",
      "admin",
      "export",
      "csv"
    ],
    "version": "1.0.0",
    "updated": "2026-10-17",
    "tests": true,
    "ai": {
      "summary": "An audit log page. <Settings5 events={[{ id, actor: { name, email, kind: 'user' | 'agent' | 'api' | 'system' }, action: 'member.role_changed', summary, day: 'Today', time: '09:42', target, ip, location, severity: 'info' | 'warning' | 'critical', changes: { before, after }, metadata }]} range onRangeChange hasMore onLoadMore onExport />. The area filter comes from the part of action before the dot.",
      "whenToUse": [
        "Security and compliance pages of a B2B product (SOC 2, enterprise plans)",
        "Showing what AI agents changed, next to what people changed"
      ],
      "whenNotToUse": [
        "A friendly activity feed for a team (use activity-feed)",
        "Raw server logs (use log-stream)",
        "Agent traces (use agent-3)"
      ],
      "composesWith": [
        "settings-3",
        "settings-4",
        "agent-3",
        "app-shell-1"
      ],
      "a11y": [
        {
          "keys": "Enter / Space on an event",
          "action": "Expands or collapses its details (aria-expanded); events with no details are plain rows"
        },
        {
          "keys": "Tab",
          "action": "Search, the who filter (a radio group), area and time range selects, Export"
        },
        {
          "keys": "Severity",
          "action": "Warnings and critical events carry a labelled icon, not colour alone"
        }
      ],
      "customization": [
        "title, description",
        "range + onRangeChange to fetch from your API; hasMore + onLoadMore for paging",
        "onExport replaces the built-in CSV download"
      ]
    },
    "states": [
      {
        "id": "loading",
        "title": "Loading",
        "example": "settings-5-loading",
        "trigger": "state=\"loading\"",
        "note": "Skeleton rows; the filters and Export are disabled."
      },
      {
        "id": "empty",
        "title": "Nothing recorded",
        "example": "settings-5-empty",
        "trigger": "state=\"empty\" (or events={[]})",
        "note": "Says nothing changed in the chosen time range. Filters with no matches say so in place."
      },
      {
        "id": "error",
        "title": "Could not load",
        "example": "settings-5-error",
        "trigger": "state=\"error\"",
        "note": "An alert with Try again (onRetry)."
      }
    ],
    "examples": [
      "settings-5-demo",
      "settings-5-loading",
      "settings-5-empty",
      "settings-5-error"
    ],
    "url": "https://ui.ballmac.com/components/settings-5"
  },
  "categories": [
    "blocks",
    "settings"
  ],
  "type": "registry:block"
}