Merge pull request #79 from artslidd/admin-panel

Admin panel
This commit is contained in:
Arthur Belleville 2026-04-24 17:08:29 +02:00 committed by GitHub
commit 1b5dc5cf6f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
78 changed files with 14248 additions and 4 deletions

12
apps/admin/index.html Normal file
View file

@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>XTablo Admin</title>
</head>
<body>
<div id="admin-root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

46
apps/admin/package.json Normal file
View file

@ -0,0 +1,46 @@
{
"name": "@xtablo/admin",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite dev --port 5176",
"build": "tsc -b && vite build --mode production",
"deploy": "wrangler deploy",
"typecheck": "tsc -b",
"lint": "biome check .",
"lint:fix": "biome check --write .",
"format": "biome format --write .",
"preview": "vite preview",
"test": "vitest run --mode test --passWithNoTests",
"test:watch": "vitest watch --mode test --passWithNoTests",
"clean": "rm -rf dist .vite tsconfig.tsbuildinfo node_modules/.vite"
},
"devDependencies": {
"@biomejs/biome": "2.2.5",
"@cloudflare/vite-plugin": "^1.9.4",
"@tailwindcss/vite": "^4.0.14",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0",
"@types/react": "19.0.10",
"@types/react-dom": "19.0.4",
"@vitejs/plugin-react": "^4.3.4",
"happy-dom": "^20.0.0",
"tailwindcss": "^4.0.14",
"tw-animate-css": "^1.4.0",
"typescript": "^5.7.0",
"vite": "^6.2.2",
"vite-tsconfig-paths": "^5.1.4",
"vitest": "^3.2.4",
"wrangler": "^4.24.3"
},
"dependencies": {
"@tanstack/react-query": "^5.69.0",
"@xtablo/shared": "workspace:*",
"@xtablo/shared-types": "workspace:*",
"@xtablo/ui": "workspace:*",
"react": "19.0.0",
"react-dom": "19.0.0",
"react-router-dom": "^7.9.4"
}
}

9
apps/admin/src/App.tsx Normal file
View file

@ -0,0 +1,9 @@
import AppRoutes from "./routes";
export default function App() {
return (
<div className="min-h-screen bg-background text-foreground">
<AppRoutes />
</div>
);
}

View file

@ -0,0 +1,66 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { storeAdminSession } from "../lib/adminSession";
import { adminApi } from "../lib/api";
import AppRoutes from "../routes";
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",
operatorId: "operator-1",
role: "operator",
sessionToken: "admin-session-token",
});
vi.mocked(adminApi.get).mockResolvedValue({
data: {
alerts: [],
metrics: [],
shortcuts: [],
},
});
});
it("shows the production badge and admin sections", async () => {
render(
<MemoryRouter initialEntries={["/"]}>
<AppRoutes />
</MemoryRouter>
);
expect(await screen.findByText(/^production$/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();
expect(screen.getByRole("link", { name: /action center/i })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /lock admin app/i })).toBeInTheDocument();
});
it("clears the stored admin session when locking the app", async () => {
render(
<MemoryRouter initialEntries={["/"]}>
<AppRoutes />
</MemoryRouter>
);
const button = await screen.findByRole("button", { name: /lock admin app/i });
fireEvent.click(button);
expect(localStorage.getItem("xtablo-admin-session")).toBeNull();
});
});

View file

@ -0,0 +1,38 @@
import { Outlet } from "react-router-dom";
import { useAdminSession } from "../hooks/useAdminSession";
import { AdminNavigation } from "./AdminNavigation";
import { ProductionBadge } from "./ProductionBadge";
export function AdminLayout() {
const { logout, operatorEmail } = useAdminSession();
return (
<div className="min-h-screen bg-background text-foreground">
<div className="grid min-h-screen gap-6 lg:grid-cols-[260px_minmax(0,1fr)] p-6">
<aside className="rounded-[2rem] border border-border bg-card p-5 shadow-sm">
<div className="flex flex-col gap-4">
<ProductionBadge />
<div>
<p className="text-xs uppercase tracking-[0.25em] text-foreground/55">Operator</p>
<p className="mt-2 text-sm font-medium">{operatorEmail ?? "Unknown operator"}</p>
</div>
<AdminNavigation />
<form action="/__admin/logout" method="post">
<button
className="w-full rounded-2xl border border-border px-3 py-2 text-left text-sm font-medium text-foreground/75 hover:bg-black/5"
onClick={() => logout()}
type="submit"
>
Lock Admin App
</button>
</form>
</div>
</aside>
<section className="rounded-[2rem] border border-border bg-card/80 p-6 shadow-sm">
<Outlet />
</section>
</div>
</div>
);
}

View file

@ -0,0 +1,30 @@
import { NavLink } from "react-router-dom";
const navItems = [
{ label: "Operations Home", to: "/" },
{ label: "Data Explorer", to: "/explorer" },
{ label: "Analytics Studio", to: "/analytics" },
{ label: "Action Center", to: "/actions" },
];
export function AdminNavigation() {
return (
<nav className="flex flex-col gap-2">
{navItems.map((item) => (
<NavLink
className={({ isActive }) =>
[
"rounded-2xl px-3 py-2 text-sm font-medium",
isActive ? "bg-foreground text-background" : "text-foreground/75 hover:bg-black/5",
].join(" ")
}
end={item.to === "/"}
key={item.to}
to={item.to}
>
{item.label}
</NavLink>
))}
</nav>
);
}

View file

@ -0,0 +1,61 @@
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";
import AppRoutes from "../routes";
vi.mock("../lib/api", () => ({
adminApi: {
get: vi.fn(),
post: vi.fn(),
},
}));
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 () => {
vi.mocked(adminApi.post).mockResolvedValue({
data: {
expiresAt: new Date(Date.now() + 15 * 60 * 1000).toISOString(),
operatorEmail: "ops@xtablo.com",
operatorId: "operator-1",
role: "operator",
sessionToken: "admin-session-token",
},
});
render(
<MemoryRouter initialEntries={["/"]}>
<AppRoutes />
</MemoryRouter>
);
fireEvent.change(screen.getByLabelText(/access token/i), {
target: { value: "valid-access-token" },
});
fireEvent.click(screen.getByRole("button", { name: /unlock admin/i }));
await waitFor(() => {
expect(adminApi.post).toHaveBeenCalledWith("/admin/auth/exchange", {
accessToken: "valid-access-token",
});
});
expect(
await screen.findByRole("heading", {
name: /production command deck for privileged supabase operations/i,
})
).toBeInTheDocument();
});
});

View file

@ -0,0 +1,51 @@
import { FormEvent, useState } from "react";
type PrivilegedGateProps = {
error?: string | null;
isPending?: boolean;
onUnlock: (accessToken: string) => Promise<unknown>;
};
export function PrivilegedGate({ error = null, isPending = false, onUnlock }: PrivilegedGateProps) {
const [accessToken, setAccessToken] = useState("");
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
await onUnlock(accessToken);
};
return (
<main className="flex min-h-screen items-center justify-center p-6">
<div className="w-full max-w-md rounded-3xl border border-border bg-card p-10 shadow-sm">
<p className="text-sm uppercase tracking-[0.25em] text-foreground/60">Internal Only</p>
<h1 className="mt-4 text-3xl font-semibold">Admin access token required</h1>
<p className="mt-3 text-sm text-foreground/70">
Normal XTablo login is not sufficient. Enter a privileged access token to unlock the
internal admin dashboard.
</p>
<form className="mt-8 space-y-4" onSubmit={handleSubmit}>
<label className="block text-sm font-medium" htmlFor="admin-access-token">
Access token
</label>
<input
id="admin-access-token"
className="w-full rounded-2xl border border-border bg-background px-4 py-3"
onChange={(event) => setAccessToken(event.target.value)}
value={accessToken}
/>
{error ? <p className="text-sm text-red-600">{error}</p> : null}
<button
className="w-full rounded-2xl bg-foreground px-4 py-3 text-background disabled:opacity-60"
disabled={isPending || accessToken.trim().length === 0}
type="submit"
>
{isPending ? "Unlocking..." : "Unlock Admin"}
</button>
</form>
</div>
</main>
);
}

View file

@ -0,0 +1,7 @@
export function ProductionBadge() {
return (
<div className="inline-flex items-center gap-2 rounded-full border border-red-300/60 bg-red-50 px-3 py-1 text-xs font-semibold uppercase tracking-[0.2em] text-red-700">
Production
</div>
);
}

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,183 @@
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

@ -0,0 +1,47 @@
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, onSelectRow, rows, selectedRowId }: AdminGridProps) {
if (!meta) {
return null;
}
return (
<div className="overflow-hidden rounded-3xl border border-border bg-card">
<table className="min-w-full border-collapse">
<thead>
<tr className="border-b border-border bg-black/5 text-left">
{meta.columns.map((column) => (
<th className="px-4 py-3 text-sm font-medium" key={column.id}>
{column.label}
</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row, 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] ?? "")}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
}

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,112 @@
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,109 @@
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

@ -0,0 +1,57 @@
import type { AdminSessionResponse } from "@xtablo/shared-types";
import { useEffect, useState } from "react";
import {
clearStoredAdminSession,
getStoredAdminSession,
type StoredAdminSession,
storeAdminSession,
} from "../lib/adminSession";
import { adminApi } from "../lib/api";
export function useAdminSession() {
const [session, setSession] = useState<StoredAdminSession | null>(() => getStoredAdminSession());
const [isPending, setIsPending] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
setSession(getStoredAdminSession());
}, []);
const unlock = async (accessToken: string) => {
setIsPending(true);
setError(null);
try {
const response = await adminApi.post<AdminSessionResponse>("/admin/auth/exchange", {
accessToken,
});
storeAdminSession(response.data);
setSession(response.data);
return response.data;
} catch {
clearStoredAdminSession();
setSession(null);
setError("Invalid privileged access token");
return null;
} finally {
setIsPending(false);
}
};
const logout = () => {
clearStoredAdminSession();
setSession(null);
};
return {
error,
isAuthenticated: session !== null,
isPending,
logout,
operatorEmail: session?.operatorEmail ?? null,
role: session?.role ?? null,
session,
unlock,
};
}

