feat(admin): add dashboard explorer analytics and actions
This commit is contained in:
parent
4f71c52e14
commit
85d44af57e
36 changed files with 2313 additions and 35 deletions
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
133
apps/admin/src/components/actions/ActionRunner.tsx
Normal file
133
apps/admin/src/components/actions/ActionRunner.tsx
Normal file
|
|
@ -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<string, string>) => Promise<void>;
|
||||
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<Record<string, string>>({});
|
||||
|
||||
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 (
|
||||
<div className="grid gap-6 lg:grid-cols-[280px_minmax(0,1fr)]">
|
||||
<aside className="rounded-[2rem] border border-border bg-card p-5">
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-foreground/50">Approved Actions</p>
|
||||
<div className="mt-4 flex flex-col gap-3">
|
||||
{actions.map((action) => (
|
||||
<button
|
||||
className={`rounded-[1.25rem] border px-4 py-3 text-left ${
|
||||
action.id === selectedActionId
|
||||
? "border-foreground bg-foreground text-background"
|
||||
: "border-border bg-background/70"
|
||||
}`}
|
||||
key={action.id}
|
||||
onClick={() => onSelectActionId(action.id)}
|
||||
type="button"
|
||||
>
|
||||
<p className="text-sm font-semibold">{action.label}</p>
|
||||
<p className="mt-1 text-sm opacity-80">{action.description}</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<section className="rounded-[2rem] border border-border bg-card p-6">
|
||||
{selectedAction ? (
|
||||
<form
|
||||
className="space-y-5"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void onRun(values);
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-foreground/50">Action</p>
|
||||
<h2 className="mt-2 text-3xl font-semibold">{selectedAction.label}</h2>
|
||||
<p className="mt-2 max-w-2xl text-sm text-foreground/70">
|
||||
{selectedAction.description}
|
||||
</p>
|
||||
</div>
|
||||
{tone ? (
|
||||
<span
|
||||
className={`rounded-full px-3 py-1 text-xs uppercase tracking-[0.2em] ${
|
||||
tone.tone === "critical"
|
||||
? "bg-red-100 text-red-700"
|
||||
: "bg-amber-100 text-amber-700"
|
||||
}`}
|
||||
>
|
||||
{tone.badge}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{selectedAction.fields.map((field) => (
|
||||
<label className="block" htmlFor={field.id} key={field.id}>
|
||||
<span className="mb-2 block text-sm font-medium">{field.label}</span>
|
||||
<input
|
||||
className="w-full rounded-2xl border border-border bg-background px-3 py-2"
|
||||
id={field.id}
|
||||
onChange={(event) =>
|
||||
setValues((currentValue) => ({
|
||||
...currentValue,
|
||||
[field.id]: event.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder={field.placeholder}
|
||||
required={field.required}
|
||||
value={values[field.id] ?? ""}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{error ? <p className="text-sm text-red-600">{error}</p> : null}
|
||||
{resultMessage ? <p className="text-sm text-emerald-700">{resultMessage}</p> : null}
|
||||
|
||||
<button
|
||||
className="rounded-2xl bg-foreground px-4 py-2 text-background disabled:cursor-not-allowed disabled:opacity-60"
|
||||
disabled={isRunning}
|
||||
type="submit"
|
||||
>
|
||||
{isRunning ? "Running..." : "Run Action"}
|
||||
</button>
|
||||
</form>
|
||||
) : (
|
||||
<p className="text-sm text-foreground/70">Select an action to begin.</p>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
173
apps/admin/src/components/analytics/ChartBuilder.tsx
Normal file
173
apps/admin/src/components/analytics/ChartBuilder.tsx
Normal file
|
|
@ -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 (
|
||||
<div className="grid min-h-64 grid-cols-[repeat(auto-fit,minmax(56px,1fr))] items-end gap-3">
|
||||
{points.map((point) => (
|
||||
<div className="flex h-full flex-col justify-end gap-2" key={point.label}>
|
||||
<div
|
||||
className="rounded-t-2xl bg-[linear-gradient(180deg,rgba(20,36,84,0.92),rgba(88,140,126,0.9))]"
|
||||
style={{ height: `${Math.max((point.value / maxValue) * 180, 12)}px` }}
|
||||
/>
|
||||
<div>
|
||||
<p className="text-xs font-medium">{point.value}</p>
|
||||
<p className="truncate text-[11px] uppercase tracking-[0.16em] text-foreground/55">
|
||||
{point.label}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="space-y-4">
|
||||
<svg
|
||||
className="w-full overflow-visible rounded-[2rem] border border-border bg-[linear-gradient(180deg,rgba(252,249,244,0.96),rgba(239,235,225,0.96))] p-4"
|
||||
viewBox={`0 0 ${width} ${height}`}
|
||||
>
|
||||
<polyline
|
||||
fill="none"
|
||||
points={polyline}
|
||||
stroke="rgb(23 37 84)"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
{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 <circle cx={x} cy={y} fill="rgb(15 118 110)" key={point.label} r="5" />;
|
||||
})}
|
||||
</svg>
|
||||
<div className="grid gap-3 md:grid-cols-3">
|
||||
{points.map((point) => (
|
||||
<div className="rounded-2xl border border-border/80 bg-background/70 px-3 py-2" key={point.label}>
|
||||
<p className="text-xs uppercase tracking-[0.18em] text-foreground/50">{point.label}</p>
|
||||
<p className="mt-1 text-lg font-semibold">{point.value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="grid gap-6 md:grid-cols-[220px_minmax(0,1fr)] md:items-center">
|
||||
<div
|
||||
className="mx-auto h-52 w-52 rounded-full border border-border"
|
||||
style={{
|
||||
background: `conic-gradient(${gradientStops})`,
|
||||
}}
|
||||
>
|
||||
<div className="m-auto mt-8 flex h-36 w-36 items-center justify-center rounded-full bg-card text-center">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.18em] text-foreground/50">Total</p>
|
||||
<p className="mt-1 text-3xl font-semibold">{total}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{points.map((point, index) => (
|
||||
<div className="flex items-center justify-between rounded-2xl border border-border/80 px-4 py-3" key={point.label}>
|
||||
<div className="flex items-center gap-3">
|
||||
<span
|
||||
className="h-3 w-3 rounded-full"
|
||||
style={{ backgroundColor: palette[index % palette.length] }}
|
||||
/>
|
||||
<p className="text-sm font-medium">{point.label}</p>
|
||||
</div>
|
||||
<p className="text-sm text-foreground/70">{point.value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChartBuilder({
|
||||
dataset,
|
||||
datasets,
|
||||
onSelectDatasetId,
|
||||
selectedDatasetId,
|
||||
}: ChartBuilderProps) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{datasets.map((entry) => (
|
||||
<button
|
||||
className={`rounded-full border px-4 py-2 text-sm ${
|
||||
entry.id === selectedDatasetId
|
||||
? "border-foreground bg-foreground text-background"
|
||||
: "border-border bg-card"
|
||||
}`}
|
||||
key={entry.id}
|
||||
onClick={() => onSelectDatasetId(entry.id)}
|
||||
type="button"
|
||||
>
|
||||
{entry.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{dataset ? (
|
||||
<section className="rounded-[2rem] border border-border bg-card p-6 shadow-[0_24px_80px_rgba(15,23,42,0.08)]">
|
||||
<div className="mb-6 flex flex-wrap items-end justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-foreground/50">Dataset</p>
|
||||
<h2 className="mt-2 text-3xl font-semibold">{dataset.label}</h2>
|
||||
<p className="mt-2 max-w-2xl text-sm text-foreground/70">{dataset.description}</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-border/80 bg-background/70 px-4 py-3 text-right">
|
||||
<p className="text-xs uppercase tracking-[0.18em] text-foreground/50">
|
||||
{dataset.dimensionLabel} x {dataset.metricLabel}
|
||||
</p>
|
||||
<p className="mt-2 text-lg font-semibold">
|
||||
{dataset.points.reduce((sum, point) => sum + point.value, 0)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{dataset.chartType === "line" ? <LineChart points={dataset.points} /> : null}
|
||||
{dataset.chartType === "bar" ? <BarChart points={dataset.points} /> : null}
|
||||
{dataset.chartType === "donut" ? <DonutChart points={dataset.points} /> : null}
|
||||
</section>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
35
apps/admin/src/components/analytics/SavedDashboardList.tsx
Normal file
35
apps/admin/src/components/analytics/SavedDashboardList.tsx
Normal file
|
|
@ -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 (
|
||||
<section className="rounded-[2rem] border border-border bg-card p-6">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-foreground/50">Saved Views</p>
|
||||
<h2 className="mt-2 text-2xl font-semibold">Operator Dashboards</h2>
|
||||
</div>
|
||||
<div className="mt-6 grid gap-4">
|
||||
{dashboards.map((dashboard) => (
|
||||
<button
|
||||
className="rounded-[1.5rem] border border-border/80 bg-[linear-gradient(135deg,rgba(255,255,255,0.74),rgba(240,236,227,0.98))] p-4 text-left"
|
||||
key={dashboard.id}
|
||||
onClick={() => onOpen(dashboard.datasetId)}
|
||||
type="button"
|
||||
>
|
||||
<p className="text-sm font-semibold">{dashboard.label}</p>
|
||||
<p className="mt-2 text-sm text-foreground/70">{dashboard.description}</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -2,10 +2,12 @@ import type { AdminTableMeta } from "@xtablo/shared-types";
|
|||
|
||||
type AdminGridProps = {
|
||||
meta: AdminTableMeta | null;
|
||||
onSelectRow: (row: Record<string, string | boolean | null>) => void;
|
||||
rows: Record<string, string | boolean | null>[];
|
||||
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) {
|
|||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row, index) => (
|
||||
<tr className="border-b border-border/60 last:border-b-0" key={`${row.id ?? index}`}>
|
||||
<tr
|
||||
className={`cursor-pointer border-b border-border/60 last:border-b-0 ${
|
||||
String(row[meta.primaryKey] ?? "") === selectedRowId ? "bg-black/5" : ""
|
||||
}`}
|
||||
key={`${row[meta.primaryKey] ?? row.id ?? index}`}
|
||||
onClick={() => onSelectRow(row)}
|
||||
>
|
||||
{meta.columns.map((column) => (
|
||||
<td className="px-4 py-3 text-sm" key={column.id}>
|
||||
{String(row[column.id] ?? "")}
|
||||
|
|
|
|||
43
apps/admin/src/components/data-explorer/RowEditForm.test.tsx
Normal file
43
apps/admin/src/components/data-explorer/RowEditForm.test.tsx
Normal file
|
|
@ -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(
|
||||
<RowEditForm
|
||||
columns={[
|
||||
{ id: "first_name", label: "First name" },
|
||||
{ id: "email", label: "Email" },
|
||||
]}
|
||||
editableFields={["first_name"]}
|
||||
onSave={onSave}
|
||||
record={{
|
||||
email: "test_owner@example.com",
|
||||
first_name: "Test",
|
||||
id: "user-1",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
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",
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
115
apps/admin/src/components/data-explorer/RowEditForm.tsx
Normal file
115
apps/admin/src/components/data-explorer/RowEditForm.tsx
Normal file
|
|
@ -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<string, string | boolean | null>) => Promise<void>;
|
||||
record: Record<string, string | boolean | null>;
|
||||
};
|
||||
|
||||
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<HTMLFormElement>) => {
|
||||
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 (
|
||||
<form className="space-y-4" onSubmit={handleSubmit}>
|
||||
{editableColumns.map((column) => (
|
||||
<label className="block" htmlFor={column.id} key={column.id}>
|
||||
<span className="mb-2 block text-sm font-medium">{column.label}</span>
|
||||
<input
|
||||
className="w-full rounded-2xl border border-border px-3 py-2"
|
||||
id={column.id}
|
||||
onChange={(event) =>
|
||||
setDraft((currentValue) => ({
|
||||
...currentValue,
|
||||
[column.id]: event.target.value,
|
||||
}))
|
||||
}
|
||||
value={String(draft[column.id] ?? "")}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
|
||||
<button
|
||||
className="rounded-2xl bg-foreground px-4 py-2 text-background"
|
||||
type="submit"
|
||||
>
|
||||
Review Changes
|
||||
</button>
|
||||
|
||||
{showDiff ? (
|
||||
<div className="rounded-3xl border border-border bg-card p-4">
|
||||
<p className="text-sm font-semibold uppercase tracking-[0.2em]">Before</p>
|
||||
{changedFields.map((column) => (
|
||||
<p key={`${column.id}-before`}>
|
||||
{column.label}: {String(record[column.id] ?? "")}
|
||||
</p>
|
||||
))}
|
||||
<p className="mt-4 text-sm font-semibold uppercase tracking-[0.2em]">After</p>
|
||||
{changedFields.map((column) => (
|
||||
<p key={`${column.id}-after`}>
|
||||
{column.label}: {String(draft[column.id] ?? "")}
|
||||
</p>
|
||||
))}
|
||||
{!hasChanges ? (
|
||||
<p className="mt-4 text-sm text-foreground/70">No changes to save yet.</p>
|
||||
) : null}
|
||||
<div className="mt-4 flex gap-3">
|
||||
<button
|
||||
className="rounded-2xl border border-border px-4 py-2"
|
||||
onClick={() => setShowDiff(false)}
|
||||
type="button"
|
||||
>
|
||||
Keep Editing
|
||||
</button>
|
||||
<button
|
||||
className="rounded-2xl bg-foreground px-4 py-2 text-background disabled:cursor-not-allowed disabled:opacity-60"
|
||||
disabled={!hasChanges || isSaving}
|
||||
onClick={() => void handleSave()}
|
||||
type="button"
|
||||
>
|
||||
{isSaving ? "Saving..." : "Confirm Update"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
98
apps/admin/src/hooks/useAdminActions.ts
Normal file
98
apps/admin/src/hooks/useAdminActions.ts
Normal file
|
|
@ -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<AdminActionSummary[]>([]);
|
||||
const [selectedActionId, setSelectedActionId] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isRunning, setIsRunning] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [resultMessage, setResultMessage] = useState<string | null>(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<string, string>) => {
|
||||
if (!selectedActionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsRunning(true);
|
||||
setError(null);
|
||||
setResultMessage(null);
|
||||
|
||||
try {
|
||||
const response = await adminApi.post<AdminActionRunResponse>(
|
||||
`/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,
|
||||
};
|
||||
}
|
||||
105
apps/admin/src/hooks/useAdminDatasets.ts
Normal file
105
apps/admin/src/hooks/useAdminDatasets.ts
Normal file
|
|
@ -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<AdminDatasetSummary[]>([]);
|
||||
const [selectedDatasetId, setSelectedDatasetId] = useState<string | null>(null);
|
||||
const [dataset, setDataset] = useState<AdminDatasetResult | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(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<AdminDatasetResult>(`/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,
|
||||
};
|
||||
}
|
||||
44
apps/admin/src/hooks/useAdminOverview.ts
Normal file
44
apps/admin/src/hooks/useAdminOverview.ts
Normal file
|
|
@ -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<AdminOverviewResponse | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
const loadOverview = async () => {
|
||||
try {
|
||||
const response = await adminApi.get<AdminOverviewResponse>("/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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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<string, string | boolean | null>;
|
||||
export type AdminRow = Record<string, string | boolean | null>;
|
||||
|
||||
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<AdminTableSummary[]>([]);
|
||||
|
|
@ -82,6 +101,37 @@ export function useAdminTables() {
|
|||
};
|
||||
}, [selectedTableId]);
|
||||
|
||||
const updateRow = async (rowId: string, changes: Partial<AdminRow>) => {
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
69
apps/admin/src/pages/ActionCenterPage.test.tsx
Normal file
69
apps/admin/src/pages/ActionCenterPage.test.tsx
Normal file
|
|
@ -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(<ActionCenterPage />);
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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 (
|
||||
<div className="space-y-4">
|
||||
<p className="text-xs uppercase tracking-[0.25em] text-foreground/55">Actions</p>
|
||||
<h1 className="text-3xl font-semibold">Action Center</h1>
|
||||
<p className="text-sm text-foreground/70">
|
||||
High-impact repair and resync workflows will run from this controlled surface.
|
||||
</p>
|
||||
<div className="space-y-6">
|
||||
<section className="rounded-[2rem] border border-border bg-[linear-gradient(135deg,rgba(255,245,245,0.95),rgba(245,236,228,0.98))] p-8">
|
||||
<p className="text-xs uppercase tracking-[0.25em] text-foreground/55">Actions</p>
|
||||
<h1 className="mt-3 text-4xl font-semibold">Action Center</h1>
|
||||
<p className="mt-4 max-w-2xl text-sm text-foreground/70">
|
||||
Run guarded production actions with explicit operator input and audit logging.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{isLoading ? <p>Loading actions...</p> : null}
|
||||
|
||||
{!isLoading ? (
|
||||
<ActionRunner
|
||||
actions={actions}
|
||||
error={error}
|
||||
isRunning={isRunning}
|
||||
onRun={runAction}
|
||||
onSelectActionId={(actionId) => {
|
||||
setSelectedActionId(actionId);
|
||||
setError(null);
|
||||
setResultMessage(null);
|
||||
}}
|
||||
resultMessage={resultMessage}
|
||||
selectedActionId={selectedActionId}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
87
apps/admin/src/pages/AnalyticsStudioPage.test.tsx
Normal file
87
apps/admin/src/pages/AnalyticsStudioPage.test.tsx
Normal file
|
|
@ -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(<AnalyticsStudioPage />);
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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 (
|
||||
<div className="space-y-4">
|
||||
<p className="text-xs uppercase tracking-[0.25em] text-foreground/55">Analytics</p>
|
||||
<h1 className="text-3xl font-semibold">Analytics Studio</h1>
|
||||
<p className="text-sm text-foreground/70">
|
||||
Curated operational datasets and chart building land here next.
|
||||
</p>
|
||||
<div className="space-y-6">
|
||||
<section className="rounded-[2rem] border border-border bg-[radial-gradient(circle_at_top_left,rgba(20,83,45,0.18),transparent_40%),linear-gradient(135deg,rgba(255,251,235,0.95),rgba(244,240,231,0.98))] p-8">
|
||||
<p className="text-xs uppercase tracking-[0.25em] text-foreground/55">Analytics</p>
|
||||
<h1 className="mt-3 text-4xl font-semibold">Analytics Studio</h1>
|
||||
<p className="mt-4 max-w-2xl text-sm text-foreground/70">
|
||||
Curated production datasets for operators who need charted context before they take
|
||||
action in the explorer or action center.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{isLoading ? <p>Loading analytics...</p> : null}
|
||||
{error ? <p className="text-red-600">{error}</p> : null}
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr)_320px]">
|
||||
<ChartBuilder
|
||||
dataset={dataset}
|
||||
datasets={datasets}
|
||||
onSelectDatasetId={setSelectedDatasetId}
|
||||
selectedDatasetId={selectedDatasetId}
|
||||
/>
|
||||
<SavedDashboardList
|
||||
dashboards={savedDashboardPresets}
|
||||
onOpen={(datasetId) => setSelectedDatasetId(datasetId)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
<MemoryRouter>
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<string | null>(null);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [saveMessage, setSaveMessage] = useState<string | null>(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<string, string | boolean | null>) => {
|
||||
if (!selectedRowId) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
setSaveMessage(null);
|
||||
|
||||
try {
|
||||
await updateRow(selectedRowId, changes);
|
||||
setSaveMessage("Row updated and logged.");
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="min-h-screen p-6">
|
||||
<div className="grid gap-6 lg:grid-cols-[220px_minmax(0,1fr)]">
|
||||
<div className="grid gap-6 lg:grid-cols-[220px_minmax(0,1fr)_360px]">
|
||||
<aside className="rounded-3xl border border-border bg-card p-4">
|
||||
<p className="text-xs uppercase tracking-[0.25em] text-foreground/60">Data Explorer</p>
|
||||
<div className="mt-4 flex flex-col gap-2">
|
||||
{tables.map((table) => (
|
||||
<button
|
||||
className="rounded-2xl border border-border px-3 py-2 text-left text-sm"
|
||||
className={`rounded-2xl border px-3 py-2 text-left text-sm ${
|
||||
selectedTableId === table.id
|
||||
? "border-foreground bg-foreground text-background"
|
||||
: "border-border"
|
||||
}`}
|
||||
key={table.id}
|
||||
onClick={() => setSelectedTableId(table.id)}
|
||||
type="button"
|
||||
>
|
||||
{table.label}
|
||||
{selectedTableId === table.id ? " *" : ""}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -35,8 +82,74 @@ export function DataExplorerPage() {
|
|||
|
||||
{isLoading ? <p>Loading explorer...</p> : null}
|
||||
{error ? <p className="text-red-600">{error}</p> : null}
|
||||
{!isLoading && !error ? <AdminGrid meta={meta} rows={rows} /> : null}
|
||||
{!isLoading && !error ? (
|
||||
<AdminGrid
|
||||
meta={meta}
|
||||
onSelectRow={(row) => {
|
||||
if (!meta) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedRowId(String(row[meta.primaryKey] ?? ""));
|
||||
setSaveMessage(null);
|
||||
}}
|
||||
rows={rows}
|
||||
selectedRowId={selectedRowId}
|
||||
/>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<aside className="rounded-3xl border border-border bg-card p-5">
|
||||
<p className="text-xs uppercase tracking-[0.25em] text-foreground/60">Row Detail</p>
|
||||
|
||||
{!selectedRow || !meta ? (
|
||||
<div className="mt-6 space-y-2 text-sm text-foreground/70">
|
||||
<p>Select a row to inspect record details.</p>
|
||||
<p>Approved edits are reviewed before they hit production.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-6 space-y-6">
|
||||
<div className="space-y-3">
|
||||
{meta.columns.map((column) => (
|
||||
<div
|
||||
className="rounded-2xl border border-border/80 px-3 py-2"
|
||||
key={column.id}
|
||||
>
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-foreground/50">
|
||||
{column.label}
|
||||
</p>
|
||||
<p className="mt-1 text-sm">{String(selectedRow[column.id] ?? "")}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{meta.editableFields.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">Guarded Edit</h2>
|
||||
<p className="text-sm text-foreground/70">
|
||||
Editable fields require a reviewed diff and create an audit log entry.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<RowEditForm
|
||||
columns={meta.columns}
|
||||
editableFields={meta.editableFields}
|
||||
isSaving={isSaving}
|
||||
onSave={handleSave}
|
||||
record={selectedRow}
|
||||
/>
|
||||
|
||||
{saveMessage ? <p className="text-sm text-emerald-700">{saveMessage}</p> : null}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-foreground/70">
|
||||
This table is currently read-only in the admin panel.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,13 +1,92 @@
|
|||
import { Link } from "react-router-dom";
|
||||
import { useAdminOverview } from "../hooks/useAdminOverview";
|
||||
|
||||
export function OperationsHomePage() {
|
||||
const { error, isLoading, overview } = useAdminOverview();
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.25em] text-foreground/55">Operations</p>
|
||||
<h1 className="mt-3 text-3xl font-semibold">Operations Home</h1>
|
||||
</div>
|
||||
<p className="text-sm text-foreground/70">
|
||||
Internal production oversight, anomaly checks, and shortcuts into the admin workflows.
|
||||
</p>
|
||||
<div className="space-y-6">
|
||||
<section className="rounded-[2rem] border border-border bg-[linear-gradient(135deg,rgba(17,24,39,0.96),rgba(24,57,76,0.88),rgba(148,88,32,0.74))] p-8 text-white shadow-[0_28px_90px_rgba(15,23,42,0.25)]">
|
||||
<p className="text-xs uppercase tracking-[0.25em] text-white/65">Operations</p>
|
||||
<h1 className="mt-3 max-w-3xl text-4xl font-semibold">
|
||||
Production command deck for privileged Supabase operations.
|
||||
</h1>
|
||||
<p className="mt-4 max-w-2xl text-sm text-white/80">
|
||||
Monitor the current state of users, access grants, and tablos before drilling into
|
||||
explorer edits, analytics, or controlled admin actions.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{isLoading ? <p>Loading operations overview...</p> : null}
|
||||
{error ? <p className="text-red-600">{error}</p> : null}
|
||||
|
||||
{overview ? (
|
||||
<>
|
||||
<section className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
{overview.metrics.map((metric) => (
|
||||
<article
|
||||
className="rounded-[1.75rem] border border-border bg-card p-5"
|
||||
key={metric.id}
|
||||
>
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-foreground/50">
|
||||
{metric.label}
|
||||
</p>
|
||||
<p className="mt-3 text-3xl font-semibold">{metric.value}</p>
|
||||
<p className="mt-2 text-sm text-foreground/65">{metric.changeLabel}</p>
|
||||
</article>
|
||||
))}
|
||||
</section>
|
||||
|
||||
<section className="grid gap-6 lg:grid-cols-[minmax(0,1fr)_360px]">
|
||||
<div className="rounded-[2rem] border border-border bg-card p-6">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-foreground/50">Alerts</p>
|
||||
<h2 className="mt-2 text-2xl font-semibold">Operational Watchlist</h2>
|
||||
</div>
|
||||
<div className="mt-6 grid gap-4">
|
||||
{overview.alerts.map((alert) => (
|
||||
<article
|
||||
className="rounded-[1.5rem] border border-border/80 bg-background/70 p-4"
|
||||
key={alert.id}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span
|
||||
className={`rounded-full px-2 py-1 text-[11px] uppercase tracking-[0.18em] ${
|
||||
alert.severity === "critical"
|
||||
? "bg-red-100 text-red-700"
|
||||
: alert.severity === "warning"
|
||||
? "bg-amber-100 text-amber-700"
|
||||
: "bg-slate-200 text-slate-700"
|
||||
}`}
|
||||
>
|
||||
{alert.severity}
|
||||
</span>
|
||||
<h3 className="text-sm font-semibold">{alert.title}</h3>
|
||||
</div>
|
||||
<p className="mt-3 text-sm text-foreground/70">{alert.description}</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-[2rem] border border-border bg-card p-6">
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-foreground/50">Shortcuts</p>
|
||||
<h2 className="mt-2 text-2xl font-semibold">Common Paths</h2>
|
||||
<div className="mt-6 flex flex-col gap-3">
|
||||
{overview.shortcuts.map((shortcut) => (
|
||||
<Link
|
||||
className="rounded-[1.25rem] border border-border/80 bg-background/70 px-4 py-3 text-sm font-medium"
|
||||
key={shortcut.id}
|
||||
to={shortcut.href}
|
||||
>
|
||||
{shortcut.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
10
apps/admin/src/registry/actions.ts
Normal file
10
apps/admin/src/registry/actions.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
export const actionSeverityCopy = {
|
||||
deactivate_tablo_access: {
|
||||
badge: "Restriction",
|
||||
tone: "warning",
|
||||
},
|
||||
grant_tablo_admin: {
|
||||
badge: "Privilege",
|
||||
tone: "critical",
|
||||
},
|
||||
} as const;
|
||||
20
apps/admin/src/registry/datasets.ts
Normal file
20
apps/admin/src/registry/datasets.ts
Normal file
|
|
@ -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;
|
||||
64
apps/api/src/__tests__/routes/adminActions.test.ts
Normal file
64
apps/api/src/__tests__/routes/adminActions.test.ts
Normal file
|
|
@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
64
apps/api/src/__tests__/routes/adminDatasets.test.ts
Normal file
64
apps/api/src/__tests__/routes/adminDatasets.test.ts
Normal file
|
|
@ -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),
|
||||
});
|
||||
});
|
||||
});
|
||||
72
apps/api/src/__tests__/routes/adminTableEdits.test.ts
Normal file
72
apps/api/src/__tests__/routes/adminTableEdits.test.ts
Normal file
|
|
@ -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<any>(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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
42
apps/api/src/helpers/adminAudit.ts
Normal file
42
apps/api/src/helpers/adminAudit.ts
Normal file
|
|
@ -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<any>)
|
||||
.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}`);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<string, AdminTableDefinition> = {
|
|||
{ 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<string, AdminTableDefinition> = {
|
|||
{ 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<string, AdminDatasetDefinition> = {
|
||||
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<string, AdminActionDefinition> = {
|
||||
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<string, unknown>[];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
109
apps/api/src/routers/adminActions.ts
Normal file
109
apps/api/src/routers/adminActions.ts
Normal file
|
|
@ -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<string, unknown>;
|
||||
|
||||
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<BaseEnv>();
|
||||
|
||||
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;
|
||||
};
|
||||
155
apps/api/src/routers/adminDatasets.ts
Normal file
155
apps/api/src/routers/adminDatasets.ts
Normal file
|
|
@ -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<string | null>) {
|
||||
const counts = new Map<string, number>();
|
||||
|
||||
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<string | null>, emptyLabel: string) {
|
||||
const counts = new Map<string, number>();
|
||||
|
||||
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<string, number>([
|
||||
["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<AdminDatasetPayload> {
|
||||
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<BaseEnv>();
|
||||
|
||||
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;
|
||||
};
|
||||
144
apps/api/src/routers/adminOverview.ts
Normal file
144
apps/api/src/routers/adminOverview.ts
Normal file
|
|
@ -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<BaseEnv>();
|
||||
|
||||
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;
|
||||
};
|
||||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
56
docs/ADMIN_APP_ACCESS_SETUP.md
Normal file
56
docs/ADMIN_APP_ACCESS_SETUP.md
Normal file
|
|
@ -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`
|
||||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -2,6 +2,17 @@
|
|||
// Admin Types
|
||||
// ============================================================================
|
||||
export type {
|
||||
AdminActionField,
|
||||
AdminActionRunResponse,
|
||||
AdminActionSummary,
|
||||
AdminDatasetChartType,
|
||||
AdminDatasetPoint,
|
||||
AdminDatasetResult,
|
||||
AdminDatasetSummary,
|
||||
AdminOverviewAlert,
|
||||
AdminOverviewMetric,
|
||||
AdminOverviewResponse,
|
||||
AdminOverviewShortcut,
|
||||
AdminRole,
|
||||
AdminSessionInfo,
|
||||
AdminSessionResponse,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
);
|
||||
Loading…
Reference in a new issue