All recipes

Recipe

Audit log table

The audit trail, newest first, with filters by actor and action and older pages on demand. Web and native call useAuditLog from better-supabase/blocks/audit/react.

The audit blockWebReact Native

1.What you build

No screenshot yet. Run the Next.js example (pnpm dev:example) to see it.

2.Add the components

Web: shadcn

terminal
npx shadcn add button select skeleton table

Native: React Native

The snippet uses ActivityIndicator, Button, Pressable, Text, View from react-native, so it needs no styling library. For shadcn-style native components, React Native Reusables serves them from a registry the shadcn CLI reads.

3.Install the glue

Install the block with npx better-supabase sql add audit. The glue items are copied into your app, so edit them as you need.

Web

None: the hook calls the block from the client.

Native

None: the hook calls the block from the client.

4.Write the component

Web: React and shadcn

View in the example
audit-log-table.tsx
"use client";

import { useAuditLog } from "better-supabase/blocks/audit/react";
import { useExtracted, useFormatter } from "next-intl";

import { Button } from "@/components/ui/button";
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select";
import { Skeleton } from "@/components/ui/skeleton";
import {
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableHeader,
  TableRow,
} from "@/components/ui/table";
import { blockConfig } from "@/lib/supabase/blocks.generated";
import { useErrorMessage } from "@/lib/use-error-message";

const ALL = "all";

/** The values a filter offers: those on the loaded page, and the one it has. */
function choices(
  values: readonly (string | undefined)[],
  current: string | undefined,
): readonly string[] {
  return [
    ...new Set([...values, current].filter((value) => value !== undefined)),
  ].toSorted();
}

/**
 * The organization's audit trail through the browser client, newest first,
 * filtered by actor and action. The audit module's read policy decides
 * which entries the caller sees.
 */
export function AuditLogTable({ organizationId }: { organizationId: string }) {
  const t = useExtracted("audit");
  const format = useFormatter();
  const errorMessage = useErrorMessage();
  const { entries, filters, hasMore, loading, error, setFilters, loadMore } =
    useAuditLog({ organizationId, schema: blockConfig.audit.api });
  const actors = new Map(
    (entries ?? []).flatMap((entry) =>
      entry.actorId ? [[entry.actorId, entry.actorLabel ?? entry.actorId]] : [],
    ),
  );
  const actions = choices(
    (entries ?? []).map((entry) => entry.eventType),
    filters.eventType,
  );
  const actorItems = [
    { value: ALL, label: t("Every actor") },
    ...choices([...actors.keys()], filters.actorId).map((id) => ({
      value: id,
      label: actors.get(id) ?? id,
    })),
  ];
  const actionItems = [
    { value: ALL, label: t("Every action") },
    ...actions.map((action) => ({ value: action, label: action })),
  ];
  const pick = (value: string | null): string | undefined =>
    value === null || value === ALL ? undefined : value;

  return (
    <div className="grid gap-4">
      <div className="flex flex-wrap gap-2">
        <Select
          value={filters.actorId ?? ALL}
          items={actorItems}
          onValueChange={(value) => {
            void setFilters({ ...filters, actorId: pick(value) });
          }}
        >
          <SelectTrigger size="sm" className="w-48" aria-label={t("Actor")}>
            <SelectValue />
          </SelectTrigger>
          <SelectContent>
            {actorItems.map((item) => (
              <SelectItem key={item.value} value={item.value}>
                {item.label}
              </SelectItem>
            ))}
          </SelectContent>
        </Select>
        <Select
          value={filters.eventType ?? ALL}
          items={actionItems}
          onValueChange={(value) => {
            void setFilters({ ...filters, eventType: pick(value) });
          }}
        >
          <SelectTrigger size="sm" className="w-56" aria-label={t("Action")}>
            <SelectValue />
          </SelectTrigger>
          <SelectContent>
            {actionItems.map((item) => (
              <SelectItem key={item.value} value={item.value}>
                {item.label}
              </SelectItem>
            ))}
          </SelectContent>
        </Select>
      </div>
      {error ? (
        <p className="text-destructive text-sm">{errorMessage(error)}</p>
      ) : null}
      {entries === undefined ? (
        <Skeleton className="h-64" />
      ) : entries.length === 0 ? (
        <p className="text-muted-foreground text-sm">
          {t("Nothing recorded yet.")}
        </p>
      ) : (
        <Table data-testid="audit-events">
          <TableHeader>
            <TableRow>
              <TableHead>{t("When")}</TableHead>
              <TableHead>{t("Event")}</TableHead>
              <TableHead className="hidden md:table-cell">
                {t("Actor")}
              </TableHead>
              <TableHead className="hidden md:table-cell">
                {t("Target")}
              </TableHead>
            </TableRow>
          </TableHeader>
          <TableBody>
            {entries.map((entry) => (
              <TableRow key={entry.id}>
                <TableCell className="text-muted-foreground whitespace-nowrap tabular-nums">
                  {format.dateTime(
                    new Date(entry.occurredAt.epochMilliseconds),
                    { dateStyle: "short", timeStyle: "short" },
                  )}
                </TableCell>
                <TableCell className="font-mono text-xs">
                  {entry.eventType ??
                    `${entry.op ?? "change"} ${entry.table ?? ""}`.trim()}
                </TableCell>
                <TableCell className="hidden max-w-48 truncate md:table-cell">
                  {entry.actorLabel ?? entry.actorId ?? "–"}
                </TableCell>
                <TableCell className="hidden max-w-48 truncate md:table-cell">
                  {entry.targetLabel ?? entry.record ?? "–"}
                </TableCell>
              </TableRow>
            ))}
          </TableBody>
        </Table>
      )}
      {hasMore ? (
        <Button
          variant="outline"
          size="sm"
          className="justify-self-center"
          disabled={loading}
          onClick={() => {
            void loadMore();
          }}
        >
          {t("Load more")}
        </Button>
      ) : null}
    </div>
  );
}