View file

@ -0,0 +1,145 @@
import type { AdminTableMeta, AdminTableSummary } from "@xtablo/shared-types";
import { useEffect, useState } from "react";
import { adminApi } from "../lib/api";
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[]>([]);
const [selectedTableId, setSelectedTableId] = useState<string | null>(null);
const [meta, setMeta] = useState<AdminTableMeta | null>(null);
const [rows, setRows] = useState<AdminRow[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let isMounted = true;
const loadTables = async () => {
try {
const response = await adminApi.get<{ tables: AdminTableSummary[] }>("/admin/tables");
if (!isMounted) {
return;
}
setTables(response.data.tables);
setSelectedTableId((currentValue) => currentValue ?? response.data.tables[0]?.id ?? null);
} catch {
if (isMounted) {
setError("Failed to load admin tables");
}
}
};
void loadTables();
return () => {
isMounted = false;
};
}, []);
useEffect(() => {
let isMounted = true;
const loadTableData = async () => {
if (!selectedTableId) {
setIsLoading(false);
return;
}
setIsLoading(true);
setError(null);
try {
const [metaResponse, rowsResponse] = await Promise.all([
adminApi.get<AdminTableMeta>(`/admin/tables/${selectedTableId}/meta`),
adminApi.get<{ rows: AdminRow[] }>(`/admin/tables/${selectedTableId}/rows`),
]);
if (!isMounted) {
return;
}
setMeta(metaResponse.data);
setRows(rowsResponse.data.rows);
} catch {
if (isMounted) {
setError("Failed to load admin table data");
setMeta(null);
setRows([]);
}
} finally {
if (isMounted) {
setIsLoading(false);
}
}
};
void loadTableData();
return () => {
isMounted = false;
};
}, [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,
meta,
rows,
selectedTableId,
setSelectedTableId,
tables,
updateRow,
};
}

View file

@ -0,0 +1,38 @@
import type { AdminRole } from "@xtablo/shared-types";
const ADMIN_SESSION_STORAGE_KEY = "xtablo-admin-session";
export type StoredAdminSession = {
expiresAt: string;
operatorEmail: string;
operatorId: string;
role: AdminRole;
sessionToken: string;
};
export function getStoredAdminSession() {
const rawSession = localStorage.getItem(ADMIN_SESSION_STORAGE_KEY);
if (!rawSession) {
return null;
}
try {
const parsedSession = JSON.parse(rawSession) as StoredAdminSession;
if (new Date(parsedSession.expiresAt).getTime() <= Date.now()) {
localStorage.removeItem(ADMIN_SESSION_STORAGE_KEY);
return null;
}
return parsedSession;
} catch {
localStorage.removeItem(ADMIN_SESSION_STORAGE_KEY);
return null;
}
}
export function storeAdminSession(session: StoredAdminSession) {
localStorage.setItem(ADMIN_SESSION_STORAGE_KEY, JSON.stringify(session));
}
export function clearStoredAdminSession() {
localStorage.removeItem(ADMIN_SESSION_STORAGE_KEY);
}

16
apps/admin/src/lib/api.ts Normal file
View file

@ -0,0 +1,16 @@
import { buildApi } from "@xtablo/shared";
import { getStoredAdminSession } from "./adminSession";
const apiBaseUrl = import.meta.env.VITE_API_URL || "http://localhost:8080/api/v1";
export const adminApi = buildApi(apiBaseUrl);
adminApi.interceptors.request.use((config) => {
const adminSession = getStoredAdminSession();
if (adminSession) {
config.headers.Authorization = `Bearer ${adminSession.sessionToken}`;
}
return config;
});

30
apps/admin/src/main.css Normal file
View file

@ -0,0 +1,30 @@
@import "tailwindcss";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
:root {
--background: oklch(0.97 0.01 95);
--foreground: oklch(0.2 0.02 255);
--card: oklch(0.995 0.002 95);
--card-foreground: oklch(0.2 0.02 255);
--border: oklch(0.88 0.01 95);
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-border: var(--border);
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground antialiased;
}
}

15
apps/admin/src/main.tsx Normal file
View file

@ -0,0 +1,15 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import App from "./App";
import "@xtablo/ui/globals.css";
import "./main.css";
createRoot(document.getElementById("admin-root")!).render(
<StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</StrictMode>
);

View file

@ -0,0 +1,66 @@
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

@ -0,0 +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-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,85 @@
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.findByRole("button", { name: /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

@ -0,0 +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-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

@ -0,0 +1,98 @@
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";
import { DataExplorerPage } from "./DataExplorerPage";
vi.mock("../lib/api", () => ({
adminApi: {
get: vi.fn(),
patch: vi.fn(),
},
}));
describe("DataExplorerPage", () => {
beforeEach(() => {
vi.clearAllMocks();
});
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 {
data: {
tables: [
{ id: "profiles", label: "Users" },
{ id: "tablo_access", label: "Tablo Access" },
],
},
};
}
if (path === "/admin/tables/profiles/meta") {
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",
},
};
}
if (path === "/admin/tables/profiles/rows") {
return {
data: {
rows: [
{
email: "test_owner@example.com",
first_name: "Test",
id: "user-1",
},
],
},
};
}
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>
<DataExplorerPage />
</MemoryRouter>
);
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

@ -0,0 +1,143 @@
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, 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)_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 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}
</button>
))}
</div>
</aside>
<section className="space-y-4">
<header>
<h1 className="text-3xl font-semibold">{meta?.label ?? "Explorer"}</h1>
<p className="mt-2 text-sm text-foreground/70">
Approved production tables exposed through the internal admin registry.
</p>
</header>
{isLoading ? <p>Loading explorer...</p> : null}
{error ? <p className="text-red-600">{error}</p> : 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

@ -0,0 +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-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,13 @@
import { render, screen } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import AppRoutes from "./routes";
it("renders the privileged gate on the root route", () => {
render(
<MemoryRouter initialEntries={["/"]}>
<AppRoutes />
</MemoryRouter>
);
expect(screen.getByText(/admin access token/i)).toBeInTheDocument();
});

33
apps/admin/src/routes.tsx Normal file
View file

@ -0,0 +1,33 @@
import { Outlet, Route, Routes } from "react-router-dom";
import { AdminLayout } from "./components/AdminLayout";
import { PrivilegedGate } from "./components/PrivilegedGate";
import { useAdminSession } from "./hooks/useAdminSession";
import { ActionCenterPage } from "./pages/ActionCenterPage";
import { AnalyticsStudioPage } from "./pages/AnalyticsStudioPage";
import { DataExplorerPage } from "./pages/DataExplorerPage";
import { OperationsHomePage } from "./pages/OperationsHomePage";
function AdminEntry() {
const { error, isAuthenticated, isPending, unlock } = useAdminSession();
if (isAuthenticated) {
return <Outlet />;
}
return <PrivilegedGate error={error} isPending={isPending} onUnlock={unlock} />;
}
export default function AppRoutes() {
return (
<Routes>
<Route element={<AdminEntry />}>
<Route element={<AdminLayout />}>
<Route index element={<OperationsHomePage />} />
<Route path="/explorer" element={<DataExplorerPage />} />
<Route path="/analytics" element={<AnalyticsStudioPage />} />
<Route path="/actions" element={<ActionCenterPage />} />
</Route>
</Route>
</Routes>
);
}

View file

@ -0,0 +1,6 @@
import "@testing-library/jest-dom";
import { cleanup } from "@testing-library/react";
afterEach(() => {
cleanup();
});

31
apps/admin/tsconfig.json Normal file
View file

@ -0,0 +1,31 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"types": ["vite/client", "vitest/globals"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"@xtablo/ui": ["../../packages/ui/src"],
"@xtablo/ui/*": ["../../packages/ui/src/*"],
"@xtablo/shared": ["../../packages/shared/src"],
"@xtablo/shared/*": ["../../packages/shared/src/*"],
"@xtablo/shared-types": ["../../packages/shared-types/src"],
"@xtablo/shared-types/*": ["../../packages/shared-types/src/*"]
}
},
"include": ["src", "worker"],
"references": []
}

View file

@ -0,0 +1 @@
{"root":["./src/app.tsx","./src/main.tsx","./src/routes.test.tsx","./src/routes.tsx","./src/setuptests.ts","./src/components/adminlayout.test.tsx","./src/components/adminlayout.tsx","./src/components/adminnavigation.tsx","./src/components/privilegedgate.test.tsx","./src/components/privilegedgate.tsx","./src/components/productionbadge.tsx","./src/components/actions/actionrunner.tsx","./src/components/analytics/chartbuilder.tsx","./src/components/analytics/saveddashboardlist.tsx","./src/components/data-explorer/admingrid.tsx","./src/components/data-explorer/roweditform.test.tsx","./src/components/data-explorer/roweditform.tsx","./src/hooks/useadminactions.ts","./src/hooks/useadmindatasets.ts","./src/hooks/useadminoverview.ts","./src/hooks/useadminsession.ts","./src/hooks/useadmintables.ts","./src/lib/adminsession.ts","./src/lib/api.ts","./src/pages/actioncenterpage.test.tsx","./src/pages/actioncenterpage.tsx","./src/pages/analyticsstudiopage.test.tsx","./src/pages/analyticsstudiopage.tsx","./src/pages/dataexplorerpage.test.tsx","./src/pages/dataexplorerpage.tsx","./src/pages/operationshomepage.tsx","./src/registry/actions.ts","./src/registry/datasets.ts","./worker/index.test.ts","./worker/index.ts"],"version":"5.9.3"}

