feat(admin): add dashboard explorer analytics and actions

This commit is contained in:
Arthur Belleville 2026-04-24 15:55:56 +02:00
parent 4f71c52e14
commit 85d44af57e
No known key found for this signature in database
36 changed files with 2313 additions and 35 deletions

View file

@ -1,12 +1,20 @@
import { render, screen } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { beforeEach, describe, expect, it } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import AppRoutes from "../routes";
import { adminApi } from "../lib/api";
import { storeAdminSession } from "../lib/adminSession";
vi.mock("../lib/api", () => ({
adminApi: {
get: vi.fn(),
},
}));
describe("AdminLayout", () => {
beforeEach(() => {
localStorage.clear();
vi.clearAllMocks();
storeAdminSession({
expiresAt: new Date(Date.now() + 15 * 60 * 1000).toISOString(),
operatorEmail: "ops@xtablo.com",
@ -14,6 +22,13 @@ describe("AdminLayout", () => {
role: "operator",
sessionToken: "admin-session-token",
});
vi.mocked(adminApi.get).mockResolvedValue({
data: {
alerts: [],
metrics: [],
shortcuts: [],
},
});
});
it("shows the production badge and admin sections", async () => {
@ -24,7 +39,9 @@ describe("AdminLayout", () => {
);
expect(await screen.findByText(/^production$/i)).toBeInTheDocument();
expect(screen.getByRole("heading", { name: /operations home/i })).toBeInTheDocument();
expect(
screen.getByRole("heading", { name: /production command deck for privileged supabase operations/i })
).toBeInTheDocument();
expect(screen.getByRole("link", { name: /operations home/i })).toBeInTheDocument();
expect(screen.getByRole("link", { name: /data explorer/i })).toBeInTheDocument();
expect(screen.getByRole("link", { name: /analytics studio/i })).toBeInTheDocument();

View file

@ -6,6 +6,7 @@ import { adminApi } from "../lib/api";
vi.mock("../lib/api", () => ({
adminApi: {
get: vi.fn(),
post: vi.fn(),
},
}));
@ -14,6 +15,13 @@ describe("PrivilegedGate", () => {
beforeEach(() => {
localStorage.clear();
vi.clearAllMocks();
vi.mocked(adminApi.get).mockResolvedValue({
data: {
alerts: [],
metrics: [],
shortcuts: [],
},
});
});
it("exchanges a privileged token and enters the admin shell", async () => {
@ -44,6 +52,10 @@ describe("PrivilegedGate", () => {
});
});
expect(await screen.findByRole("heading", { name: /operations home/i })).toBeInTheDocument();
expect(
await screen.findByRole("heading", {
name: /production command deck for privileged supabase operations/i,
})
).toBeInTheDocument();
});
});

View file

@ -0,0 +1,133 @@
import type { AdminActionSummary } from "@xtablo/shared-types";
import { useEffect, useMemo, useState } from "react";
import { actionSeverityCopy } from "../../registry/actions";
type ActionRunnerProps = {
actions: AdminActionSummary[];
error: string | null;
isRunning: boolean;
onRun: (payload: Record<string, string>) => Promise<void>;
onSelectActionId: (actionId: string) => void;
resultMessage: string | null;
selectedActionId: string | null;
};
export function ActionRunner({
actions,
error,
isRunning,
onRun,
onSelectActionId,
resultMessage,
selectedActionId,
}: ActionRunnerProps) {
const selectedAction = useMemo(
() => actions.find((action) => action.id === selectedActionId) ?? null,
[actions, selectedActionId]
);
const [values, setValues] = useState<Record<string, string>>({});
useEffect(() => {
if (!selectedAction) {
return;
}
setValues(
Object.fromEntries(selectedAction.fields.map((field) => [field.id, ""]))
);
}, [selectedAction]);
const tone = selectedAction ? actionSeverityCopy[selectedAction.id as keyof typeof actionSeverityCopy] : null;
return (
<div className="grid gap-6 lg:grid-cols-[280px_minmax(0,1fr)]">
<aside className="rounded-[2rem] border border-border bg-card p-5">
<p className="text-xs uppercase tracking-[0.2em] text-foreground/50">Approved Actions</p>
<div className="mt-4 flex flex-col gap-3">
{actions.map((action) => (
<button
className={`rounded-[1.25rem] border px-4 py-3 text-left ${
action.id === selectedActionId
? "border-foreground bg-foreground text-background"
: "border-border bg-background/70"
}`}
key={action.id}
onClick={() => onSelectActionId(action.id)}
type="button"
>
<p className="text-sm font-semibold">{action.label}</p>
<p className="mt-1 text-sm opacity-80">{action.description}</p>
</button>
))}
</div>
</aside>
<section className="rounded-[2rem] border border-border bg-card p-6">
{selectedAction ? (
<form
className="space-y-5"
onSubmit={(event) => {
event.preventDefault();
void onRun(values);
}}
>
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<p className="text-xs uppercase tracking-[0.2em] text-foreground/50">Action</p>
<h2 className="mt-2 text-3xl font-semibold">{selectedAction.label}</h2>
<p className="mt-2 max-w-2xl text-sm text-foreground/70">
{selectedAction.description}
</p>
</div>
{tone ? (
<span
className={`rounded-full px-3 py-1 text-xs uppercase tracking-[0.2em] ${
tone.tone === "critical"
? "bg-red-100 text-red-700"
: "bg-amber-100 text-amber-700"
}`}
>
{tone.badge}
</span>
) : null}
</div>
<div className="grid gap-4 md:grid-cols-2">
{selectedAction.fields.map((field) => (
<label className="block" htmlFor={field.id} key={field.id}>
<span className="mb-2 block text-sm font-medium">{field.label}</span>
<input
className="w-full rounded-2xl border border-border bg-background px-3 py-2"
id={field.id}
onChange={(event) =>
setValues((currentValue) => ({
...currentValue,
[field.id]: event.target.value,
}))
}
placeholder={field.placeholder}
required={field.required}
value={values[field.id] ?? ""}
/>
</label>
))}
</div>
{error ? <p className="text-sm text-red-600">{error}</p> : null}
{resultMessage ? <p className="text-sm text-emerald-700">{resultMessage}</p> : null}
<button
className="rounded-2xl bg-foreground px-4 py-2 text-background disabled:cursor-not-allowed disabled:opacity-60"
disabled={isRunning}
type="submit"
>
{isRunning ? "Running..." : "Run Action"}
</button>
</form>
) : (
<p className="text-sm text-foreground/70">Select an action to begin.</p>
)}
</section>
</div>
);
}

View file

@ -0,0 +1,173 @@
import type { AdminDatasetPoint, AdminDatasetResult, AdminDatasetSummary } from "@xtablo/shared-types";
type ChartBuilderProps = {
dataset: AdminDatasetResult | null;
datasets: AdminDatasetSummary[];
onSelectDatasetId: (datasetId: string) => void;
selectedDatasetId: string | null;
};
function BarChart({ points }: { points: AdminDatasetPoint[] }) {
const maxValue = Math.max(...points.map((point) => point.value), 1);
return (
<div className="grid min-h-64 grid-cols-[repeat(auto-fit,minmax(56px,1fr))] items-end gap-3">
{points.map((point) => (
<div className="flex h-full flex-col justify-end gap-2" key={point.label}>
<div
className="rounded-t-2xl bg-[linear-gradient(180deg,rgba(20,36,84,0.92),rgba(88,140,126,0.9))]"
style={{ height: `${Math.max((point.value / maxValue) * 180, 12)}px` }}
/>
<div>
<p className="text-xs font-medium">{point.value}</p>
<p className="truncate text-[11px] uppercase tracking-[0.16em] text-foreground/55">
{point.label}
</p>
</div>
</div>
))}
</div>
);
}
function LineChart({ points }: { points: AdminDatasetPoint[] }) {
const width = 560;
const height = 220;
const maxValue = Math.max(...points.map((point) => point.value), 1);
const polyline = points
.map((point, index) => {
const x = points.length === 1 ? width / 2 : (index / (points.length - 1)) * width;
const y = height - (point.value / maxValue) * (height - 24) - 12;
return `${x},${y}`;
})
.join(" ");
return (
<div className="space-y-4">
<svg
className="w-full overflow-visible rounded-[2rem] border border-border bg-[linear-gradient(180deg,rgba(252,249,244,0.96),rgba(239,235,225,0.96))] p-4"
viewBox={`0 0 ${width} ${height}`}
>
<polyline
fill="none"
points={polyline}
stroke="rgb(23 37 84)"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="4"
/>
{points.map((point, index) => {
const x = points.length === 1 ? width / 2 : (index / (points.length - 1)) * width;
const y = height - (point.value / maxValue) * (height - 24) - 12;
return <circle cx={x} cy={y} fill="rgb(15 118 110)" key={point.label} r="5" />;
})}
</svg>
<div className="grid gap-3 md:grid-cols-3">
{points.map((point) => (
<div className="rounded-2xl border border-border/80 bg-background/70 px-3 py-2" key={point.label}>
<p className="text-xs uppercase tracking-[0.18em] text-foreground/50">{point.label}</p>
<p className="mt-1 text-lg font-semibold">{point.value}</p>
</div>
))}
</div>
</div>
);
}
function DonutChart({ points }: { points: AdminDatasetPoint[] }) {
const total = points.reduce((sum, point) => sum + point.value, 0) || 1;
const palette = ["#172554", "#0f766e", "#b45309", "#7c2d12", "#475569"];
let currentStop = 0;
const gradientStops = points
.map((point, index) => {
const start = currentStop;
currentStop += (point.value / total) * 100;
return `${palette[index % palette.length]} ${start}% ${currentStop}%`;
})
.join(", ");
return (
<div className="grid gap-6 md:grid-cols-[220px_minmax(0,1fr)] md:items-center">
<div
className="mx-auto h-52 w-52 rounded-full border border-border"
style={{
background: `conic-gradient(${gradientStops})`,
}}
>
<div className="m-auto mt-8 flex h-36 w-36 items-center justify-center rounded-full bg-card text-center">
<div>
<p className="text-xs uppercase tracking-[0.18em] text-foreground/50">Total</p>
<p className="mt-1 text-3xl font-semibold">{total}</p>
</div>
</div>
</div>
<div className="space-y-3">
{points.map((point, index) => (
<div className="flex items-center justify-between rounded-2xl border border-border/80 px-4 py-3" key={point.label}>
<div className="flex items-center gap-3">
<span
className="h-3 w-3 rounded-full"
style={{ backgroundColor: palette[index % palette.length] }}
/>
<p className="text-sm font-medium">{point.label}</p>
</div>
<p className="text-sm text-foreground/70">{point.value}</p>
</div>
))}
</div>
</div>
);
}
export function ChartBuilder({
dataset,
datasets,
onSelectDatasetId,
selectedDatasetId,
}: ChartBuilderProps) {
return (
<div className="space-y-6">
<div className="flex flex-wrap gap-3">
{datasets.map((entry) => (
<button
className={`rounded-full border px-4 py-2 text-sm ${
entry.id === selectedDatasetId
? "border-foreground bg-foreground text-background"
: "border-border bg-card"
}`}
key={entry.id}
onClick={() => onSelectDatasetId(entry.id)}
type="button"
>
{entry.label}
</button>
))}
</div>
{dataset ? (
<section className="rounded-[2rem] border border-border bg-card p-6 shadow-[0_24px_80px_rgba(15,23,42,0.08)]">
<div className="mb-6 flex flex-wrap items-end justify-between gap-4">
<div>
<p className="text-xs uppercase tracking-[0.2em] text-foreground/50">Dataset</p>
<h2 className="mt-2 text-3xl font-semibold">{dataset.label}</h2>
<p className="mt-2 max-w-2xl text-sm text-foreground/70">{dataset.description}</p>
</div>
<div className="rounded-2xl border border-border/80 bg-background/70 px-4 py-3 text-right">
<p className="text-xs uppercase tracking-[0.18em] text-foreground/50">
{dataset.dimensionLabel} x {dataset.metricLabel}
</p>
<p className="mt-2 text-lg font-semibold">
{dataset.points.reduce((sum, point) => sum + point.value, 0)}
</p>
</div>
</div>
{dataset.chartType === "line" ? <LineChart points={dataset.points} /> : null}
{dataset.chartType === "bar" ? <BarChart points={dataset.points} /> : null}
{dataset.chartType === "donut" ? <DonutChart points={dataset.points} /> : null}
</section>
) : null}
</div>
);
}

View file

@ -0,0 +1,35 @@
type SavedDashboard = {
datasetId: string;
description: string;
id: string;
label: string;
};
type SavedDashboardListProps = {
dashboards: readonly SavedDashboard[];
onOpen: (datasetId: string) => void;
};
export function SavedDashboardList({ dashboards, onOpen }: SavedDashboardListProps) {
return (
<section className="rounded-[2rem] border border-border bg-card p-6">
<div>
<p className="text-xs uppercase tracking-[0.2em] text-foreground/50">Saved Views</p>
<h2 className="mt-2 text-2xl font-semibold">Operator Dashboards</h2>
</div>
<div className="mt-6 grid gap-4">
{dashboards.map((dashboard) => (
<button
className="rounded-[1.5rem] border border-border/80 bg-[linear-gradient(135deg,rgba(255,255,255,0.74),rgba(240,236,227,0.98))] p-4 text-left"
key={dashboard.id}
onClick={() => onOpen(dashboard.datasetId)}
type="button"
>
<p className="text-sm font-semibold">{dashboard.label}</p>
<p className="mt-2 text-sm text-foreground/70">{dashboard.description}</p>
</button>
))}
</div>
</section>
);
}

View file

@ -2,10 +2,12 @@ import type { AdminTableMeta } from "@xtablo/shared-types";
type AdminGridProps = {
meta: AdminTableMeta | null;
onSelectRow: (row: Record<string, string | boolean | null>) => void;
rows: Record<string, string | boolean | null>[];
selectedRowId: string | null;
};
export function AdminGrid({ meta, rows }: AdminGridProps) {
export function AdminGrid({ meta, onSelectRow, rows, selectedRowId }: AdminGridProps) {
if (!meta) {
return null;
}
@ -24,7 +26,13 @@ export function AdminGrid({ meta, rows }: AdminGridProps) {
</thead>
<tbody>
{rows.map((row, index) => (
<tr className="border-b border-border/60 last:border-b-0" key={`${row.id ?? index}`}>
<tr
className={`cursor-pointer border-b border-border/60 last:border-b-0 ${
String(row[meta.primaryKey] ?? "") === selectedRowId ? "bg-black/5" : ""
}`}
key={`${row[meta.primaryKey] ?? row.id ?? index}`}
onClick={() => onSelectRow(row)}
>
{meta.columns.map((column) => (
<td className="px-4 py-3 text-sm" key={column.id}>
{String(row[column.id] ?? "")}

View file

@ -0,0 +1,43 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { RowEditForm } from "./RowEditForm";
describe("RowEditForm", () => {
it("shows a diff preview before saving a sensitive record", async () => {
const onSave = vi.fn().mockResolvedValue(undefined);
render(
<RowEditForm
columns={[
{ id: "first_name", label: "First name" },
{ id: "email", label: "Email" },
]}
editableFields={["first_name"]}
onSave={onSave}
record={{
email: "test_owner@example.com",
first_name: "Test",
id: "user-1",
}}
/>
);
fireEvent.change(screen.getByLabelText(/first name/i), {
target: { value: "Ada" },
});
fireEvent.click(screen.getByRole("button", { name: /review changes/i }));
expect(await screen.findByText(/before/i)).toBeInTheDocument();
expect(screen.getByText(/after/i)).toBeInTheDocument();
expect(screen.getByText(/first name:\s*test/i)).toBeInTheDocument();
expect(screen.getByText(/first name:\s*ada/i)).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: /confirm update/i }));
await waitFor(() =>
expect(onSave).toHaveBeenCalledWith({
first_name: "Ada",
})
);
});
});

View file

@ -0,0 +1,115 @@
import type { AdminTableColumn } from "@xtablo/shared-types";
import { FormEvent, useEffect, useMemo, useState } from "react";
type RowEditFormProps = {
columns: AdminTableColumn[];
editableFields: string[];
isSaving?: boolean;
onSave: (changes: Record<string, string | boolean | null>) => Promise<void>;
record: Record<string, string | boolean | null>;
};
export function RowEditForm({
columns,
editableFields,
isSaving = false,
onSave,
record,
}: RowEditFormProps) {
const [draft, setDraft] = useState(record);
const [showDiff, setShowDiff] = useState(false);
useEffect(() => {
setDraft(record);
setShowDiff(false);
}, [record]);
const editableColumns = useMemo(
() => columns.filter((column) => editableFields.includes(column.id)),
[columns, editableFields]
);
const changedFields = editableColumns.filter((column) => draft[column.id] !== record[column.id]);
const hasChanges = changedFields.length > 0;
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
setShowDiff(true);
};
const handleSave = async () => {
if (!hasChanges) {
return;
}
await onSave(
Object.fromEntries(changedFields.map((column) => [column.id, draft[column.id] ?? null]))
);
setShowDiff(false);
};
return (
<form className="space-y-4" onSubmit={handleSubmit}>
{editableColumns.map((column) => (
<label className="block" htmlFor={column.id} key={column.id}>
<span className="mb-2 block text-sm font-medium">{column.label}</span>
<input
className="w-full rounded-2xl border border-border px-3 py-2"
id={column.id}
onChange={(event) =>
setDraft((currentValue) => ({
...currentValue,
[column.id]: event.target.value,
}))
}
value={String(draft[column.id] ?? "")}
/>
</label>
))}
<button
className="rounded-2xl bg-foreground px-4 py-2 text-background"
type="submit"
>
Review Changes
</button>
{showDiff ? (
<div className="rounded-3xl border border-border bg-card p-4">
<p className="text-sm font-semibold uppercase tracking-[0.2em]">Before</p>
{changedFields.map((column) => (
<p key={`${column.id}-before`}>
{column.label}: {String(record[column.id] ?? "")}
</p>
))}
<p className="mt-4 text-sm font-semibold uppercase tracking-[0.2em]">After</p>
{changedFields.map((column) => (
<p key={`${column.id}-after`}>
{column.label}: {String(draft[column.id] ?? "")}
</p>
))}
{!hasChanges ? (
<p className="mt-4 text-sm text-foreground/70">No changes to save yet.</p>
) : null}
<div className="mt-4 flex gap-3">
<button
className="rounded-2xl border border-border px-4 py-2"
onClick={() => setShowDiff(false)}
type="button"
>
Keep Editing
</button>
<button
className="rounded-2xl bg-foreground px-4 py-2 text-background disabled:cursor-not-allowed disabled:opacity-60"
disabled={!hasChanges || isSaving}
onClick={() => void handleSave()}
type="button"
>
{isSaving ? "Saving..." : "Confirm Update"}
</button>
</div>
</div>
) : null}
</form>
);
}

View file

@ -0,0 +1,98 @@
import type { AdminActionRunResponse, AdminActionSummary } from "@xtablo/shared-types";
import { useEffect, useState } from "react";
import { adminApi } from "../lib/api";
function getErrorMessage(error: unknown, fallbackMessage: string) {
if (typeof error === "object" && error !== null && "response" in error) {
const response = error.response;
if (
typeof response === "object" &&
response !== null &&
"data" in response &&
typeof response.data === "object" &&
response.data !== null &&
"error" in response.data &&
typeof response.data.error === "string"
) {
return response.data.error;
}
}
return fallbackMessage;
}
export function useAdminActions() {
const [actions, setActions] = useState<AdminActionSummary[]>([]);
const [selectedActionId, setSelectedActionId] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isRunning, setIsRunning] = useState(false);
const [error, setError] = useState<string | null>(null);
const [resultMessage, setResultMessage] = useState<string | null>(null);
useEffect(() => {
let isMounted = true;
const loadActions = async () => {
try {
const response = await adminApi.get<{ actions: AdminActionSummary[] }>("/admin/actions");
if (!isMounted) {
return;
}
setActions(response.data.actions);
setSelectedActionId((currentValue) => currentValue ?? response.data.actions[0]?.id ?? null);
} catch (error) {
if (isMounted) {
setError(getErrorMessage(error, "Failed to load admin actions"));
}
} finally {
if (isMounted) {
setIsLoading(false);
}
}
};
void loadActions();
return () => {
isMounted = false;
};
}, []);
const runAction = async (payload: Record<string, string>) => {
if (!selectedActionId) {
return;
}
setIsRunning(true);
setError(null);
setResultMessage(null);
try {
const response = await adminApi.post<AdminActionRunResponse>(
`/admin/actions/${selectedActionId}/run`,
payload
);
setResultMessage(response.data.message);
} catch (error) {
const message = getErrorMessage(error, "Failed to run admin action");
setError(message);
throw new Error(message);
} finally {
setIsRunning(false);
}
};
return {
actions,
error,
isLoading,
isRunning,
resultMessage,
runAction,
selectedActionId,
setError,
setResultMessage,
setSelectedActionId,
};
}

View file

@ -0,0 +1,105 @@
import type { AdminDatasetResult, AdminDatasetSummary } from "@xtablo/shared-types";
import { useEffect, useState } from "react";
import { adminApi } from "../lib/api";
function getErrorMessage(error: unknown, fallbackMessage: string) {
if (typeof error === "object" && error !== null && "response" in error) {
const response = error.response;
if (
typeof response === "object" &&
response !== null &&
"data" in response &&
typeof response.data === "object" &&
response.data !== null &&
"error" in response.data &&
typeof response.data.error === "string"
) {
return response.data.error;
}
}
return fallbackMessage;
}
export function useAdminDatasets() {
const [datasets, setDatasets] = useState<AdminDatasetSummary[]>([]);
const [selectedDatasetId, setSelectedDatasetId] = useState<string | null>(null);
const [dataset, setDataset] = useState<AdminDatasetResult | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let isMounted = true;
const loadDatasets = async () => {
try {
const response = await adminApi.get<{ datasets: AdminDatasetSummary[] }>("/admin/datasets");
if (!isMounted) {
return;
}
setDatasets(response.data.datasets);
setSelectedDatasetId((currentValue) => currentValue ?? response.data.datasets[0]?.id ?? null);
} catch (error) {
if (isMounted) {
setError(getErrorMessage(error, "Failed to load admin datasets"));
setIsLoading(false);
}
}
};
void loadDatasets();
return () => {
isMounted = false;
};
}, []);
useEffect(() => {
let isMounted = true;
const loadDataset = async () => {
if (!selectedDatasetId) {
setIsLoading(false);
return;
}
setIsLoading(true);
setError(null);
try {
const response = await adminApi.get<AdminDatasetResult>(`/admin/datasets/${selectedDatasetId}`);
if (!isMounted) {
return;
}
setDataset(response.data);
} catch (error) {
if (isMounted) {
setError(getErrorMessage(error, "Failed to load admin dataset"));
setDataset(null);
}
} finally {
if (isMounted) {
setIsLoading(false);
}
}
};
void loadDataset();
return () => {
isMounted = false;
};
}, [selectedDatasetId]);
return {
dataset,
datasets,
error,
isLoading,
selectedDatasetId,
setSelectedDatasetId,
};
}

View file

@ -0,0 +1,44 @@
import type { AdminOverviewResponse } from "@xtablo/shared-types";
import { useEffect, useState } from "react";
import { adminApi } from "../lib/api";
export function useAdminOverview() {
const [overview, setOverview] = useState<AdminOverviewResponse | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let isMounted = true;
const loadOverview = async () => {
try {
const response = await adminApi.get<AdminOverviewResponse>("/admin/overview");
if (!isMounted) {
return;
}
setOverview(response.data);
} catch {
if (isMounted) {
setError("Failed to load admin overview");
}
} finally {
if (isMounted) {
setIsLoading(false);
}
}
};
void loadOverview();
return () => {
isMounted = false;
};
}, []);
return {
error,
isLoading,
overview,
};
}

View file

@ -2,7 +2,26 @@ import type { AdminTableMeta, AdminTableSummary } from "@xtablo/shared-types";
import { useEffect, useState } from "react";
import { adminApi } from "../lib/api";
type AdminRow = Record<string, string | boolean | null>;
export type AdminRow = Record<string, string | boolean | null>;
function getErrorMessage(error: unknown, fallbackMessage: string) {
if (typeof error === "object" && error !== null && "response" in error) {
const response = error.response;
if (
typeof response === "object" &&
response !== null &&
"data" in response &&
typeof response.data === "object" &&
response.data !== null &&
"error" in response.data &&
typeof response.data.error === "string"
) {
return response.data.error;
}
}
return fallbackMessage;
}
export function useAdminTables() {
const [tables, setTables] = useState<AdminTableSummary[]>([]);
@ -82,6 +101,37 @@ export function useAdminTables() {
};
}, [selectedTableId]);
const updateRow = async (rowId: string, changes: Partial<AdminRow>) => {
if (!selectedTableId) {
throw new Error("No admin table selected");
}
try {
const response = await adminApi.patch<{ row: AdminRow }>(
`/admin/tables/${selectedTableId}/rows/${rowId}`,
changes
);
const updatedRow = response.data.row;
setRows((currentRows) =>
currentRows.map((row) => {
if (String(row[meta?.primaryKey ?? "id"] ?? "") !== rowId) {
return row;
}
return updatedRow;
})
);
setError(null);
return updatedRow;
} catch (error) {
const message = getErrorMessage(error, "Failed to update admin row");
setError(message);
throw new Error(message);
}
};
return {
error,
isLoading,
@ -90,5 +140,6 @@ export function useAdminTables() {
selectedTableId,
setSelectedTableId,
tables,
updateRow,
};
}

View file

@ -0,0 +1,69 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { adminApi } from "../lib/api";
import { ActionCenterPage } from "./ActionCenterPage";
vi.mock("../lib/api", () => ({
adminApi: {
get: vi.fn(),
post: vi.fn(),
},
}));
describe("ActionCenterPage", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("loads actions and runs a guarded workflow", async () => {
vi.mocked(adminApi.get).mockResolvedValue({
data: {
actions: [
{
description: "Disable a user's access to a tablo.",
fields: [
{ id: "tabloId", label: "Tablo ID", required: true },
{ id: "userId", label: "User ID", required: true },
{ id: "reason", label: "Reason", required: true },
],
id: "deactivate_tablo_access",
label: "Deactivate Tablo Access",
},
],
},
});
vi.mocked(adminApi.post).mockResolvedValue({
data: {
message: "Tablo access deactivated and logged.",
success: true,
},
});
render(<ActionCenterPage />);
expect(await screen.findByText(/action center/i)).toBeInTheDocument();
fireEvent.change(screen.getByLabelText(/tablo id/i), {
target: { value: "tablo-1" },
});
fireEvent.change(screen.getByLabelText(/user id/i), {
target: { value: "user-1" },
});
fireEvent.change(screen.getByLabelText(/reason/i), {
target: { value: "manual cleanup" },
});
fireEvent.click(screen.getByRole("button", { name: /run action/i }));
await waitFor(() =>
expect(adminApi.post).toHaveBeenCalledWith(
"/admin/actions/deactivate_tablo_access/run",
{
reason: "manual cleanup",
tabloId: "tablo-1",
userId: "user-1",
}
)
);
expect(await screen.findByText(/deactivated and logged/i)).toBeInTheDocument();
});
});

View file

@ -1,11 +1,47 @@
import { ActionRunner } from "../components/actions/ActionRunner";
import { useAdminActions } from "../hooks/useAdminActions";
export function ActionCenterPage() {
const {
actions,
error,
isLoading,
isRunning,
resultMessage,
runAction,
selectedActionId,
setError,
setResultMessage,
setSelectedActionId,
} = useAdminActions();
return (
<div className="space-y-4">
<p className="text-xs uppercase tracking-[0.25em] text-foreground/55">Actions</p>
<h1 className="text-3xl font-semibold">Action Center</h1>
<p className="text-sm text-foreground/70">
High-impact repair and resync workflows will run from this controlled surface.
</p>
<div className="space-y-6">
<section className="rounded-[2rem] border border-border bg-[linear-gradient(135deg,rgba(255,245,245,0.95),rgba(245,236,228,0.98))] p-8">
<p className="text-xs uppercase tracking-[0.25em] text-foreground/55">Actions</p>
<h1 className="mt-3 text-4xl font-semibold">Action Center</h1>
<p className="mt-4 max-w-2xl text-sm text-foreground/70">
Run guarded production actions with explicit operator input and audit logging.
</p>
</section>
{isLoading ? <p>Loading actions...</p> : null}
{!isLoading ? (
<ActionRunner
actions={actions}
error={error}
isRunning={isRunning}
onRun={runAction}
onSelectActionId={(actionId) => {
setSelectedActionId(actionId);
setError(null);
setResultMessage(null);
}}
resultMessage={resultMessage}
selectedActionId={selectedActionId}
/>
) : null}
</div>
);
}

View file

@ -0,0 +1,87 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { adminApi } from "../lib/api";
import { AnalyticsStudioPage } from "./AnalyticsStudioPage";
vi.mock("../lib/api", () => ({
adminApi: {
get: vi.fn(),
},
}));
describe("AnalyticsStudioPage", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("loads curated datasets and switches charts", async () => {
vi.mocked(adminApi.get).mockImplementation(async (path: string) => {
if (path === "/admin/datasets") {
return {
data: {
datasets: [
{
description: "New users over time.",
id: "profile_growth",
label: "User Growth",
},
{
description: "Users by plan.",
id: "plan_mix",
label: "Plan Mix",
},
],
},
};
}
if (path === "/admin/datasets/profile_growth") {
return {
data: {
chartType: "line",
description: "New users over time.",
dimensionLabel: "Created Day",
id: "profile_growth",
label: "User Growth",
metricLabel: "Users Created",
points: [
{ label: "2026-04-20", value: 2 },
{ label: "2026-04-21", value: 4 },
],
},
};
}
if (path === "/admin/datasets/plan_mix") {
return {
data: {
chartType: "donut",
description: "Users by plan.",
dimensionLabel: "Plan",
id: "plan_mix",
label: "Plan Mix",
metricLabel: "Users",
points: [
{ label: "solo", value: 6 },
{ label: "team", value: 3 },
],
},
};
}
throw new Error(`Unexpected path: ${path}`);
});
render(<AnalyticsStudioPage />);
expect(await screen.findByText(/analytics studio/i)).toBeInTheDocument();
expect(await screen.findByText(/user growth/i)).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: /plan mix/i }));
await waitFor(() =>
expect(adminApi.get).toHaveBeenCalledWith("/admin/datasets/plan_mix")
);
expect(await screen.findByText(/total/i)).toBeInTheDocument();
});
});

