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.
1.What you build
No screenshot yet. Run the Next.js example (pnpm dev:example) to see it.
2.Add the components
Web: shadcn
npx shadcn add button select skeleton tableNative: 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"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 exampleimport { 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>
);
}