29
apps/admin/vite.config.ts Normal file
View file

@ -0,0 +1,29 @@
/// <reference types="vitest" />
import { cloudflare } from "@cloudflare/vite-plugin";
import tailwindcss from "@tailwindcss/vite";
import react from "@vitejs/plugin-react";
import { defineConfig, type PluginOption } from "vite";
import tsconfigPaths from "vite-tsconfig-paths";
export default defineConfig(({ mode }) => {
const plugins: PluginOption[] = [
react(),
tailwindcss(),
tsconfigPaths({ ignoreConfigErrors: true }),
];
if (mode !== "test" && process.env.VITEST !== "true") {
plugins.push(cloudflare({ inspectorPort: 9233 }));
}
return {
plugins,
server: { cors: false },
test: {
globals: true,
environment: "happy-dom",
setupFiles: "./src/setupTests.ts",
},
};
});

View file

@ -0,0 +1,88 @@
// @vitest-environment node
import { beforeEach, describe, expect, it, vi } from "vitest";
import worker, {
ADMIN_APP_SESSION_COOKIE,
buildAccessDeniedHtml,
createSignedAdminAppSession,
} from "./index";
const env = {
ADMIN_APP_ACCESS_TOKEN: "super-secret-admin-app-token",
ADMIN_APP_SESSION_SECRET: "worker-session-secret",
ASSETS: {
fetch: vi.fn(async () => new Response("<html>app</html>", { status: 200 })),
},
};
describe("admin worker firewall", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("serves the admin access gate when no session cookie is present", async () => {
const response = await worker.fetch(
new Request("https://admin-panel.xtablo.com/", {
headers: {
accept: "text/html",
},
}),
env
);
expect(response.status).toBe(401);
await expect(response.text()).resolves.toContain("Internal Admin Access");
});
it("creates a signed app session cookie from a valid access token", async () => {
const request = new Request("https://admin-panel.xtablo.com/__admin/access", {
body: new URLSearchParams({ accessToken: env.ADMIN_APP_ACCESS_TOKEN }),
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
method: "POST",
});
const response = await worker.fetch(request, env);
expect(response.status).toBe(302);
expect(response.headers.get("location")).toBe("https://admin-panel.xtablo.com/");
expect(response.headers.get("set-cookie")).toContain(`${ADMIN_APP_SESSION_COOKIE}=`);
});
it("allows authenticated requests through to static assets", async () => {
const session = await createSignedAdminAppSession(env.ADMIN_APP_SESSION_SECRET);
const request = new Request("https://admin-panel.xtablo.com/", {
headers: {
cookie: `${ADMIN_APP_SESSION_COOKIE}=${session}`,
},
});
const response = await worker.fetch(request, env);
expect(response.status).toBe(200);
expect(env.ASSETS.fetch).toHaveBeenCalledOnce();
});
it("rejects invalid access tokens", async () => {
const request = new Request("https://admin-panel.xtablo.com/__admin/access", {
body: new URLSearchParams({ accessToken: "wrong-token" }),
headers: {
accept: "text/html",
"Content-Type": "application/x-www-form-urlencoded",
},
method: "POST",
});
const response = await worker.fetch(request, env);
expect(response.status).toBe(401);
await expect(response.text()).resolves.toContain("Invalid app access token");
});
});
describe("buildAccessDeniedHtml", () => {
it("renders the access error when provided", () => {
expect(buildAccessDeniedHtml("Bad token")).toContain("Bad token");
});
});

280
apps/admin/worker/index.ts Normal file
View file

@ -0,0 +1,280 @@
export const ADMIN_APP_SESSION_COOKIE = "xtablo-admin-app-session";
const ADMIN_ACCESS_PATH = "/__admin/access";
const ADMIN_LOGOUT_PATH = "/__admin/logout";
const SESSION_TTL_SECONDS = 60 * 60 * 12;
type WorkerEnv = {
ADMIN_APP_ACCESS_TOKEN: string;
ADMIN_APP_SESSION_SECRET: string;
ASSETS: {
fetch: (request: Request) => Promise<Response>;
};
};
function base64UrlEncode(bytes: Uint8Array) {
return btoa(String.fromCharCode(...bytes))
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=/g, "");
}
function base64UrlDecode(value: string) {
const normalized = value.replace(/-/g, "+").replace(/_/g, "/");
const padding = normalized.length % 4 === 0 ? "" : "=".repeat(4 - (normalized.length % 4));
const binary = atob(`${normalized}${padding}`);
return Uint8Array.from(binary, (character) => character.charCodeAt(0));
}
async function importSigningKey(secret: string) {
return crypto.subtle.importKey(
"raw",
new TextEncoder().encode(secret),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign", "verify"]
);
}
async function signValue(value: string, secret: string) {
const key = await importSigningKey(secret);
const signature = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(value));
return base64UrlEncode(new Uint8Array(signature));
}
async function verifyValueSignature(value: string, signature: string, secret: string) {
const key = await importSigningKey(secret);
return crypto.subtle.verify(
"HMAC",
key,
base64UrlDecode(signature),
new TextEncoder().encode(value)
);
}
function parseCookie(cookieHeader: string | null, cookieName: string) {
if (!cookieHeader) {
return null;
}
const match = cookieHeader.match(new RegExp(`(?:^|;\\s*)${cookieName}=([^;]+)`));
return match?.[1] ?? null;
}
export async function createSignedAdminAppSession(secret: string, now = Date.now()) {
const expiresAt = Math.floor(now / 1000) + SESSION_TTL_SECONDS;
const payload = `${expiresAt}`;
const signature = await signValue(payload, secret);
return `${payload}.${signature}`;
}
export async function hasValidAdminAppSession(request: Request, secret: string, now = Date.now()) {
const sessionCookie = parseCookie(request.headers.get("cookie"), ADMIN_APP_SESSION_COOKIE);
if (!sessionCookie) {
return false;
}
const [expiresAtValue, signature] = sessionCookie.split(".");
if (!expiresAtValue || !signature) {
return false;
}
const expiresAt = Number.parseInt(expiresAtValue, 10);
if (Number.isNaN(expiresAt) || expiresAt <= Math.floor(now / 1000)) {
return false;
}
return verifyValueSignature(expiresAtValue, signature, secret);
}
function isHtmlRequest(request: Request) {
const accept = request.headers.get("accept") ?? "";
return accept.includes("text/html") || accept.includes("*/*");
}
function buildSessionCookie(session: string) {
return `${ADMIN_APP_SESSION_COOKIE}=${session}; HttpOnly; Path=/; SameSite=Strict; Secure; Max-Age=${SESSION_TTL_SECONDS}`;
}
function buildExpiredSessionCookie() {
return `${ADMIN_APP_SESSION_COOKIE}=; HttpOnly; Path=/; SameSite=Strict; Secure; Max-Age=0`;
}
export function buildAccessDeniedHtml(error?: string) {
return `<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>XTablo Admin Access</title>
<style>
:root {
color-scheme: light;
font-family: ui-serif, Georgia, Cambria, "Times New Roman", Times, serif;
background: #f7f3ea;
color: #162033;
}
body {
margin: 0;
min-height: 100vh;
display: grid;
place-items: center;
background:
radial-gradient(circle at top left, rgba(15, 118, 110, 0.15), transparent 32%),
linear-gradient(135deg, #f9f4ea 0%, #f1ede3 48%, #efe4d1 100%);
}
main {
width: min(92vw, 440px);
background: rgba(255, 252, 247, 0.92);
border: 1px solid rgba(22, 32, 51, 0.12);
border-radius: 28px;
padding: 32px;
box-shadow: 0 24px 80px rgba(22, 32, 51, 0.12);
}
.eyebrow {
font: 600 11px/1.2 ui-sans-serif, system-ui, sans-serif;
letter-spacing: 0.22em;
text-transform: uppercase;
color: #6a7280;
}
h1 {
margin: 14px 0 0;
font-size: 2rem;
line-height: 1.05;
}
p {
margin: 14px 0 0;
font: 400 0.98rem/1.55 ui-sans-serif, system-ui, sans-serif;
color: #465062;
}
form {
margin-top: 24px;
}
label {
display: block;
font: 600 0.88rem/1.4 ui-sans-serif, system-ui, sans-serif;
}
input {
width: 100%;
margin-top: 10px;
padding: 14px 16px;
border-radius: 18px;
border: 1px solid rgba(22, 32, 51, 0.16);
background: white;
font: 400 0.95rem/1.4 ui-monospace, SFMono-Regular, monospace;
box-sizing: border-box;
}
button {
width: 100%;
margin-top: 16px;
border: 0;
border-radius: 18px;
padding: 14px 16px;
background: #172554;
color: white;
font: 600 0.95rem/1.2 ui-sans-serif, system-ui, sans-serif;
cursor: pointer;
}
.error {
margin-top: 16px;
padding: 12px 14px;
border-radius: 16px;
background: #fef2f2;
color: #b91c1c;
font: 600 0.85rem/1.4 ui-sans-serif, system-ui, sans-serif;
}
.footnote {
margin-top: 18px;
font-size: 0.82rem;
}
</style>
</head>
<body>
<main>
<div class="eyebrow">Internal Only</div>
<h1>Internal Admin Access</h1>
<p>
This app is firewalled behind a dedicated app-access token before any admin session
can be established.
</p>
<form action="${ADMIN_ACCESS_PATH}" method="post">
<label for="accessToken">App Access Token</label>
<input id="accessToken" name="accessToken" type="password" autocomplete="off" required />
<button type="submit">Enter Admin App</button>
</form>
${error ? `<div class="error">${error}</div>` : ""}
<p class="footnote">A second privileged token is still required inside the admin shell.</p>
</main>
</body>
</html>`;
}
function respondUnauthorized(request: Request, error?: string) {
if (isHtmlRequest(request)) {
return new Response(buildAccessDeniedHtml(error), {
headers: {
"Content-Type": "text/html; charset=utf-8",
"Cache-Control": "no-store",
},
status: 401,
});
}
return new Response("Unauthorized", { status: 401 });
}
async function handleAccessRequest(request: Request, env: WorkerEnv) {
const formData = await request.formData().catch(() => null);
const accessToken = formData?.get("accessToken");
if (accessToken !== env.ADMIN_APP_ACCESS_TOKEN) {
return respondUnauthorized(request, "Invalid app access token");
}
const session = await createSignedAdminAppSession(env.ADMIN_APP_SESSION_SECRET);
const location = new URL("/", request.url).toString();
return new Response(null, {
headers: {
Location: location,
"Set-Cookie": buildSessionCookie(session),
"Cache-Control": "no-store",
},
status: 302,
});
}
async function handleLogoutRequest(request: Request) {
return new Response(null, {
headers: {
Location: new URL("/", request.url).toString(),
"Set-Cookie": buildExpiredSessionCookie(),
"Cache-Control": "no-store",
},
status: 302,
});
}
export default {
async fetch(request: Request, env: WorkerEnv) {
const url = new URL(request.url);
if (request.method === "POST" && url.pathname === ADMIN_ACCESS_PATH) {
return handleAccessRequest(request, env);
}
if (request.method === "POST" && url.pathname === ADMIN_LOGOUT_PATH) {
return handleLogoutRequest(request);
}
const hasSession = await hasValidAdminAppSession(request, env.ADMIN_APP_SESSION_SECRET);
if (!hasSession) {
return respondUnauthorized(request);
}
return env.ASSETS.fetch(request);
},
};