Native: React Native

View in the example
audit-log-table.tsx
import { useAuditLog } from "better-supabase/blocks/audit/react";
import { tenantOf, useAuth } from "better-supabase/react";
import { ActivityIndicator, Button, Pressable, Text, View } from "react-native";

import { API_SCHEMA } from "../lib/blocks";

/** A row of chips that filters by one value, or by none. */
function Chips({
  values,
  selected,
  onSelect,
}: {
  readonly values: readonly string[];
  readonly selected: string | undefined;
  readonly onSelect: (value: string | undefined) => void;
}) {
  return (
    <View style={{ flexDirection: "row", flexWrap: "wrap", gap: 6 }}>
      {[undefined, ...values].map((value) => (
        <Pressable
          key={value ?? "all"}
          accessibilityRole="radio"
          accessibilityState={{ checked: value === selected }}
          onPress={() => {
            onSelect(value);
          }}
          style={{
            borderWidth: 1,
            borderRadius: 16,
            paddingHorizontal: 10,
            paddingVertical: 2,
            backgroundColor: value === selected ? "#e5e5e5" : undefined,
          }}
        >
          <Text>{value ?? "All"}</Text>
        </Pressable>
      ))}
    </View>
  );
}

/**
 * The organization's audit trail, newest first, filtered by actor and by
 * action with the values on the loaded page. Members without `audit.read`
 * get the module's error.
 */
export function AuditLogTable() {
  const { entries, filters, hasMore, loading, error, setFilters, loadMore } =
    useAuditLog({ organizationId: tenantOf(useAuth()), schema: API_SCHEMA });
  const distinct = (values: readonly (string | undefined)[]) =>
    [...new Set(values.filter((value) => value !== undefined))].toSorted();
  const actors = distinct([
    ...(entries ?? []).map((entry) => entry.actorId),
    filters.actorId,
  ]);
  const actions = distinct([
    ...(entries ?? []).map((entry) => entry.eventType),
    filters.eventType,
  ]);

  return (
    <View style={{ gap: 8 }}>
      <Text style={{ fontSize: 17, fontWeight: "600" }}>Audit log</Text>
      <Chips
        values={actors}
        selected={filters.actorId}
        onSelect={(actorId) => void setFilters({ ...filters, actorId })}
      />
      <Chips
        values={actions}
        selected={filters.eventType}
        onSelect={(eventType) => void setFilters({ ...filters, eventType })}
      />
      {error ? <Text style={{ color: "#b91c1c" }}>{error.message}</Text> : null}
      {entries === undefined ? (
        <ActivityIndicator />
      ) : (
        entries.map((entry) => (
          <View key={entry.id} style={{ paddingVertical: 6 }}>
            <Text style={{ fontFamily: "monospace" }}>
              {entry.eventType ?? entry.op}
            </Text>
            <Text style={{ color: "#737373", fontSize: 12 }}>
              {entry.occurredAt.toLocaleString()} by{" "}
              {entry.actorLabel ?? entry.actorId ?? "the system"}
            </Text>
          </View>
        ))
      )}
      {hasMore ? (
        <Button
          title="Load more"
          disabled={loading}
          onPress={() => void loadMore()}
        />
      ) : null}
    </View>
  );
}