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) => ( + + ))} + + + + {rows.map((row, index) => ( + + {meta.columns.map((column) => ( + + ))} + + ))} + +
+ {column.label} +
+ {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 ( +
+
+ + +
+
+

{meta?.label ?? "Explorer"}

+

+ Approved production tables exposed through the internal admin registry. +

+
+ + {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 ( -
-
-

Operations Home

-
-
- ); + 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 // ============================================================================