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 */} + +
+
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ +
+ +