feat(admin): add explorer shell and registry reads

This commit is contained in:
Arthur Belleville 2026-04-24 15:39:03 +02:00
parent 49cdd3b755
commit 4f71c52e14
No known key found for this signature in database
20 changed files with 635 additions and 14 deletions

View file

@ -0,0 +1,33 @@
import { render, screen } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { beforeEach, describe, expect, it } from "vitest";
import AppRoutes from "../routes";
import { storeAdminSession } from "../lib/adminSession";
describe("AdminLayout", () => {
beforeEach(() => {
localStorage.clear();
storeAdminSession({
expiresAt: new Date(Date.now() + 15 * 60 * 1000).toISOString(),
operatorEmail: "ops@xtablo.com",
operatorId: "operator-1",
role: "operator",
sessionToken: "admin-session-token",
});
});
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: /operations home/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();
});
});

View file

@ -0,0 +1,29 @@
import { Outlet } from "react-router-dom";
import { useAdminSession } from "../hooks/useAdminSession";
import { AdminNavigation } from "./AdminNavigation";
import { ProductionBadge } from "./ProductionBadge";
export function AdminLayout() {
const { 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 />
</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

@ -44,6 +44,6 @@ describe("PrivilegedGate", () => {
});
});
expect(await screen.findByText(/operations home/i)).toBeInTheDocument();
expect(await screen.findByRole("heading", { name: /operations home/i })).toBeInTheDocument();
});
});

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,39 @@
import type { AdminTableMeta } from "@xtablo/shared-types";
type AdminGridProps = {
meta: AdminTableMeta | null;
rows: Record<string, string | boolean | null>[];
};
export function AdminGrid({ meta, rows }: 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="border-b border-border/60 last:border-b-0" key={`${row.id ?? index}`}>
{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,94 @@
import type { AdminTableMeta, AdminTableSummary } from "@xtablo/shared-types";
import { useEffect, useState } from "react";
import { adminApi } from "../lib/api";
type AdminRow = Record<string, string | boolean | null>;
export 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]);
return {
error,
isLoading,
meta,
rows,
selectedTableId,
setSelectedTableId,
tables,
};
}

View file

@ -1,5 +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;
});

View file

@ -0,0 +1,11 @@
export function ActionCenterPage() {
return (
<div className="space-y-4">
<p className="text-xs uppercase tracking-[0.25em] text-foreground/55">Actions</p>
<h1 className="text-3xl font-semibold">Action Center</h1>
<p className="text-sm text-foreground/70">
High-impact repair and resync workflows will run from this controlled surface.
</p>
</div>
);
}

View file

@ -0,0 +1,11 @@
export function AnalyticsStudioPage() {
return (
<div className="space-y-4">
<p className="text-xs uppercase tracking-[0.25em] text-foreground/55">Analytics</p>
<h1 className="text-3xl font-semibold">Analytics Studio</h1>
<p className="text-sm text-foreground/70">
Curated operational datasets and chart building land here next.
</p>
</div>
);
}

View file

