From 85d44af57e06585e26dd1954cac2ba2cad529415 Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Fri, 24 Apr 2026 15:55:56 +0200 Subject: [PATCH] feat(admin): add dashboard explorer analytics and actions --- .../admin/src/components/AdminLayout.test.tsx | 21 ++- .../src/components/PrivilegedGate.test.tsx | 14 +- .../src/components/actions/ActionRunner.tsx | 133 ++++++++++++++ .../src/components/analytics/ChartBuilder.tsx | 173 ++++++++++++++++++ .../analytics/SavedDashboardList.tsx | 35 ++++ .../components/data-explorer/AdminGrid.tsx | 12 +- .../data-explorer/RowEditForm.test.tsx | 43 +++++ .../components/data-explorer/RowEditForm.tsx | 115 ++++++++++++ apps/admin/src/hooks/useAdminActions.ts | 98 ++++++++++ apps/admin/src/hooks/useAdminDatasets.ts | 105 +++++++++++ apps/admin/src/hooks/useAdminOverview.ts | 44 +++++ apps/admin/src/hooks/useAdminTables.ts | 53 +++++- .../admin/src/pages/ActionCenterPage.test.tsx | 69 +++++++ apps/admin/src/pages/ActionCenterPage.tsx | 48 ++++- .../src/pages/AnalyticsStudioPage.test.tsx | 87 +++++++++ apps/admin/src/pages/AnalyticsStudioPage.tsx | 39 +++- .../admin/src/pages/DataExplorerPage.test.tsx | 31 +++- apps/admin/src/pages/DataExplorerPage.tsx | 125 ++++++++++++- apps/admin/src/pages/OperationsHomePage.tsx | 95 +++++++++- apps/admin/src/registry/actions.ts | 10 + apps/admin/src/registry/datasets.ts | 20 ++ .../src/__tests__/routes/adminActions.test.ts | 64 +++++++ .../__tests__/routes/adminDatasets.test.ts | 64 +++++++ .../__tests__/routes/adminTableEdits.test.ts | 72 ++++++++ .../src/__tests__/routes/adminTables.test.ts | 2 + apps/api/src/helpers/adminAudit.ts | 42 +++++ apps/api/src/helpers/adminRegistry.ts | 93 ++++++++++ apps/api/src/routers/admin.ts | 13 ++ apps/api/src/routers/adminActions.ts | 109 +++++++++++ apps/api/src/routers/adminDatasets.ts | 155 ++++++++++++++++ apps/api/src/routers/adminOverview.ts | 144 +++++++++++++++ apps/api/src/routers/adminTables.ts | 74 +++++++- docs/ADMIN_APP_ACCESS_SETUP.md | 56 ++++++ packages/shared-types/src/admin.types.ts | 67 +++++++ packages/shared-types/src/index.ts | 11 ++ .../20260424110000_create_admin_audit_log.sql | 12 ++ 36 files changed, 2313 insertions(+), 35 deletions(-) create mode 100644 apps/admin/src/components/actions/ActionRunner.tsx create mode 100644 apps/admin/src/components/analytics/ChartBuilder.tsx create mode 100644 apps/admin/src/components/analytics/SavedDashboardList.tsx create mode 100644 apps/admin/src/components/data-explorer/RowEditForm.test.tsx create mode 100644 apps/admin/src/components/data-explorer/RowEditForm.tsx create mode 100644 apps/admin/src/hooks/useAdminActions.ts create mode 100644 apps/admin/src/hooks/useAdminDatasets.ts create mode 100644 apps/admin/src/hooks/useAdminOverview.ts create mode 100644 apps/admin/src/pages/ActionCenterPage.test.tsx create mode 100644 apps/admin/src/pages/AnalyticsStudioPage.test.tsx create mode 100644 apps/admin/src/registry/actions.ts create mode 100644 apps/admin/src/registry/datasets.ts create mode 100644 apps/api/src/__tests__/routes/adminActions.test.ts create mode 100644 apps/api/src/__tests__/routes/adminDatasets.test.ts create mode 100644 apps/api/src/__tests__/routes/adminTableEdits.test.ts create mode 100644 apps/api/src/helpers/adminAudit.ts create mode 100644 apps/api/src/routers/adminActions.ts create mode 100644 apps/api/src/routers/adminDatasets.ts create mode 100644 apps/api/src/routers/adminOverview.ts create mode 100644 docs/ADMIN_APP_ACCESS_SETUP.md create mode 100644 supabase/migrations/20260424110000_create_admin_audit_log.sql diff --git a/apps/admin/src/components/AdminLayout.test.tsx b/apps/admin/src/components/AdminLayout.test.tsx index dc014f8..b9c5998 100644 --- a/apps/admin/src/components/AdminLayout.test.tsx +++ b/apps/admin/src/components/AdminLayout.test.tsx @@ -1,12 +1,20 @@ import { render, screen } from "@testing-library/react"; import { MemoryRouter } from "react-router-dom"; -import { beforeEach, describe, expect, it } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import AppRoutes from "../routes"; +import { adminApi } from "../lib/api"; import { storeAdminSession } from "../lib/adminSession"; +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", @@ -14,6 +22,13 @@ describe("AdminLayout", () => { role: "operator", sessionToken: "admin-session-token", }); + vi.mocked(adminApi.get).mockResolvedValue({ + data: { + alerts: [], + metrics: [], + shortcuts: [], + }, + }); }); it("shows the production badge and admin sections", async () => { @@ -24,7 +39,9 @@ describe("AdminLayout", () => { ); expect(await screen.findByText(/^production$/i)).toBeInTheDocument(); - expect(screen.getByRole("heading", { name: /operations home/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(); diff --git a/apps/admin/src/components/PrivilegedGate.test.tsx b/apps/admin/src/components/PrivilegedGate.test.tsx index 04aa323..60c1687 100644 --- a/apps/admin/src/components/PrivilegedGate.test.tsx +++ b/apps/admin/src/components/PrivilegedGate.test.tsx @@ -6,6 +6,7 @@ import { adminApi } from "../lib/api"; vi.mock("../lib/api", () => ({ adminApi: { + get: vi.fn(), post: vi.fn(), }, })); @@ -14,6 +15,13 @@ 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 () => { @@ -44,6 +52,10 @@ describe("PrivilegedGate", () => { }); }); - expect(await screen.findByRole("heading", { name: /operations home/i })).toBeInTheDocument(); + expect( + await screen.findByRole("heading", { + name: /production command deck for privileged supabase operations/i, + }) + ).toBeInTheDocument(); }); }); diff --git a/apps/admin/src/components/actions/ActionRunner.tsx b/apps/admin/src/components/actions/ActionRunner.tsx new file mode 100644 index 0000000..a656aec --- /dev/null +++ b/apps/admin/src/components/actions/ActionRunner.tsx @@ -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) => Promise; + 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>({}); + + 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 ( +
+ + +
+ {selectedAction ? ( +
{ + event.preventDefault(); + void onRun(values); + }} + > +
+
+

Action

+

{selectedAction.label}

+

+ {selectedAction.description} +

+
+ {tone ? ( + + {tone.badge} + + ) : null} +
+ +
+ {selectedAction.fields.map((field) => ( + + ))} +
+ + {error ?

{error}

: null} + {resultMessage ?

{resultMessage}

: null} + + +
+ ) : ( +

Select an action to begin.

+ )} +
+
+ ); +} diff --git a/apps/admin/src/components/analytics/ChartBuilder.tsx b/apps/admin/src/components/analytics/ChartBuilder.tsx new file mode 100644 index 0000000..9a60cad --- /dev/null +++ b/apps/admin/src/components/analytics/ChartBuilder.tsx @@ -0,0 +1,173 @@ +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 ( +
+ {points.map((point) => ( +
+
+
+

{point.value}

+

+ {point.label} +

+
+
+ ))} +
+ ); +} + +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 ( +
+ + + {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 ; + })} + +
+ {points.map((point) => ( +
+

{point.label}

+

{point.value}

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

Total

+

{total}

+
+
+
+
+ {points.map((point, index) => ( +
+
+ +

{point.label}

+
+

{point.value}

+
+ ))} +
+
+ ); +} + +export function ChartBuilder({ + dataset, + datasets, + onSelectDatasetId, + selectedDatasetId, +}: ChartBuilderProps) { + return ( +
+
+ {datasets.map((entry) => ( + + ))} +
+ + {dataset ? ( +
+
+
+

Dataset

+

{dataset.label}

+

{dataset.description}

+
+
+

+ {dataset.dimensionLabel} x {dataset.metricLabel} +

+

+ {dataset.points.reduce((sum, point) => sum + point.value, 0)} +

+
+
+ + {dataset.chartType === "line" ? : null} + {dataset.chartType === "bar" ? : null} + {dataset.chartType === "donut" ? : null} +
+ ) : null} +
+ ); +} diff --git a/apps/admin/src/components/analytics/SavedDashboardList.tsx b/apps/admin/src/components/analytics/SavedDashboardList.tsx new file mode 100644 index 0000000..a76fa65 --- /dev/null +++ b/apps/admin/src/components/analytics/SavedDashboardList.tsx @@ -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 ( +
+
+

Saved Views

+

Operator Dashboards

+
+
+ {dashboards.map((dashboard) => ( + + ))} +
+
+ ); +} diff --git a/apps/admin/src/components/data-explorer/AdminGrid.tsx b/apps/admin/src/components/data-explorer/AdminGrid.tsx index 9c8f46e..db49c90 100644 --- a/apps/admin/src/components/data-explorer/AdminGrid.tsx +++ b/apps/admin/src/components/data-explorer/AdminGrid.tsx @@ -2,10 +2,12 @@ import type { AdminTableMeta } from "@xtablo/shared-types"; type AdminGridProps = { meta: AdminTableMeta | null; + onSelectRow: (row: Record) => void; rows: Record[]; + selectedRowId: string | null; }; -export function AdminGrid({ meta, rows }: AdminGridProps) { +export function AdminGrid({ meta, onSelectRow, rows, selectedRowId }: AdminGridProps) { if (!meta) { return null; } @@ -24,7 +26,13 @@ export function AdminGrid({ meta, rows }: AdminGridProps) { {rows.map((row, index) => ( - + onSelectRow(row)} + > {meta.columns.map((column) => ( {String(row[column.id] ?? "")} diff --git a/apps/admin/src/components/data-explorer/RowEditForm.test.tsx b/apps/admin/src/components/data-explorer/RowEditForm.test.tsx new file mode 100644 index 0000000..26892f7 --- /dev/null +++ b/apps/admin/src/components/data-explorer/RowEditForm.test.tsx @@ -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( + + ); + + 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", + }) + ); + }); +}); diff --git a/apps/admin/src/components/data-explorer/RowEditForm.tsx b/apps/admin/src/components/data-explorer/RowEditForm.tsx new file mode 100644 index 0000000..4ae738e --- /dev/null +++ b/apps/admin/src/components/data-explorer/RowEditForm.tsx @@ -0,0 +1,115 @@ +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) => Promise; + record: Record; +}; + +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) => { + 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 ( +
+ {editableColumns.map((column) => ( + + ))} + + + + {showDiff ? ( +
+

Before

+ {changedFields.map((column) => ( +

+ {column.label}: {String(record[column.id] ?? "")} +

+ ))} +

After

+ {changedFields.map((column) => ( +

+ {column.label}: {String(draft[column.id] ?? "")} +

+ ))} + {!hasChanges ? ( +

No changes to save yet.

+ ) : null} +
+ + +
+
+ ) : null} +
+ ); +} diff --git a/apps/admin/src/hooks/useAdminActions.ts b/apps/admin/src/hooks/useAdminActions.ts new file mode 100644 index 0000000..4256922 --- /dev/null +++ b/apps/admin/src/hooks/useAdminActions.ts @@ -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([]); + const [selectedActionId, setSelectedActionId] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [isRunning, setIsRunning] = useState(false); + const [error, setError] = useState(null); + const [resultMessage, setResultMessage] = useState(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) => { + if (!selectedActionId) { + return; + } + + setIsRunning(true); + setError(null); + setResultMessage(null); + + try { + const response = await adminApi.post( + `/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, + }; +} diff --git a/apps/admin/src/hooks/useAdminDatasets.ts b/apps/admin/src/hooks/useAdminDatasets.ts new file mode 100644 index 0000000..12907dd --- /dev/null +++ b/apps/admin/src/hooks/useAdminDatasets.ts @@ -0,0 +1,105 @@ +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([]); + const [selectedDatasetId, setSelectedDatasetId] = useState(null); + const [dataset, setDataset] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(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(`/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, + }; +} diff --git a/apps/admin/src/hooks/useAdminOverview.ts b/apps/admin/src/hooks/useAdminOverview.ts new file mode 100644 index 0000000..f33df94 --- /dev/null +++ b/apps/admin/src/hooks/useAdminOverview.ts @@ -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(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + let isMounted = true; + + const loadOverview = async () => { + try { + const response = await adminApi.get("/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, + }; +} diff --git a/apps/admin/src/hooks/useAdminTables.ts b/apps/admin/src/hooks/useAdminTables.ts index cf21114..3335d6e 100644 --- a/apps/admin/src/hooks/useAdminTables.ts +++ b/apps/admin/src/hooks/useAdminTables.ts @@ -2,7 +2,26 @@ import type { AdminTableMeta, AdminTableSummary } from "@xtablo/shared-types"; import { useEffect, useState } from "react"; import { adminApi } from "../lib/api"; -type AdminRow = Record; +export type AdminRow = Record; + +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([]); @@ -82,6 +101,37 @@ export function useAdminTables() { }; }, [selectedTableId]); + const updateRow = async (rowId: string, changes: Partial) => { + 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, @@ -90,5 +140,6 @@ export function useAdminTables() { selectedTableId, setSelectedTableId, tables, + updateRow, }; } diff --git a/apps/admin/src/pages/ActionCenterPage.test.tsx b/apps/admin/src/pages/ActionCenterPage.test.tsx new file mode 100644 index 0000000..e7ce473 --- /dev/null +++ b/apps/admin/src/pages/ActionCenterPage.test.tsx @@ -0,0 +1,69 @@ +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(); + + 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(); + }); +}); diff --git a/apps/admin/src/pages/ActionCenterPage.tsx b/apps/admin/src/pages/ActionCenterPage.tsx index 19107ca..b4330bf 100644 --- a/apps/admin/src/pages/ActionCenterPage.tsx +++ b/apps/admin/src/pages/ActionCenterPage.tsx @@ -1,11 +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 ( -
-

Actions

-

Action Center

-

- High-impact repair and resync workflows will run from this controlled surface. -

+
+
+

Actions

+

Action Center

+

+ Run guarded production actions with explicit operator input and audit logging. +

+
+ + {isLoading ?

Loading actions...

: null} + + {!isLoading ? ( + { + setSelectedActionId(actionId); + setError(null); + setResultMessage(null); + }} + resultMessage={resultMessage} + selectedActionId={selectedActionId} + /> + ) : null}
); } diff --git a/apps/admin/src/pages/AnalyticsStudioPage.test.tsx b/apps/admin/src/pages/AnalyticsStudioPage.test.tsx new file mode 100644 index 0000000..e6b710c --- /dev/null +++ b/apps/admin/src/pages/AnalyticsStudioPage.test.tsx @@ -0,0 +1,87 @@ +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(); + + expect(await screen.findByText(/analytics studio/i)).toBeInTheDocument(); + expect(await screen.findByText(/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(); + }); +}); diff --git a/apps/admin/src/pages/AnalyticsStudioPage.tsx b/apps/admin/src/pages/AnalyticsStudioPage.tsx index 7ef381a..7cbbe8b 100644 --- a/apps/admin/src/pages/AnalyticsStudioPage.tsx +++ b/apps/admin/src/pages/AnalyticsStudioPage.tsx @@ -1,11 +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 ( -
-

Analytics

-

Analytics Studio

-

- Curated operational datasets and chart building land here next. -

+
+
+

Analytics

+

Analytics Studio

+

+ Curated production datasets for operators who need charted context before they take + action in the explorer or action center. +

+
+ + {isLoading ?

Loading analytics...

: null} + {error ?

{error}

: null} + +
+ + setSelectedDatasetId(datasetId)} + /> +
); } diff --git a/apps/admin/src/pages/DataExplorerPage.test.tsx b/apps/admin/src/pages/DataExplorerPage.test.tsx index 0337663..944d5c3 100644 --- a/apps/admin/src/pages/DataExplorerPage.test.tsx +++ b/apps/admin/src/pages/DataExplorerPage.test.tsx @@ -1,4 +1,4 @@ -import { render, screen } from "@testing-library/react"; +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"; @@ -7,6 +7,7 @@ import { DataExplorerPage } from "./DataExplorerPage"; vi.mock("../lib/api", () => ({ adminApi: { get: vi.fn(), + patch: vi.fn(), }, })); @@ -15,7 +16,7 @@ describe("DataExplorerPage", () => { vi.clearAllMocks(); }); - it("loads rows for the selected table", async () => { + 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 { @@ -32,11 +33,14 @@ describe("DataExplorerPage", () => { 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", }, }; } @@ -57,6 +61,15 @@ describe("DataExplorerPage", () => { 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( @@ -67,5 +80,19 @@ describe("DataExplorerPage", () => { 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(); }); }); diff --git a/apps/admin/src/pages/DataExplorerPage.tsx b/apps/admin/src/pages/DataExplorerPage.tsx index d928f12..f448f6f 100644 --- a/apps/admin/src/pages/DataExplorerPage.tsx +++ b/apps/admin/src/pages/DataExplorerPage.tsx @@ -1,25 +1,72 @@ +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 } = - useAdminTables(); + const { + error, + isLoading, + meta, + rows, + selectedTableId, + setSelectedTableId, + tables, + updateRow, + } = useAdminTables(); + const [selectedRowId, setSelectedRowId] = useState(null); + const [isSaving, setIsSaving] = useState(false); + const [saveMessage, setSaveMessage] = useState(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) => { + if (!selectedRowId) { + return; + } + + setIsSaving(true); + setSaveMessage(null); + + try { + await updateRow(selectedRowId, changes); + setSaveMessage("Row updated and logged."); + } finally { + setIsSaving(false); + } + }; return (
-
+
); diff --git a/apps/admin/src/pages/OperationsHomePage.tsx b/apps/admin/src/pages/OperationsHomePage.tsx index 8caeb92..6260c83 100644 --- a/apps/admin/src/pages/OperationsHomePage.tsx +++ b/apps/admin/src/pages/OperationsHomePage.tsx @@ -1,13 +1,92 @@ +import { Link } from "react-router-dom"; +import { useAdminOverview } from "../hooks/useAdminOverview"; + export function OperationsHomePage() { + const { error, isLoading, overview } = useAdminOverview(); + return ( -
-
-

Operations

-

Operations Home

-
-

- Internal production oversight, anomaly checks, and shortcuts into the admin workflows. -

+
+
+

Operations

+

+ Production command deck for privileged Supabase operations. +

+

+ Monitor the current state of users, access grants, and tablos before drilling into + explorer edits, analytics, or controlled admin actions. +

+
+ + {isLoading ?

Loading operations overview...

: null} + {error ?

{error}

: null} + + {overview ? ( + <> +
+ {overview.metrics.map((metric) => ( +
+

+ {metric.label} +

+

{metric.value}

+

{metric.changeLabel}

+
+ ))} +
+ +
+
+
+

Alerts

+

Operational Watchlist

+
+
+ {overview.alerts.map((alert) => ( +
+
+ + {alert.severity} + +

{alert.title}

+
+

{alert.description}

+
+ ))} +
+
+ +
+

Shortcuts

+

Common Paths

+
+ {overview.shortcuts.map((shortcut) => ( + + {shortcut.label} + + ))} +
+
+
+ + ) : null}
); } diff --git a/apps/admin/src/registry/actions.ts b/apps/admin/src/registry/actions.ts new file mode 100644 index 0000000..d159b57 --- /dev/null +++ b/apps/admin/src/registry/actions.ts @@ -0,0 +1,10 @@ +export const actionSeverityCopy = { + deactivate_tablo_access: { + badge: "Restriction", + tone: "warning", + }, + grant_tablo_admin: { + badge: "Privilege", + tone: "critical", + }, +} as const; diff --git a/apps/admin/src/registry/datasets.ts b/apps/admin/src/registry/datasets.ts new file mode 100644 index 0000000..8fa262e --- /dev/null +++ b/apps/admin/src/registry/datasets.ts @@ -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; diff --git a/apps/api/src/__tests__/routes/adminActions.test.ts b/apps/api/src/__tests__/routes/adminActions.test.ts new file mode 100644 index 0000000..f16423f --- /dev/null +++ b/apps/api/src/__tests__/routes/adminActions.test.ts @@ -0,0 +1,64 @@ +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 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", + }); + }); +}); diff --git a/apps/api/src/__tests__/routes/adminDatasets.test.ts b/apps/api/src/__tests__/routes/adminDatasets.test.ts new file mode 100644 index 0000000..b233560 --- /dev/null +++ b/apps/api/src/__tests__/routes/adminDatasets.test.ts @@ -0,0 +1,64 @@ +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 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), + }); + }); +}); diff --git a/apps/api/src/__tests__/routes/adminTableEdits.test.ts b/apps/api/src/__tests__/routes/adminTableEdits.test.ts new file mode 100644 index 0000000..58d6110 --- /dev/null +++ b/apps/api/src/__tests__/routes/adminTableEdits.test.ts @@ -0,0 +1,72 @@ +import { createClient } from "@supabase/supabase-js"; +import { describe, expect, it } from "vitest"; +import { createSignedAdminToken } from "../helpers/adminTokenTestUtils.js"; +import { getTestData } from "../helpers/dbSetup.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 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", + }); + }); +}); diff --git a/apps/api/src/__tests__/routes/adminTables.test.ts b/apps/api/src/__tests__/routes/adminTables.test.ts index f3d7603..443d1db 100644 --- a/apps/api/src/__tests__/routes/adminTables.test.ts +++ b/apps/api/src/__tests__/routes/adminTables.test.ts @@ -57,6 +57,8 @@ describe("Admin Tables Router", () => { await expect(res.json()).resolves.toMatchObject({ id: "profiles", label: "Users", + editableFields: ["first_name", "last_name"], + primaryKey: "id", columns: expect.arrayContaining([ expect.objectContaining({ id: "email", diff --git a/apps/api/src/helpers/adminAudit.ts b/apps/api/src/helpers/adminAudit.ts new file mode 100644 index 0000000..3abf933 --- /dev/null +++ b/apps/api/src/helpers/adminAudit.ts @@ -0,0 +1,42 @@ +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 as SupabaseClient) + .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}`); + } +} diff --git a/apps/api/src/helpers/adminRegistry.ts b/apps/api/src/helpers/adminRegistry.ts index bf2b52f..8f17c22 100644 --- a/apps/api/src/helpers/adminRegistry.ts +++ b/apps/api/src/helpers/adminRegistry.ts @@ -7,8 +7,10 @@ type AdminTableColumn = { type AdminTableDefinition = { columns: AdminTableColumn[]; + editableColumns?: string[]; id: string; label: string; + primaryKey: string; select: string; source: keyof Database["public"]["Tables"]; }; @@ -21,8 +23,10 @@ export const adminTableRegistry: Record = { { 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", }, @@ -33,13 +37,86 @@ export const adminTableRegistry: Record = { { 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 = { + 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 = { + 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; } @@ -48,6 +125,22 @@ 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[]; } diff --git a/apps/api/src/routers/admin.ts b/apps/api/src/routers/admin.ts index c1eb040..00aa5b1 100644 --- a/apps/api/src/routers/admin.ts +++ b/apps/api/src/routers/admin.ts @@ -2,7 +2,10 @@ 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) => { @@ -11,9 +14,19 @@ export const getAdminRouter = (config: AppConfig) => { 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; }; diff --git a/apps/api/src/routers/adminActions.ts b/apps/api/src/routers/adminActions.ts new file mode 100644 index 0000000..0aea6fe --- /dev/null +++ b/apps/api/src/routers/adminActions.ts @@ -0,0 +1,109 @@ +import { Hono } from "hono"; +import type { AdminActionRunResponse } from "@xtablo/shared-types"; +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; + + 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(); + + 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; +}; diff --git a/apps/api/src/routers/adminDatasets.ts b/apps/api/src/routers/adminDatasets.ts new file mode 100644 index 0000000..7b8206a --- /dev/null +++ b/apps/api/src/routers/adminDatasets.ts @@ -0,0 +1,155 @@ +import { Hono } from "hono"; +import type { AdminDatasetResult } from "@xtablo/shared-types"; +import { + getAdminDatasetDefinition, + listAdminDatasets, +} from "../helpers/adminRegistry.js"; +import type { BaseEnv } from "../types/app.types.js"; + +function bucketByDay(values: Array) { + const counts = new Map(); + + 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, emptyLabel: string) { + const counts = new Map(); + + 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([ + ["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 { + 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(); + + 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; +}; diff --git a/apps/api/src/routers/adminOverview.ts b/apps/api/src/routers/adminOverview.ts new file mode 100644 index 0000000..fc5b3f1 --- /dev/null +++ b/apps/api/src/routers/adminOverview.ts @@ -0,0 +1,144 @@ +import { Hono } from "hono"; +import type { AdminOverviewResponse } from "@xtablo/shared-types"; +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(); + + 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; +}; diff --git a/apps/api/src/routers/adminTables.ts b/apps/api/src/routers/adminTables.ts index 02ec6ea..7f2b9da 100644 --- a/apps/api/src/routers/adminTables.ts +++ b/apps/api/src/routers/adminTables.ts @@ -1,5 +1,10 @@ import { Hono } from "hono"; -import { getAdminTableDefinition, listAdminTables, normalizeAdminRows } from "../helpers/adminRegistry.js"; +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 = () => { @@ -25,8 +30,10 @@ export const getAdminTablesRouter = () => { return c.json( { columns: tableDefinition.columns, + editableFields: tableDefinition.editableColumns ?? [], id: tableDefinition.id, label: tableDefinition.label, + primaryKey: tableDefinition.primaryKey, }, 200 ); @@ -63,5 +70,70 @@ export const getAdminTablesRouter = () => { 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; }; diff --git a/docs/ADMIN_APP_ACCESS_SETUP.md b/docs/ADMIN_APP_ACCESS_SETUP.md new file mode 100644 index 0000000..0e27441 --- /dev/null +++ b/docs/ADMIN_APP_ACCESS_SETUP.md @@ -0,0 +1,56 @@ +# Admin App Access Setup + +The admin app is designed to be internal-only and requires a separate privileged token flow. + +## 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 internal admin surface. + +## Access model + +1. The operator reaches the private `apps/admin` deployment from the internal network boundary. +2. The operator pastes a privileged access token into the admin gate. +3. `POST /admin/auth/exchange` validates that token and returns a short-lived `admin_session`. +4. The admin app stores that session locally and attaches it as a bearer token for admin routes. +5. 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`. +- Use a valid privileged access 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` diff --git a/packages/shared-types/src/admin.types.ts b/packages/shared-types/src/admin.types.ts index 2859ec8..bc85f9c 100644 --- a/packages/shared-types/src/admin.types.ts +++ b/packages/shared-types/src/admin.types.ts @@ -28,6 +28,73 @@ export type AdminTableColumn = { 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; +}; diff --git a/packages/shared-types/src/index.ts b/packages/shared-types/src/index.ts index 11a7446..8c42acc 100644 --- a/packages/shared-types/src/index.ts +++ b/packages/shared-types/src/index.ts @@ -2,6 +2,17 @@ // Admin Types // ============================================================================ export type { + AdminActionField, + AdminActionRunResponse, + AdminActionSummary, + AdminDatasetChartType, + AdminDatasetPoint, + AdminDatasetResult, + AdminDatasetSummary, + AdminOverviewAlert, + AdminOverviewMetric, + AdminOverviewResponse, + AdminOverviewShortcut, AdminRole, AdminSessionInfo, AdminSessionResponse, diff --git a/supabase/migrations/20260424110000_create_admin_audit_log.sql b/supabase/migrations/20260424110000_create_admin_audit_log.sql new file mode 100644 index 0000000..45d8ebc --- /dev/null +++ b/supabase/migrations/20260424110000_create_admin_audit_log.sql @@ -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() +);