Skip to content

Commit 416feee

Browse files
authored
Merge pull request #28 from AdaInTheLab/ledger-admin-tokens-and-notes-hardening
Adds admin pages for notes and API tokens
2 parents 270cb05 + 1c63591 commit 416feee

10 files changed

Lines changed: 548 additions & 33 deletions

File tree

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@
4646
"dev": "vite",
4747
"build": "vite build",
4848
"preview": "vite preview",
49+
"sync:labnotes": "tsx src/scripts/syncLabNotesFromMd.ts",
4950
"test": "vitest",
5051
"test:watch": "vitest watch",
5152
"test:coverage": "vitest run --coverage",

scripts/syncLabNotesFromMd.ts

Whitespace-only changes.

src/lib/adminTokensClient.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
type ApiTokenRow = {
2+
id: string;
3+
label: string;
4+
scopes: string[];
5+
is_active: 0 | 1;
6+
expires_at: string | null;
7+
created_by_user: string | null;
8+
last_used_at: string | null;
9+
created_at: string;
10+
};
11+
12+
type ApiOk<T> = { ok: true; data: T };
13+
type ApiErr = { ok: false; error?: { code?: string; message?: string } };
14+
15+
function apiBase(): string {
16+
// match whatever you do elsewhere (VITE_API_BASE_URL, etc.)
17+
return import.meta.env.VITE_API_BASE_URL ?? "http://localhost:8001";
18+
}
19+
20+
async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
21+
const res = await fetch(`${apiBase()}${path}`, {
22+
credentials: "include", // IMPORTANT for GitHub session cookie auth
23+
headers: {
24+
"content-type": "application/json",
25+
...(init?.headers ?? {}),
26+
},
27+
...init,
28+
});
29+
30+
const json = (await res.json().catch(() => ({}))) as any;
31+
32+
if (!res.ok) {
33+
const msg = json?.error?.message || json?.error || `HTTP ${res.status}`;
34+
throw new Error(msg);
35+
}
36+
return json as T;
37+
}
38+
39+
export const adminTokensClient = {
40+
async list(): Promise<ApiTokenRow[]> {
41+
const out = await apiFetch<ApiOk<ApiTokenRow[]> | ApiErr>("/admin/tokens");
42+
if (!("ok" in out) || (out as any).ok !== true) throw new Error("Request failed");
43+
return (out as ApiOk<ApiTokenRow[]>).data;
44+
},
45+
46+
async mint(input: { label: string; scopes: string[]; expires_at?: string | null }): Promise<{ id: string; token: string }> {
47+
const out = await apiFetch<ApiOk<{ id: string; token: string }> | ApiErr>("/admin/tokens", {
48+
method: "POST",
49+
body: JSON.stringify(input),
50+
});
51+
if ((out as any).ok !== true) throw new Error("Request failed");
52+
return (out as ApiOk<{ id: string; token: string }>).data;
53+
},
54+
55+
async revoke(id: string): Promise<void> {
56+
const out = await apiFetch<{ ok: true } | ApiErr>(`/admin/tokens/${id}/revoke`, {
57+
method: "POST",
58+
});
59+
if ((out as any).ok !== true) throw new Error("Request failed");
60+
},
61+
};

src/pages/admin/admin.routes.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { AdminDeniedPage } from "@/pages/admin/pages/AdminDeniedPage";
1010
import { AdminDashboardPage } from "@/pages/admin/pages/AdminDashboardPage";
1111
import { AdminNotesPage } from "@/pages/admin/pages/AdminNotesPage";
1212
import AdminApiDocsPage from "@/pages/admin/AdminApiDocsPage";
13+
import { AdminTokensPage } from "@/pages/admin/pages/AdminTokensPage";
1314

