diff --git a/docs/superpowers/plans/2026-04-24-supabase-admin-dashboard.md b/docs/superpowers/plans/2026-04-24-supabase-admin-dashboard.md new file mode 100644 index 0000000..aea8956 --- /dev/null +++ b/docs/superpowers/plans/2026-04-24-supabase-admin-dashboard.md @@ -0,0 +1,1009 @@ +# Supabase Admin Dashboard Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build an internal-only `apps/admin` operations console for production Supabase data with privileged token-gated access, safe row editing, curated analytics, and audited admin actions. + +**Architecture:** Add a standalone React app in `apps/admin`, but keep privileged trust on the server by introducing admin-only API routes, short-lived admin sessions, and registry-driven table, dataset, and action definitions. Deliver the product in slices: access foundation first, then explorer and auditability, then operations home, analytics, and custom actions. + +**Tech Stack:** React 19, Vite, React Router, TanStack Query, Tailwind, `@xtablo/ui`, Hono, Supabase, Zod, AG Grid, Recharts, Vitest, React Testing Library + +--- + +## File Map + +### New app + +- Create: `apps/admin/package.json` +- Create: `apps/admin/tsconfig.json` +- Create: `apps/admin/vite.config.ts` +- Create: `apps/admin/wrangler.toml` +- Create: `apps/admin/index.html` +- Create: `apps/admin/worker/index.ts` +- Create: `apps/admin/src/main.tsx` +- Create: `apps/admin/src/App.tsx` +- Create: `apps/admin/src/routes.tsx` +- Create: `apps/admin/src/main.css` +- Create: `apps/admin/src/lib/api.ts` +- Create: `apps/admin/src/lib/adminSession.ts` +- Create: `apps/admin/src/hooks/useAdminSession.ts` +- Create: `apps/admin/src/hooks/useAdminTables.ts` +- Create: `apps/admin/src/hooks/useAdminDatasets.ts` +- Create: `apps/admin/src/hooks/useAdminActions.ts` +- Create: `apps/admin/src/components/AdminLayout.tsx` +- Create: `apps/admin/src/components/AdminNavigation.tsx` +- Create: `apps/admin/src/components/ProductionBadge.tsx` +- Create: `apps/admin/src/components/PrivilegedGate.tsx` +- Create: `apps/admin/src/components/data-explorer/AdminGrid.tsx` +- Create: `apps/admin/src/components/data-explorer/RowDetailDrawer.tsx` +- Create: `apps/admin/src/components/data-explorer/RowEditForm.tsx` +- Create: `apps/admin/src/components/analytics/ChartBuilder.tsx` +- Create: `apps/admin/src/components/analytics/SavedDashboardList.tsx` +- Create: `apps/admin/src/components/actions/ActionRunner.tsx` +- Create: `apps/admin/src/pages/OperationsHomePage.tsx` +- Create: `apps/admin/src/pages/DataExplorerPage.tsx` +- Create: `apps/admin/src/pages/AnalyticsStudioPage.tsx` +- Create: `apps/admin/src/pages/ActionCenterPage.tsx` +- Create: `apps/admin/src/registry/tables.ts` +- Create: `apps/admin/src/registry/datasets.ts` +- Create: `apps/admin/src/registry/actions.ts` +- Test: `apps/admin/src/routes.test.tsx` +- Test: `apps/admin/src/components/PrivilegedGate.test.tsx` +- Test: `apps/admin/src/pages/DataExplorerPage.test.tsx` +- Test: `apps/admin/src/pages/AnalyticsStudioPage.test.tsx` +- Test: `apps/admin/src/pages/ActionCenterPage.test.tsx` + +### Shared types and docs + +- Create: `packages/shared-types/src/admin.types.ts` +- Modify: `packages/shared-types/src/index.ts` +- Create: `docs/ADMIN_APP_ACCESS_SETUP.md` + +### API and auth + +- Create: `apps/api/src/helpers/adminTokens.ts` +- Create: `apps/api/src/helpers/adminAudit.ts` +- Create: `apps/api/src/helpers/adminRegistry.ts` +- Create: `apps/api/src/routers/adminAuth.ts` +- Create: `apps/api/src/routers/adminTables.ts` +- Create: `apps/api/src/routers/adminDatasets.ts` +- Create: `apps/api/src/routers/adminActions.ts` +- Create: `apps/api/src/routers/admin.ts` +- Modify: `apps/api/src/config.ts` +- Modify: `apps/api/src/index.ts` +- Modify: `apps/api/src/routers/index.ts` +- Modify: `apps/api/src/middlewares/middleware.ts` +- Test: `apps/api/src/__tests__/routes/adminAuth.test.ts` +- Test: `apps/api/src/__tests__/routes/adminTables.test.ts` +- Test: `apps/api/src/__tests__/routes/adminDatasets.test.ts` +- Test: `apps/api/src/__tests__/routes/adminActions.test.ts` +- Test: `apps/api/src/__tests__/middlewares/adminAuth.test.ts` + +### Database + +- Create: `supabase/migrations/20260424110000_create_admin_audit_log.sql` +- Create: `supabase/migrations/20260424111000_create_admin_dataset_views.sql` + +### Workspace wiring + +- Modify: `package.json` +- Modify: `turbo.json` only if new task inputs or outputs are needed + +## Chunk 1: Access Foundation And App Scaffolding + +### Task 1: Scaffold `apps/admin` and workspace wiring + +**Files:** +- Create: `apps/admin/package.json` +- Create: `apps/admin/tsconfig.json` +- Create: `apps/admin/vite.config.ts` +- Create: `apps/admin/wrangler.toml` +- Create: `apps/admin/index.html` +- Create: `apps/admin/worker/index.ts` +- Create: `apps/admin/src/main.tsx` +- Create: `apps/admin/src/App.tsx` +- Create: `apps/admin/src/routes.tsx` +- Create: `apps/admin/src/main.css` +- Create: `apps/admin/src/routes.test.tsx` +- Modify: `package.json` + +- [ ] **Step 1: Write the failing route smoke test** + +```tsx +import { MemoryRouter } from "react-router-dom"; +import { render, screen } from "@testing-library/react"; +import AppRoutes from "./routes"; + +it("renders the privileged gate on the root route", () => { + render( + + + + ); + + expect(screen.getByText(/admin access token/i)).toBeInTheDocument(); +}); +``` + +- [ ] **Step 2: Run the new app test and verify it fails** + +Run: `pnpm --filter @xtablo/admin test -- src/routes.test.tsx` +Expected: FAIL because `@xtablo/admin` and `src/routes.tsx` do not exist yet + +- [ ] **Step 3: Copy the standalone app structure from `apps/clients`** + +Create a minimal app shell modeled on: + +- `apps/clients/package.json` +- `apps/clients/vite.config.ts` +- `apps/clients/wrangler.toml` +- `apps/clients/src/main.tsx` +- `apps/clients/src/App.tsx` + +Use: + +- package name `@xtablo/admin` +- dev port `5176` +- Cloudflare worker name `xtablo-admin` +- private hostname route placeholders in `wrangler.toml` + +- [ ] **Step 4: Add root workspace scripts** + +Add scripts to `package.json`: + +```json +{ + "dev:admin": "turbo dev --filter=@xtablo/admin", + "deploy:admin": "turbo deploy --filter=@xtablo/admin" +} +``` + +- [ ] **Step 5: Add a minimal gate screen so the test passes** + +Stub the root route to render: + +```tsx +export function PrivilegedGatePlaceholder() { + return
Admin access token required
; +} +``` + +- [ ] **Step 6: Re-run the route smoke test** + +Run: `pnpm --filter @xtablo/admin test -- src/routes.test.tsx` +Expected: PASS + +- [ ] **Step 7: Verify the app typechecks** + +Run: `pnpm --filter @xtablo/admin typecheck` +Expected: PASS + +- [ ] **Step 8: Commit the scaffold** + +```bash +git add package.json apps/admin +git commit -m "feat(admin): scaffold internal admin app" +``` + +### Task 2: Add privileged token exchange and admin session verification in the API + +**Files:** +- Create: `apps/api/src/helpers/adminTokens.ts` +- Create: `apps/api/src/routers/adminAuth.ts` +- Create: `apps/api/src/routers/admin.ts` +- Create: `apps/api/src/__tests__/routes/adminAuth.test.ts` +- Create: `apps/api/src/__tests__/middlewares/adminAuth.test.ts` +- Modify: `apps/api/src/config.ts` +- Modify: `apps/api/src/index.ts` +- Modify: `apps/api/src/routers/index.ts` +- Modify: `apps/api/src/middlewares/middleware.ts` +- Create: `packages/shared-types/src/admin.types.ts` +- Modify: `packages/shared-types/src/index.ts` + +- [ ] **Step 1: Write failing tests for token exchange and protected-session rejection** + +```ts +it("rejects requests without a valid privileged token", async () => { + const res = await app.request("/api/v1/admin/auth/exchange", { + method: "POST", + body: JSON.stringify({ accessToken: "bad-token" }), + headers: { "Content-Type": "application/json" }, + }); + + expect(res.status).toBe(401); +}); + +it("rejects admin routes without an admin session", async () => { + const res = await app.request("/api/v1/admin/tables/profiles"); + expect(res.status).toBe(401); +}); +``` + +- [ ] **Step 2: Run the targeted API tests and verify failure** + +Run: `pnpm --filter @xtablo/api test -- src/__tests__/routes/adminAuth.test.ts src/__tests__/middlewares/adminAuth.test.ts` +Expected: FAIL because the admin auth router and middleware do not exist + +- [ ] **Step 3: Extend config for privileged token validation** + +Add to `apps/api/src/config.ts`: + +```ts +ADMIN_TOKEN_SIGNING_SECRET: string; +ADMIN_TOKEN_AUDIENCE: string; +ADMIN_APP_URL: string; +``` + +Load them from env or secrets and fail fast if missing outside test mode. + +- [ ] **Step 4: Add admin token helper and session payload types** + +In `apps/api/src/helpers/adminTokens.ts`, implement helpers shaped like: + +```ts +export type AdminSessionClaims = { + sub: string; + role: "viewer" | "operator" | "superadmin"; + aud: string; + exp: number; +}; + +export async function exchangePrivilegedToken(input: string, config: AppConfig) { + // verify privileged token, then issue short-lived admin session +} + +export async function verifyAdminSession(token: string, config: AppConfig) { + // decode and validate admin session claims +} +``` + +- [ ] **Step 5: Add admin auth middleware** + +Extend `apps/api/src/middlewares/middleware.ts` with a dedicated admin middleware that: + +- reads `Authorization: Bearer ` +- verifies session claims +- attaches `adminSession` to the Hono context +- never falls back to normal product auth + +- [ ] **Step 6: Add `/api/v1/admin/auth/exchange` and `/api/v1/admin/auth/session`** + +`adminAuth.ts` should expose: + +- `POST /auth/exchange` +- `GET /auth/session` +- `POST /auth/logout` + +Use `zod` request validation and structured error responses. + +- [ ] **Step 7: Register the new admin router** + +Mount it under `/api/v1/admin` without reusing the normal authenticated router chain. + +- [ ] **Step 8: Add shared admin types** + +Export common types like: + +```ts +export type AdminRole = "viewer" | "operator" | "superadmin"; + +export type AdminSessionResponse = { + sessionToken: string; + expiresAt: string; + role: AdminRole; + operatorEmail: string; +}; +``` + +- [ ] **Step 9: Re-run API tests** + +Run: `pnpm --filter @xtablo/api test -- src/__tests__/routes/adminAuth.test.ts src/__tests__/middlewares/adminAuth.test.ts` +Expected: PASS + +- [ ] **Step 10: Commit the access backend** + +```bash +git add apps/api/src/config.ts apps/api/src/index.ts apps/api/src/routers/index.ts apps/api/src/middlewares/middleware.ts apps/api/src/helpers/adminTokens.ts apps/api/src/routers/adminAuth.ts apps/api/src/routers/admin.ts apps/api/src/__tests__/routes/adminAuth.test.ts apps/api/src/__tests__/middlewares/adminAuth.test.ts packages/shared-types/src/admin.types.ts packages/shared-types/src/index.ts +git commit -m "feat(admin): add privileged admin session exchange" +``` + +### Task 3: Build the privileged gate and admin session client in `apps/admin` + +**Files:** +- Create: `apps/admin/src/lib/api.ts` +- Create: `apps/admin/src/lib/adminSession.ts` +- Create: `apps/admin/src/hooks/useAdminSession.ts` +- Create: `apps/admin/src/components/PrivilegedGate.tsx` +- Create: `apps/admin/src/components/PrivilegedGate.test.tsx` +- Modify: `apps/admin/src/routes.tsx` +- Modify: `apps/admin/src/main.tsx` + +- [ ] **Step 1: Write the failing component test for exchanging a privileged token** + +```tsx +it("exchanges a privileged token and enters the admin shell", async () => { + render(); + + await userEvent.type(screen.getByLabelText(/access token/i), "valid-token"); + await userEvent.click(screen.getByRole("button", { name: /unlock admin/i })); + + expect(await screen.findByText(/operations home/i)).toBeInTheDocument(); +}); +``` + +- [ ] **Step 2: Run the test to confirm it fails** + +Run: `pnpm --filter @xtablo/admin test -- src/components/PrivilegedGate.test.tsx` +Expected: FAIL because no session client or gate exists + +- [ ] **Step 3: Create an admin-only API client** + +Implement `apps/admin/src/lib/api.ts` with: + +```ts +export const adminApi = buildApi(import.meta.env.VITE_API_URL); + +adminApi.interceptors.request.use((config) => { + const token = getAdminSessionToken(); + if (token) config.headers.Authorization = `Bearer ${token}`; + return config; +}); +``` + +- [ ] **Step 4: Create local admin session storage helpers** + +In `apps/admin/src/lib/adminSession.ts`, keep the stored shape minimal: + +```ts +type StoredAdminSession = { + token: string; + expiresAt: string; + role: AdminRole; + operatorEmail: string; +}; +``` + +- [ ] **Step 5: Add `useAdminSession`** + +The hook should: + +- exchange privileged tokens +- fetch current session metadata +- clear expired sessions +- expose `isAuthenticated`, `role`, `operatorEmail`, `unlock`, and `logout` + +- [ ] **Step 6: Build the gate screen** + +`PrivilegedGate.tsx` should: + +- explain that normal Xtablo login is not sufficient +- accept the special token +- handle loading and invalid-token states +- redirect authenticated operators into the shell + +- [ ] **Step 7: Wire root routing through the gate** + +Root route behavior: + +- no admin session: gate +- active admin session: `AdminLayout` + +- [ ] **Step 8: Re-run admin frontend tests and typecheck** + +Run: `pnpm --filter @xtablo/admin test -- src/components/PrivilegedGate.test.tsx src/routes.test.tsx` +Expected: PASS + +Run: `pnpm --filter @xtablo/admin typecheck` +Expected: PASS + +- [ ] **Step 9: Commit the gate flow** + +```bash +git add apps/admin/src/lib/api.ts apps/admin/src/lib/adminSession.ts apps/admin/src/hooks/useAdminSession.ts apps/admin/src/components/PrivilegedGate.tsx apps/admin/src/components/PrivilegedGate.test.tsx apps/admin/src/routes.tsx apps/admin/src/main.tsx +git commit -m "feat(admin): add privileged token gate" +``` + +## Chunk 2: Explorer, Guarded Mutations, And Auditability + +### Task 4: Build the admin shell and visual system + +**Files:** +- Create: `apps/admin/src/components/AdminLayout.tsx` +- Create: `apps/admin/src/components/AdminNavigation.tsx` +- Create: `apps/admin/src/components/ProductionBadge.tsx` +- Create: `apps/admin/src/pages/OperationsHomePage.tsx` +- Modify: `apps/admin/src/App.tsx` +- Modify: `apps/admin/src/routes.tsx` +- Modify: `apps/admin/src/main.css` +- Test: `apps/admin/src/components/AdminLayout.test.tsx` + +- [ ] **Step 1: Write a failing shell test for navigation and production context** + +```tsx +it("shows the production badge and admin sections", () => { + render(); + + expect(screen.getByText(/production/i)).toBeInTheDocument(); + expect(screen.getByRole("link", { name: /data explorer/i })).toBeInTheDocument(); + expect(screen.getByRole("link", { name: /analytics studio/i })).toBeInTheDocument(); +}); +``` + +- [ ] **Step 2: Run the shell test and verify failure** + +Run: `pnpm --filter @xtablo/admin test -- src/components/AdminLayout.test.tsx` +Expected: FAIL because the admin shell does not exist + +- [ ] **Step 3: Build the navigation and shell** + +The shell should include: + +- top-level nav for `Operations Home`, `Data Explorer`, `Analytics Studio`, `Action Center` +- persistent operator identity +- prominent but tasteful production badge +- strong visual hierarchy using the design spec colors and spacing + +- [ ] **Step 4: Replace the placeholder route target with the real shell** + +Route structure: + +```tsx +}> + } /> + } /> + } /> + } /> + +``` + +- [ ] **Step 5: Re-run the shell test** + +Run: `pnpm --filter @xtablo/admin test -- src/components/AdminLayout.test.tsx` +Expected: PASS + +- [ ] **Step 6: Commit the shell** + +```bash +git add apps/admin/src/components/AdminLayout.tsx apps/admin/src/components/AdminNavigation.tsx apps/admin/src/components/ProductionBadge.tsx apps/admin/src/pages/OperationsHomePage.tsx apps/admin/src/App.tsx apps/admin/src/routes.tsx apps/admin/src/main.css +git commit -m "feat(admin): add admin shell and visual system" +``` + +### Task 5: Add registry-driven table exploration and approved read endpoints + +**Files:** +- Create: `apps/admin/src/registry/tables.ts` +- Create: `apps/admin/src/hooks/useAdminTables.ts` +- Create: `apps/admin/src/pages/DataExplorerPage.tsx` +- Create: `apps/admin/src/components/data-explorer/AdminGrid.tsx` +- Create: `apps/admin/src/components/data-explorer/RowDetailDrawer.tsx` +- Create: `apps/admin/src/pages/DataExplorerPage.test.tsx` +- Create: `apps/api/src/helpers/adminRegistry.ts` +- Create: `apps/api/src/routers/adminTables.ts` +- Create: `apps/api/src/__tests__/routes/adminTables.test.ts` + +- [ ] **Step 1: Write failing API tests for approved-table reads** + +```ts +it("returns table metadata for an approved table", async () => { + const res = await authedAdminRequest("/api/v1/admin/tables/profiles/meta"); + expect(res.status).toBe(200); +}); + +it("rejects a table that is not in the registry", async () => { + const res = await authedAdminRequest("/api/v1/admin/tables/secrets/rows"); + expect(res.status).toBe(404); +}); +``` + +- [ ] **Step 2: Write a failing frontend test for switching tables** + +```tsx +it("loads rows for the selected table", async () => { + render(); + + await userEvent.click(screen.getByRole("button", { name: /profiles/i })); + + expect(await screen.findByText(/email/i)).toBeInTheDocument(); +}); +``` + +- [ ] **Step 3: Run API and frontend tests to verify failure** + +Run: `pnpm --filter @xtablo/api test -- src/__tests__/routes/adminTables.test.ts` +Expected: FAIL + +Run: `pnpm --filter @xtablo/admin test -- src/pages/DataExplorerPage.test.tsx` +Expected: FAIL + +- [ ] **Step 4: Define the table registry** + +Seed the registry with 3 to 5 core resources only: + +```ts +export const adminTables = { + profiles: { + label: "Users", + source: "profiles", + editableColumns: ["first_name", "last_name", "plan"], + }, + organizations: { + label: "Organizations", + source: "organizations", + editableColumns: ["name"], + }, + tablo_access: { + label: "Tablo Access", + source: "tablo_access", + editableColumns: ["is_active", "is_admin"], + }, +}; +``` + +- [ ] **Step 5: Add the admin tables API** + +Endpoints: + +- `GET /api/v1/admin/tables` +- `GET /api/v1/admin/tables/:tableId/meta` +- `GET /api/v1/admin/tables/:tableId/rows` +- `GET /api/v1/admin/tables/:tableId/rows/:rowId` + +Keep all queries registry-driven. Do not allow arbitrary table names. + +- [ ] **Step 6: Build the explorer UI** + +Implement: + +- left rail of table groups +- AG Grid-based row list +- row detail drawer with linked values and metadata +- saved filter state scoped per table + +- [ ] **Step 7: Re-run table API and explorer tests** + +Run: `pnpm --filter @xtablo/api test -- src/__tests__/routes/adminTables.test.ts` +Expected: PASS + +Run: `pnpm --filter @xtablo/admin test -- src/pages/DataExplorerPage.test.tsx` +Expected: PASS + +- [ ] **Step 8: Commit approved reads** + +```bash +git add apps/admin/src/registry/tables.ts apps/admin/src/hooks/useAdminTables.ts apps/admin/src/pages/DataExplorerPage.tsx apps/admin/src/components/data-explorer/AdminGrid.tsx apps/admin/src/components/data-explorer/RowDetailDrawer.tsx apps/admin/src/pages/DataExplorerPage.test.tsx apps/api/src/helpers/adminRegistry.ts apps/api/src/routers/adminTables.ts apps/api/src/__tests__/routes/adminTables.test.ts +git commit -m "feat(admin): add registry-driven data explorer reads" +``` + +### Task 6: Add guarded row updates and audit logging + +**Files:** +- Create: `supabase/migrations/20260424110000_create_admin_audit_log.sql` +- Create: `apps/api/src/helpers/adminAudit.ts` +- Modify: `apps/api/src/routers/adminTables.ts` +- Create: `apps/admin/src/components/data-explorer/RowEditForm.tsx` +- Modify: `apps/admin/src/components/data-explorer/RowDetailDrawer.tsx` +- Create: `apps/api/src/__tests__/routes/adminTableEdits.test.ts` +- Create: `apps/admin/src/components/data-explorer/RowEditForm.test.tsx` + +- [ ] **Step 1: Write failing tests for guarded edits and audit emission** + +```ts +it("writes an audit log entry for a successful update", async () => { + const res = await authedOperatorRequest("/api/v1/admin/tables/profiles/rows/user-1", { + method: "PATCH", + body: JSON.stringify({ first_name: "Ada" }), + }); + + expect(res.status).toBe(200); + expect(mockInsertAuditLog).toHaveBeenCalled(); +}); +``` + +```tsx +it("shows a diff preview before saving a sensitive record", async () => { + render(); + + await userEvent.type(screen.getByLabelText(/plan/i), "team"); + await userEvent.click(screen.getByRole("button", { name: /review changes/i })); + + expect(screen.getByText(/before/i)).toBeInTheDocument(); + expect(screen.getByText(/after/i)).toBeInTheDocument(); +}); +``` + +- [ ] **Step 2: Run the edit tests and verify failure** + +Run: `pnpm --filter @xtablo/api test -- src/__tests__/routes/adminTableEdits.test.ts` +Expected: FAIL + +Run: `pnpm --filter @xtablo/admin test -- src/components/data-explorer/RowEditForm.test.tsx` +Expected: FAIL + +- [ ] **Step 3: Add the audit log migration** + +Create a migration like: + +```sql +create table 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() +); +``` + +- [ ] **Step 4: Implement audit helper** + +In `apps/api/src/helpers/adminAudit.ts`: + +```ts +export async function recordAdminAuditLog(args: { + operatorId: string; + operatorEmail: string; + role: string; + action: string; + targetType: string; + targetId: string; + before?: unknown; + after?: unknown; +}) { + // insert into admin_audit_log +} +``` + +- [ ] **Step 5: Add PATCH support to `adminTables.ts`** + +Rules: + +- only registry-approved columns may change +- role-aware permissions enforce `viewer` vs `operator` vs `superadmin` +- sensitive fields return a diff payload before final confirmation +- every successful write records an audit entry + +- [ ] **Step 6: Add the row edit form and diff preview** + +Support typed editors for: + +- text +- booleans +- enums +- nullable values +- timestamps rendered read-only + +- [ ] **Step 7: Re-run edit tests** + +Run: `pnpm --filter @xtablo/api test -- src/__tests__/routes/adminTableEdits.test.ts` +Expected: PASS + +Run: `pnpm --filter @xtablo/admin test -- src/components/data-explorer/RowEditForm.test.tsx` +Expected: PASS + +- [ ] **Step 8: Commit guarded edits** + +```bash +git add supabase/migrations/20260424110000_create_admin_audit_log.sql apps/api/src/helpers/adminAudit.ts apps/api/src/routers/adminTables.ts apps/api/src/__tests__/routes/adminTableEdits.test.ts apps/admin/src/components/data-explorer/RowEditForm.tsx apps/admin/src/components/data-explorer/RowEditForm.test.tsx apps/admin/src/components/data-explorer/RowDetailDrawer.tsx +git commit -m "feat(admin): add guarded row edits with audit logging" +``` + +## Chunk 3: Operations Views, Analytics, Actions, And Rollout + +### Task 7: Build `Operations Home` with curated operational cards + +**Files:** +- Modify: `apps/admin/src/pages/OperationsHomePage.tsx` +- Create: `apps/api/src/routers/adminOverview.ts` +- Create: `apps/api/src/__tests__/routes/adminOverview.test.ts` +- Create: `apps/admin/src/pages/OperationsHomePage.test.tsx` +- Modify: `apps/api/src/routers/admin.ts` + +- [ ] **Step 1: Write failing tests for overview cards** + +```ts +it("returns overview sections for the operations home", async () => { + const res = await authedAdminRequest("/api/v1/admin/overview"); + expect(res.status).toBe(200); +}); +``` + +```tsx +it("renders anomaly and recent-signup cards", async () => { + render(); + expect(await screen.findByText(/recent signups/i)).toBeInTheDocument(); + expect(await screen.findByText(/operational anomalies/i)).toBeInTheDocument(); +}); +``` + +- [ ] **Step 2: Run the overview tests and confirm failure** + +Run: `pnpm --filter @xtablo/api test -- src/__tests__/routes/adminOverview.test.ts` +Expected: FAIL + +Run: `pnpm --filter @xtablo/admin test -- src/pages/OperationsHomePage.test.tsx` +Expected: FAIL + +- [ ] **Step 3: Add the overview endpoint** + +Return a small curated payload: + +- recent signups +- recent organizations +- subscription exceptions +- pinned saved explorer views +- anomaly counts + +- [ ] **Step 4: Build the home page cards** + +Make the first screen useful even if analytics is unfinished. + +- [ ] **Step 5: Re-run overview tests** + +Run: `pnpm --filter @xtablo/api test -- src/__tests__/routes/adminOverview.test.ts` +Expected: PASS + +Run: `pnpm --filter @xtablo/admin test -- src/pages/OperationsHomePage.test.tsx` +Expected: PASS + +- [ ] **Step 6: Commit operations home** + +```bash +git add apps/api/src/routers/adminOverview.ts apps/api/src/__tests__/routes/adminOverview.test.ts apps/api/src/routers/admin.ts apps/admin/src/pages/OperationsHomePage.tsx apps/admin/src/pages/OperationsHomePage.test.tsx +git commit -m "feat(admin): add operations home dashboard" +``` + +### Task 8: Add curated analytics datasets and dashboard building + +**Files:** +- Create: `supabase/migrations/20260424111000_create_admin_dataset_views.sql` +- Create: `apps/api/src/routers/adminDatasets.ts` +- Create: `apps/api/src/__tests__/routes/adminDatasets.test.ts` +- Create: `apps/admin/src/registry/datasets.ts` +- Create: `apps/admin/src/hooks/useAdminDatasets.ts` +- Create: `apps/admin/src/pages/AnalyticsStudioPage.tsx` +- Create: `apps/admin/src/components/analytics/ChartBuilder.tsx` +- Create: `apps/admin/src/components/analytics/SavedDashboardList.tsx` +- Create: `apps/admin/src/pages/AnalyticsStudioPage.test.tsx` + +- [ ] **Step 1: Write failing analytics dataset tests** + +```ts +it("lists curated datasets only", async () => { + const res = await authedAdminRequest("/api/v1/admin/datasets"); + expect(res.status).toBe(200); + expect(await res.json()).toMatchObject({ + datasets: expect.arrayContaining([expect.objectContaining({ id: "signups" })]), + }); +}); +``` + +```tsx +it("builds a chart from a curated dataset", async () => { + render(); + + await userEvent.selectOptions(screen.getByLabelText(/dataset/i), "signups"); + await userEvent.selectOptions(screen.getByLabelText(/metric/i), "count"); + + expect(await screen.findByRole("img", { name: /signups chart/i })).toBeInTheDocument(); +}); +``` + +- [ ] **Step 2: Run the analytics tests and confirm failure** + +Run: `pnpm --filter @xtablo/api test -- src/__tests__/routes/adminDatasets.test.ts` +Expected: FAIL + +Run: `pnpm --filter @xtablo/admin test -- src/pages/AnalyticsStudioPage.test.tsx` +Expected: FAIL + +- [ ] **Step 3: Create dataset views** + +Back them with curated views such as: + +- `admin_signups_daily` +- `admin_organizations_daily` +- `admin_subscription_exceptions` + +- [ ] **Step 4: Add dataset registry and API** + +Expose: + +- `GET /api/v1/admin/datasets` +- `GET /api/v1/admin/datasets/:datasetId/query` + +Only allow registry-defined dimensions, metrics, and grains. + +- [ ] **Step 5: Build Analytics Studio** + +Implement: + +- dataset picker +- metric and dimension controls +- date-range filters +- chart type picker +- saved dashboard list + +Use `recharts` and accessible titles for chart containers. + +- [ ] **Step 6: Re-run analytics tests** + +Run: `pnpm --filter @xtablo/api test -- src/__tests__/routes/adminDatasets.test.ts` +Expected: PASS + +Run: `pnpm --filter @xtablo/admin test -- src/pages/AnalyticsStudioPage.test.tsx` +Expected: PASS + +- [ ] **Step 7: Commit analytics** + +```bash +git add supabase/migrations/20260424111000_create_admin_dataset_views.sql apps/api/src/routers/adminDatasets.ts apps/api/src/__tests__/routes/adminDatasets.test.ts apps/admin/src/registry/datasets.ts apps/admin/src/hooks/useAdminDatasets.ts apps/admin/src/pages/AnalyticsStudioPage.tsx apps/admin/src/components/analytics/ChartBuilder.tsx apps/admin/src/components/analytics/SavedDashboardList.tsx apps/admin/src/pages/AnalyticsStudioPage.test.tsx +git commit -m "feat(admin): add curated analytics studio" +``` + +### Task 9: Add `Action Center` and first custom admin workflows + +**Files:** +- Create: `apps/admin/src/registry/actions.ts` +- Create: `apps/admin/src/hooks/useAdminActions.ts` +- Create: `apps/admin/src/pages/ActionCenterPage.tsx` +- Create: `apps/admin/src/components/actions/ActionRunner.tsx` +- Create: `apps/admin/src/pages/ActionCenterPage.test.tsx` +- Create: `apps/api/src/routers/adminActions.ts` +- Create: `apps/api/src/__tests__/routes/adminActions.test.ts` +- Modify: `apps/api/src/routers/admin.ts` + +- [ ] **Step 1: Write failing tests for action discovery and execution** + +```ts +it("executes a registry-defined admin action", async () => { + const res = await authedSuperadminRequest("/api/v1/admin/actions/repair-membership", { + method: "POST", + body: JSON.stringify({ userId: "user-1", organizationId: "org-1" }), + }); + + expect(res.status).toBe(200); +}); +``` + +```tsx +it("shows structured action results", async () => { + render(); + await userEvent.click(screen.getByRole("button", { name: /repair membership/i })); + expect(await screen.findByText(/result/i)).toBeInTheDocument(); +}); +``` + +- [ ] **Step 2: Run the action tests and confirm failure** + +Run: `pnpm --filter @xtablo/api test -- src/__tests__/routes/adminActions.test.ts` +Expected: FAIL + +Run: `pnpm --filter @xtablo/admin test -- src/pages/ActionCenterPage.test.tsx` +Expected: FAIL + +- [ ] **Step 3: Define the action registry** + +Start with 2 to 4 actions only: + +- `repair-membership` +- `resync-subscription` +- `merge-duplicate-profile` + +Each entry must define: + +- role requirement +- form schema +- confirmation copy +- handler route + +- [ ] **Step 4: Add admin action endpoints** + +Pattern: + +```ts +adminActions.post("/:actionId", async (c) => { + // load registry entry + // validate input + // execute handler + // write audit log + // return structured result +}); +``` + +- [ ] **Step 5: Build the Action Center UI** + +Include: + +- action catalog +- permission-aware disabled states +- typed form rendering +- confirmation step +- structured success and partial-failure results + +- [ ] **Step 6: Re-run action tests** + +Run: `pnpm --filter @xtablo/api test -- src/__tests__/routes/adminActions.test.ts` +Expected: PASS + +Run: `pnpm --filter @xtablo/admin test -- src/pages/ActionCenterPage.test.tsx` +Expected: PASS + +- [ ] **Step 7: Commit custom actions** + +```bash +git add apps/admin/src/registry/actions.ts apps/admin/src/hooks/useAdminActions.ts apps/admin/src/pages/ActionCenterPage.tsx apps/admin/src/components/actions/ActionRunner.tsx apps/admin/src/pages/ActionCenterPage.test.tsx apps/api/src/routers/adminActions.ts apps/api/src/__tests__/routes/adminActions.test.ts apps/api/src/routers/admin.ts +git commit -m "feat(admin): add audited action center workflows" +``` + +### Task 10: Deployment hardening, access docs, and final verification + +**Files:** +- Create: `docs/ADMIN_APP_ACCESS_SETUP.md` +- Modify: `apps/admin/wrangler.toml` +- Modify: `apps/api/src/config.ts` if any env names changed during implementation + +- [ ] **Step 1: Write the failing docs checklist** + +Create a checklist in `docs/ADMIN_APP_ACCESS_SETUP.md` covering: + +- private hostname or protected subdomain +- Cloudflare Access or VPN requirements +- privileged token issuance and rotation +- required env vars +- rollback path + +- [ ] **Step 2: Add private-route deployment details** + +Document placeholders like: + +```toml +[env.production] +route = { pattern = "admin.internal.xtablo.com", custom_domain = true } +``` + +Also document that public DNS alone is not sufficient; edge access policy must be enabled. + +- [ ] **Step 3: Run focused verification** + +Run: + +```bash +pnpm --filter @xtablo/api test -- src/__tests__/routes/adminAuth.test.ts src/__tests__/routes/adminTables.test.ts src/__tests__/routes/adminDatasets.test.ts src/__tests__/routes/adminActions.test.ts +pnpm --filter @xtablo/admin test -- src/routes.test.tsx src/components/PrivilegedGate.test.tsx src/pages/DataExplorerPage.test.tsx src/pages/AnalyticsStudioPage.test.tsx src/pages/ActionCenterPage.test.tsx +pnpm --filter @xtablo/api typecheck +pnpm --filter @xtablo/admin typecheck +``` + +Expected: all PASS + +- [ ] **Step 4: Run broader workspace checks before merge** + +Run: + +```bash +pnpm lint +pnpm typecheck +``` + +Expected: PASS, or only pre-existing unrelated failures clearly identified + +- [ ] **Step 5: Commit docs and hardening** + +```bash +git add docs/ADMIN_APP_ACCESS_SETUP.md apps/admin/wrangler.toml apps/api/src/config.ts +git commit -m "docs(admin): add deployment and access runbook" +``` + +## Notes For Execution + +- Do not bypass the privileged token model by reusing `apps/main/src/lib/supabase.ts` directly in the admin app. +- Keep the initial registry intentionally small; expand after the guarded end-to-end slice is working. +- Prefer API-mediated reads and writes for admin resources even where direct browser Supabase access would be faster to code. +- When a task touches both API and frontend, land the contract tests first, then the server, then the UI. +- If the private edge boundary cannot be fully provisioned in code, keep the repo changes ready and document the exact manual platform steps in `docs/ADMIN_APP_ACCESS_SETUP.md`. + +Plan complete and saved to `docs/superpowers/plans/2026-04-24-supabase-admin-dashboard.md`. Ready to execute?