View file

@ -1,11 +1,38 @@
import { ChartBuilder } from "../components/analytics/ChartBuilder";
import { SavedDashboardList } from "../components/analytics/SavedDashboardList";
import { useAdminDatasets } from "../hooks/useAdminDatasets";
import { savedDashboardPresets } from "../registry/datasets";
export function AnalyticsStudioPage() {
const { dataset, datasets, error, isLoading, selectedDatasetId, setSelectedDatasetId } =
useAdminDatasets();
return (
<div className="space-y-4">
<p className="text-xs uppercase tracking-[0.25em] text-foreground/55">Analytics</p>
<h1 className="text-3xl font-semibold">Analytics Studio</h1>
<p className="text-sm text-foreground/70">
Curated operational datasets and chart building land here next.
</p>
<div className="space-y-6">
<section className="rounded-[2rem] border border-border bg-[radial-gradient(circle_at_top_left,rgba(20,83,45,0.18),transparent_40%),linear-gradient(135deg,rgba(255,251,235,0.95),rgba(244,240,231,0.98))] p-8">
<p className="text-xs uppercase tracking-[0.25em] text-foreground/55">Analytics</p>
<h1 className="mt-3 text-4xl font-semibold">Analytics Studio</h1>
<p className="mt-4 max-w-2xl text-sm text-foreground/70">
Curated production datasets for operators who need charted context before they take
action in the explorer or action center.
</p>
</section>
{isLoading ? <p>Loading analytics...</p> : null}
{error ? <p className="text-red-600">{error}</p> : null}
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr)_320px]">
<ChartBuilder
dataset={dataset}
datasets={datasets}
onSelectDatasetId={setSelectedDatasetId}
selectedDatasetId={selectedDatasetId}
/>
<SavedDashboardList
dashboards={savedDashboardPresets}
onOpen={(datasetId) => setSelectedDatasetId(datasetId)}
/>
</div>
</div>
);
}

