Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Empty file added scripts/syncLabNotesFromMd.ts
Empty file.
61 changes: 61 additions & 0 deletions src/lib/adminTokensClient.ts
Original file line number Diff line number Diff line change
@@ -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<T> = { 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<T>(path: string, init?: RequestInit): Promise<T> {
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<ApiTokenRow[]> {
const out = await apiFetch<ApiOk<ApiTokenRow[]> | ApiErr>("/admin/tokens");
if (!("ok" in out) || (out as any).ok !== true) throw new Error("Request failed");
return (out as ApiOk<ApiTokenRow[]>).data;
},

async mint(input: { label: string; scopes: string[]; expires_at?: string | null }): Promise<{ id: string; token: string }> {
const out = await apiFetch<ApiOk<{ id: string; token: string }> | 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<void> {
const out = await apiFetch<{ ok: true } | ApiErr>(`/admin/tokens/${id}/revoke`, {
method: "POST",
});
if ((out as any).ok !== true) throw new Error("Request failed");
},
};
2 changes: 2 additions & 0 deletions src/pages/admin/admin.routes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <div className="p-6 text-slate-300">Loading…</div>;
Expand Down Expand Up @@ -48,6 +49,7 @@ export const adminRoutes = [
{ index: true, element: <Navigate to="dashboard" replace /> },
{ path: "dashboard", element: <AdminDashboardPage /> },
{ path: "notes", element: <AdminNotesPage /> },
{ path: "tokens", element: <AdminTokensPage /> },
{ path: "docs", element: <AdminApiDocsPage /> },
{ path: "*", element: <Navigate to="dashboard" replace /> },
],
Expand Down
2 changes: 1 addition & 1 deletion src/pages/admin/components/Panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ export function Panel({
${muted ? "bg-zinc-950" : "bg-zinc-900"}
`}
>
<h2 className="text-sm font-semibold text-zinc-300 mb-3">
<h2 className="mb-3 border-b border-zinc-800 pb-2 text-sm font-semibold text-zinc-300">
{title}
</h2>
{children}
Expand Down
101 changes: 101 additions & 0 deletions src/pages/admin/components/SyncLabNotesPanel.tsx
Original file line number Diff line number Diff line change
@@ -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<Result>(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 (
<section className="rounded-xl border border-zinc-800 bg-zinc-950 p-4 space-y-3">
<header className="flex items-center justify-between">
<h2 className="text-sm uppercase tracking-widest text-zinc-400">
Markdown Sync
</h2>

<button
onClick={runSync}
disabled={syncing}
className="
px-3 py-2 rounded-md text-sm
bg-zinc-800 hover:bg-zinc-700
disabled:opacity-50 disabled:cursor-not-allowed
"
>
{syncing ? "Syncing…" : "Sync Markdown → DB"}
</button>
</header>

{result && "error" in result && (
<div className="text-sm text-red-400">
❌ {result.error}
</div>
)}

{result && "ok" in result && result.ok && (
<div className="text-sm text-zinc-300 space-y-1">
<div>📁 Root: <span className="text-zinc-400">{result.rootDir}</span></div>
<div>🌍 Locales: {result.locales.join(", ")}</div>
<div>📄 Scanned: {result.scanned}</div>
<div>✏️ Upserted: {result.upserted}</div>
<div>⏭ Skipped: {result.skipped}</div>

{result.errors.length > 0 && (
<details className="mt-2">
<summary className="cursor-pointer text-red-300">
⚠ Errors ({result.errors.length})
</summary>
<ul className="mt-2 space-y-1">
{result.errors.map((e, i) => (
<li key={i} className="text-xs text-red-200">
{e.file}: {e.error}
</li>
))}
</ul>
</details>
)}
</div>
)}
</section>
);
}
37 changes: 25 additions & 12 deletions src/pages/admin/layout/AdminGate.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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<GateStatus>("checking");

useEffect(() => {
// ✅ If dev bypass is enabled, don't gate the UI at all
if (devBypass) {
setStatus("ok");
return;
Expand All @@ -32,44 +33,42 @@ 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 ??
data?.data?.user ??
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");
}
})();

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

if (status === "error") {
return (
<div className="p-6 text-zinc-300">
<div className="text-lg font-semibold text-zinc-100">
Can’t reach the Lab API
</div>
<div className="mt-2 text-sm text-zinc-400">
This usually means <code>VITE_API_BASE_URL</code> is wrong, CORS/session
settings are off, or the API is down.
</div>
</div>
);
}

return <Outlet />;
}
}
1 change: 1 addition & 0 deletions src/pages/admin/layout/AdminNav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading