Data Display

Audit Log Row

An audit-log entry that expands via a grid-rows transition to a field-level before/after diff with removed values struck through and added values tinted.

Install

npx shadcn@latest add @paragon/audit-log-row

Also installs: avatar

audit-log-row.tsx

"use client";

import * as React from "react";
import { ChevronRight } from "lucide-react";
import { Avatar, AvatarFallback } from "@/registry/paragon/ui/avatar";
import { cn } from "@/lib/utils";

export interface AuditFieldChange {
  field: string;
  /** Prior value; omit for a newly-set field. */
  before?: string;
  /** New value; omit for a removed field. */
  after?: string;
}

export interface AuditLogRowProps extends React.ComponentProps<"div"> {
  /** Actor who performed the action. */
  actor?: string;
  /** Human action summary, e.g. "updated role for". */
  action?: string;
  /** Object acted on. */
  target?: string;
  /** Relative timestamp. */
  time?: string;
  /** Source IP / client, shown in the expanded header. */
  ip?: string;
  /** Field-level before/after changes revealed on expand. */
  changes?: AuditFieldChange[];
  /** Start expanded. */
  defaultExpanded?: boolean;
}

const DEFAULT_CHANGES: AuditFieldChange[] = [
  { field: "role", before: "member", after: "admin" },
  { field: "can_manage_billing", before: "false", after: "true" },
  { field: "mfa_required", before: "false", after: "true" },
];

/**
 * An audit-log entry that expands to a field-level before/after diff. The
 * disclosure grows through a grid-template-rows 0fr→1fr transition (the one
 * height animation the spec permits), the chevron rotates, and removed values
 * tint destructive with added values tinting success. Fully keyboard-operable
 * via a real toggle button; the panel is `inert` while collapsed so its
 * controls stay out of the tab order. Reduced motion drops the grow to an
 * instant reveal.
 */
export function AuditLogRow({
  actor = "Priya Raghavan",
  action = "updated permissions for",
  target = "marcus@acme.io",
  time = "Today at 14:32",
  ip = "203.0.113.24",
  changes = DEFAULT_CHANGES,
  defaultExpanded = false,
  className,
  ...props
}: AuditLogRowProps) {
  const [expanded, setExpanded] = React.useState(defaultExpanded);
  const panelId = React.useId();

  return (
    <div
      data-slot="audit-log-row"
      data-expanded={expanded || undefined}
      className={cn(
        "overflow-hidden rounded-xl bg-card text-card-foreground shadow-border",
        className,
      )}
      {...props}
    >
      <button
        type="button"
        aria-expanded={expanded}
        aria-controls={panelId}
        onClick={() => setExpanded((v) => !v)}
        className={cn(
          "flex w-full items-center gap-3 px-4 py-3 text-left",
          "transition-colors duration-(--duration-fast) hover:bg-muted/40",
          "outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset",
        )}
      >
        <ChevronRight
          aria-hidden
          className={cn(
            "size-4 shrink-0 text-muted-foreground transition-transform duration-(--duration-base) ease-(--ease-out) motion-reduce:transition-none",
            expanded && "rotate-90",
          )}
        />
        <Avatar size="sm" className="shrink-0">
          <AvatarFallback name={actor} />
        </Avatar>
        <p className="min-w-0 flex-1 truncate text-[13px]">
          <span className="font-medium">{actor}</span>{" "}
          <span className="text-muted-foreground">{action}</span>{" "}
          <span className="font-medium">{target}</span>
        </p>
        <span className="shrink-0 text-xs text-muted-foreground whitespace-nowrap tabular-nums">
          {time}
        </span>
      </button>

      <div
        className={cn(
          "grid transition-[grid-template-rows] duration-(--duration-base) ease-(--ease-out) motion-reduce:transition-none",
          expanded ? "grid-rows-[1fr]" : "grid-rows-[0fr]",
        )}
      >
        <div id={panelId} inert={!expanded} className="min-h-0 overflow-hidden">
          <div className="border-t px-4 py-3">
            <div className="mb-2 flex items-center gap-3 text-[11px] text-muted-foreground">
              <span>
                IP <span className="font-mono text-foreground">{ip}</span>
              </span>
              <span>
                {changes.length} field{changes.length === 1 ? "" : "s"} changed
              </span>
            </div>
            <dl className="overflow-hidden rounded-lg border font-mono text-xs">
              {changes.map((change, i) => (
                <div
                  key={change.field}
                  className={cn(
                    "grid grid-cols-[7rem_1fr] items-start gap-x-3 px-3 py-1.5",
                    i > 0 && "border-t",
                  )}
                >
                  <dt className="truncate text-muted-foreground">
                    {change.field}
                  </dt>
                  <dd className="flex min-w-0 flex-wrap items-center gap-1.5">
                    {change.before !== undefined && (
                      <span className="rounded bg-destructive/10 px-1.5 py-0.5 text-destructive line-through">
                        {change.before}
                      </span>
                    )}
                    {change.before !== undefined &&
                      change.after !== undefined && (
                        <ChevronRight
                          aria-hidden
                          className="size-3 text-muted-foreground"
                        />
                      )}
                    {change.after !== undefined && (
                      <span className="rounded bg-success/12 px-1.5 py-0.5 text-success">
                        {change.after}
                      </span>
                    )}
                  </dd>
                </div>
              ))}
            </dl>
          </div>
        </div>
      </div>
    </div>
  );
}