14
apps/admin/wrangler.toml Normal file
View file

@ -0,0 +1,14 @@
name = "xtablo-admin"
main = "worker/index.ts"
compatibility_date = "2025-07-09"
[assets]
directory = "./dist/"
not_found_handling = "single-page-application"
[observability]
enabled = true
[[routes]]
pattern = "admin-panel.xtablo.com"
custom_domain = true

View file

@ -3,6 +3,7 @@ import { createConfig } from "../../config.js";
import type { Secrets } from "../../secrets.js";
const baseSecrets: Secrets = {
adminTokenSigningSecret: "admin-token-signing-secret",
supabaseServiceRoleKey: "service-role-from-secret-manager",
supabaseConnectionString: "postgres://secret-manager",
supabaseCaCert: "ca-cert",

View file

@ -0,0 +1,22 @@
import { createHmac } from "node:crypto";
type TestAdminTokenClaims = {
aud: string;
email: string;
exp: number;
role: "viewer" | "operator" | "superadmin";
sub: string;
type: "admin_access" | "admin_session";
};
function encodeSegment(value: unknown) {
return Buffer.from(JSON.stringify(value)).toString("base64url");
}
export function createSignedAdminToken(claims: TestAdminTokenClaims, secret: string) {
const header = encodeSegment({ alg: "HS256", typ: "JWT" });
const payload = encodeSegment(claims);
const signature = createHmac("sha256", secret).update(`${header}.${payload}`).digest("base64url");
return `${header}.${payload}.${signature}`;
}

View file

@ -0,0 +1,55 @@
import { describe, expect, it } from "vitest";
import { createConfig } from "../../config.js";
import { MiddlewareManager } from "../../middlewares/middleware.js";
import { getMainRouter } from "../../routers/index.js";
import { createSignedAdminToken } from "../helpers/adminTokenTestUtils.js";
const ADMIN_TOKEN_SIGNING_SECRET = "admin-test-secret";
const ADMIN_TOKEN_AUDIENCE = "xtablo-admin";
describe("Admin Auth Middleware", () => {
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);
it("rejects admin routes without an admin session", async () => {
const res = await app.request("/admin/tables/profiles");
expect(res.status).toBe(401);
await expect(res.json()).resolves.toMatchObject({
error: "Admin session required",
code: "ADMIN_SESSION_REQUIRED",
});
});
it("returns the current admin session for a valid admin session token", async () => {
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
);
const res = await app.request("/admin/auth/session", {
headers: {
Authorization: `Bearer ${sessionToken}`,
},
});
expect(res.status).toBe(200);
await expect(res.json()).resolves.toMatchObject({
role: "operator",
operatorEmail: "ops@xtablo.com",
operatorId: "operator-1",
});
});
});

View file

@ -0,0 +1,64 @@
import { describe, expect, it } from "vitest";
import { createConfig } from "../../config.js";
import { MiddlewareManager } from "../../middlewares/middleware.js";
import { getMainRouter } from "../../routers/index.js";
import { createSignedAdminToken } from "../helpers/adminTokenTestUtils.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 { createConfig } from "../../config.js";
import { MiddlewareManager } from "../../middlewares/middleware.js";
import { getMainRouter } from "../../routers/index.js";
import { createSignedAdminToken } from "../helpers/adminTokenTestUtils.js";
const ADMIN_TOKEN_SIGNING_SECRET = "admin-test-secret";
const ADMIN_TOKEN_AUDIENCE = "xtablo-admin";
describe("Admin Auth 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);
it("rejects requests without a valid privileged token", async () => {
const res = await app.request("/admin/auth/exchange", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ accessToken: "bad-token" }),
});
expect(res.status).toBe(401);
await expect(res.json()).resolves.toMatchObject({
error: "Invalid privileged access token",
code: "INVALID_ADMIN_ACCESS_TOKEN",
});
});
it("exchanges a valid privileged token for an admin session", async () => {
const accessToken = createSignedAdminToken(
{
aud: ADMIN_TOKEN_AUDIENCE,
email: "ops@xtablo.com",
exp: Math.floor(Date.now() / 1000) + 3600,
role: "operator",
sub: "operator-1",
type: "admin_access",
},
ADMIN_TOKEN_SIGNING_SECRET
);
const res = await app.request("/admin/auth/exchange", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ accessToken }),
});
expect(res.status).toBe(200);
await expect(res.json()).resolves.toMatchObject({
role: "operator",
operatorEmail: "ops@xtablo.com",
sessionToken: expect.any(String),
expiresAt: expect.any(String),
});
});
});

View file

@ -0,0 +1,64 @@
import { describe, expect, it } from "vitest";
import { createConfig } from "../../config.js";
import { MiddlewareManager } from "../../middlewares/middleware.js";
import { getMainRouter } from "../../routers/index.js";
import { createSignedAdminToken } from "../helpers/adminTokenTestUtils.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 { createConfig } from "../../config.js";
import { MiddlewareManager } from "../../middlewares/middleware.js";
import { getMainRouter } from "../../routers/index.js";
import { createSignedAdminToken } from "../helpers/adminTokenTestUtils.js";
import { getTestData } from "../helpers/dbSetup.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(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

@ -0,0 +1,100 @@
import { describe, expect, it } from "vitest";
import { createConfig } from "../../config.js";
import { MiddlewareManager } from "../../middlewares/middleware.js";
import { getMainRouter } from "../../routers/index.js";
import { createSignedAdminToken } from "../helpers/adminTokenTestUtils.js";
const ADMIN_TOKEN_SIGNING_SECRET = "admin-test-secret";
const ADMIN_TOKEN_AUDIENCE = "xtablo-admin";
describe("Admin Tables 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 only approved admin tables", async () => {
const res = await app.request("/admin/tables", {
headers: {
Authorization: `Bearer ${sessionToken}`,
},
});
expect(res.status).toBe(200);
await expect(res.json()).resolves.toMatchObject({
tables: expect.arrayContaining([
expect.objectContaining({
id: "profiles",
label: "Users",
}),
]),
});
});
it("returns metadata for an approved table", async () => {
const res = await app.request("/admin/tables/profiles/meta", {
headers: {
Authorization: `Bearer ${sessionToken}`,
},
});
expect(res.status).toBe(200);
await expect(res.json()).resolves.toMatchObject({
id: "profiles",
label: "Users",
editableFields: ["first_name", "last_name"],
primaryKey: "id",
columns: expect.arrayContaining([
expect.objectContaining({
id: "email",
label: "Email",
}),
]),
});
});
it("returns rows for an approved table", async () => {
const res = await app.request("/admin/tables/profiles/rows", {
headers: {
Authorization: `Bearer ${sessionToken}`,
},
});
expect(res.status).toBe(200);
await expect(res.json()).resolves.toMatchObject({
rows: expect.arrayContaining([
expect.objectContaining({
email: "test_owner@example.com",
}),
]),
});
});
it("rejects tables that are not in the registry", async () => {
const res = await app.request("/admin/tables/secrets/meta", {
headers: {
Authorization: `Bearer ${sessionToken}`,
},
});
expect(res.status).toBe(404);
await expect(res.json()).resolves.toMatchObject({
error: "Admin table 'secrets' is not registered",
});
});
});

View file