1415
function PageLoader() {
1516
return <div className="p-6 text-slate-300">Loading…</div>;
@@ -48,6 +49,7 @@ export const adminRoutes = [
4849
{ index: true, element: <Navigate to="dashboard" replace /> },
4950
{ path: "dashboard", element: <AdminDashboardPage /> },
5051
{ path: "notes", element: <AdminNotesPage /> },
52+
{ path: "tokens", element: <AdminTokensPage /> },
5153
{ path: "docs", element: <AdminApiDocsPage /> },
5254
{ path: "*", element: <Navigate to="dashboard" replace /> },
5355
],

src/pages/admin/components/Panel.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ export function Panel({
1414
${muted ? "bg-zinc-950" : "bg-zinc-900"}
1515
`}
1616
>
17-
<h2 className="text-sm font-semibold text-zinc-300 mb-3">
17+
<h2 className="mb-3 border-b border-zinc-800 pb-2 text-sm font-semibold text-zinc-300">
1818
{title}
1919
</h2>
2020
{children}
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import { useState } from "react";
2+
3+
type SyncResult = {
4+
ok: true;
5+
rootDir: string;
6+
locales: string[];
7+
scanned: number;
8+
upserted: number;
9+
skipped: number;
10+
errors: Array<{ file: string; error: string }>;
11+
};
12+
13+
type SyncError = {
14+
ok?: false;
15+
error: string;
16+
};
17+
18+
type Result = SyncResult | SyncError | null;
19+
20+
export function SyncLabNotesPanel() {
21+
const [syncing, setSyncing] = useState(false);
22+
const [result, setResult] = useState<Result>(null);
23+
24+
async function runSync() {
25+
setSyncing(true);
26+
setResult(null);
27+
28+
try {
29+
const res = await fetch("/api/admin/notes/sync", {
30+
method: "POST",
31+
credentials: "include",
32+
headers: { "Content-Type": "application/json" },
33+
});
34+
35+
const data = await res.json().catch(() => ({}));
36+
37+
if (!res.ok || data?.ok === false) {
38+
throw new Error(data?.error || `Sync failed (${res.status})`);
39+
}
40+
41+
setResult(data);
42+
} catch (err: any) {
43+
setResult({ ok: false, error: err?.message ?? String(err) });
44+
} finally {
45+
setSyncing(false);
46+
}
47+
}
48+
49+
return (
50+
<section className="rounded-xl border border-zinc-800 bg-zinc-950 p-4 space-y-3">
51+
<header className="flex items-center justify-between">
52+
<h2 className="text-sm uppercase tracking-widest text-zinc-400">
53+
Markdown Sync
54+
</h2>
55+
56+
<button
57+
onClick={runSync}
58+
disabled={syncing}
59+
className="
60+
px-3 py-2 rounded-md text-sm
61+
bg-zinc-800 hover:bg-zinc-700
62+
disabled:opacity-50 disabled:cursor-not-allowed
63+
"
64+
>
65+
{syncing ? "Syncing…" : "Sync Markdown → DB"}
66+
</button>
67+
</header>
68+
69+
{result && "error" in result && (
70+
<div className="text-sm text-red-400">
71+
{result.error}
72+
</div>
73+
)}
74+
75+
{result && "ok" in result && result.ok && (
76+
<div className="text-sm text-zinc-300 space-y-1">
77+
<div>📁 Root: <span className="text-zinc-400">{result.rootDir}</span></div>
78+
<div>🌍 Locales: {result.locales.join(", ")}</div>
79+
<div>📄 Scanned: {result.scanned}</div>
80+
<div>✏️ Upserted: {result.upserted}</div>
81+
<div>⏭ Skipped: {result.skipped}</div>
82+
83+
{result.errors.length > 0 && (
84+
<details className="mt-2">
85+
<summary className="cursor-pointer text-red-300">
86+
⚠ Errors ({result.errors.length})
87+
</summary>
88+
<ul className="mt-2 space-y-1">
89+
{result.errors.map((e, i) => (
90+
<li key={i} className="text-xs text-red-200">
91+
{e.file}: {e.error}
92+
</li>
93+
))}
94+
</ul>
95+
</details>
96+
)}
97+
</div>
98+
)}
99+
</section>
100+
);
101+
}

src/pages/admin/layout/AdminGate.tsx

Lines changed: 25 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import { useEffect, useState } from "react";
33
import { Outlet, useLocation, useNavigate } from "react-router-dom";
44
import { apiBaseUrl } from "@/api/api";
55

6+
type GateStatus = "checking" | "ok" | "error";
7+
68
export function AdminGate() {
79
const navigate = useNavigate();
810
const location = useLocation();
@@ -11,10 +13,9 @@ export function AdminGate() {
1113
const devBypass =
1214
import.meta.env.DEV && import.meta.env.VITE_ADMIN_DEV_BYPASS === "true";
1315

14-
const [status, setStatus] = useState<"checking" | "ok">("checking");
16+
const [status, setStatus] = useState<GateStatus>("checking");
1517

1618
useEffect(() => {
17-
// ✅ If dev bypass is enabled, don't gate the UI at all
1819
if (devBypass) {
1920
setStatus("ok");
2021
return;
@@ -32,44 +33,42 @@ export function AdminGate() {
3233
headers: { Accept: "application/json" },
3334
});
3435

35-
// 401 = not logged in -> go to admin login
36+
if (!alive) return;
37+
3638
if (res.status === 401) {
3739
navigate(loginUrl, { replace: true });
3840
return;
3941
}
4042

41-
// 403 = logged in but forbidden/allowlist/etc -> go to denied
4243
if (res.status === 403) {
4344
navigate("/admin/denied", { replace: true });
4445
return;
4546
}
4647

47-
// Any other non-OK -> treat as not authorized (or API misrouted/down)
4848
if (!res.ok) {
49-
navigate(loginUrl, { replace: true });
49+
// Unexpected status: show error (don’t pretend it’s a login problem)
50+
setStatus("error");
5051
return;
5152
}
5253

5354
const data = await res.json();
5455

55-
// accept common shapes, but prefer { user }
5656
const user =
5757
data?.user ??
5858
data?.me ??
5959
data?.data?.user ??
6060
data?.data?.me ??
6161
(data?.id ? data : null);
6262

63-
// If API returns 200 but user is null, treat as unauthenticated
6463
if (!user) {
6564
navigate(loginUrl, { replace: true });
6665
return;
6766
}
6867

69-
if (alive) setStatus("ok");
68+
setStatus("ok");
7069
} catch {
71-
// Network error / CORS / API down: treat as unauthenticated
72-
navigate(loginUrl, { replace: true });
70+
if (!alive) return;
71+
setStatus("error");
7372
}
7473
})();
7574

@@ -82,5 +81,19 @@ export function AdminGate() {
8281
return <div className="p-6 text-zinc-300">Checking clearance…</div>;
8382
}
8483

84+
if (status === "error") {
85+
return (
86+
<div className="p-6 text-zinc-300">
87+
<div className="text-lg font-semibold text-zinc-100">
88+
Can’t reach the Lab API
89+
</div>
90+
<div className="mt-2 text-sm text-zinc-400">
91+
This usually means <code>VITE_API_BASE_URL</code> is wrong, CORS/session
92+
settings are off, or the API is down.
93+
</div>
94+
</div>
95+
);
96+
}
97+
8598
return <Outlet />;
86-
}
99+
}

src/pages/admin/layout/AdminNav.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { NavLink } from "react-router-dom";
33
const NAV_ITEMS = [
44
{ label: "Dashboard", path: "/admin/dashboard" },
55
{ label: "Lab Notes", path: "/admin/notes" },
6+
{ label: "API Tokens", path: "/admin/tokens" },
67
{ label: "API Docs", path: "/admin/docs" },
78

89
// Future controls

0 commit comments

Comments
 (0)