diff --git a/apps/admin/src/components/AdminLayout.test.tsx b/apps/admin/src/components/AdminLayout.test.tsx
new file mode 100644
index 0000000..dc014f8
--- /dev/null
+++ b/apps/admin/src/components/AdminLayout.test.tsx
@@ -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(
+
+
+
+ );
+
+ 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();
+ });
+});
diff --git a/apps/admin/src/components/AdminLayout.tsx b/apps/admin/src/components/AdminLayout.tsx
new file mode 100644
index 0000000..4133974
--- /dev/null
+++ b/apps/admin/src/components/AdminLayout.tsx
@@ -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 (
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/admin/src/components/AdminNavigation.tsx b/apps/admin/src/components/AdminNavigation.tsx
new file mode 100644
index 0000000..c004fe8
--- /dev/null
+++ b/apps/admin/src/components/AdminNavigation.tsx
@@ -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 (
+
+ );
+}
diff --git a/apps/admin/src/components/PrivilegedGate.test.tsx b/apps/admin/src/components/PrivilegedGate.test.tsx
index cdd72a5..04aa323 100644
--- a/apps/admin/src/components/PrivilegedGate.test.tsx
+++ b/apps/admin/src/components/PrivilegedGate.test.tsx
@@ -44,6 +44,6 @@ describe("PrivilegedGate", () => {
});
});
- expect(await screen.findByText(/operations home/i)).toBeInTheDocument();
+ expect(await screen.findByRole("heading", { name: /operations home/i })).toBeInTheDocument();
});
});
diff --git a/apps/admin/src/components/ProductionBadge.tsx b/apps/admin/src/components/ProductionBadge.tsx
new file mode 100644
index 0000000..7583812
--- /dev/null
+++ b/apps/admin/src/components/ProductionBadge.tsx
@@ -0,0 +1,7 @@
+export function ProductionBadge() {
+ return (
+
+ Production
+
+ );
+}
diff --git a/apps/admin/src/components/data-explorer/AdminGrid.tsx b/apps/admin/src/components/data-explorer/AdminGrid.tsx
new file mode 100644
index 0000000..9c8f46e
--- /dev/null
+++ b/apps/admin/src/components/data-explorer/AdminGrid.tsx
@@ -0,0 +1,39 @@
+import type { AdminTableMeta } from "@xtablo/shared-types";
+
+type AdminGridProps = {
+ meta: AdminTableMeta | null;
+ rows: Record[];
+};
+
+export function AdminGrid({ meta, rows }: AdminGridProps) {
+ if (!meta) {
+ return null;
+ }
+
+ return (
+
+
+
+
+ {meta.columns.map((column) => (
+ |
+ {column.label}
+ |
+ ))}
+
+
+
+ {rows.map((row, index) => (
+
+ {meta.columns.map((column) => (
+ |
+ {String(row[column.id] ?? "")}
+ |
+ ))}
+
+ ))}
+
+
+
+ );
+}
diff --git a/apps/admin/src/hooks/useAdminTables.ts b/apps/admin/src/hooks/useAdminTables.ts
new file mode 100644
index 0000000..cf21114
--- /dev/null
+++ b/apps/admin/src/hooks/useAdminTables.ts
@@ -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;
+
+export function useAdminTables() {
+ const [tables, setTables] = useState([]);
+ const [selectedTableId, setSelectedTableId] = useState(null);
+ const [meta, setMeta] = useState(null);
+ const [rows, setRows] = useState([]);
+ const [isLoading, setIsLoading] = useState(true);
+ const [error, setError] = useState(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(`/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,
+ };
+}
diff --git a/apps/admin/src/lib/api.ts b/apps/admin/src/lib/api.ts
index b562524..7db3ca1 100644
--- a/apps/admin/src/lib/api.ts
+++ b/apps/admin/src/lib/api.ts
@@ -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;
+});
diff --git a/apps/admin/src/pages/ActionCenterPage.tsx b/apps/admin/src/pages/ActionCenterPage.tsx
new file mode 100644
index 0000000..19107ca
--- /dev/null
+++ b/apps/admin/src/pages/ActionCenterPage.tsx
@@ -0,0 +1,11 @@
+export function ActionCenterPage() {
+ return (
+
+
Actions
+
Action Center
+
+ High-impact repair and resync workflows will run from this controlled surface.
+
+
+ );
+}
diff --git a/apps/admin/src/pages/AnalyticsStudioPage.tsx b/apps/admin/src/pages/AnalyticsStudioPage.tsx
new file mode 100644
index 0000000..7ef381a
--- /dev/null
+++ b/apps/admin/src/pages/AnalyticsStudioPage.tsx
@@ -0,0 +1,11 @@
+export function AnalyticsStudioPage() {
+ return (
+
+
Analytics
+
Analytics Studio
+
+ Curated operational datasets and chart building land here next.
+
+
+ );
+}
diff --git a/apps/admin/src/pages/DataExplorerPage.test.tsx b/apps/admin/src/pages/DataExplorerPage.test.tsx
new file mode 100644
index 0000000..0337663
--- /dev/null
+++ b/apps/admin/src/pages/DataExplorerPage.test.tsx
@@ -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(
+
+
+
+ );
+
+ 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();
+ });
+});
diff --git a/apps/admin/src/pages/DataExplorerPage.tsx b/apps/admin/src/pages/DataExplorerPage.tsx
new file mode 100644
index 0000000..d928f12
--- /dev/null
+++ b/apps/admin/src/pages/DataExplorerPage.tsx
@@ -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 (
+
+
+
+
+
+
+
+ {isLoading ? Loading explorer...
: null}
+ {error ? {error}
: null}
+ {!isLoading && !error ? : null}
+
+
+
+ );
+}
diff --git a/apps/admin/src/pages/OperationsHomePage.tsx b/apps/admin/src/pages/OperationsHomePage.tsx
new file mode 100644
index 0000000..8caeb92
--- /dev/null
+++ b/apps/admin/src/pages/OperationsHomePage.tsx
@@ -0,0 +1,13 @@
+export function OperationsHomePage() {
+ return (
+
+
+
Operations
+
Operations Home
+
+
+ Internal production oversight, anomaly checks, and shortcuts into the admin workflows.
+
+
+ );
+}
diff --git a/apps/admin/src/routes.tsx b/apps/admin/src/routes.tsx
index a37f488..b637b3b 100644
--- a/apps/admin/src/routes.tsx
+++ b/apps/admin/src/routes.tsx
@@ -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 (
-
-
-
- );
+ return ;
}
return ;
@@ -21,7 +20,14 @@ function AdminEntry() {
export default function AppRoutes() {
return (
- } />
+ }>
+ }>
+ } />
+ } />
+ } />
+ } />
+
+
);
}
diff --git a/apps/api/src/__tests__/routes/adminTables.test.ts b/apps/api/src/__tests__/routes/adminTables.test.ts
new file mode 100644
index 0000000..f3d7603
--- /dev/null
+++ b/apps/api/src/__tests__/routes/adminTables.test.ts
@@ -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",
+ });
+ });
+});
diff --git a/apps/api/src/helpers/adminRegistry.ts b/apps/api/src/helpers/adminRegistry.ts
new file mode 100644
index 0000000..bf2b52f
--- /dev/null
+++ b/apps/api/src/helpers/adminRegistry.ts
@@ -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 = {
+ 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[];
+}
diff --git a/apps/api/src/routers/admin.ts b/apps/api/src/routers/admin.ts
index 1eb8f8b..c1eb040 100644
--- a/apps/api/src/routers/admin.ts
+++ b/apps/api/src/routers/admin.ts
@@ -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());
diff --git a/apps/api/src/routers/adminTables.ts b/apps/api/src/routers/adminTables.ts
index 29709f0..02ec6ea 100644
--- a/apps/api/src/routers/adminTables.ts
+++ b/apps/api/src/routers/adminTables.ts
@@ -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();
- 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;
};
diff --git a/packages/shared-types/src/admin.types.ts b/packages/shared-types/src/admin.types.ts
index 1a8859b..2859ec8 100644
--- a/packages/shared-types/src/admin.types.ts
+++ b/packages/shared-types/src/admin.types.ts
@@ -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;
+};
diff --git a/packages/shared-types/src/index.ts b/packages/shared-types/src/index.ts
index 78db254..11a7446 100644
--- a/packages/shared-types/src/index.ts
+++ b/packages/shared-types/src/index.ts
@@ -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
// ============================================================================