From 1c63591e8085dd29bcc948ea412b81e5731ec5f2 Mon Sep 17 00:00:00 2001 From: Ada Date: Tue, 6 Jan 2026 19:27:55 -0500 Subject: [PATCH] Adds admin pages for notes and API tokens Adds new admin pages for managing lab notes and API tokens. Includes functionality to sync notes from Markdown files, view and manage notes in a table format, and create/edit notes. Also introduces an API tokens management page to view active tokens. Improves API error handling on the AdminGate to prevent redirect loops. --- package.json | 1 + scripts/syncLabNotesFromMd.ts | 0 src/lib/adminTokensClient.ts | 61 ++++ src/pages/admin/admin.routes.tsx | 2 + src/pages/admin/components/Panel.tsx | 2 +- .../admin/components/SyncLabNotesPanel.tsx | 101 +++++++ src/pages/admin/layout/AdminGate.tsx | 37 ++- src/pages/admin/layout/AdminNav.tsx | 1 + src/pages/admin/pages/AdminNotesPage.tsx | 274 ++++++++++++++++-- src/pages/admin/pages/AdminTokensPage.tsx | 102 +++++++ 10 files changed, 548 insertions(+), 33 deletions(-) create mode 100644 scripts/syncLabNotesFromMd.ts create mode 100644 src/lib/adminTokensClient.ts create mode 100644 src/pages/admin/components/SyncLabNotesPanel.tsx create mode 100644 src/pages/admin/pages/AdminTokensPage.tsx diff --git a/package.json b/package.json index 139dc13..c713826 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,7 @@ "dev": "vite", "build": "vite build", "preview": "vite preview", + "sync:labnotes": "tsx src/scripts/syncLabNotesFromMd.ts", "test": "vitest", "test:watch": "vitest watch", "test:coverage": "vitest run --coverage", diff --git a/scripts/syncLabNotesFromMd.ts b/scripts/syncLabNotesFromMd.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/lib/adminTokensClient.ts b/src/lib/adminTokensClient.ts new file mode 100644 index 0000000..70e41a6 --- /dev/null +++ b/src/lib/adminTokensClient.ts @@ -0,0 +1,61 @@ +type ApiTokenRow = { + id: string; + label: string; + scopes: string[]; + is_active: 0 | 1; + expires_at: string | null; + created_by_user: string | null; + last_used_at: string | null; + created_at: string; +}; + +type ApiOk = { ok: true; data: T }; +type ApiErr = { ok: false; error?: { code?: string; message?: string } }; + +function apiBase(): string { + // match whatever you do elsewhere (VITE_API_BASE_URL, etc.) + return import.meta.env.VITE_API_BASE_URL ?? "http://localhost:8001"; +} + +async function apiFetch(path: string, init?: RequestInit): Promise { + const res = await fetch(`${apiBase()}${path}`, { + credentials: "include", // IMPORTANT for GitHub session cookie auth + headers: { + "content-type": "application/json", + ...(init?.headers ?? {}), + }, + ...init, + }); + + const json = (await res.json().catch(() => ({}))) as any; + + if (!res.ok) { + const msg = json?.error?.message || json?.error || `HTTP ${res.status}`; + throw new Error(msg); + } + return json as T; +} + +export const adminTokensClient = { + async list(): Promise { + const out = await apiFetch | ApiErr>("/admin/tokens"); + if (!("ok" in out) || (out as any).ok !== true) throw new Error("Request failed"); + return (out as ApiOk).data; + }, + + async mint(input: { label: string; scopes: string[]; expires_at?: string | null }): Promise<{ id: string; token: string }> { + const out = await apiFetch | ApiErr>("/admin/tokens", { + method: "POST", + body: JSON.stringify(input), + }); + if ((out as any).ok !== true) throw new Error("Request failed"); + return (out as ApiOk<{ id: string; token: string }>).data; + }, + + async revoke(id: string): Promise { + const out = await apiFetch<{ ok: true } | ApiErr>(`/admin/tokens/${id}/revoke`, { + method: "POST", + }); + if ((out as any).ok !== true) throw new Error("Request failed"); + }, +}; diff --git a/src/pages/admin/admin.routes.tsx b/src/pages/admin/admin.routes.tsx index 53d9efd..ec277e5 100644 --- a/src/pages/admin/admin.routes.tsx +++ b/src/pages/admin/admin.routes.tsx @@ -10,6 +10,7 @@ import { AdminDeniedPage } from "@/pages/admin/pages/AdminDeniedPage"; import { AdminDashboardPage } from "@/pages/admin/pages/AdminDashboardPage"; import { AdminNotesPage } from "@/pages/admin/pages/AdminNotesPage"; import AdminApiDocsPage from "@/pages/admin/AdminApiDocsPage"; +import { AdminTokensPage } from "@/pages/admin/pages/AdminTokensPage"; function PageLoader() { return
Loading…
; @@ -48,6 +49,7 @@ export const adminRoutes = [ { index: true, element: }, { path: "dashboard", element: }, { path: "notes", element: }, + { path: "tokens", element: }, { path: "docs", element: }, { path: "*", element: }, ], diff --git a/src/pages/admin/components/Panel.tsx b/src/pages/admin/components/Panel.tsx index 037696a..27b6533 100644 --- a/src/pages/admin/components/Panel.tsx +++ b/src/pages/admin/components/Panel.tsx @@ -14,7 +14,7 @@ export function Panel({ ${muted ? "bg-zinc-950" : "bg-zinc-900"} `} > -

+

{title}

{children} diff --git a/src/pages/admin/components/SyncLabNotesPanel.tsx b/src/pages/admin/components/SyncLabNotesPanel.tsx new file mode 100644 index 0000000..c814045 --- /dev/null +++ b/src/pages/admin/components/SyncLabNotesPanel.tsx @@ -0,0 +1,101 @@ +import { useState } from "react"; + +type SyncResult = { + ok: true; + rootDir: string; + locales: string[]; + scanned: number; + upserted: number; + skipped: number; + errors: Array<{ file: string; error: string }>; +}; + +type SyncError = { + ok?: false; + error: string; +}; + +type Result = SyncResult | SyncError | null; + +export function SyncLabNotesPanel() { + const [syncing, setSyncing] = useState(false); + const [result, setResult] = useState(null); + + async function runSync() { + setSyncing(true); + setResult(null); + + try { + const res = await fetch("/api/admin/notes/sync", { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + }); + + const data = await res.json().catch(() => ({})); + + if (!res.ok || data?.ok === false) { + throw new Error(data?.error || `Sync failed (${res.status})`); + } + + setResult(data); + } catch (err: any) { + setResult({ ok: false, error: err?.message ?? String(err) }); + } finally { + setSyncing(false); + } + } + + return ( +
+
+

+ Markdown Sync +

+ + +
+ + {result && "error" in result && ( +
+ ❌ {result.error} +
+ )} + + {result && "ok" in result && result.ok && ( +
+
📁 Root: {result.rootDir}
+
🌍 Locales: {result.locales.join(", ")}
+
📄 Scanned: {result.scanned}
+
✏️ Upserted: {result.upserted}
+
⏭ Skipped: {result.skipped}
+ + {result.errors.length > 0 && ( +
+ + ⚠ Errors ({result.errors.length}) + +
    + {result.errors.map((e, i) => ( +
  • + {e.file}: {e.error} +
  • + ))} +
+
+ )} +
+ )} +
+ ); +} diff --git a/src/pages/admin/layout/AdminGate.tsx b/src/pages/admin/layout/AdminGate.tsx index a21036f..3966cd3 100644 --- a/src/pages/admin/layout/AdminGate.tsx +++ b/src/pages/admin/layout/AdminGate.tsx @@ -3,6 +3,8 @@ import { useEffect, useState } from "react"; import { Outlet, useLocation, useNavigate } from "react-router-dom"; import { apiBaseUrl } from "@/api/api"; +type GateStatus = "checking" | "ok" | "error"; + export function AdminGate() { const navigate = useNavigate(); const location = useLocation(); @@ -11,10 +13,9 @@ export function AdminGate() { const devBypass = import.meta.env.DEV && import.meta.env.VITE_ADMIN_DEV_BYPASS === "true"; - const [status, setStatus] = useState<"checking" | "ok">("checking"); + const [status, setStatus] = useState("checking"); useEffect(() => { - // ✅ If dev bypass is enabled, don't gate the UI at all if (devBypass) { setStatus("ok"); return; @@ -32,27 +33,26 @@ export function AdminGate() { headers: { Accept: "application/json" }, }); - // 401 = not logged in -> go to admin login + if (!alive) return; + if (res.status === 401) { navigate(loginUrl, { replace: true }); return; } - // 403 = logged in but forbidden/allowlist/etc -> go to denied if (res.status === 403) { navigate("/admin/denied", { replace: true }); return; } - // Any other non-OK -> treat as not authorized (or API misrouted/down) if (!res.ok) { - navigate(loginUrl, { replace: true }); + // Unexpected status: show error (don’t pretend it’s a login problem) + setStatus("error"); return; } const data = await res.json(); - // accept common shapes, but prefer { user } const user = data?.user ?? data?.me ?? @@ -60,16 +60,15 @@ export function AdminGate() { data?.data?.me ?? (data?.id ? data : null); - // If API returns 200 but user is null, treat as unauthenticated if (!user) { navigate(loginUrl, { replace: true }); return; } - if (alive) setStatus("ok"); + setStatus("ok"); } catch { - // Network error / CORS / API down: treat as unauthenticated - navigate(loginUrl, { replace: true }); + if (!alive) return; + setStatus("error"); } })(); @@ -82,5 +81,19 @@ export function AdminGate() { return
Checking clearance…
; } + if (status === "error") { + return ( +
+
+ Can’t reach the Lab API +
+
+ This usually means VITE_API_BASE_URL is wrong, CORS/session + settings are off, or the API is down. +
+
+ ); + } + return ; -} +} \ No newline at end of file diff --git a/src/pages/admin/layout/AdminNav.tsx b/src/pages/admin/layout/AdminNav.tsx index 8fe2aca..c61a1c5 100644 --- a/src/pages/admin/layout/AdminNav.tsx +++ b/src/pages/admin/layout/AdminNav.tsx @@ -3,6 +3,7 @@ import { NavLink } from "react-router-dom"; const NAV_ITEMS = [ { label: "Dashboard", path: "/admin/dashboard" }, { label: "Lab Notes", path: "/admin/notes" }, + { label: "API Tokens", path: "/admin/tokens" }, { label: "API Docs", path: "/admin/docs" }, // Future controls diff --git a/src/pages/admin/pages/AdminNotesPage.tsx b/src/pages/admin/pages/AdminNotesPage.tsx index 023523d..3972e57 100644 --- a/src/pages/admin/pages/AdminNotesPage.tsx +++ b/src/pages/admin/pages/AdminNotesPage.tsx @@ -2,6 +2,7 @@ import { useState, useEffect } from "react"; import { useNavigate } from "react-router-dom"; import { apiBaseUrl } from "@/api/api"; import { Panel } from "../components/Panel"; +import {SyncLabNotesPanel} from "@/pages/admin/components/SyncLabNotesPanel"; export function AdminNotesPage() { const navigate = useNavigate(); @@ -14,6 +15,7 @@ export function AdminNotesPage() { slug: "", category: "", excerpt: "", + content_html: "", // ✅ add read_time_minutes: 5, published_at: new Date().toISOString().split("T")[0], }); @@ -24,19 +26,39 @@ export function AdminNotesPage() { if (res.status === 401) return navigate("/admin/login"); if (res.status === 403) return navigate("/admin/denied"); - if (!res.ok) throw new Error("Failed to load notes"); - setNotes(await res.json()); + const text = await res.text(); + if (!res.ok) throw new Error(`Failed to load notes (${res.status}): ${text}`); + + const json = text ? JSON.parse(text) : null; + + // Accept common shapes: + const list = + Array.isArray(json) ? json : + Array.isArray(json?.notes) ? json.notes : + Array.isArray(json?.data?.notes) ? json.data.notes : + Array.isArray(json?.data) ? json.data : + []; + + setNotes(list); }; useEffect(() => { + console.log("API base:", API); refreshNotes().catch(() => navigate("/admin/login")); // eslint-disable-next-line react-hooks/exhaustive-deps }, [API, navigate]); const handleChange = ( e: React.ChangeEvent - ) => setForm({ ...form, [e.target.name]: e.target.value }); + ) => { + const { name, value } = e.target; + + setForm((prev) => ({ + ...prev, + [name]: name === "read_time_minutes" ? Number(value) : value, + })); + }; const resetForm = () => { setForm({ @@ -45,6 +67,7 @@ export function AdminNotesPage() { slug: "", category: "", excerpt: "", + content_html: "", // ✅ add read_time_minutes: 5, published_at: new Date().toISOString().split("T")[0], }); @@ -54,30 +77,45 @@ export function AdminNotesPage() { const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); - const method = editingId ? "PUT" : "POST"; - const url = editingId - ? `${API}/admin/notes/${editingId}` - : `${API}/admin/notes`; - - const res = await fetch(url, { - method, + const res = await fetch(`${API}/admin/notes`, { + method: "POST", credentials: "include", headers: { "Content-Type": "application/json" }, - body: JSON.stringify(form), + body: JSON.stringify({ + ...form, + // ensure id is included when editing, so backend updates instead of creating + id: editingId ?? form.id ?? undefined, + }), }); if (res.status === 401) return navigate("/admin/login"); if (res.status === 403) return navigate("/admin/denied"); - if (res.ok) { - await refreshNotes(); - resetForm(); + if (!res.ok) { + const t = await res.text(); + console.error("Save failed:", res.status, t); + alert(`Save failed (${res.status}). Check console.`); + return; } + + await refreshNotes(); + resetForm(); }; + const handleEdit = (note: any) => { - setForm(note); + setForm({ + id: note.id ?? "", + title: note.title ?? "", + slug: note.slug ?? "", + category: note.category ?? "", + excerpt: note.excerpt ?? "", + content_html: note.content_html ?? "", + read_time_minutes: Number(note.read_time_minutes ?? 5), + published_at: (note.published_at ?? new Date().toISOString()).split("T")[0], + }); setEditingId(note.id); + }; const handleDelete = async (id: string) => { @@ -94,6 +132,26 @@ export function AdminNotesPage() { if (res.ok) await refreshNotes(); }; + const handleNotesSync = async () => { + const res = await fetch(`${API}/admin/notes/sync`, { + method: "POST", + credentials: "include", // IMPORTANT if you auth via cookies + headers: { "Content-Type": "application/json" }, + }); + + if (res.status === 401) return navigate("/admin/login"); + if (res.status === 403) return navigate("/admin/denied"); + + if (!res.ok) { + const t = await res.text(); + console.error("Import failed:", t); + alert("Import failed. Check console."); + return; + } + + await refreshNotes(); + }; + return (
{/* Header */} @@ -105,17 +163,193 @@ export function AdminNotesPage() { Lab Notes — Control Room

- - {/* Notes table */} + -
- {/* ...your table exactly as-is... */} +
+ + + + {[ + "Title", + "Slug", + "Dept", + "Type", + "Status", + "Published", + "Actions", + ].map((label) => ( + + ))} + + + + + {notes.length === 0 ? ( + + + + ) : ( + notes.map((n) => ( + + + + + + + + + + + )) + )} + +
+ + {label} + {label !== "Actions" && ( + + )} + +
+ No notes found. +
+ {n.title ?? (untitled)} + {n.locale ? ( + [{n.locale}] + ) : null} + {n.slug}{n.department_id ?? "—"}{n.type ?? "—"}{n.status ?? "—"}{n.published_at ?? "—"} +
+ + +
+
+ {/* Editor */} - {/* your form exactly as-is */} + +
+
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ +
+ +