@ -23,6 +23,9 @@ export interface AppConfig {
R2_SECRET_ACCESS_KEY: string;
LOG_LEVEL: "debug" | "info" | "warn" | "error";
TASKS_SECRET: string;
ADMIN_TOKEN_SIGNING_SECRET: string;
ADMIN_TOKEN_AUDIENCE: string;
ADMIN_APP_URL: string;
/**
* Test user
@ -107,6 +110,11 @@ export function createConfig(secrets?: Secrets): AppConfig {
? validateEnvVar("R2_SECRET_ACCESS_KEY", process.env.R2_SECRET_ACCESS_KEY)
: secrets!.r2SecretAccessKey,
TASKS_SECRET: process.env.TASKS_SECRET || "",
ADMIN_TOKEN_SIGNING_SECRET: isTestMode
? validateEnvVar("ADMIN_TOKEN_SIGNING_SECRET", process.env.ADMIN_TOKEN_SIGNING_SECRET)
: secrets!.adminTokenSigningSecret,
ADMIN_TOKEN_AUDIENCE: process.env.ADMIN_TOKEN_AUDIENCE || "xtablo-admin",
ADMIN_APP_URL: process.env.ADMIN_APP_URL || "http://localhost:5176",
LOG_LEVEL: "info",
TEST_USER_DATA: {
id: "test",

View file

@ -0,0 +1,40 @@
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.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

@ -0,0 +1,146 @@
import type { Database } from "@xtablo/shared-types";
type AdminTableColumn = {
id: string;
label: string;
};
type AdminTableDefinition = {
columns: AdminTableColumn[];
editableColumns?: string[];
id: string;
label: string;
primaryKey: string;
select: string;
source: keyof Database["public"]["Tables"];
};
export const adminTableRegistry: Record<string, AdminTableDefinition> = {
profiles: {
columns: [
{ id: "id", label: "ID" },
{ id: "email", label: "Email" },
{ 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",
},
tablo_access: {
columns: [
{ id: "tablo_id", label: "Tablo ID" },
{ id: "user_id", label: "User ID" },
{ 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;
}
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

@ -0,0 +1,201 @@
import { createHmac, timingSafeEqual } from "node:crypto";
import type { AppConfig } from "../config.js";
export type AdminRole = "viewer" | "operator" | "superadmin";
type TokenKind = "admin_access" | "admin_session";
type AdminTokenClaims = {
aud: string;
email: string;
exp: number;
role: AdminRole;
sub: string;
type: TokenKind;
};
export type AdminSessionClaims = {
aud: string;
exp: number;
operatorEmail: string;
operatorId: string;
role: AdminRole;
};
type AdminTokenErrorCode =
| "ADMIN_SESSION_REQUIRED"
| "INVALID_ADMIN_ACCESS_TOKEN"
| "INVALID_ADMIN_SESSION";
type AdminTokenFailure = {
code: AdminTokenErrorCode;
error: string;
statusCode: 401;
success: false;
};
type AdminTokenSuccess<T> = {
success: true;
value: T;
};
export type AdminTokenResult<T> = AdminTokenFailure | AdminTokenSuccess<T>;
type ExchangeResult = {
expiresAt: string;
operatorEmail: string;
operatorId: string;
role: AdminRole;
sessionToken: string;
};
function encodeSegment(value: unknown) {
return Buffer.from(JSON.stringify(value)).toString("base64url");
}
function decodeSegment<T>(segment: string): T | null {
try {
return JSON.parse(Buffer.from(segment, "base64url").toString("utf8")) as T;
} catch {
return null;
}
}
function signToken(claims: AdminTokenClaims, secret: string) {
const header = encodeSegment({ alg: "HS256", typ: "JWT" });
const payload = encodeSegment(claims);
const signature = createHmac("sha256", secret).update(`${header}.${payload}`).digest("base64url");
return `${header}.${payload}.${signature}`;
}
function invalidToken(error: string, code: AdminTokenErrorCode): AdminTokenFailure {
return {
code,
error,
statusCode: 401,
success: false,
};
}
function isFailure<T>(result: AdminTokenResult<T>): result is AdminTokenFailure {
return !result.success;
}
function verifyToken(
token: string,
config: AppConfig,
expectedType: TokenKind
): AdminTokenResult<AdminTokenClaims> {
const segments = token.split(".");
if (segments.length !== 3) {
return invalidToken(
expectedType === "admin_access" ? "Invalid privileged access token" : "Invalid admin session",
expectedType === "admin_access" ? "INVALID_ADMIN_ACCESS_TOKEN" : "INVALID_ADMIN_SESSION"
);
}
const [header, payload, signature] = segments;
const expectedSignature = createHmac("sha256", config.ADMIN_TOKEN_SIGNING_SECRET)
.update(`${header}.${payload}`)
.digest();
const receivedSignature = Buffer.from(signature, "base64url");
if (
expectedSignature.length !== receivedSignature.length ||
!timingSafeEqual(expectedSignature, receivedSignature)
) {
return invalidToken(
expectedType === "admin_access" ? "Invalid privileged access token" : "Invalid admin session",
expectedType === "admin_access" ? "INVALID_ADMIN_ACCESS_TOKEN" : "INVALID_ADMIN_SESSION"
);
}
const claims = decodeSegment<AdminTokenClaims>(payload);
if (!claims) {
return invalidToken(
expectedType === "admin_access" ? "Invalid privileged access token" : "Invalid admin session",
expectedType === "admin_access" ? "INVALID_ADMIN_ACCESS_TOKEN" : "INVALID_ADMIN_SESSION"
);
}
if (claims.type !== expectedType || claims.aud !== config.ADMIN_TOKEN_AUDIENCE) {
return invalidToken(
expectedType === "admin_access" ? "Invalid privileged access token" : "Invalid admin session",
expectedType === "admin_access" ? "INVALID_ADMIN_ACCESS_TOKEN" : "INVALID_ADMIN_SESSION"
);
}
if (claims.exp <= Math.floor(Date.now() / 1000)) {
return invalidToken(
expectedType === "admin_access" ? "Invalid privileged access token" : "Invalid admin session",
expectedType === "admin_access" ? "INVALID_ADMIN_ACCESS_TOKEN" : "INVALID_ADMIN_SESSION"
);
}
return { success: true, value: claims };
}
export function exchangePrivilegedToken(
token: string,
config: AppConfig
): AdminTokenResult<ExchangeResult> {
const verifiedAccessToken = verifyToken(token, config, "admin_access");
if (isFailure(verifiedAccessToken)) {
return verifiedAccessToken;
}
const accessClaims = verifiedAccessToken.value;
const sessionExpiry = Math.floor(Date.now() / 1000) + 15 * 60;
const sessionToken = signToken(
{
aud: config.ADMIN_TOKEN_AUDIENCE,
email: accessClaims.email,
exp: sessionExpiry,
role: accessClaims.role,
sub: accessClaims.sub,
type: "admin_session",
},
config.ADMIN_TOKEN_SIGNING_SECRET
);
return {
success: true,
value: {
expiresAt: new Date(sessionExpiry * 1000).toISOString(),
operatorEmail: accessClaims.email,
operatorId: accessClaims.sub,
role: accessClaims.role,
sessionToken,
},
};
}
export function verifyAdminSession(
token: string | undefined,
config: AppConfig
): AdminTokenResult<AdminSessionClaims> {
if (!token) {
return invalidToken("Admin session required", "ADMIN_SESSION_REQUIRED");
}
const verifiedSession = verifyToken(token, config, "admin_session");
if (isFailure(verifiedSession)) {
return {
...verifiedSession,
code: "ADMIN_SESSION_REQUIRED",
error: "Admin session required",
};
}
return {
success: true,
value: {
aud: verifiedSession.value.aud,
exp: verifiedSession.value.exp,
operatorEmail: verifiedSession.value.email,
operatorId: verifiedSession.value.sub,
role: verifiedSession.value.role,
},
};
}

View file

@ -6,6 +6,7 @@ import { createMiddleware } from "hono/factory";
import type { Transporter } from "nodemailer";
import { Stripe } from "stripe";
import { type AppConfig } from "../config.js";
import { type AdminTokenResult, verifyAdminSession } from "../helpers/adminTokens.js";
import { authenticateFromHeader } from "../helpers/auth.js";
import { createStripeSync } from "./stripeSync.js";
import { createTransporter } from "./transporter.js";
@ -24,6 +25,9 @@ export type Middlewares = {
Variables: { supabase: SupabaseClient; user: User };
Bindings: { user: User };
}>;
adminAuthMiddleware: MiddlewareHandler<{
Variables: { adminSession: import("../helpers/adminTokens.js").AdminSessionClaims };
}>;
r2Middleware: MiddlewareHandler<{
Variables: { s3_client: S3Client };
}>;
@ -74,6 +78,10 @@ export class MiddlewareManager {
}
private initializeMiddlewares(config: AppConfig): Middlewares {
const isAdminTokenFailure = <T>(
result: AdminTokenResult<T>
): result is Extract<AdminTokenResult<T>, { success: false }> => !result.success;
const createProfileAccessMiddleware = (allowTemporaryUsers: boolean) =>
createMiddleware<{
Variables: { supabase: SupabaseClient; user: User };
@ -141,6 +149,38 @@ export class MiddlewareManager {
await next();
});
const adminAuthMiddleware = createMiddleware<{
Variables: { adminSession: import("../helpers/adminTokens.js").AdminSessionClaims };
}>(async (c, next) => {
const authHeader = c.req.header("Authorization");
if (!authHeader || !authHeader.startsWith("Bearer ")) {
return c.json(
{
code: "ADMIN_SESSION_REQUIRED",
error: "Admin session required",
},
401
);
}
const sessionToken = authHeader.substring(7);
const verifiedSession = verifyAdminSession(sessionToken, config);
if (isAdminTokenFailure(verifiedSession)) {
return c.json(
{
code: verifiedSession.code,
error: verifiedSession.error,
},
verifiedSession.statusCode
);
}
c.set("adminSession", verifiedSession.value);
await next();
});
const maybeAuthenticatedMiddleware = createMiddleware<{
Variables: { supabase: SupabaseClient; user: User | null };
}>(async (c, next) => {
@ -241,6 +281,7 @@ export class MiddlewareManager {
supabaseMiddleware,
basicAuthMiddleware,
authMiddleware,
adminAuthMiddleware,
maybeAuthenticatedMiddleware,
r2Middleware,
regularUserCheckMiddleware,
@ -264,6 +305,10 @@ export class MiddlewareManager {
return this.middlewares.authMiddleware;
}
get adminAuth() {
return this.middlewares.adminAuthMiddleware;
}
get maybeAuthenticated() {
return this.middlewares.maybeAuthenticatedMiddleware;
}

View file

@ -0,0 +1,32 @@
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) => {
const adminRouter = new Hono<BaseEnv>();
const middlewareManager = MiddlewareManager.getInstance();
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,104 @@
import type { AdminActionRunResponse } from "@xtablo/shared-types";
import { Hono } from "hono";
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,55 @@
import { Hono } from "hono";
import type { AppConfig } from "../config.js";
import { type AdminTokenResult, exchangePrivilegedToken } from "../helpers/adminTokens.js";
import { MiddlewareManager } from "../middlewares/middleware.js";
import type { BaseEnv } from "../types/app.types.js";
export const getAdminAuthRouter = (config: AppConfig) => {
const adminAuthRouter = new Hono<BaseEnv>();
const middlewareManager = MiddlewareManager.getInstance();
const isAdminTokenFailure = <T>(
result: AdminTokenResult<T>
): result is Extract<AdminTokenResult<T>, { success: false }> => !result.success;
adminAuthRouter.post("/exchange", async (c) => {
const body = await c.req.json().catch(() => null);
const accessToken =
body && typeof body === "object" && "accessToken" in body ? body.accessToken : undefined;
if (typeof accessToken !== "string" || accessToken.length === 0) {
return c.json(
{
code: "INVALID_ADMIN_ACCESS_TOKEN",
error: "Invalid privileged access token",
},
401
);
}
const exchangeResult = exchangePrivilegedToken(accessToken, config);
if (isAdminTokenFailure(exchangeResult)) {
return c.json(
{
code: exchangeResult.code,
error: exchangeResult.error,
},
exchangeResult.statusCode
);
}
return c.json(exchangeResult.value, 200);
});
adminAuthRouter.use("/session", middlewareManager.adminAuth);
adminAuthRouter.get("/session", async (c) => {
const adminSession = c.get("adminSession");
return c.json(adminSession, 200);
});
adminAuthRouter.post("/logout", async (c) => c.json({ success: true }, 200));
return adminAuthRouter;
};

View file

@ -0,0 +1,155 @@
import type { AdminDatasetResult } from "@xtablo/shared-types";
import { Hono } from "hono";
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,141 @@
import type { AdminOverviewResponse } from "@xtablo/shared-types";
import { Hono } from "hono";
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

@ -0,0 +1,139 @@
import { Hono } from "hono";
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 = () => {
const adminTablesRouter = new Hono<BaseEnv>();
adminTablesRouter.get("/", async (c) => {
return c.json({ tables: listAdminTables() }, 200);
});
adminTablesRouter.get("/:tableId/meta", async (c) => {
const tableId = c.req.param("tableId");
const tableDefinition = getAdminTableDefinition(tableId);
if (!tableDefinition) {
return c.json(
{
error: `Admin table '${tableId}' is not registered`,
},
404
);
}
return c.json(
{
columns: tableDefinition.columns,
editableFields: tableDefinition.editableColumns ?? [],
id: tableDefinition.id,
label: tableDefinition.label,
primaryKey: tableDefinition.primaryKey,
},
200
);
});
adminTablesRouter.get("/:tableId/rows", async (c) => {
const supabase = c.get("supabase");
const tableId = c.req.param("tableId");
const tableDefinition = getAdminTableDefinition(tableId);
if (!tableDefinition) {
return c.json(
{
error: `Admin table '${tableId}' is not registered`,
},
404
);
}
const { data, error } = await supabase
.from(tableDefinition.source)
.select(tableDefinition.select)
.limit(50);
if (error) {
return c.json(
{
error: `Failed to load admin table '${tableId}'`,
},
500
);
}
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

@ -2,6 +2,7 @@ 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 { getAdminRouter } from "./admin.js";
import { getAuthenticatedRouter } from "./authRouter.js";
import { getPublicClientInvitesRouter } from "./clientInvites.js";
import { getMaybeAuthenticatedRouter } from "./maybeAuthRouter.js";
@ -32,6 +33,9 @@ export const getMainRouter = (config: AppConfig) => {
// webhooks
mainRouter.route("/stripe-webhook", getStripeWebhookRouter());
// admin routes
mainRouter.route("/admin", getAdminRouter(config));
// public client onboarding routes
mainRouter.route("/client-invites", getPublicClientInvitesRouter());

View file

@ -21,6 +21,7 @@ export type Secrets = {
supabaseServiceRoleKey: string;
supabaseConnectionString: string;
supabaseCaCert: string;
adminTokenSigningSecret: string;
emailClientSecret: string;
emailRefreshToken: string;
r2AccessKeyId: string;
@ -42,6 +43,7 @@ export async function loadSecrets(): Promise<Secrets> {
supabaseServiceRoleKey: await fetchSecret("supabase-service-role-key"),
supabaseConnectionString: await fetchSecret("supabase-connection-string"),
supabaseCaCert: await fetchSecret("supabase-ca-cert"),
adminTokenSigningSecret: await fetchSecret("admin-token-signing-secret"),
emailClientSecret: await fetchSecret("email-client-secret"),
emailRefreshToken: await fetchSecret("email-refresh-token"),
r2AccessKeyId: await fetchSecret("r2-access-key-id"),

View file

@ -4,12 +4,14 @@ import type { SupabaseClient, User } from "@supabase/supabase-js";
import type { Hono } from "hono";
import type { Transporter } from "nodemailer";
import type Stripe from "stripe";
import type { AdminSessionClaims } from "../helpers/adminTokens.js";
/**
* Base environment variables available across all routes
*/
export type BaseEnv = {
Variables: {
adminSession: AdminSessionClaims;
supabase: SupabaseClient;
s3_client: S3Client;
transporter: Transporter;

View file

@ -1,5 +1,76 @@
import { Navigate } from "react-router-dom";
import { useEffect, useRef, useState } from "react";
import { Navigate, useNavigate, useSearchParams } from "react-router-dom";
import { useSession } from "@xtablo/shared/contexts/SessionContext";
export function AuthCallback() {
return <Navigate to="/login" replace />;
const [searchParams] = useSearchParams();
const token = searchParams.get("token");
const { session } = useSession();
const navigate = useNavigate();
const [error, setError] = useState<string | null>(null);
const hasAccepted = useRef(false);
useEffect(() => {
if (!token) {
return;
}
if (!session || hasAccepted.current) {
return;
}
hasAccepted.current = true;
const apiUrl = import.meta.env.VITE_API_URL as string;
fetch(`${apiUrl}/api/v1/client-invites/accept/${token}`, {
method: "POST",
headers: {
Authorization: `Bearer ${session.access_token}`,
"Content-Type": "application/json",
},
})
.then(async (res) => {
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(
(body as { message?: string }).message ?? "Erreur lors de l'acceptation de l'invitation"
);
}
return res.json() as Promise<{ tabloId: string }>;
})
.then((data) => {
navigate(`/tablo/${data.tabloId}`, { replace: true });
})
.catch((err: unknown) => {
console.error("Accept invite error:", err);
setError(
"Une erreur est survenue lors de l'acceptation de l'invitation. Veuillez contacter la personne qui vous a invite."
);
});
}, [session, token, navigate]);
if (!token) {
return <Navigate to="/login" replace />;
}
if (error) {
return (
<div className="min-h-screen flex items-center justify-center bg-background">
<div className="text-center space-y-3 max-w-md px-4">
<p className="text-lg font-medium text-destructive">Erreur</p>
<p className="text-sm text-muted-foreground">{error}</p>
</div>
</div>
);
}
return (
<div className="min-h-screen flex items-center justify-center bg-background">
<div className="text-center space-y-3">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto" />
<p className="text-sm text-muted-foreground">Authentification en cours...</p>
</div>
</div>
);
}

View file

@ -1 +1 @@
{"root":["./src/app.tsx","./src/envproduction.test.ts","./src/i18n.test.ts","./src/i18n.ts","./src/main.tsx","./src/maincss.test.ts","./src/routes.tsx","./src/setuptests.ts","./src/components/clientauthgate.tsx","./src/components/clientlayout.test.tsx","./src/components/clientlayout.tsx","./src/lib/supabase.ts","./src/pages/authcallback.tsx","./src/pages/clienttablolistpage.tsx","./src/pages/clienttablopage.test.tsx","./src/pages/clienttablopage.tsx","./src/pages/loginpage.test.tsx","./src/pages/loginpage.tsx","./src/pages/resetpasswordpage.test.tsx","./src/pages/resetpasswordpage.tsx","./src/pages/setpasswordpage.test.tsx","./src/pages/setpasswordpage.tsx","./src/test/testhelpers.test.tsx","./src/test/testhelpers.tsx"],"version":"5.9.3"}
{"root":["./src/app.tsx","./src/envproduction.test.ts","./src/i18n.test.ts","./src/i18n.ts","./src/main.tsx","./src/maincss.test.ts","./src/routes.tsx","./src/setuptests.ts","./src/vite-env.d.ts","./src/viteconfig.test.ts","./src/components/clientauthgate.tsx","./src/components/clientlayout.test.tsx","./src/components/clientlayout.tsx","./src/lib/supabase.ts","./src/pages/authcallback.tsx","./src/pages/clienttablolistpage.tsx","./src/pages/clienttablopage.test.tsx","./src/pages/clienttablopage.tsx","./src/pages/loginpage.test.tsx","./src/pages/loginpage.tsx","./src/pages/resetpasswordpage.test.tsx","./src/pages/resetpasswordpage.tsx","./src/pages/setpasswordpage.test.tsx","./src/pages/setpasswordpage.tsx","./src/test/testhelpers.test.tsx","./src/test/testhelpers.tsx"],"version":"5.9.3"}

8376
apps/clients/worker-configuration.d.ts vendored Normal file

File diff suppressed because it is too large Load diff

View file

@ -9,6 +9,9 @@
"apps/external/src/**/*",
"apps/external/worker/**/*",
"apps/external/*.{ts,tsx,js,jsx,json}",
"apps/admin/src/**/*",
"apps/admin/worker/**/*",
"apps/admin/*.{ts,tsx,js,jsx,json}",
"apps/api/src/**/*",
"apps/api/*.{ts,tsx,js,jsx,json}",
"packages/ui/src/**/*",
@ -300,6 +303,9 @@
"apps/external/src/**/*.{ts,tsx}",
"apps/external/worker/**/*.{ts,tsx}",
"apps/external/*.{ts,tsx}",
"apps/admin/src/**/*.{ts,tsx}",
"apps/admin/worker/**/*.{ts,tsx}",
"apps/admin/*.{ts,tsx}",
"apps/api/src/**/*.{ts,tsx}",
"apps/api/*.{ts,tsx}",
"packages/ui/src/**/*.{ts,tsx}",

View file

@ -0,0 +1,86 @@
# Admin App Access Setup
The admin app is designed to be internal-only and requires a separate privileged token flow.
## Required admin worker configuration
Set these values for `apps/admin`:
- `ADMIN_APP_ACCESS_TOKEN`
- `ADMIN_APP_SESSION_SECRET`
`ADMIN_APP_ACCESS_TOKEN` is the first-layer token required before the admin SPA will be served.
`ADMIN_APP_SESSION_SECRET` signs the worker-issued app session cookie after that token is accepted.
Production domain: `https://admin-panel.xtablo.com`
## Deploy commands
Use the root command:
```bash
pnpm deploy:admin
```
Or directly from the app package:
```bash
pnpm --filter @xtablo/admin run deploy
```
## 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 admin surface, for example `https://admin-panel.xtablo.com`.
## Access model
1. The operator reaches the private `apps/admin` deployment from the internal network boundary.
2. The admin worker presents a dedicated app-access gate before any SPA asset is served.
3. The operator submits the app access token, and the worker issues a signed session cookie.
4. Only then does the browser load the React admin shell.
5. Inside the shell, the operator pastes a separate privileged admin API token.
6. `POST /admin/auth/exchange` validates that token and returns a short-lived `admin_session`.
7. The admin app stores that session locally and attaches it as a bearer token for admin routes.
8. 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`.
- Configure worker env for `ADMIN_APP_ACCESS_TOKEN` and `ADMIN_APP_SESSION_SECRET`.
- Use the app-access token at the worker gate, then use a valid privileged API 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`

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,428 @@
# Supabase Admin Dashboard — Design Spec
## Overview
Add a new internal-only app alongside `apps/main`, implemented as `apps/admin`, to operate the production Supabase database through a polished operations dashboard. The app is not customer-facing. Its primary purpose is operational data inspection and repair, with analytics and charting as supporting capabilities for investigation and monitoring.
The product shape is:
- an internal operations console for browsing and editing approved production data
- a guarded action center for high-impact business workflows
- a semi-flexible analytics studio for charting curated datasets and saving dashboards
This should feel closer to a premium internal instrument than a generic admin template.
## Goals
- Give internal operators a fast way to inspect, filter, and modify approved production rows
- Support business-safe admin actions that should not be performed as ad hoc table edits
- Provide chart-based analytics over curated datasets for investigation and monitoring
- Keep the app visually strong, dense, and pleasant to operate for power users
- Make production access intentionally hard to misuse through layered security and auditability
## Non-Goals
- Building a full Tableau, Metabase, or Power BI replacement in v1
- Exposing this app to regular product users or customer organization admins
- Allowing arbitrary SQL execution from the browser in v1
- Making every database table directly editable
- Reusing `apps/main` navigation and UX constraints for internal operations work
## Recommended App Shape
Create a dedicated app at `apps/admin` rather than extending `apps/main`.
Reasons:
- Internal operations workflows have different navigation, information density, and safety needs than the main product
- The app should remain isolated from customer-facing code paths and feature assumptions
- A separate app can share the existing monorepo stack without inheriting the product shell
## Technical Foundation
The app should reuse the current workspace patterns:
- React 19
- Vite
- React Router
- TanStack React Query
- Tailwind
- `@xtablo/ui`
- shared Supabase and type packages where appropriate
Specialized libraries are acceptable where they materially improve operator workflows:
- `ag-grid-react` or the existing AG Grid dependency for dense table exploration
- a charting library suited for composable dashboards and interactive filters
- lightweight schema/form helpers for registry-driven edit UIs
This keeps the app consistent with the repo while allowing purpose-built admin and analytics ergonomics.
Unlike `apps/main`, this app should not rely on normal browser-side Supabase access as its primary trust boundary. Privileged access should be enforced through backend-verified admin sessions and server-mediated data operations.
## Internal-Only Access Model
This app must be firewalled and internal only.
Access should be enforced in layers:
### Layer 1: Network / edge boundary
The app should be hosted behind an internal boundary rather than a public route. The preferred model is a private hostname or protected subdomain with an edge access control layer such as:
- Cloudflare Access
- VPN-only reachability
- IP allowlist for office/VPN egress
At least one network-level gate should exist before the app is reachable.
### Layer 2: Special admin access token
Reaching the app is not sufficient. Operators must also present a dedicated admin access token that is separate from normal product authentication.
Properties of this token:
- not the regular Supabase session token used by `apps/main`
- issued only to approved internal operators
- short-lived
- bound to operator identity and role claims
- verified server-side before the app shell can load protected data
Recommended model:
1. Operator reaches the private app endpoint
2. Operator enters or redeems a privileged access token
3. Backend verifies the token and exchanges it for a short-lived admin session
4. The app stores only the short-lived admin session artifacts needed for operation
The token exchange endpoint should be the only way to unlock privileged access. Public frontend credentials alone must never be enough to talk to admin-capable data paths.
Normal product users must not be able to access this app by logging in with standard app credentials alone.
### Layer 3: In-app roles
After the privileged token gate, the app should still tier capabilities:
- `viewer`: inspect data and dashboards
- `operator`: edit approved rows and use standard tools
- `superadmin`: use destructive or high-impact actions
### Layer 4: Action-specific friction
High-risk workflows should require stronger confirmation than read or low-risk edit paths:
- confirmation modals with exact record identity
- diff previews before sensitive changes
- explicit typing for destructive actions where appropriate
- bulk-action result summaries
- audit logging for every consequential mutation
## Product Modules
The app should have four top-level modules.
### 1. Operations Home
The landing screen is a command deck for operators, not a blank dashboard shell.
It should surface:
- key health cards
- recent operational anomalies
- recent signups and organizations
- billing or subscription exceptions
- pinned saved views
- quick links into frequent tables and admin actions
This page should optimize for “what needs attention now?” rather than for abstract BI storytelling.
### 2. Data Explorer
This is the primary workspace for day-to-day operations. It should behave like a polished spreadsheet-database hybrid.
Capabilities:
- table group navigation
- dense data grid with filtering, sorting, resizing, pinning, and saved views
- column selection and per-table presets
- row selection and bulk action support
- row detail drawer with linked records, history, and edit tools
- schema-aware editing for enums, booleans, dates, JSON, nullable fields, and foreign keys
Not every table should appear. Only approved tables defined in a registry should be exposed.
### 3. Analytics Studio
This is a semi-flexible analytics layer, not an open query workbench.
Capabilities:
- choose a curated dataset or view
- apply filters and date ranges
- pick dimensions, metrics, and grouping
- render charts and summary tables
- save charts into reusable dashboards
Supported visual shapes in v1 should be the practical set:
- KPI cards
- bar charts
- line charts
- stacked comparisons
- pie or donut only where the dataset justifies it
- tabular breakdowns
The focus is fast investigative charting over approved datasets, not raw BI authoring.
### 4. Action Center
This module isolates high-impact business operations from routine browsing.
Examples:
- repair a broken organization membership
- merge duplicate records
- trigger a re-sync or backfill
- re-run a workflow cleanup
- repair broken references after a partial failure
Each action should have:
- a typed input form
- explicit eligibility checks
- confirmation copy describing impact
- structured result output
- audit-log emission
## Data Access Architecture
Use three backend access layers with different trust levels.
All privileged data access should be mediated by backend-verified admin sessions. The app should not expose broad direct production table access purely through a public browser Supabase client.
### Read layer
Prefer curated Postgres views or RPC-backed read models for dashboards and analytics. This yields:
- stable schemas for charts
- human-readable labels
- better performance characteristics
- fewer accidental joins in the frontend
Direct table reads should still be modeled as approved explorer resources, but they should preferably flow through admin APIs or narrowly scoped RPC wrappers that enforce the privileged token and table registry. Analytics should primarily be dataset-driven.
### Edit layer
Use structured mutations for simple edits on approved tables and safe fields. These edits should be governed by a table registry that declares:
- table visibility
- editable columns
- field widgets
- validation rules
- read-only or computed fields
- related-record links
Sensitive tables or writes with cross-table invariants should not mutate directly from the browser. They should route through API endpoints or database functions with explicit validation, and even low-risk edits should pass through an admin-aware boundary instead of depending on the main product's client auth model.
### Action layer
High-impact admin workflows should live behind narrow APIs or RPCs with:
- validation
- authorization
- idempotency where needed
- audit logging
- structured success and failure payloads
This layer is how the app avoids solving every problem as a raw row edit.
## Registry-Driven Configuration
The app should be intentionally configured rather than schema-introspective by default.
Introduce three registries:
### Table registry
Defines:
- which tables are exposed
- labels and groupings
- visible columns
- editable columns
- per-column rendering rules
- row detail sections
- allowed bulk actions
### Dataset registry
Defines:
- which analytics datasets exist
- metrics and dimensions available
- supported filters
- supported chart types
- default aggregations
- dashboard-friendly naming
### Action registry
Defines:
- which admin actions exist
- required permissions
- input schemas
- confirmation copy
- expected result payloads
This registry approach is what makes the app feel curated and safe rather than like a generic database browser.
## Production Safety Model
This app operates on production from day one, so the interface should continuously communicate context without becoming noisy.
Recommended safety affordances:
- persistent production environment indicator
- visible operator identity in the shell
- visible “last modified by” and “last modified at” metadata in row details where possible
- change history in row drawers for tracked entities
- limited inline edits to low-risk fields
- full-form edits with diff preview for sensitive records
- destructive actions kept out of casual explorer flows
Bulk operations should never resolve to a single toast. Operators need a result screen that distinguishes:
- succeeded rows
- failed rows
- skipped rows
- next actions
## Visual Design Direction
The visual system should avoid generic white-label admin aesthetics.
Recommended direction:
- deep ink or navy structure colors
- warm neutrals for surfaces
- one disciplined accent family for actions and focus states
- crisp, expressive typography with clear density hierarchy
- subtle layered backgrounds and panel contrast rather than flat gray cards
- charts with strong contrast and restrained but deliberate color semantics
The overall tone should be “serious internal operations instrument”:
- information-dense
- elegant
- calm under pressure
- visually credible for long sessions
## Error Handling
Errors should be explicit and categorized so operators know whether to retry, escalate, or correct input.
Primary categories:
- validation errors
- concurrency conflicts
- permission failures
- system or connectivity failures
Patterns:
- field-level validation on forms
- diff conflict messaging when a row changed concurrently
- structured action results instead of generic error banners
- partial failure summaries for bulk operations
## Testing Strategy
Testing should mirror the architecture.
### Unit tests
- table, dataset, and action registries
- column renderers and formatter helpers
- chart config builders
- edit reducers and validation logic
### Component tests
- explorer grid behaviors
- row detail drawers
- edit forms
- action forms
- dashboard widgets
### Integration tests
- internal token gate
- role restrictions
- safe edit flows
- audit-log emission
- failure handling for partial bulk actions
### API or RPC tests
- every high-impact admin action
- guarded write paths
- authorization boundaries for privileged tokens
## Delivery Phases
### Phase 1: App shell and access control
- create `apps/admin`
- implement internal-only deployment boundary
- add privileged token gate
- establish app shell, navigation, and visual system
### Phase 2: Data Explorer foundation
- implement table registry
- ship explorer for a small set of core tables
- add row detail drawers and safe field editing
### Phase 3: Audit and guarded writes
- add audit logging
- add diff previews and sensitive edit flows
- add bulk action result handling
### Phase 4: Operations Home
- add curated operational cards
- add pinned saved views and shortcuts
### Phase 5: Analytics Studio
- implement dataset registry
- add chart builder for curated datasets
- add saved dashboards
### Phase 6: Action Center
- implement first high-value admin workflows
- add structured action results and stronger confirmation patterns
## Initial Implementation Recommendation
For v1, start with a deliberately small surface:
- 3 to 5 core tables in the explorer
- 2 to 3 dashboards
- 2 to 4 high-value admin actions
This keeps the first release useful without pretending the system is fully modeled from day one.
## Open Design Decisions For Planning
These should be resolved during implementation planning:
- exact privileged token issuance flow and storage model
- whether the edge boundary is Cloudflare Access, VPN-only reachability, or a hybrid
- initial list of exposed tables
- first analytics datasets to support
- first custom admin actions to build
- audit log storage location and retention model

View file

@ -14,11 +14,13 @@
"build:prod": "turbo build:prod --filter=@xtablo/main",
"dev": "turbo dev",
"dev:main": "turbo dev --filter=@xtablo/main",
"dev:admin": "turbo dev --filter=@xtablo/admin",
"dev:external": "turbo dev --filter=@xtablo/external",
"dev:clients": "turbo dev --filter=@xtablo/clients",
"dev:api": "turbo dev --filter=@xtablo/api",
"deploy:main:staging": "turbo deploy:staging --filter=@xtablo/main",
"deploy:main:prod": "turbo deploy:prod --filter=@xtablo/main",
"deploy:admin": "turbo deploy --filter=@xtablo/admin",
"deploy:chat": "turbo deploy --filter=@xtablo/chat-worker",
"deploy:external": "turbo deploy --filter=@xtablo/external",
"deploy:clients": "turbo deploy:prod --filter=@xtablo/clients",

View file

@ -0,0 +1,100 @@
export type AdminRole = "viewer" | "operator" | "superadmin";
export type AdminSessionResponse = {
expiresAt: string;
operatorEmail: string;
operatorId: string;
role: AdminRole;
sessionToken: string;
};
export type AdminSessionInfo = {
aud: string;
exp: number;
operatorEmail: string;
operatorId: string;
role: AdminRole;
};
export type AdminTableSummary = {
id: string;
label: string;
};
export type AdminTableColumn = {
id: string;
label: string;
};
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

@ -1,4 +1,26 @@
// ============================================================================
// Admin Types
// ============================================================================
export type {
AdminActionField,
AdminActionRunResponse,
AdminActionSummary,
AdminDatasetChartType,
AdminDatasetPoint,
AdminDatasetResult,
AdminDatasetSummary,
AdminOverviewAlert,
AdminOverviewMetric,
AdminOverviewResponse,
AdminOverviewShortcut,
AdminRole,
AdminSessionInfo,
AdminSessionResponse,
AdminTableColumn,
AdminTableMeta,
AdminTableSummary,
} from "./admin.types.js";
// ============================================================================
// Database Types
// ============================================================================
export type { Database, Json } from "./database.types.js";

View file

@ -27,6 +27,79 @@ importers:
specifier: ^5.7.0
version: 5.9.3
apps/admin:
dependencies:
'@tanstack/react-query':
specifier: ^5.69.0
version: 5.90.5(react@19.0.0)
'@xtablo/shared':
specifier: workspace:*
version: link:../../packages/shared
'@xtablo/shared-types':
specifier: workspace:*
version: link:../../packages/shared-types
'@xtablo/ui':
specifier: workspace:*
version: link:../../packages/ui
react:
specifier: 19.0.0
version: 19.0.0
react-dom:
specifier: 19.0.0
version: 19.0.0(react@19.0.0)
react-router-dom:
specifier: ^7.9.4
version: 7.9.4(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
devDependencies:
'@biomejs/biome':
specifier: 2.2.5
version: 2.2.5
'@cloudflare/vite-plugin':
specifier: ^1.9.4
version: 1.13.14(vite@6.4.1(@types/node@22.18.12)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.1)(tsx@4.20.6))(workerd@1.20251011.0)(wrangler@4.44.0(@cloudflare/workers-types@4.20260411.1))
'@tailwindcss/vite':
specifier: ^4.0.14
version: 4.1.15(vite@6.4.1(@types/node@22.18.12)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.1)(tsx@4.20.6))
'@testing-library/jest-dom':
specifier: ^6.6.3
version: 6.9.1
'@testing-library/react':
specifier: ^16.3.0
version: 16.3.0(@testing-library/dom@10.4.1)(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
'@types/react':
specifier: 19.0.10
version: 19.0.10
'@types/react-dom':
specifier: 19.0.4
version: 19.0.4(@types/react@19.0.10)
'@vitejs/plugin-react':
specifier: ^4.3.4
version: 4.7.0(vite@6.4.1(@types/node@22.18.12)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.1)(tsx@4.20.6))
happy-dom:
specifier: ^20.0.0
version: 20.0.7
tailwindcss:
specifier: ^4.0.14
version: 4.1.15
tw-animate-css:
specifier: ^1.4.0
version: 1.4.0
typescript:
specifier: ^5.7.0
version: 5.9.3
vite:
specifier: ^6.2.2
version: 6.4.1(@types/node@22.18.12)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.1)(tsx@4.20.6)
vite-tsconfig-paths:
specifier: ^5.1.4
version: 5.1.4(typescript@5.9.3)(vite@6.4.1(@types/node@22.18.12)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.1)(tsx@4.20.6))
vitest:
specifier: ^3.2.4
version: 3.2.4(@types/debug@4.1.12)(@types/node@22.18.12)(happy-dom@20.0.7)(jiti@2.6.1)(jsdom@20.0.3)(lightningcss@1.30.2)(terser@5.46.1)(tsx@4.20.6)
wrangler:
specifier: ^4.24.3
version: 4.44.0(@cloudflare/workers-types@4.20260411.1)
apps/api:
dependencies:
'@aws-sdk/client-s3':

