diff --git a/src/pages/admin/components/CreateTokenModal.tsx b/src/pages/admin/components/CreateTokenModal.tsx new file mode 100644 index 0000000..f5c1fce --- /dev/null +++ b/src/pages/admin/components/CreateTokenModal.tsx @@ -0,0 +1,212 @@ +// src/pages/admin/components/CreateTokenModal.tsx +import { useState } from "react"; +import { apiBaseUrl } from "@/api/api"; + +type CreateTokenModalProps = { + isOpen: boolean; + onClose: () => void; + onTokenCreated: () => void; +}; + +type CreateState = + | { status: "form" } + | { status: "creating" } + | { status: "success"; token: string; tokenId: string } + | { status: "error"; message: string }; + +export function CreateTokenModal({ isOpen, onClose, onTokenCreated }: CreateTokenModalProps) { + const [label, setLabel] = useState(""); + const [state, setState] = useState({ status: "form" }); + const [copied, setCopied] = useState(false); + + if (!isOpen) return null; + + const handleCreate = async () => { + if (!label.trim()) { + setState({ status: "error", message: "Label is required" }); + return; + } + + setState({ status: "creating" }); + + try { + const res = await fetch(`${apiBaseUrl}/admin/tokens`, { + method: "POST", + credentials: "include", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify({ + label: label.trim(), + scopes: ["admin"], + expires_at: null, // Never expires + }), + }); + + if (!res.ok) { + const errorText = await res.text(); + throw new Error(`Failed to create token: ${res.status} ${errorText}`); + } + + const json = await res.json(); + + if (!json.ok || !json.data?.token) { + throw new Error("Invalid response from server"); + } + + setState({ + status: "success", + token: json.data.token, + tokenId: json.data.id, + }); + } catch (e: any) { + setState({ + status: "error", + message: e?.message ?? "Failed to create token", + }); + } + }; + + const handleCopy = async () => { + if (state.status === "success") { + await navigator.clipboard.writeText(state.token); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } + }; + + const handleDone = () => { + onTokenCreated(); + onClose(); + // Reset state for next time + setState({ status: "form" }); + setLabel(""); + setCopied(false); + }; + + return ( +
+
+ {/* Form State */} + {state.status === "form" && ( + <> +

+ Create API Token +

+
+ + setLabel(e.target.value)} + placeholder="e.g., Claude AI Agent" + className="w-full px-3 py-2 bg-zinc-800 border border-zinc-700 rounded text-zinc-100 placeholder-zinc-500 focus:outline-none focus:border-teal-500" + autoFocus + /> +
+
+ This token will have admin scope + and never expire. +
+
+ + +
+ + )} + + {/* Creating State */} + {state.status === "creating" && ( +
+
Generating token...
+
This will only take a moment
+
+ )} + + {/* Success State */} + {state.status === "success" && ( + <> +

+ ✅ Token Created! +

+
+
+ ⚠️ Copy this token now! +
+
+ For security reasons, this token will only be shown once. + If you lose it, you'll need to create a new one. +
+
+ +
+ +
+ {state.token} +
+
+ +
+
+ 💡 How to use this token: +
+
+
1. Copy the token above
+
2. Give it to your AI agent (Claude, GPT, etc.)
+
3. Agent can use it to create Lab Notes autonomously
+
+
+ +
+ + +
+ + )} + + {/* Error State */} + {state.status === "error" && ( + <> +

Error

+
+
{state.message}
+
+
+ +
+ + )} +
+
+ ); +} \ No newline at end of file diff --git a/src/pages/admin/pages/AdminTokensPage.tsx b/src/pages/admin/pages/AdminTokensPage.tsx index 73d1159..393c474 100644 --- a/src/pages/admin/pages/AdminTokensPage.tsx +++ b/src/pages/admin/pages/AdminTokensPage.tsx @@ -1,6 +1,7 @@ // src/pages/admin/pages/AdminTokensPage.tsx import { useEffect, useMemo, useState } from "react"; import { apiBaseUrl } from "@/api/api"; +import { CreateTokenModal } from "../components/CreateTokenModal"; type TokenRow = { id: string; @@ -25,7 +26,7 @@ function formatMaybeDate(iso: string | null) { } function parseTokensPayload(json: any): TokenRow[] { - // Accept a few shapes so the page doesn’t break when backend evolves: + // Accept a few shapes so the page doesn't break when backend evolves: // { data: [...] } // { tokens: [...] } // { data: { tokens: [...] } } @@ -38,63 +39,68 @@ export function AdminTokensPage() { const API = apiBaseUrl; const [tokens, setTokens] = useState([]); const [state, setState] = useState({ status: "loading" }); + const [isModalOpen, setIsModalOpen] = useState(false); // stable key in case apiBaseUrl is derived; avoids weird reruns const url = useMemo(() => `${API}/admin/tokens`, [API]); - useEffect(() => { - const controller = new AbortController(); - - (async () => { - setState({ status: "loading" }); - try { - const res = await fetch(url, { - credentials: "include", - headers: { Accept: "application/json" }, - signal: controller.signal, - }); - - if (!res.ok) { - // Try to extract server-provided message - let serverMsg = ""; - try { - const maybeJson = await res.json(); - serverMsg = maybeJson?.error ?? maybeJson?.message ?? ""; - } catch { - // ignore json parse errors; maybe non-json - } - - const hint = - res.status === 401 - ? "Not authenticated (401). Try logging in again." - : res.status === 403 - ? "Authenticated but not authorized (403)." - : res.status >= 500 - ? "Server error. Check API logs." - : "Request failed."; - - throw Object.assign(new Error(serverMsg || hint), { code: res.status }); + const loadTokens = async () => { + setState({ status: "loading" }); + try { + const res = await fetch(url, { + credentials: "include", + headers: { Accept: "application/json" }, + }); + + if (!res.ok) { + // Try to extract server-provided message + let serverMsg = ""; + try { + const maybeJson = await res.json(); + serverMsg = maybeJson?.error ?? maybeJson?.message ?? ""; + } catch { + // ignore json parse errors; maybe non-json } - const json = await res.json(); - const rows = parseTokensPayload(json); - - setTokens(rows); - setState({ status: "ready" }); - } catch (e: any) { - if (e?.name === "AbortError") return; + const hint = + res.status === 401 + ? "Not authenticated (401). Try logging in again." + : res.status === 403 + ? "Authenticated but not authorized (403)." + : res.status >= 500 + ? "Server error. Check API logs." + : "Request failed."; - setState({ - status: "error", - message: e?.message ?? "Failed to load tokens", - code: e?.code, - }); + throw Object.assign(new Error(serverMsg || hint), { code: res.status }); } - })(); + const json = await res.json(); + const rows = parseTokensPayload(json); + + setTokens(rows); + setState({ status: "ready" }); + } catch (e: any) { + if (e?.name === "AbortError") return; + + setState({ + status: "error", + message: e?.message ?? "Failed to load tokens", + code: e?.code, + }); + } + }; + + useEffect(() => { + const controller = new AbortController(); + loadTokens(); return () => controller.abort(); }, [url]); + const handleTokenCreated = () => { + // Reload the token list + loadTokens(); + }; + if (state.status === "loading") { return
Loading API tokens…
; } @@ -108,8 +114,8 @@ export function AdminTokensPage() { {state.message}
- If this is a 401/403, it’s an auth/permissions issue. If it’s 5xx, - it’s backend. + If this is a 401/403, it's an auth/permissions issue. If it's 5xx, + it's backend.
); @@ -117,10 +123,20 @@ export function AdminTokensPage() { return (
-

API Tokens

+
+

API Tokens

+ +
{tokens.length === 0 ? ( -
No tokens minted yet.
+
+ No tokens minted yet. Create one to get started! +
) : ( @@ -149,6 +165,12 @@ export function AdminTokensPage() {
)} + + setIsModalOpen(false)} + onTokenCreated={handleTokenCreated} + />
); -} +} \ No newline at end of file