feat(admin): add explorer shell and registry reads
This commit is contained in:
parent
49cdd3b755
commit
4f71c52e14
20 changed files with 635 additions and 14 deletions
33
apps/admin/src/components/AdminLayout.test.tsx
Normal file
33
apps/admin/src/components/AdminLayout.test.tsx
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import { render, screen } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import AppRoutes from "../routes";
|
||||
import { storeAdminSession } from "../lib/adminSession";
|
||||
|
||||
describe("AdminLayout", () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
storeAdminSession({
|
||||
expiresAt: new Date(Date.now() + 15 * 60 * 1000).toISOString(),
|
||||
operatorEmail: "ops@xtablo.com",
|
||||
operatorId: "operator-1",
|
||||
role: "operator",
|
||||
sessionToken: "admin-session-token",
|
||||
});
|
||||
});
|
||||
|
||||
it("shows the production badge and admin sections", async () => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/"]}>
|
||||
<AppRoutes />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
expect(await screen.findByText(/^production$/i)).toBeInTheDocument();
|
||||
expect(screen.getByRole("heading", { name: /operations home/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole("link", { name: /operations home/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole("link", { name: /data explorer/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole("link", { name: /analytics studio/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole("link", { name: /action center/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
29
apps/admin/src/components/AdminLayout.tsx
Normal file
29
apps/admin/src/components/AdminLayout.tsx
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import { Outlet } from "react-router-dom";
|
||||
import { useAdminSession } from "../hooks/useAdminSession";
|
||||
import { AdminNavigation } from "./AdminNavigation";
|
||||
import { ProductionBadge } from "./ProductionBadge";
|
||||
|
||||
export function AdminLayout() {
|
||||
const { operatorEmail } = useAdminSession();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background text-foreground">
|
||||
<div className="grid min-h-screen gap-6 lg:grid-cols-[260px_minmax(0,1fr)] p-6">
|
||||
<aside className="rounded-[2rem] border border-border bg-card p-5 shadow-sm">
|
||||
<div className="flex flex-col gap-4">
|
||||
<ProductionBadge />
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.25em] text-foreground/55">Operator</p>
|
||||
<p className="mt-2 text-sm font-medium">{operatorEmail ?? "Unknown operator"}</p>
|
||||
</div>
|
||||
<AdminNavigation />
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<section className="rounded-[2rem] border border-border bg-card/80 p-6 shadow-sm">
|
||||
<Outlet />
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
30
apps/admin/src/components/AdminNavigation.tsx
Normal file
30
apps/admin/src/components/AdminNavigation.tsx
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import { NavLink } from "react-router-dom";
|
||||
|
||||
const navItems = [
|
||||
{ label: "Operations Home", to: "/" },
|
||||
{ label: "Data Explorer", to: "/explorer" },
|
||||
{ label: "Analytics Studio", to: "/analytics" },
|
||||
{ label: "Action Center", to: "/actions" },
|
||||
];
|
||||
|
||||
export function AdminNavigation() {
|
||||
return (
|
||||
<nav className="flex flex-col gap-2">
|
||||
{navItems.map((item) => (
|
||||
<NavLink
|
||||
className={({ isActive }) =>
|
||||
[
|
||||
"rounded-2xl px-3 py-2 text-sm font-medium",
|
||||
isActive ? "bg-foreground text-background" : "text-foreground/75 hover:bg-black/5",
|
||||
].join(" ")
|
||||
}
|
||||
end={item.to === "/"}
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
>
|
||||
{item.label}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
|
@ -44,6 +44,6 @@ describe("PrivilegedGate", () => {
|
|||
});
|
||||
});
|
||||
|
||||
expect(await screen.findByText(/operations home/i)).toBeInTheDocument();
|
||||
expect(await screen.findByRole("heading", { name: /operations home/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
7
apps/admin/src/components/ProductionBadge.tsx
Normal file
7
apps/admin/src/components/ProductionBadge.tsx
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
export function ProductionBadge() {
|
||||
return (
|
||||
<div className="inline-flex items-center gap-2 rounded-full border border-red-300/60 bg-red-50 px-3 py-1 text-xs font-semibold uppercase tracking-[0.2em] text-red-700">
|
||||
Production
|
||||
</div>
|
||||
);
|
||||
}
|
||||
39
apps/admin/src/components/data-explorer/AdminGrid.tsx
Normal file
39
apps/admin/src/components/data-explorer/AdminGrid.tsx
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import type { AdminTableMeta } from "@xtablo/shared-types";
|
||||
|
||||
type AdminGridProps = {
|
||||
meta: AdminTableMeta | null;
|
||||
rows: Record<string, string | boolean | null>[];
|
||||
};
|
||||
|
||||
export function AdminGrid({ meta, rows }: AdminGridProps) {
|
||||
if (!meta) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-3xl border border-border bg-card">
|
||||
<table className="min-w-full border-collapse">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-black/5 text-left">
|
||||
{meta.columns.map((column) => (
|
||||
<th className="px-4 py-3 text-sm font-medium" key={column.id}>
|
||||
{column.label}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row, index) => (
|
||||
<tr className="border-b border-border/60 last:border-b-0" key={`${row.id ?? index}`}>
|
||||
{meta.columns.map((column) => (
|
||||
<td className="px-4 py-3 text-sm" key={column.id}>
|
||||
{String(row[column.id] ?? "")}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
94
apps/admin/src/hooks/useAdminTables.ts
Normal file
94
apps/admin/src/hooks/useAdminTables.ts
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
import type { AdminTableMeta, AdminTableSummary } from "@xtablo/shared-types";
|
||||
import { useEffect, useState } from "react";
|
||||
import { adminApi } from "../lib/api";
|
||||
|
||||
type AdminRow = Record<string, string | boolean | null>;
|
||||
|
||||
export function useAdminTables() {
|
||||
const [tables, setTables] = useState<AdminTableSummary[]>([]);
|
||||
const [selectedTableId, setSelectedTableId] = useState<string | null>(null);
|
||||
const [meta, setMeta] = useState<AdminTableMeta | null>(null);
|
||||
const [rows, setRows] = useState<AdminRow[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
const loadTables = async () => {
|
||||
try {
|
||||
const response = await adminApi.get<{ tables: AdminTableSummary[] }>("/admin/tables");
|
||||
if (!isMounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
setTables(response.data.tables);
|
||||
setSelectedTableId((currentValue) => currentValue ?? response.data.tables[0]?.id ?? null);
|
||||
} catch {
|
||||
if (isMounted) {
|
||||
setError("Failed to load admin tables");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
void loadTables();
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
const loadTableData = async () => {
|
||||
if (!selectedTableId) {
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const [metaResponse, rowsResponse] = await Promise.all([
|
||||
adminApi.get<AdminTableMeta>(`/admin/tables/${selectedTableId}/meta`),
|
||||
adminApi.get<{ rows: AdminRow[] }>(`/admin/tables/${selectedTableId}/rows`),
|
||||
]);
|
||||
|
||||
if (!isMounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
setMeta(metaResponse.data);
|
||||
setRows(rowsResponse.data.rows);
|
||||
} catch {
|
||||
if (isMounted) {
|
||||
setError("Failed to load admin table data");
|
||||
setMeta(null);
|
||||
setRows([]);
|
||||
}
|
||||
} finally {
|
||||
if (isMounted) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
void loadTableData();
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, [selectedTableId]);
|
||||
|
||||
return {
|
||||
error,
|
||||
isLoading,
|
||||
meta,
|
||||
rows,
|
||||
selectedTableId,
|
||||
setSelectedTableId,
|
||||
tables,
|
||||
};
|
||||
}
|
||||
|
|
@ -1,5 +1,16 @@
|
|||
import { buildApi } from "@xtablo/shared";
|
||||
import { getStoredAdminSession } from "./adminSession";
|
||||
|
||||
const apiBaseUrl = import.meta.env.VITE_API_URL || "http://localhost:8080/api/v1";
|
||||
|
||||
export const adminApi = buildApi(apiBaseUrl);
|
||||
|
||||
adminApi.interceptors.request.use((config) => {
|
||||
const adminSession = getStoredAdminSession();
|
||||
|
||||
if (adminSession) {
|
||||
config.headers.Authorization = `Bearer ${adminSession.sessionToken}`;
|
||||
}
|
||||
|
||||
return config;
|
||||
});
|
||||
|
|
|
|||
11
apps/admin/src/pages/ActionCenterPage.tsx
Normal file
11
apps/admin/src/pages/ActionCenterPage.tsx
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
export function ActionCenterPage() {
|
||||
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>
|
||||
);
|
||||
}
|
||||
11
apps/admin/src/pages/AnalyticsStudioPage.tsx
Normal file
11
apps/admin/src/pages/AnalyticsStudioPage.tsx
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
export function AnalyticsStudioPage() {
|
||||
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>
|
||||
);
|
||||
}
|
||||
71
apps/admin/src/pages/DataExplorerPage.test.tsx
Normal file
71
apps/admin/src/pages/DataExplorerPage.test.tsx
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
import { render, screen } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { adminApi } from "../lib/api";
|
||||
import { DataExplorerPage } from "./DataExplorerPage";
|
||||
|
||||
vi.mock("../lib/api", () => ({
|
||||
adminApi: {
|
||||
get: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe("DataExplorerPage", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("loads rows for the selected table", async () => {
|
||||
vi.mocked(adminApi.get).mockImplementation(async (path: string) => {
|
||||
if (path === "/admin/tables") {
|
||||
return {
|
||||
data: {
|
||||
tables: [
|
||||
{ id: "profiles", label: "Users" },
|
||||
{ id: "tablo_access", label: "Tablo Access" },
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (path === "/admin/tables/profiles/meta") {
|
||||
return {
|
||||
data: {
|
||||
columns: [
|
||||
{ id: "email", label: "Email" },
|
||||
{ id: "first_name", label: "First name" },
|
||||
],
|
||||
id: "profiles",
|
||||
label: "Users",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (path === "/admin/tables/profiles/rows") {
|
||||
return {
|
||||
data: {
|
||||
rows: [
|
||||
{
|
||||
email: "test_owner@example.com",
|
||||
first_name: "Test",
|
||||
id: "user-1",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error(`Unexpected path: ${path}`);
|
||||
});
|
||||
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<DataExplorerPage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
43
apps/admin/src/pages/DataExplorerPage.tsx
Normal file
43
apps/admin/src/pages/DataExplorerPage.tsx
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import { AdminGrid } from "../components/data-explorer/AdminGrid";
|
||||
import { useAdminTables } from "../hooks/useAdminTables";
|
||||
|
||||
export function DataExplorerPage() {
|
||||
const { error, isLoading, meta, rows, selectedTableId, setSelectedTableId, tables } =
|
||||
useAdminTables();
|
||||
|
||||
return (
|
||||
<main className="min-h-screen p-6">
|
||||
<div className="grid gap-6 lg:grid-cols-[220px_minmax(0,1fr)]">
|
||||
<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"
|
||||
key={table.id}
|
||||
onClick={() => setSelectedTableId(table.id)}
|
||||
type="button"
|
||||
>
|
||||
{table.label}
|
||||
{selectedTableId === table.id ? " *" : ""}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<section className="space-y-4">
|
||||
<header>
|
||||
<h1 className="text-3xl font-semibold">{meta?.label ?? "Explorer"}</h1>
|
||||
<p className="mt-2 text-sm text-foreground/70">
|
||||
Approved production tables exposed through the internal admin registry.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{isLoading ? <p>Loading explorer...</p> : null}
|
||||
{error ? <p className="text-red-600">{error}</p> : null}
|
||||
{!isLoading && !error ? <AdminGrid meta={meta} rows={rows} /> : null}
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
13
apps/admin/src/pages/OperationsHomePage.tsx
Normal file
13
apps/admin/src/pages/OperationsHomePage.tsx
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
export function OperationsHomePage() {
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,4 +1,9 @@
|
|||
import { Route, Routes } from "react-router-dom";
|
||||
import { Outlet, Route, Routes } from "react-router-dom";
|
||||
import { ActionCenterPage } from "./pages/ActionCenterPage";
|
||||
import { AnalyticsStudioPage } from "./pages/AnalyticsStudioPage";
|
||||
import { DataExplorerPage } from "./pages/DataExplorerPage";
|
||||
import { OperationsHomePage } from "./pages/OperationsHomePage";
|
||||
import { AdminLayout } from "./components/AdminLayout";
|
||||
import { PrivilegedGate } from "./components/PrivilegedGate";
|
||||
import { useAdminSession } from "./hooks/useAdminSession";
|
||||
|
||||
|
|
@ -6,13 +11,7 @@ function AdminEntry() {
|
|||
const { error, isAuthenticated, isPending, unlock } = useAdminSession();
|
||||
|
||||
if (isAuthenticated) {
|
||||
return (
|
||||
<main className="flex min-h-screen items-center justify-center p-6">
|
||||
<div className="rounded-3xl border border-border bg-card p-10 shadow-sm">
|
||||
<p className="text-lg font-semibold">Operations Home</p>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
return <Outlet />;
|
||||
}
|
||||
|
||||
return <PrivilegedGate error={error} isPending={isPending} onUnlock={unlock} />;
|
||||
|
|
@ -21,7 +20,14 @@ function AdminEntry() {
|
|||
export default function AppRoutes() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="*" element={<AdminEntry />} />
|
||||
<Route element={<AdminEntry />}>
|
||||
<Route element={<AdminLayout />}>
|
||||
<Route index element={<OperationsHomePage />} />
|
||||
<Route path="/explorer" element={<DataExplorerPage />} />
|
||||
<Route path="/analytics" element={<AnalyticsStudioPage />} />
|
||||
<Route path="/actions" element={<ActionCenterPage />} />
|
||||
</Route>
|
||||
</Route>
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
98
apps/api/src/__tests__/routes/adminTables.test.ts
Normal file
98
apps/api/src/__tests__/routes/adminTables.test.ts
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { createSignedAdminToken } from "../helpers/adminTokenTestUtils.js";
|
||||
import { createConfig } from "../../config.js";
|
||||
import { MiddlewareManager } from "../../middlewares/middleware.js";
|
||||
import { getMainRouter } from "../../routers/index.js";
|
||||
|
||||
const ADMIN_TOKEN_SIGNING_SECRET = "admin-test-secret";
|
||||
const ADMIN_TOKEN_AUDIENCE = "xtablo-admin";
|
||||
|
||||
describe("Admin Tables Router", () => {
|
||||
process.env.ADMIN_TOKEN_SIGNING_SECRET = ADMIN_TOKEN_SIGNING_SECRET;
|
||||
process.env.ADMIN_TOKEN_AUDIENCE = ADMIN_TOKEN_AUDIENCE;
|
||||
process.env.ADMIN_APP_URL = "http://localhost:5176";
|
||||
|
||||
const config = createConfig();
|
||||
MiddlewareManager.initialize(config);
|
||||
const app = getMainRouter(config);
|
||||
|
||||
const sessionToken = createSignedAdminToken(
|
||||
{
|
||||
aud: ADMIN_TOKEN_AUDIENCE,
|
||||
email: "ops@xtablo.com",
|
||||
exp: Math.floor(Date.now() / 1000) + 900,
|
||||
role: "operator",
|
||||
sub: "operator-1",
|
||||
type: "admin_session",
|
||||
},
|
||||
ADMIN_TOKEN_SIGNING_SECRET
|
||||
);
|
||||
|
||||
it("lists only approved admin tables", async () => {
|
||||
const res = await app.request("/admin/tables", {
|
||||
headers: {
|
||||
Authorization: `Bearer ${sessionToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
await expect(res.json()).resolves.toMatchObject({
|
||||
tables: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: "profiles",
|
||||
label: "Users",
|
||||
}),
|
||||
]),
|
||||
});
|
||||
});
|
||||
|
||||
it("returns metadata for an approved table", async () => {
|
||||
const res = await app.request("/admin/tables/profiles/meta", {
|
||||
headers: {
|
||||
Authorization: `Bearer ${sessionToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
await expect(res.json()).resolves.toMatchObject({
|
||||
id: "profiles",
|
||||
label: "Users",
|
||||
columns: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: "email",
|
||||
label: "Email",
|
||||
}),
|
||||
]),
|
||||
});
|
||||
});
|
||||
|
||||
it("returns rows for an approved table", async () => {
|
||||
const res = await app.request("/admin/tables/profiles/rows", {
|
||||
headers: {
|
||||
Authorization: `Bearer ${sessionToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
await expect(res.json()).resolves.toMatchObject({
|
||||
rows: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
email: "test_owner@example.com",
|
||||
}),
|
||||
]),
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects tables that are not in the registry", async () => {
|
||||
const res = await app.request("/admin/tables/secrets/meta", {
|
||||
headers: {
|
||||
Authorization: `Bearer ${sessionToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
await expect(res.json()).resolves.toMatchObject({
|
||||
error: "Admin table 'secrets' is not registered",
|
||||
});
|
||||
});
|
||||
});
|
||||
53
apps/api/src/helpers/adminRegistry.ts
Normal file
53
apps/api/src/helpers/adminRegistry.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import type { Database } from "@xtablo/shared-types";
|
||||
|
||||
type AdminTableColumn = {
|
||||
id: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
type AdminTableDefinition = {
|
||||
columns: AdminTableColumn[];
|
||||
id: string;
|
||||
label: string;
|
||||
select: string;
|
||||
source: keyof Database["public"]["Tables"];
|
||||
};
|
||||
|
||||
export const adminTableRegistry: Record<string, AdminTableDefinition> = {
|
||||
profiles: {
|
||||
columns: [
|
||||
{ id: "id", label: "ID" },
|
||||
{ id: "email", label: "Email" },
|
||||
{ id: "first_name", label: "First name" },
|
||||
{ id: "last_name", label: "Last name" },
|
||||
],
|
||||
id: "profiles",
|
||||
label: "Users",
|
||||
select: "id,email,first_name,last_name",
|
||||
source: "profiles",
|
||||
},
|
||||
tablo_access: {
|
||||
columns: [
|
||||
{ id: "tablo_id", label: "Tablo ID" },
|
||||
{ id: "user_id", label: "User ID" },
|
||||
{ id: "is_active", label: "Active" },
|
||||
{ id: "is_admin", label: "Admin" },
|
||||
],
|
||||
id: "tablo_access",
|
||||
label: "Tablo Access",
|
||||
select: "tablo_id,user_id,is_active,is_admin",
|
||||
source: "tablo_access",
|
||||
},
|
||||
};
|
||||
|
||||
export function getAdminTableDefinition(tableId: string) {
|
||||
return adminTableRegistry[tableId] ?? null;
|
||||
}
|
||||
|
||||
export function listAdminTables() {
|
||||
return Object.values(adminTableRegistry).map(({ id, label }) => ({ id, label }));
|
||||
}
|
||||
|
||||
export function normalizeAdminRows(rows: unknown[]) {
|
||||
return rows as Record<string, unknown>[];
|
||||
}
|
||||
|
|
@ -11,6 +11,7 @@ export const getAdminRouter = (config: AppConfig) => {
|
|||
|
||||
adminRouter.route("/auth", getAdminAuthRouter(config));
|
||||
|
||||
adminRouter.use("/tables", middlewareManager.adminAuth);
|
||||
adminRouter.use("/tables/*", middlewareManager.adminAuth);
|
||||
adminRouter.route("/tables", getAdminTablesRouter());
|
||||
|
||||
|
|
|
|||
|
|
@ -1,19 +1,67 @@
|
|||
import { Hono } from "hono";
|
||||
import { getAdminTableDefinition, listAdminTables, normalizeAdminRows } from "../helpers/adminRegistry.js";
|
||||
import type { BaseEnv } from "../types/app.types.js";
|
||||
|
||||
export const getAdminTablesRouter = () => {
|
||||
const adminTablesRouter = new Hono<BaseEnv>();
|
||||
|
||||
adminTablesRouter.get("/:tableId", async (c) => {
|
||||
adminTablesRouter.get("/", async (c) => {
|
||||
return c.json({ tables: listAdminTables() }, 200);
|
||||
});
|
||||
|
||||
adminTablesRouter.get("/:tableId/meta", async (c) => {
|
||||
const tableId = c.req.param("tableId");
|
||||
const tableDefinition = getAdminTableDefinition(tableId);
|
||||
|
||||
if (!tableDefinition) {
|
||||
return c.json(
|
||||
{
|
||||
error: `Admin table '${tableId}' is not registered`,
|
||||
},
|
||||
404
|
||||
);
|
||||
}
|
||||
|
||||
return c.json(
|
||||
{
|
||||
error: `Admin table '${tableId}' is not implemented yet`,
|
||||
columns: tableDefinition.columns,
|
||||
id: tableDefinition.id,
|
||||
label: tableDefinition.label,
|
||||
},
|
||||
501
|
||||
200
|
||||
);
|
||||
});
|
||||
|
||||
adminTablesRouter.get("/:tableId/rows", async (c) => {
|
||||
const supabase = c.get("supabase");
|
||||
const tableId = c.req.param("tableId");
|
||||
const tableDefinition = getAdminTableDefinition(tableId);
|
||||
|
||||
if (!tableDefinition) {
|
||||
return c.json(
|
||||
{
|
||||
error: `Admin table '${tableId}' is not registered`,
|
||||
},
|
||||
404
|
||||
);
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from(tableDefinition.source)
|
||||
.select(tableDefinition.select)
|
||||
.limit(50);
|
||||
|
||||
if (error) {
|
||||
return c.json(
|
||||
{
|
||||
error: `Failed to load admin table '${tableId}'`,
|
||||
},
|
||||
500
|
||||
);
|
||||
}
|
||||
|
||||
return c.json({ rows: normalizeAdminRows(data ?? []) }, 200);
|
||||
});
|
||||
|
||||
return adminTablesRouter;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -15,3 +15,19 @@ export type AdminSessionInfo = {
|
|||
operatorId: string;
|
||||
role: AdminRole;
|
||||
};
|
||||
|
||||
export type AdminTableSummary = {
|
||||
id: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
export type AdminTableColumn = {
|
||||
id: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
export type AdminTableMeta = {
|
||||
columns: AdminTableColumn[];
|
||||
id: string;
|
||||
label: string;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,7 +1,14 @@
|
|||
// ============================================================================
|
||||
// Admin Types
|
||||
// ============================================================================
|
||||
export type { AdminRole, AdminSessionInfo, AdminSessionResponse } from "./admin.types.js";
|
||||
export type {
|
||||
AdminRole,
|
||||
AdminSessionInfo,
|
||||
AdminSessionResponse,
|
||||
AdminTableColumn,
|
||||
AdminTableMeta,
|
||||
AdminTableSummary,
|
||||
} from "./admin.types.js";
|
||||
// ============================================================================
|
||||
// Database Types
|
||||
// ============================================================================
|
||||
|
|
|
|||
Loading…
Reference in a new issue