{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "audit-1",
  "title": "Audit 1: audit log",
  "author": "Ballmac <https://ui.ballmac.com>",
  "description": "An audit log you can search: who did what to what, when and from where, filters with counts, failed attempts written out, rows that open to show exactly which values changed, show-more paging and an export of what the filter shows.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "utils",
    "https://ui.ballmac.com/r/button.json",
    "https://ui.ballmac.com/r/match-highlight.json",
    "https://ui.ballmac.com/r/skeleton.json",
    "https://ui.ballmac.com/r/state-view.json"
  ],
  "files": [
    {
      "path": "registry/ballmac/components/blocks/audit-1/audit-1.tsx",
      "content": "// Ballmac UI: Audit 1. https://ui.ballmac.com/blocks/audit-1\n\"use client\"\n\nimport * as React from \"react\"\nimport { ArrowRight, ChevronDown, CircleX, Download, Search } from \"lucide-react\"\n\nimport { Button } from \"@/components/ballmac/button\"\nimport { MatchHighlight, matchesAll, queryTokens } from \"@/components/ballmac/match-highlight\"\nimport { Skeleton } from \"@/components/ballmac/skeleton\"\nimport { frameProps, StateView, type StateProps } from \"@/components/ballmac/state-view\"\nimport { cn } from \"@/lib/utils\"\n\ntype Audit1Category = \"signin\" | \"permissions\" | \"data\" | \"billing\"\n\ntype Audit1Event = {\n  id: string\n  /** When it happened, already formatted, such as \"24 Oct, 14:02\". */\n  time: string\n  actor: { name: string; email?: string }\n  /** What was done, for example \"Changed a role\". */\n  action: string\n  category: Audit1Category\n  /** What it was done to, for example \"Priya Shah\". */\n  target: string\n  /** Network address the request came from. */\n  ip?: string\n  /** Where that address is, for example \"Lisbon, Portugal\". */\n  place?: string\n  /** Whether it went through. A failed attempt is written out, not just coloured. */\n  result?: \"success\" | \"failed\"\n  /** Values that changed, shown when the row is opened. */\n  changes?: { field: string; from: string; to: string }[]\n}\n\ntype Audit1Props = Omit<React.ComponentProps<\"section\">, \"title\"> &\n  StateProps & {\n    events?: Audit1Event[]\n    title?: string\n    /** How many events show before \"Show more\". */\n    pageSize?: number\n    /** The event whose details are open at first. */\n    defaultOpenId?: string\n    /** Called with the events that match the current search and filter. Return a promise; reject and the message says it failed. */\n    onExport?: (events: Audit1Event[]) => void | Promise<void>\n  }\n\nconst CATEGORIES: { id: Audit1Category | \"all\"; label: string }[] = [\n  { id: \"all\", label: \"All\" },\n  { id: \"signin\", label: \"Sign-in\" },\n  { id: \"permissions\", label: \"Permissions\" },\n  { id: \"data\", label: \"Data\" },\n  { id: \"billing\", label: \"Billing\" },\n]\n\nconst sample: Audit1Event[] = [\n  { id: \"e1\", time: \"24 Oct, 14:02\", actor: { name: \"Priya Shah\", email: \"priya@acme.example\" }, action: \"Changed a role\", category: \"permissions\", target: \"Theo Nakamura\", ip: \"203.0.113.24\", place: \"Lisbon, Portugal\", changes: [{ field: \"Role\", from: \"Member\", to: \"Admin\" }] },\n  { id: \"e2\", time: \"24 Oct, 13:47\", actor: { name: \"Theo Nakamura\", email: \"theo@acme.example\" }, action: \"Signed in\", category: \"signin\", target: \"Dashboard\", ip: \"198.51.100.7\", place: \"Osaka, Japan\" },\n  { id: \"e3\", time: \"24 Oct, 13:31\", actor: { name: \"Unknown\" }, action: \"Tried to sign in\", category: \"signin\", target: \"noor@acme.example\", ip: \"192.0.2.88\", place: \"Unknown location\", result: \"failed\" },\n  { id: \"e4\", time: \"24 Oct, 11:12\", actor: { name: \"Noor Haddad\", email: \"noor@acme.example\" }, action: \"Exported customers\", category: \"data\", target: \"customers.csv, 4,812 rows\", ip: \"203.0.113.90\", place: \"Dubai, UAE\" },\n  { id: \"e5\", time: \"24 Oct, 10:05\", actor: { name: \"Ana Souza\", email: \"ana@acme.example\" }, action: \"Changed the plan\", category: \"billing\", target: \"Workspace plan\", ip: \"198.51.100.31\", place: \"Lisbon, Portugal\", changes: [{ field: \"Plan\", from: \"Team\", to: \"Business\" }, { field: \"Seats\", from: \"10\", to: \"25\" }] },\n  { id: \"e6\", time: \"23 Oct, 18:20\", actor: { name: \"Kofi Mensah\", email: \"kofi@acme.example\" }, action: \"Deleted a project\", category: \"data\", target: \"Q3 experiments\", ip: \"203.0.113.5\", place: \"Accra, Ghana\", changes: [{ field: \"Status\", from: \"Active\", to: \"Deleted\" }] },\n  { id: \"e7\", time: \"23 Oct, 16:44\", actor: { name: \"Priya Shah\", email: \"priya@acme.example\" }, action: \"Invited a member\", category: \"permissions\", target: \"oliver@acme.example\", ip: \"203.0.113.24\", place: \"Lisbon, Portugal\", changes: [{ field: \"Role\", from: \"(none)\", to: \"Member\" }] },\n  { id: \"e8\", time: \"23 Oct, 09:18\", actor: { name: \"Oliver Reed\", email: \"oliver@acme.example\" }, action: \"Signed in\", category: \"signin\", target: \"Dashboard\", ip: \"198.51.100.12\", place: \"Leeds, United Kingdom\" },\n  { id: \"e9\", time: \"22 Oct, 17:02\", actor: { name: \"Ana Souza\", email: \"ana@acme.example\" }, action: \"Updated the billing email\", category: \"billing\", target: \"Billing contact\", ip: \"198.51.100.31\", place: \"Lisbon, Portugal\", changes: [{ field: \"Email\", from: \"ap@acme.example\", to: \"finance@acme.example\" }] },\n  { id: \"e10\", time: \"22 Oct, 12:40\", actor: { name: \"Kofi Mensah\", email: \"kofi@acme.example\" }, action: \"Created an API key\", category: \"permissions\", target: \"Key ending 4f2a\", ip: \"203.0.113.5\", place: \"Accra, Ghana\" },\n]\n\nfunction Skel() {\n  return (\n    <div aria-hidden=\"true\" className=\"grid gap-2\">\n      <Skeleton className=\"h-10 rounded-lg\" />\n      {[0, 1, 2, 3, 4, 5].map((i) => (\n        <Skeleton key={i} className=\"h-14 rounded-lg\" />\n      ))}\n    </div>\n  )\n}\n\n/**\n * An audit log people can search: who did what to what, when and from where. Filters by kind with counts, a failed attempt written out as\n * \"Failed\", rows that open to show exactly which values changed, a \"Show more\" button instead of endless scrolling, and an export of whatever\n * the search and filter currently show.\n */\nfunction Audit1({ events: eventsProp = sample, title = \"Audit log\", pageSize = 8, defaultOpenId, onExport, state = \"ready\", onRetry, emptyState, errorState, className, ...props }: Audit1Props) {\n  const id = React.useId()\n  const [query, setQuery] = React.useState(\"\")\n  const [category, setCategory] = React.useState<Audit1Category | \"all\">(\"all\")\n  const [open, setOpen] = React.useState<string | null>(defaultOpenId ?? null)\n  const [visible, setVisible] = React.useState(pageSize)\n  const [exported, setExported] = React.useState<{ ok: boolean; text: string } | null>(null)\n  const tokens = queryTokens(query)\n\n  const view = state === \"ready\" && eventsProp.length === 0 ? \"empty\" : state\n  const matched = eventsProp.filter((e) => (category === \"all\" || e.category === category) && (!tokens.length || matchesAll(`${e.actor.name} ${e.actor.email ?? \"\"} ${e.action} ${e.target} ${e.ip ?? \"\"}`, tokens)))\n  const shown = matched.slice(0, visible)\n  const count = (c: Audit1Category | \"all\") => (c === \"all\" ? eventsProp.length : eventsProp.filter((e) => e.category === c).length)\n\n  async function exportCsv() {\n    setExported(null)\n    try {\n      await onExport?.(matched)\n      setExported({ ok: true, text: `Exported ${matched.length} ${matched.length === 1 ? \"event\" : \"events\"}.` })\n    } catch (e) {\n      setExported({ ok: false, text: e instanceof Error ? e.message : \"The export did not finish. Nothing was downloaded.\" })\n    }\n  }\n\n  return (\n    <section data-slot=\"audit-1\" data-state={state} aria-labelledby={`${id}-title`} className={cn(\"mx-auto w-full max-w-5xl px-4 py-10 sm:px-6\", className)} {...frameProps(props)}>\n      <header className=\"mb-8 flex flex-wrap items-end justify-between gap-4\">\n        <div>\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\">Who did what, to what, when and from where. Open a row to see what changed.</p>\n        </div>\n        {view === \"ready\" && (\n          <Button variant=\"outline\" onClick={() => void exportCsv()} disabled={matched.length === 0}>\n            <Download aria-hidden=\"true\" /> Export {matched.length}\n          </Button>\n        )}\n      </header>\n      <StateView state={view} onRetry={onRetry} label=\"Loading the audit log\" loading={<Skel />} empty={{ title: \"No activity yet\", description: \"Sign-ins, permission changes, exports and billing changes will appear here as they happen.\", ...emptyState }} error={{ title: \"The audit log could not be loaded\", description: \"Nothing was changed. Check your connection and try again.\", ...errorState }}>\n        <div className=\"flex flex-col gap-3 sm:flex-row sm:items-center\">\n          <div className=\"bg-background focus-within:ring-ring/50 flex h-10 flex-1 items-center gap-2 rounded-lg border px-3 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 by person, action, target or address\" value={query} onChange={(e) => { setQuery(e.target.value); setVisible(pageSize) }} className=\"placeholder:text-muted-foreground min-w-0 flex-1 bg-transparent text-sm outline-none\" />\n          </div>\n          <div role=\"group\" aria-label=\"Filter by kind\" className=\"flex flex-wrap gap-2\">\n            {CATEGORIES.map((c) => (\n              <button key={c.id} type=\"button\" aria-pressed={category === c.id} onClick={() => { setCategory(c.id); setVisible(pageSize) }} className={cn(\"focus-visible:ring-ring/50 shrink-0 rounded-full border px-3.5 py-1.5 text-sm font-medium outline-none transition-colors focus-visible:ring-[3px] motion-reduce:transition-none\", category === c.id ? \"bg-foreground text-background border-foreground\" : \"hover:bg-accent\")}>\n                {c.label} <span className={cn(\"tabular-nums\", category === c.id ? \"opacity-70\" : \"text-muted-foreground\")}>{count(c.id)}</span>\n              </button>\n            ))}\n          </div>\n        </div>\n        <p aria-live=\"polite\" className=\"text-muted-foreground mt-4 text-sm\">\n          {matched.length} {matched.length === 1 ? \"event\" : \"events\"}\n          {exported && (\n            <span role={exported.ok ? \"status\" : \"alert\"} className={cn(\"ms-3\", exported.ok ? \"text-foreground\" : \"text-destructive font-medium\")}>\n              {exported.text}\n            </span>\n          )}\n        </p>\n\n        {shown.length ? (\n          <div className=\"mt-3 overflow-x-auto rounded-xl border\">\n            <table className=\"w-full min-w-[40rem] border-collapse text-sm\">\n              <caption className=\"sr-only\">{title}</caption>\n              <thead className=\"bg-muted/40 text-muted-foreground text-xs\">\n                <tr>\n                  <th scope=\"col\" className=\"px-4 py-2.5 text-start font-medium\">When</th>\n                  <th scope=\"col\" className=\"px-4 py-2.5 text-start font-medium\">Who</th>\n                  <th scope=\"col\" className=\"px-4 py-2.5 text-start font-medium\">What</th>\n                  <th scope=\"col\" className=\"hidden px-4 py-2.5 text-start font-medium md:table-cell\">From</th>\n                </tr>\n              </thead>\n              <tbody className=\"divide-y\">\n                {shown.map((e) => {\n                  const isOpen = open === e.id\n                  const failed = e.result === \"failed\"\n                  return (\n                    <React.Fragment key={e.id}>\n                      <tr data-category={e.category} className={cn(isOpen && \"bg-muted/30\")}>\n                        <td className=\"text-muted-foreground px-4 py-3 align-top font-mono text-xs whitespace-nowrap tabular-nums\" dir=\"ltr\">{e.time}</td>\n                        <td className=\"px-4 py-3 align-top\">\n                          <span className=\"block font-medium\"><MatchHighlight text={e.actor.name} query={tokens} /></span>\n                          {e.actor.email && <span className=\"text-muted-foreground block text-xs\" dir=\"ltr\">{e.actor.email}</span>}\n                        </td>\n                        <td className=\"px-4 py-3 align-top\">\n                          <button type=\"button\" aria-expanded={e.changes?.length ? isOpen : undefined} disabled={!e.changes?.length} onClick={() => setOpen(isOpen ? null : e.id)} aria-controls={e.changes?.length ? `${id}-${e.id}` : undefined} className=\"focus-visible:ring-ring/50 -m-1 flex w-full items-start gap-1.5 rounded-md p-1 text-start outline-none focus-visible:ring-[3px] disabled:cursor-default\">\n                            <span className=\"min-w-0 flex-1\">\n                              <span className=\"flex flex-wrap items-center gap-x-2\">\n                                <MatchHighlight text={e.action} query={tokens} />\n                                {failed && (\n                                  <span className=\"text-destructive inline-flex items-center gap-1 text-xs font-medium\">\n                                    <CircleX aria-hidden=\"true\" className=\"size-3\" /> Failed\n                                  </span>\n                                )}\n                              </span>\n                              <span className=\"text-muted-foreground block text-xs\"><MatchHighlight text={e.target} query={tokens} /></span>\n                            </span>\n                            {e.changes?.length ? <ChevronDown aria-hidden=\"true\" className={cn(\"text-muted-foreground mt-0.5 size-4 shrink-0 transition-transform motion-reduce:transition-none\", isOpen && \"rotate-180\")} /> : null}\n                          </button>\n                        </td>\n                        <td className=\"text-muted-foreground hidden px-4 py-3 align-top text-xs md:table-cell\">\n                          <span className=\"block\" dir=\"ltr\">{e.ip ?? \"—\"}</span>\n                          {e.place && <span className=\"block\">{e.place}</span>}\n                        </td>\n                      </tr>\n                      {isOpen && e.changes?.length ? (\n                        <tr className=\"bg-muted/30\">\n                          <td colSpan={4} id={`${id}-${e.id}`} className=\"px-4 pt-0 pb-4\">\n                            <dl className=\"bg-background grid gap-px overflow-hidden rounded-lg border text-sm\">\n                              {e.changes.map((c) => (\n                                <div key={c.field} className=\"grid grid-cols-[7rem_1fr] items-baseline gap-3 px-3 py-2\">\n                                  <dt className=\"text-muted-foreground text-xs font-medium\">{c.field}</dt>\n                                  <dd className=\"flex flex-wrap items-center gap-2\">\n                                    <span className=\"text-muted-foreground line-through decoration-1\">{c.from}</span>\n                                    <ArrowRight aria-label=\"changed to\" role=\"img\" className=\"text-muted-foreground size-3.5 rtl:rotate-180\" />\n                                    <span className=\"font-medium\">{c.to}</span>\n                                  </dd>\n                                </div>\n                              ))}\n                            </dl>\n                          </td>\n                        </tr>\n                      ) : null}\n                    </React.Fragment>\n                  )\n                })}\n              </tbody>\n            </table>\n          </div>\n        ) : (\n          <div className=\"bg-card mt-3 rounded-xl border px-6 py-14 text-center\">\n            <h2 className=\"text-lg font-semibold tracking-tight\">No events match</h2>\n            <p className=\"text-muted-foreground mx-auto mt-1.5 max-w-sm text-sm text-pretty\">Try a different search, or look at all kinds of activity.</p>\n            <Button variant=\"outline\" size=\"sm\" className=\"mt-4\" onClick={() => { setQuery(\"\"); setCategory(\"all\") }}>\n              Clear filters\n            </Button>\n          </div>\n        )}\n        {matched.length > shown.length && (\n          <div className=\"mt-4 flex justify-center\">\n            <Button variant=\"outline\" onClick={() => setVisible((v) => v + pageSize)}>\n              Show {Math.min(pageSize, matched.length - shown.length)} more\n            </Button>\n          </div>\n        )}\n      </StateView>\n    </section>\n  )\n}\n\nexport { Audit1, type Audit1Props, type Audit1Event, type Audit1Category }\n",
      "type": "registry:component",
      "target": "@components/ballmac/blocks/audit-1/audit-1.tsx"
    }
  ],
  "meta": {
    "schema": 1,
    "tier": "free",
    "tags": [
      "audit log",
      "security",
      "compliance",
      "activity",
      "admin",
      "export",
      "history"
    ],
    "version": "1.0.0",
    "updated": "2026-10-24",
    "tests": true,
    "ai": {
      "summary": "An audit log. <Audit1 events={[{ id, time, actor: { name, email? }, action, category: 'signin' | 'permissions' | 'data' | 'billing', target, ip?, place?, result?: 'failed', changes?: [{ field, from, to }] }]} onExport={(events) => Promise} />. Search and the kind filter narrow the list and the count; Export sends what is currently shown. Rows with changes open to show from and to.",
      "whenToUse": [
        "An admin area where owners review security and data activity",
        "A compliance screen that needs an exportable trail"
      ],
      "whenNotToUse": [
        "A user's own activity feed; use activity-feed",
        "The live output of a process; use log-stream",
        "Agent guardrail checks; use guardrail-feed"
      ],
      "composesWith": [
        "access-1",
        "settings-1",
        "webhooks-1",
        "app-shell-1"
      ],
      "a11y": [
        {
          "keys": "Table",
          "action": "A real table with a caption and column headings; times and addresses stay left to right in right-to-left layouts"
        },
        {
          "keys": "Enter / Space on an action",
          "action": "Opens the details of what changed (aria-expanded); rows with nothing to show are not buttons"
        },
        {
          "keys": "Failed attempts",
          "action": "Written as the word Failed with an icon, never colour alone"
        },
        {
          "keys": "Filters",
          "action": "Toggle buttons with counts; the event count and the export result are polite announcements"
        }
      ],
      "customization": [
        "Page size and the event you open at first",
        "onExport receives the filtered events; reject it and the message says the export failed",
        "Search matches person, action, target and address"
      ]
    },
    "states": [
      {
        "id": "loading",
        "title": "Loading",
        "example": "audit-1-loading",
        "trigger": "state=\"loading\"",
        "note": "Skeleton rows under the real heading, announced as loading."
      },
      {
        "id": "empty",
        "title": "No activity yet",
        "example": "audit-1-empty",
        "trigger": "state=\"empty\"",
        "note": "Says what will appear and when; emptyState sets the copy. A search or filter with no match says so with Clear filters."
      },
      {
        "id": "error",
        "title": "Could not load",
        "example": "audit-1-error",
        "trigger": "state=\"error\"",
        "note": "An alert saying nothing was changed, with Try again (onRetry)."
      }
    ],
    "examples": [
      "audit-1-demo",
      "audit-1-failed",
      "audit-1-loading",
      "audit-1-empty",
      "audit-1-error"
    ],
    "url": "https://ui.ballmac.com/components/audit-1"
  },
  "categories": [
    "blocks",
    "audit"
  ],
  "type": "registry:block"
}