View file

@ -0,0 +1,20 @@
/superpowers:brainstorming
You are going to rework the is_temporary feature that was brought earlier to the project. The idea is that a tablo can be accessed by the organization members,
as admin of the tablo, but also by temporary users that are invited to the tablo.
Although this is a good pitch, it brings up a lot of issues since a temporary user is a special user in our systems, and doesn't have the same privileges.
For instance, a temporary user is not forced to have a paid plan to be on xtablo. It also implies more complex permission checks in the backend api.
We are going to remove this type of user, and instead rework the invitations into magic links.
The idea is that the tablo has a magic link access, which is outside of the domain: currently it will be clients.xtablo.com. This allows the members to send an invite to the tablo via a magic link, and allow their
clients to access their portal at this url, and interact.
This means: create a new package called at apps/clients that is React-based, with a wrangler.toml since it will be a new worker on Cloudlfare. This package will share some code with apps/main since the tablo logic
is to be remimplemented there. This new website will not have the left navigation bar, and instead contain only the scoped view to the tablo.
Temporary users can stay for now, since we need to talk to our clients and let them upgrade to magic links before removing all temporary users.
With magic links, you still need to create a user that will be impersonated when accessing the link, so that discussions work.

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()
);

View file

@ -18,6 +18,11 @@
"cache": false,
"persistent": true
},
"deploy": {
"cache": false,
"dependsOn": ["build"],
"outputLogs": "new-only"
},
"lint": {
"inputs": ["src/**", "biome.json", "package.json"],
"outputLogs": "new-only"
@ -70,4 +75,3 @@
}
}
}