@ -0,0 +1,71 @@
import { render, screen } 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(),
},
}));
describe("DataExplorerPage", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("loads rows for the selected table", 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: "email", label: "Email" },
{ id: "first_name", label: "First name" },
],
id: "profiles",
label: "Users",
},
};
}
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}`);
});
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();
});
});

View file

@ -0,0 +1,43 @@
import { AdminGrid } from "../components/data-explorer/AdminGrid";
import { useAdminTables } from "../hooks/useAdminTables";
export function DataExplorerPage() {
const { error, isLoading, meta, rows, selectedTableId, setSelectedTableId, tables } =
useAdminTables();
return (
<main className="min-h-screen p-6">
<div className="grid gap-6 lg:grid-cols-[220px_minmax(0,1fr)]">
<aside className="rounded-3xl border border-border bg-card p-4">
<p className="text-xs uppercase tracking-[0.25em] text-foreground/60">Data Explorer</p>
<div className="mt-4 flex flex-col gap-2">
{tables.map((table) => (
<button
className="rounded-2xl border border-border px-3 py-2 text-left text-sm"
key={table.id}
onClick={() => setSelectedTableId(table.id)}
type="button"
>
{table.label}
{selectedTableId === table.id ? " *" : ""}
</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} rows={rows} /> : null}
</section>
</div>
</main>
);
}

View file

@ -0,0 +1,13 @@
export function OperationsHomePage() {
return (
<div className="space-y-4">
<div>
<p className="text-xs uppercase tracking-[0.25em] text-foreground/55">Operations</p>
<h1 className="mt-3 text-3xl font-semibold">Operations Home</h1>
</div>
<p className="text-sm text-foreground/70">
Internal production oversight, anomaly checks, and shortcuts into the admin workflows.
</p>
</div>
);
}

View file

@ -1,4 +1,9 @@
import { Route, Routes } from "react-router-dom";
import { Outlet, Route, Routes } from "react-router-dom";
import { ActionCenterPage } from "./pages/ActionCenterPage";
import { AnalyticsStudioPage } from "./pages/AnalyticsStudioPage";
import { DataExplorerPage } from "./pages/DataExplorerPage";
import { OperationsHomePage } from "./pages/OperationsHomePage";
import { AdminLayout } from "./components/AdminLayout";
import { PrivilegedGate } from "./components/PrivilegedGate";
import { useAdminSession } from "./hooks/useAdminSession";
@ -6,13 +11,7 @@ function AdminEntry() {
const { error, isAuthenticated, isPending, unlock } = useAdminSession();
if (isAuthenticated) {
return (
<main className="flex min-h-screen items-center justify-center p-6">
<div className="rounded-3xl border border-border bg-card p-10 shadow-sm">
<p className="text-lg font-semibold">Operations Home</p>
</div>
</main>
);
return <Outlet />;
}
return <PrivilegedGate error={error} isPending={isPending} onUnlock={unlock} />;
@ -21,7 +20,14 @@ function AdminEntry() {
export default function AppRoutes() {
return (
<Routes>
<Route path="*" element={<AdminEntry />} />
<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,98 @@
import { describe, expect, it } from "vitest";
import { createSignedAdminToken } from "../helpers/adminTokenTestUtils.js";
import { createConfig } from "../../config.js";
import { MiddlewareManager } from "../../middlewares/middleware.js";
import { getMainRouter } from "../../routers/index.js";
const ADMIN_TOKEN_SIGNING_SECRET = "admin-test-secret";
const ADMIN_TOKEN_AUDIENCE = "xtablo-admin";
describe("Admin 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",
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

@ -0,0 +1,53 @@
import type { Database } from "@xtablo/shared-types";
type AdminTableColumn = {
id: string;
label: string;
};
type AdminTableDefinition = {
columns: AdminTableColumn[];
id: string;
label: 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" },
],
id: "profiles",
label: "Users",
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" },
],
id: "tablo_access",
label: "Tablo Access",
select: "tablo_id,user_id,is_active,is_admin",
source: "tablo_access",
},
};
export function getAdminTableDefinition(tableId: string) {
return adminTableRegistry[tableId] ?? null;
}
export function listAdminTables() {
return Object.values(adminTableRegistry).map(({ id, label }) => ({ id, label }));
}
export function normalizeAdminRows(rows: unknown[]) {
return rows as Record<string, unknown>[];
}

View file

@ -11,6 +11,7 @@ export const getAdminRouter = (config: AppConfig) => {
adminRouter.route("/auth", getAdminAuthRouter(config));
adminRouter.use("/tables", middlewareManager.adminAuth);
adminRouter.use("/tables/*", middlewareManager.adminAuth);
adminRouter.route("/tables", getAdminTablesRouter());

View file

@ -1,19 +1,67 @@
import { Hono } from "hono";
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("/:tableId", async (c) => {
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(
{
error: `Admin table '${tableId}' is not implemented yet`,
columns: tableDefinition.columns,
id: tableDefinition.id,
label: tableDefinition.label,
},
501
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);
});
return adminTablesRouter;
};

View file

@ -15,3 +15,19 @@ export type AdminSessionInfo = {
operatorId: string;
role: AdminRole;
};
export type AdminTableSummary = {
id: string;
label: string;
};
export type AdminTableColumn = {
id: string;
label: string;
};
export type AdminTableMeta = {
columns: AdminTableColumn[];
id: string;
label: string;
};

View file

@ -1,7 +1,14 @@
// ============================================================================
// Admin Types
// ============================================================================
export type { AdminRole, AdminSessionInfo, AdminSessionResponse } from "./admin.types.js";
export type {
AdminRole,
AdminSessionInfo,
AdminSessionResponse,
AdminTableColumn,
AdminTableMeta,
AdminTableSummary,
} from "./admin.types.js";
// ============================================================================
// Database Types
// ============================================================================