View file

@ -1,4 +1,4 @@
import { render, screen } from "@testing-library/react";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { adminApi } from "../lib/api";
@ -7,6 +7,7 @@ import { DataExplorerPage } from "./DataExplorerPage";
vi.mock("../lib/api", () => ({
adminApi: {
get: vi.fn(),
patch: vi.fn(),
},
}));
@ -15,7 +16,7 @@ describe("DataExplorerPage", () => {
vi.clearAllMocks();
});
it("loads rows for the selected table", async () => {
it("loads rows for the selected table and saves approved edits", async () => {
vi.mocked(adminApi.get).mockImplementation(async (path: string) => {
if (path === "/admin/tables") {
return {
@ -32,11 +33,14 @@ describe("DataExplorerPage", () => {
return {
data: {
columns: [
{ id: "id", label: "ID" },
{ id: "email", label: "Email" },
{ id: "first_name", label: "First name" },
],
editableFields: ["first_name"],
id: "profiles",
label: "Users",
primaryKey: "id",
},
};
}
@ -57,6 +61,15 @@ describe("DataExplorerPage", () => {
throw new Error(`Unexpected path: ${path}`);
});
vi.mocked(adminApi.patch).mockResolvedValue({
data: {
row: {
email: "test_owner@example.com",
first_name: "Ada",
id: "user-1",
},
},
});
render(
<MemoryRouter>
@ -67,5 +80,19 @@ describe("DataExplorerPage", () => {
expect(await screen.findByRole("button", { name: /users/i })).toBeInTheDocument();
expect(await screen.findByText(/email/i)).toBeInTheDocument();
expect(await screen.findByText(/test_owner@example.com/i)).toBeInTheDocument();
fireEvent.click(screen.getByText(/test_owner@example.com/i));
fireEvent.change(screen.getByLabelText(/first name/i), {
target: { value: "Ada" },
});
fireEvent.click(screen.getByRole("button", { name: /review changes/i }));
fireEvent.click(screen.getByRole("button", { name: /confirm update/i }));
await waitFor(() =>
expect(adminApi.patch).toHaveBeenCalledWith("/admin/tables/profiles/rows/user-1", {
first_name: "Ada",
})
);
expect(await screen.findByText(/row updated and logged/i)).toBeInTheDocument();
});
});

View file

@ -1,25 +1,72 @@
import { useEffect, useMemo, useState } from "react";
import { AdminGrid } from "../components/data-explorer/AdminGrid";
import { RowEditForm } from "../components/data-explorer/RowEditForm";
import { useAdminTables } from "../hooks/useAdminTables";
export function DataExplorerPage() {
const { error, isLoading, meta, rows, selectedTableId, setSelectedTableId, tables } =
useAdminTables();
const {
error,
isLoading,
meta,
rows,
selectedTableId,
setSelectedTableId,
tables,
updateRow,
} = useAdminTables();
const [selectedRowId, setSelectedRowId] = useState<string | null>(null);
const [isSaving, setIsSaving] = useState(false);
const [saveMessage, setSaveMessage] = useState<string | null>(null);
useEffect(() => {
setSelectedRowId(null);
setSaveMessage(null);
}, [selectedTableId]);
const selectedRow = useMemo(() => {
if (!meta || !selectedRowId) {
return null;
}
return (
rows.find((row) => String(row[meta.primaryKey] ?? "") === selectedRowId) ?? null
);
}, [meta, rows, selectedRowId]);
const handleSave = async (changes: Record<string, string | boolean | null>) => {
if (!selectedRowId) {
return;
}
setIsSaving(true);
setSaveMessage(null);
try {
await updateRow(selectedRowId, changes);
setSaveMessage("Row updated and logged.");
} finally {
setIsSaving(false);
}
};
return (
<main className="min-h-screen p-6">
<div className="grid gap-6 lg:grid-cols-[220px_minmax(0,1fr)]">
<div className="grid gap-6 lg:grid-cols-[220px_minmax(0,1fr)_360px]">
<aside className="rounded-3xl border border-border bg-card p-4">
<p className="text-xs uppercase tracking-[0.25em] text-foreground/60">Data Explorer</p>
<div className="mt-4 flex flex-col gap-2">
{tables.map((table) => (
<button
className="rounded-2xl border border-border px-3 py-2 text-left text-sm"
className={`rounded-2xl border px-3 py-2 text-left text-sm ${
selectedTableId === table.id
? "border-foreground bg-foreground text-background"
: "border-border"
}`}
key={table.id}
onClick={() => setSelectedTableId(table.id)}
type="button"
>
{table.label}
{selectedTableId === table.id ? " *" : ""}
</button>
))}
</div>
@ -35,8 +82,74 @@ export function DataExplorerPage() {
{isLoading ? <p>Loading explorer...</p> : null}
{error ? <p className="text-red-600">{error}</p> : null}
{!isLoading && !error ? <AdminGrid meta={meta} rows={rows} /> : null}
{!isLoading && !error ? (
<AdminGrid
meta={meta}
onSelectRow={(row) => {
if (!meta) {
return;
}
setSelectedRowId(String(row[meta.primaryKey] ?? ""));
setSaveMessage(null);
}}
rows={rows}
selectedRowId={selectedRowId}
/>
) : null}
</section>
<aside className="rounded-3xl border border-border bg-card p-5">
<p className="text-xs uppercase tracking-[0.25em] text-foreground/60">Row Detail</p>
{!selectedRow || !meta ? (
<div className="mt-6 space-y-2 text-sm text-foreground/70">
<p>Select a row to inspect record details.</p>
<p>Approved edits are reviewed before they hit production.</p>
</div>
) : (
<div className="mt-6 space-y-6">
<div className="space-y-3">
{meta.columns.map((column) => (
<div
className="rounded-2xl border border-border/80 px-3 py-2"
key={column.id}
>
<p className="text-xs uppercase tracking-[0.2em] text-foreground/50">
{column.label}
</p>
<p className="mt-1 text-sm">{String(selectedRow[column.id] ?? "")}</p>
</div>
))}
</div>
{meta.editableFields.length > 0 ? (
<div className="space-y-3">
<div>
<h2 className="text-lg font-semibold">Guarded Edit</h2>
<p className="text-sm text-foreground/70">
Editable fields require a reviewed diff and create an audit log entry.
</p>
</div>
<RowEditForm
columns={meta.columns}
editableFields={meta.editableFields}
isSaving={isSaving}
onSave={handleSave}
record={selectedRow}
/>
{saveMessage ? <p className="text-sm text-emerald-700">{saveMessage}</p> : null}
</div>
) : (
<p className="text-sm text-foreground/70">
This table is currently read-only in the admin panel.
</p>
)}
</div>
)}
</aside>
</div>
</main>
);

View file

@ -1,13 +1,92 @@
import { Link } from "react-router-dom";
import { useAdminOverview } from "../hooks/useAdminOverview";
export function OperationsHomePage() {
const { error, isLoading, overview } = useAdminOverview();
return (
<div className="space-y-4">
<div>
<p className="text-xs uppercase tracking-[0.25em] text-foreground/55">Operations</p>
<h1 className="mt-3 text-3xl font-semibold">Operations Home</h1>
</div>
<p className="text-sm text-foreground/70">
Internal production oversight, anomaly checks, and shortcuts into the admin workflows.
</p>
<div className="space-y-6">
<section className="rounded-[2rem] border border-border bg-[linear-gradient(135deg,rgba(17,24,39,0.96),rgba(24,57,76,0.88),rgba(148,88,32,0.74))] p-8 text-white shadow-[0_28px_90px_rgba(15,23,42,0.25)]">
<p className="text-xs uppercase tracking-[0.25em] text-white/65">Operations</p>
<h1 className="mt-3 max-w-3xl text-4xl font-semibold">
Production command deck for privileged Supabase operations.
</h1>
<p className="mt-4 max-w-2xl text-sm text-white/80">
Monitor the current state of users, access grants, and tablos before drilling into
explorer edits, analytics, or controlled admin actions.
</p>
</section>
{isLoading ? <p>Loading operations overview...</p> : null}
{error ? <p className="text-red-600">{error}</p> : null}
{overview ? (
<>
<section className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
{overview.metrics.map((metric) => (
<article
className="rounded-[1.75rem] border border-border bg-card p-5"
key={metric.id}
>
<p className="text-xs uppercase tracking-[0.2em] text-foreground/50">
{metric.label}
</p>
<p className="mt-3 text-3xl font-semibold">{metric.value}</p>
<p className="mt-2 text-sm text-foreground/65">{metric.changeLabel}</p>
</article>
))}
</section>
<section className="grid gap-6 lg:grid-cols-[minmax(0,1fr)_360px]">
<div className="rounded-[2rem] border border-border bg-card p-6">
<div>
<p className="text-xs uppercase tracking-[0.2em] text-foreground/50">Alerts</p>
<h2 className="mt-2 text-2xl font-semibold">Operational Watchlist</h2>
</div>
<div className="mt-6 grid gap-4">
{overview.alerts.map((alert) => (
<article
className="rounded-[1.5rem] border border-border/80 bg-background/70 p-4"
key={alert.id}
>
<div className="flex items-center gap-3">
<span
className={`rounded-full px-2 py-1 text-[11px] uppercase tracking-[0.18em] ${
alert.severity === "critical"
? "bg-red-100 text-red-700"
: alert.severity === "warning"
? "bg-amber-100 text-amber-700"
: "bg-slate-200 text-slate-700"
}`}
>
{alert.severity}
</span>
<h3 className="text-sm font-semibold">{alert.title}</h3>
</div>
<p className="mt-3 text-sm text-foreground/70">{alert.description}</p>
</article>
))}
</div>
</div>
<div className="rounded-[2rem] border border-border bg-card p-6">
<p className="text-xs uppercase tracking-[0.2em] text-foreground/50">Shortcuts</p>
<h2 className="mt-2 text-2xl font-semibold">Common Paths</h2>
<div className="mt-6 flex flex-col gap-3">
{overview.shortcuts.map((shortcut) => (
<Link
className="rounded-[1.25rem] border border-border/80 bg-background/70 px-4 py-3 text-sm font-medium"
key={shortcut.id}
to={shortcut.href}
>
{shortcut.label}
</Link>
))}
</div>
</div>
</section>
</>
) : null}
</div>
);
}

View file

@ -0,0 +1,10 @@
export const actionSeverityCopy = {
deactivate_tablo_access: {
badge: "Restriction",
tone: "warning",
},
grant_tablo_admin: {
badge: "Privilege",
tone: "critical",
},
} as const;

View file

@ -0,0 +1,20 @@
export const savedDashboardPresets = [
{
datasetId: "profile_growth",
description: "Track production user creation velocity.",
id: "growth",
label: "Growth Watch",
},
{
datasetId: "plan_mix",
description: "Review monetization mix across the current user base.",
id: "plans",
label: "Plan Pulse",
},
{
datasetId: "tablo_access_mix",
description: "Spot access drift and admin-heavy tablos.",
id: "access",
label: "Access Posture",
},
] as const;

View file

@ -0,0 +1,64 @@
import { describe, expect, it } from "vitest";
import { createSignedAdminToken } from "../helpers/adminTokenTestUtils.js";
import { createConfig } from "../../config.js";
import { MiddlewareManager } from "../../middlewares/middleware.js";
import { getMainRouter } from "../../routers/index.js";
const ADMIN_TOKEN_SIGNING_SECRET = "admin-test-secret";
const ADMIN_TOKEN_AUDIENCE = "xtablo-admin";
describe("Admin Actions Router", () => {
process.env.ADMIN_TOKEN_SIGNING_SECRET = ADMIN_TOKEN_SIGNING_SECRET;
process.env.ADMIN_TOKEN_AUDIENCE = ADMIN_TOKEN_AUDIENCE;
process.env.ADMIN_APP_URL = "http://localhost:5176";
const config = createConfig();
MiddlewareManager.initialize(config);
const app = getMainRouter(config);
const sessionToken = createSignedAdminToken(
{
aud: ADMIN_TOKEN_AUDIENCE,
email: "ops@xtablo.com",
exp: Math.floor(Date.now() / 1000) + 900,
role: "operator",
sub: "operator-1",
type: "admin_session",
},
ADMIN_TOKEN_SIGNING_SECRET
);
it("lists curated admin actions", async () => {
const res = await app.request("/admin/actions", {
headers: {
Authorization: `Bearer ${sessionToken}`,
},
});
expect(res.status).toBe(200);
await expect(res.json()).resolves.toMatchObject({
actions: expect.arrayContaining([
expect.objectContaining({
id: "deactivate_tablo_access",
label: "Deactivate Tablo Access",
}),
]),
});
});
it("validates required input before running an action", async () => {
const res = await app.request("/admin/actions/deactivate_tablo_access/run", {
body: JSON.stringify({ tabloId: "tablo-1" }),
headers: {
Authorization: `Bearer ${sessionToken}`,
"Content-Type": "application/json",
},
method: "POST",
});
expect(res.status).toBe(400);
await expect(res.json()).resolves.toMatchObject({
error: "tabloId, userId, and reason are required",
});
});
});

View file

@ -0,0 +1,64 @@
import { describe, expect, it } from "vitest";
import { createSignedAdminToken } from "../helpers/adminTokenTestUtils.js";
import { createConfig } from "../../config.js";
import { MiddlewareManager } from "../../middlewares/middleware.js";
import { getMainRouter } from "../../routers/index.js";
const ADMIN_TOKEN_SIGNING_SECRET = "admin-test-secret";
const ADMIN_TOKEN_AUDIENCE = "xtablo-admin";
describe("Admin Datasets Router", () => {
process.env.ADMIN_TOKEN_SIGNING_SECRET = ADMIN_TOKEN_SIGNING_SECRET;
process.env.ADMIN_TOKEN_AUDIENCE = ADMIN_TOKEN_AUDIENCE;
process.env.ADMIN_APP_URL = "http://localhost:5176";
const config = createConfig();
MiddlewareManager.initialize(config);
const app = getMainRouter(config);
const sessionToken = createSignedAdminToken(
{
aud: ADMIN_TOKEN_AUDIENCE,
email: "ops@xtablo.com",
exp: Math.floor(Date.now() / 1000) + 900,
role: "operator",
sub: "operator-1",
type: "admin_session",
},
ADMIN_TOKEN_SIGNING_SECRET
);
it("lists curated admin datasets", async () => {
const res = await app.request("/admin/datasets", {
headers: {
Authorization: `Bearer ${sessionToken}`,
},
});
expect(res.status).toBe(200);
await expect(res.json()).resolves.toMatchObject({
datasets: expect.arrayContaining([
expect.objectContaining({
id: "profile_growth",
label: "User Growth",
}),
]),
});
});
it("returns chart-ready data for a registered dataset", async () => {
const res = await app.request("/admin/datasets/plan_mix", {
headers: {
Authorization: `Bearer ${sessionToken}`,
},
});
expect(res.status).toBe(200);
await expect(res.json()).resolves.toMatchObject({
chartType: "donut",
id: "plan_mix",
metricLabel: "Users",
points: expect.any(Array),
});
});
});

View file

@ -0,0 +1,72 @@
import { createClient } from "@supabase/supabase-js";
import { describe, expect, it } from "vitest";
import { createSignedAdminToken } from "../helpers/adminTokenTestUtils.js";
import { getTestData } from "../helpers/dbSetup.js";
import { createConfig } from "../../config.js";
import { MiddlewareManager } from "../../middlewares/middleware.js";
import { getMainRouter } from "../../routers/index.js";
const ADMIN_TOKEN_SIGNING_SECRET = "admin-test-secret";
const ADMIN_TOKEN_AUDIENCE = "xtablo-admin";
describe("Admin Table Edit Router", () => {
process.env.ADMIN_TOKEN_SIGNING_SECRET = ADMIN_TOKEN_SIGNING_SECRET;
process.env.ADMIN_TOKEN_AUDIENCE = ADMIN_TOKEN_AUDIENCE;
process.env.ADMIN_APP_URL = "http://localhost:5176";
const config = createConfig();
MiddlewareManager.initialize(config);
const app = getMainRouter(config);
const sessionToken = createSignedAdminToken(
{
aud: ADMIN_TOKEN_AUDIENCE,
email: "ops@xtablo.com",
exp: Math.floor(Date.now() / 1000) + 900,
role: "operator",
sub: "operator-1",
type: "admin_session",
},
ADMIN_TOKEN_SIGNING_SECRET
);
it("writes an audit log entry for a successful update", async () => {
const ownerUserId = getTestData().users.owner.userId;
const res = await app.request(`/admin/tables/profiles/rows/${ownerUserId}`, {
method: "PATCH",
headers: {
Authorization: `Bearer ${sessionToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ first_name: "Ada" }),
});
expect(res.status).toBe(200);
await expect(res.json()).resolves.toMatchObject({
row: expect.objectContaining({
first_name: "Ada",
id: ownerUserId,
}),
});
const auditClient = createClient<any>(config.SUPABASE_URL, config.SUPABASE_SERVICE_ROLE_KEY, {
auth: { autoRefreshToken: false, persistSession: false },
});
const { data: auditRows, error } = await auditClient
.from("admin_audit_log")
.select("*")
.eq("target_id", ownerUserId)
.eq("action", "update")
.order("created_at", { ascending: false })
.limit(1);
expect(error).toBeNull();
expect(auditRows).toHaveLength(1);
expect(auditRows?.[0]).toMatchObject({
operator_email: "ops@xtablo.com",
target_type: "profiles",
});
});
});

View file

@ -57,6 +57,8 @@ describe("Admin Tables Router", () => {
await expect(res.json()).resolves.toMatchObject({
id: "profiles",
label: "Users",
editableFields: ["first_name", "last_name"],
primaryKey: "id",
columns: expect.arrayContaining([
expect.objectContaining({
id: "email",

View file

@ -0,0 +1,42 @@
import type { SupabaseClient } from "@supabase/supabase-js";
type AdminAuditArgs = {
action: string;
after?: unknown;
before?: unknown;
operatorEmail: string;
operatorId: string;
role: string;
supabase: SupabaseClient;
targetId: string;
targetType: string;
};
export async function recordAdminAuditLog({
action,
after,
before,
operatorEmail,
operatorId,
role,
supabase,
targetId,
targetType,
}: AdminAuditArgs) {
const { error } = await (supabase as SupabaseClient<any>)
.from("admin_audit_log")
.insert({
action,
after,
before,
operator_email: operatorEmail,
operator_id: operatorId,
role,
target_id: targetId,
target_type: targetType,
});
if (error) {
throw new Error(`Failed to write admin audit log: ${error.message}`);
}
}

View file

@ -7,8 +7,10 @@ type AdminTableColumn = {
type AdminTableDefinition = {
columns: AdminTableColumn[];
editableColumns?: string[];
id: string;
label: string;
primaryKey: string;
select: string;
source: keyof Database["public"]["Tables"];
};
@ -21,8 +23,10 @@ export const adminTableRegistry: Record<string, AdminTableDefinition> = {
{ id: "first_name", label: "First name" },
{ id: "last_name", label: "Last name" },
],
editableColumns: ["first_name", "last_name"],
id: "profiles",
label: "Users",
primaryKey: "id",
select: "id,email,first_name,last_name",
source: "profiles",
},
@ -33,13 +37,86 @@ export const adminTableRegistry: Record<string, AdminTableDefinition> = {
{ id: "is_active", label: "Active" },
{ id: "is_admin", label: "Admin" },
],
editableColumns: [],
id: "tablo_access",
label: "Tablo Access",
primaryKey: "user_id",
select: "tablo_id,user_id,is_active,is_admin",
source: "tablo_access",
},
};
type AdminDatasetDefinition = {
description: string;
id: string;
label: string;
};
type AdminActionFieldDefinition = {
id: string;
label: string;
placeholder?: string;
required?: boolean;
};
type AdminActionDefinition = {
description: string;
fields: AdminActionFieldDefinition[];
id: string;
label: string;
};
export const adminDatasetRegistry: Record<string, AdminDatasetDefinition> = {
profile_growth: {
description: "New user creation trend over time.",
id: "profile_growth",
label: "User Growth",
},
plan_mix: {
description: "Production users by current subscription plan.",
id: "plan_mix",
label: "Plan Mix",
},
tablo_access_mix: {
description: "Current active, inactive, and admin access posture.",
id: "tablo_access_mix",
label: "Tablo Access Mix",
},
};
export const adminActionRegistry: Record<string, AdminActionDefinition> = {
deactivate_tablo_access: {
description: "Disable a user's access to a tablo and log the operator reason.",
fields: [
{ id: "tabloId", label: "Tablo ID", placeholder: "tablo_123", required: true },
{ id: "userId", label: "User ID", placeholder: "user_123", required: true },
{
id: "reason",
label: "Reason",
placeholder: "Explain why this access is being removed",
required: true,
},
],
id: "deactivate_tablo_access",
label: "Deactivate Tablo Access",
},
grant_tablo_admin: {
description: "Promote an existing tablo member to admin and force active access.",
fields: [
{ id: "tabloId", label: "Tablo ID", placeholder: "tablo_123", required: true },
{ id: "userId", label: "User ID", placeholder: "user_123", required: true },
{
id: "reason",
label: "Reason",
placeholder: "Explain why admin access is being granted",
required: true,
},
],
id: "grant_tablo_admin",
label: "Grant Tablo Admin",
},
};
export function getAdminTableDefinition(tableId: string) {
return adminTableRegistry[tableId] ?? null;
}
@ -48,6 +125,22 @@ export function listAdminTables() {
return Object.values(adminTableRegistry).map(({ id, label }) => ({ id, label }));
}
export function getAdminDatasetDefinition(datasetId: string) {
return adminDatasetRegistry[datasetId] ?? null;
}
export function listAdminDatasets() {
return Object.values(adminDatasetRegistry);
}
export function getAdminActionDefinition(actionId: string) {
return adminActionRegistry[actionId] ?? null;
}
export function listAdminActions() {
return Object.values(adminActionRegistry);
}
export function normalizeAdminRows(rows: unknown[]) {
return rows as Record<string, unknown>[];
}

View file

@ -2,7 +2,10 @@ import { Hono } from "hono";
import type { AppConfig } from "../config.js";
import { MiddlewareManager } from "../middlewares/middleware.js";
import type { BaseEnv } from "../types/app.types.js";
import { getAdminActionsRouter } from "./adminActions.js";
import { getAdminAuthRouter } from "./adminAuth.js";
import { getAdminDatasetsRouter } from "./adminDatasets.js";
import { getAdminOverviewRouter } from "./adminOverview.js";
import { getAdminTablesRouter } from "./adminTables.js";
export const getAdminRouter = (config: AppConfig) => {
@ -11,9 +14,19 @@ export const getAdminRouter = (config: AppConfig) => {
adminRouter.route("/auth", getAdminAuthRouter(config));
adminRouter.use("/overview", middlewareManager.adminAuth);
adminRouter.use("/overview/*", middlewareManager.adminAuth);
adminRouter.use("/tables", middlewareManager.adminAuth);
adminRouter.use("/tables/*", middlewareManager.adminAuth);
adminRouter.use("/datasets", middlewareManager.adminAuth);
adminRouter.use("/datasets/*", middlewareManager.adminAuth);
adminRouter.use("/actions", middlewareManager.adminAuth);
adminRouter.use("/actions/*", middlewareManager.adminAuth);
adminRouter.route("/overview", getAdminOverviewRouter());
adminRouter.route("/tables", getAdminTablesRouter());
adminRouter.route("/datasets", getAdminDatasetsRouter());
adminRouter.route("/actions", getAdminActionsRouter());
return adminRouter;
};

View file

@ -0,0 +1,109 @@
import { Hono } from "hono";
import type { AdminActionRunResponse } from "@xtablo/shared-types";
import { recordAdminAuditLog } from "../helpers/adminAudit.js";
import {
getAdminActionDefinition,
listAdminActions,
} from "../helpers/adminRegistry.js";
import type { BaseEnv } from "../types/app.types.js";
type ActionInput = {
reason?: string;
tabloId?: string;
userId?: string;
};
function getActionInput(body: unknown): ActionInput {
if (!body || typeof body !== "object") {
return {};
}
const input = body as Record<string, unknown>;
return {
reason: typeof input.reason === "string" ? input.reason : undefined,
tabloId: typeof input.tabloId === "string" ? input.tabloId : undefined,
userId: typeof input.userId === "string" ? input.userId : undefined,
};
}
export const getAdminActionsRouter = () => {
const adminActionsRouter = new Hono<BaseEnv>();
adminActionsRouter.get("/", async (c) => {
return c.json({ actions: listAdminActions() }, 200);
});
adminActionsRouter.post("/:actionId/run", async (c) => {
const supabase = c.get("supabase");
const adminSession = c.get("adminSession");
const actionId = c.req.param("actionId");
const definition = getAdminActionDefinition(actionId);
if (!definition) {
return c.json({ error: `Admin action '${actionId}' is not registered` }, 404);
}
const { reason, tabloId, userId } = getActionInput(await c.req.json().catch(() => null));
if (!tabloId || !userId || !reason) {
return c.json({ error: "tabloId, userId, and reason are required" }, 400);
}
const { data: before, error: beforeError } = await supabase
.from("tablo_access")
.select("id,tablo_id,user_id,is_active,is_admin")
.eq("tablo_id", tabloId)
.eq("user_id", userId)
.maybeSingle();
if (beforeError) {
return c.json({ error: `Failed to load tablo access for action '${actionId}'` }, 500);
}
if (!before) {
return c.json({ error: "Target tablo access row was not found" }, 404);
}
const changes =
actionId === "grant_tablo_admin"
? { is_active: true, is_admin: true }
: { is_active: false };
const { data: after, error: updateError } = await supabase
.from("tablo_access")
.update(changes)
.eq("id", before.id)
.select("id,tablo_id,user_id,is_active,is_admin")
.single();
if (updateError || !after) {
return c.json({ error: `Failed to run admin action '${actionId}'` }, 500);
}
await recordAdminAuditLog({
action: `${actionId}:${reason}`,
after,
before,
operatorEmail: adminSession.operatorEmail,
operatorId: adminSession.operatorId,
role: adminSession.role,
supabase,
targetId: `${tabloId}:${userId}`,
targetType: "tablo_access",
});
return c.json(
{
message:
actionId === "grant_tablo_admin"
? "Tablo admin access granted and logged."
: "Tablo access deactivated and logged.",
success: true,
} satisfies AdminActionRunResponse,
200
);
});
return adminActionsRouter;
};

View file

@ -0,0 +1,155 @@
import { Hono } from "hono";
import type { AdminDatasetResult } from "@xtablo/shared-types";
import {
getAdminDatasetDefinition,
listAdminDatasets,
} from "../helpers/adminRegistry.js";
import type { BaseEnv } from "../types/app.types.js";
function bucketByDay(values: Array<string | null>) {
const counts = new Map<string, number>();
values.forEach((value) => {
if (!value) {
return;
}
const bucket = value.slice(0, 10);
counts.set(bucket, (counts.get(bucket) ?? 0) + 1);
});
return Array.from(counts.entries())
.sort(([left], [right]) => left.localeCompare(right))
.map(([label, value]) => ({ label, value }));
}
function bucketByValue(values: Array<string | null>, emptyLabel: string) {
const counts = new Map<string, number>();
values.forEach((value) => {
const bucket = value?.trim() || emptyLabel;
counts.set(bucket, (counts.get(bucket) ?? 0) + 1);
});
return Array.from(counts.entries())
.sort(([left], [right]) => left.localeCompare(right))
.map(([label, value]) => ({ label, value }));
}
function bucketTabloAccess(rows: Array<{ is_active: boolean | null; is_admin: boolean | null }>) {
const counts = new Map<string, number>([
["Active Member", 0],
["Active Admin", 0],
["Inactive", 0],
]);
rows.forEach((row) => {
if (row.is_active) {
const label = row.is_admin ? "Active Admin" : "Active Member";
counts.set(label, (counts.get(label) ?? 0) + 1);
return;
}
counts.set("Inactive", (counts.get("Inactive") ?? 0) + 1);
});
return Array.from(counts.entries()).map(([label, value]) => ({ label, value }));
}
type AdminDatasetPayload = Pick<
AdminDatasetResult,
"chartType" | "dimensionLabel" | "metricLabel" | "points"
>;
async function getDatasetPoints(
datasetId: string,
supabase: BaseEnv["Variables"]["supabase"]
): Promise<AdminDatasetPayload> {
switch (datasetId) {
case "profile_growth": {
const { data, error } = await supabase
.from("profiles")
.select("created_at")
.order("created_at", { ascending: true })
.limit(365);
if (error) {
throw new Error(error.message);
}
return {
chartType: "line",
dimensionLabel: "Created Day",
metricLabel: "Users Created",
points: bucketByDay((data ?? []).map((row) => row.created_at)),
};
}
case "plan_mix": {
const { data, error } = await supabase.from("profiles").select("plan").limit(500);
if (error) {
throw new Error(error.message);
}
return {
chartType: "donut",
dimensionLabel: "Plan",
metricLabel: "Users",
points: bucketByValue((data ?? []).map((row) => row.plan), "No Plan"),
};
}
case "tablo_access_mix": {
const { data, error } = await supabase
.from("tablo_access")
.select("is_active,is_admin")
.limit(500);
if (error) {
throw new Error(error.message);
}
return {
chartType: "bar",
dimensionLabel: "Access Type",
metricLabel: "Rows",
points: bucketTabloAccess(data ?? []),
};
}
default:
throw new Error(`Unknown admin dataset '${datasetId}'`);
}
}
export const getAdminDatasetsRouter = () => {
const adminDatasetsRouter = new Hono<BaseEnv>();
adminDatasetsRouter.get("/", async (c) => {
return c.json({ datasets: listAdminDatasets() }, 200);
});
adminDatasetsRouter.get("/:datasetId", async (c) => {
const supabase = c.get("supabase");
const datasetId = c.req.param("datasetId");
const definition = getAdminDatasetDefinition(datasetId);
if (!definition) {
return c.json({ error: `Admin dataset '${datasetId}' is not registered` }, 404);
}
try {
const dataset = await getDatasetPoints(datasetId, supabase);
return c.json(
{
...definition,
...dataset,
} satisfies AdminDatasetResult,
200
);
} catch {
return c.json({ error: `Failed to load admin dataset '${datasetId}'` }, 500);
}
});
return adminDatasetsRouter;
};

View file

@ -0,0 +1,144 @@
import { Hono } from "hono";
import type { AdminOverviewResponse } from "@xtablo/shared-types";
import type { BaseEnv } from "../types/app.types.js";
function startOfRecentWindow(days: number) {
const date = new Date();
date.setUTCDate(date.getUTCDate() - days);
return date.toISOString();
}
async function countRows(
query: PromiseLike<{ count: number | null; error: { message: string } | null }>
) {
const { count, error } = await query;
if (error) {
throw new Error(error.message);
}
return count ?? 0;
}
export const getAdminOverviewRouter = () => {
const adminOverviewRouter = new Hono<BaseEnv>();
adminOverviewRouter.get("/", async (c) => {
const supabase = c.get("supabase");
const sevenDaysAgo = startOfRecentWindow(7);
try {
const [
totalUsers,
recentUsers,
totalTablos,
recentTablos,
activeAccess,
adminAccess,
temporaryUsers,
inactiveAccess,
] = await Promise.all([
countRows(supabase.from("profiles").select("*", { count: "exact", head: true })),
countRows(
supabase
.from("profiles")
.select("*", { count: "exact", head: true })
.gte("created_at", sevenDaysAgo)
),
countRows(
supabase
.from("tablos")
.select("*", { count: "exact", head: true })
.is("deleted_at", null)
),
countRows(
supabase
.from("tablos")
.select("*", { count: "exact", head: true })
.is("deleted_at", null)
.gte("created_at", sevenDaysAgo)
),
countRows(
supabase
.from("tablo_access")
.select("*", { count: "exact", head: true })
.eq("is_active", true)
),
countRows(
supabase
.from("tablo_access")
.select("*", { count: "exact", head: true })
.eq("is_active", true)
.eq("is_admin", true)
),
countRows(
supabase
.from("profiles")
.select("*", { count: "exact", head: true })
.eq("is_temporary", true)
),
countRows(
supabase
.from("tablo_access")
.select("*", { count: "exact", head: true })
.eq("is_active", false)
),
]);
const response: AdminOverviewResponse = {
alerts: [
{
description: `${temporaryUsers} temporary users still exist in production.`,
id: "temporary-users",
severity: temporaryUsers > 0 ? "warning" : "info",
title: "Temporary Accounts",
},
{
description: `${inactiveAccess} tablo access rows are inactive and may need review.`,
id: "inactive-access",
severity: inactiveAccess > 10 ? "critical" : "warning",
title: "Inactive Access Drift",
},
],
metrics: [
{
changeLabel: `+${recentUsers} last 7d`,
id: "total-users",
label: "Total Users",
value: totalUsers.toLocaleString(),
},
{
changeLabel: `+${recentTablos} last 7d`,
id: "total-tablos",
label: "Active Tablos",
value: totalTablos.toLocaleString(),
},
{
changeLabel: `${adminAccess} admin grants`,
id: "active-access",
label: "Active Access",
value: activeAccess.toLocaleString(),
},
{
changeLabel: `${temporaryUsers} temporary`,
id: "admin-access",
label: "Admin Grants",
value: adminAccess.toLocaleString(),
},
],
shortcuts: [
{ href: "/explorer", id: "profiles", label: "Inspect Users" },
{ href: "/explorer", id: "access", label: "Review Tablo Access" },
{ href: "/analytics", id: "growth", label: "Open Growth Analytics" },
{ href: "/actions", id: "actions", label: "Run Admin Actions" },
],
};
return c.json(response, 200);
} catch {
return c.json({ error: "Failed to load admin overview" }, 500);
}
});
return adminOverviewRouter;
};

View file

@ -1,5 +1,10 @@
import { Hono } from "hono";
import { getAdminTableDefinition, listAdminTables, normalizeAdminRows } from "../helpers/adminRegistry.js";
import { recordAdminAuditLog } from "../helpers/adminAudit.js";
import {
getAdminTableDefinition,
listAdminTables,
normalizeAdminRows,
} from "../helpers/adminRegistry.js";
import type { BaseEnv } from "../types/app.types.js";
export const getAdminTablesRouter = () => {
@ -25,8 +30,10 @@ export const getAdminTablesRouter = () => {
return c.json(
{
columns: tableDefinition.columns,
editableFields: tableDefinition.editableColumns ?? [],
id: tableDefinition.id,
label: tableDefinition.label,
primaryKey: tableDefinition.primaryKey,
},
200
);
@ -63,5 +70,70 @@ export const getAdminTablesRouter = () => {
return c.json({ rows: normalizeAdminRows(data ?? []) }, 200);
});
adminTablesRouter.patch("/:tableId/rows/:rowId", async (c) => {
const supabase = c.get("supabase");
const adminSession = c.get("adminSession");
const tableId = c.req.param("tableId");
const rowId = c.req.param("rowId");
const tableDefinition = getAdminTableDefinition(tableId);
if (!tableDefinition) {
return c.json(
{
error: `Admin table '${tableId}' is not registered`,
},
404
);
}
const body = await c.req.json().catch(() => null);
if (!body || typeof body !== "object") {
return c.json({ error: "Invalid update payload" }, 400);
}
const requestedChanges = Object.fromEntries(
Object.entries(body).filter(([key]) => tableDefinition.editableColumns?.includes(key))
);
if (Object.keys(requestedChanges).length === 0) {
return c.json({ error: "No editable fields provided" }, 400);
}
const { data: existingRow, error: existingRowError } = await supabase
.from(tableDefinition.source)
.select(tableDefinition.select)
.eq(tableDefinition.primaryKey, rowId)
.single();
if (existingRowError || !existingRow) {
return c.json({ error: `Admin row '${rowId}' was not found` }, 404);
}
const { data: updatedRow, error: updateError } = await supabase
.from(tableDefinition.source)
.update(requestedChanges)
.eq(tableDefinition.primaryKey, rowId)
.select(tableDefinition.select)
.single();
if (updateError || !updatedRow) {
return c.json({ error: `Failed to update admin table '${tableId}'` }, 500);
}
await recordAdminAuditLog({
action: "update",
after: updatedRow,
before: existingRow,
operatorEmail: adminSession.operatorEmail,
operatorId: adminSession.operatorId,
role: adminSession.role,
supabase,
targetId: rowId,
targetType: tableId,
});
return c.json({ row: updatedRow }, 200);
});
return adminTablesRouter;
};

View file

@ -0,0 +1,56 @@
# Admin App Access Setup
The admin app is designed to be internal-only and requires a separate privileged token flow.
## Required API configuration
Set these values for `apps/api`:
- `ADMIN_TOKEN_SIGNING_SECRET`
- `ADMIN_TOKEN_AUDIENCE`
- `ADMIN_APP_URL`
`ADMIN_TOKEN_SIGNING_SECRET` signs short-lived admin session tokens.
`ADMIN_TOKEN_AUDIENCE` scopes privileged access to the admin app only.
`ADMIN_APP_URL` is the allowed frontend origin for the internal admin surface.
## Access model
1. The operator reaches the private `apps/admin` deployment from the internal network boundary.
2. The operator pastes a privileged access token into the admin gate.
3. `POST /admin/auth/exchange` validates that token and returns a short-lived `admin_session`.
4. The admin app stores that session locally and attaches it as a bearer token for admin routes.
5. All privileged data and mutations go through `/admin/*` API routes guarded by admin middleware.
Normal product auth is not sufficient for admin access.
## Current guarded routes
- `GET /admin/overview`
- `GET /admin/tables`
- `GET /admin/tables/:tableId/meta`
- `GET /admin/tables/:tableId/rows`
- `PATCH /admin/tables/:tableId/rows/:rowId`
- `GET /admin/datasets`
- `GET /admin/datasets/:datasetId`
- `GET /admin/actions`
- `POST /admin/actions/:actionId/run`
All write paths emit admin audit log entries.
## Local development
- Run the API and local Supabase stack.
- Start the admin app with `pnpm dev:admin`.
- Use a valid privileged access token compatible with `ADMIN_TOKEN_SIGNING_SECRET` and `ADMIN_TOKEN_AUDIENCE`.
## Initial action coverage
- `deactivate_tablo_access`
- `grant_tablo_admin`
## Initial analytics coverage
- `profile_growth`
- `plan_mix`
- `tablo_access_mix`

View file

@ -28,6 +28,73 @@ export type AdminTableColumn = {
export type AdminTableMeta = {
columns: AdminTableColumn[];
editableFields: string[];
id: string;
label: string;
primaryKey: string;
};
export type AdminOverviewMetric = {
changeLabel: string;
id: string;
label: string;
value: string;
};
export type AdminOverviewAlert = {
description: string;
id: string;
severity: "info" | "warning" | "critical";
title: string;
};
export type AdminOverviewShortcut = {
href: string;
id: string;
label: string;
};
export type AdminOverviewResponse = {
alerts: AdminOverviewAlert[];
metrics: AdminOverviewMetric[];
shortcuts: AdminOverviewShortcut[];
};
export type AdminDatasetChartType = "bar" | "line" | "donut";
export type AdminDatasetSummary = {
description: string;
id: string;
label: string;
};
export type AdminDatasetPoint = {
label: string;
value: number;
};
export type AdminDatasetResult = AdminDatasetSummary & {
chartType: AdminDatasetChartType;
dimensionLabel: string;
metricLabel: string;
points: AdminDatasetPoint[];
};
export type AdminActionField = {
id: string;
label: string;
placeholder?: string;
required?: boolean;
};
export type AdminActionSummary = {
description: string;
fields: AdminActionField[];
id: string;
label: string;
};
export type AdminActionRunResponse = {
message: string;
success: boolean;
};

View file

@ -2,6 +2,17 @@
// Admin Types
// ============================================================================
export type {
AdminActionField,
AdminActionRunResponse,
AdminActionSummary,
AdminDatasetChartType,
AdminDatasetPoint,
AdminDatasetResult,
AdminDatasetSummary,
AdminOverviewAlert,
AdminOverviewMetric,
AdminOverviewResponse,
AdminOverviewShortcut,
AdminRole,
AdminSessionInfo,
AdminSessionResponse,

View file

@ -0,0 +1,12 @@
create table if not exists public.admin_audit_log (
id bigserial primary key,
operator_id text not null,
operator_email text not null,
role text not null,
action text not null,
target_type text not null,
target_id text not null,
before jsonb,
after jsonb,
created_at timestamptz not null default now()
);