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
212 changes: 212 additions & 0 deletions src/pages/admin/components/CreateTokenModal.tsx
Original file line number Diff line number Diff line change
@@ -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<CreateState>({ 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 (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80">
<div className="bg-zinc-900 border border-zinc-700 rounded-lg p-6 max-w-lg w-full mx-4 shadow-2xl">
{/* Form State */}
{state.status === "form" && (
<>
<h3 className="text-xl font-semibold text-zinc-100 mb-4">
Create API Token
</h3>
<div className="mb-4">
<label className="block text-sm text-zinc-400 mb-2">
Token Label
</label>
<input
type="text"
value={label}
onChange={(e) => 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
/>
</div>
<div className="text-xs text-zinc-500 mb-4">
This token will have <code className="text-teal-400">admin</code> scope
and never expire.
</div>
<div className="flex gap-3 justify-end">
<button
onClick={onClose}
className="px-4 py-2 text-zinc-400 hover:text-zinc-200 transition-colors"
>
Cancel
</button>
<button
onClick={handleCreate}
className="px-4 py-2 bg-teal-600 hover:bg-teal-500 text-white rounded transition-colors"
>
Generate Token
</button>
</div>
</>
)}

{/* Creating State */}
{state.status === "creating" && (
<div className="text-center py-8">
<div className="text-zinc-300 mb-2">Generating token...</div>
<div className="text-zinc-500 text-sm">This will only take a moment</div>
</div>
)}

{/* Success State */}
{state.status === "success" && (
<>
<h3 className="text-xl font-semibold text-teal-400 mb-4">
✅ Token Created!
</h3>
<div className="bg-red-900/20 border border-red-700 rounded p-4 mb-4">
<div className="text-red-400 font-semibold mb-2">
⚠️ Copy this token now!
</div>
<div className="text-red-300 text-sm">
For security reasons, this token will only be shown once.
If you lose it, you'll need to create a new one.
</div>
</div>

<div className="mb-4">
<label className="block text-sm text-zinc-400 mb-2">
Your Bearer Token
</label>
<div className="bg-zinc-800 border border-zinc-700 rounded p-3 font-mono text-xs text-teal-300 break-all">
{state.token}
</div>
</div>

<div className="bg-zinc-800 border border-zinc-700 rounded p-3 mb-4 text-xs text-zinc-400">
<div className="font-semibold text-zinc-300 mb-2">
💡 How to use this token:
</div>
<div className="space-y-1">
<div>1. Copy the token above</div>
<div>2. Give it to your AI agent (Claude, GPT, etc.)</div>
<div>3. Agent can use it to create Lab Notes autonomously</div>
</div>
</div>

<div className="flex gap-3 justify-end">
<button
onClick={handleCopy}
className="px-4 py-2 bg-zinc-700 hover:bg-zinc-600 text-zinc-200 rounded transition-colors"
>
{copied ? "✓ Copied!" : "Copy Token"}
</button>
<button
onClick={handleDone}
className="px-4 py-2 bg-teal-600 hover:bg-teal-500 text-white rounded transition-colors"
>
Done
</button>
</div>
</>
)}

{/* Error State */}
{state.status === "error" && (
<>
<h3 className="text-xl font-semibold text-red-400 mb-4">Error</h3>
<div className="bg-red-900/20 border border-red-700 rounded p-4 mb-4">
<div className="text-red-300">{state.message}</div>
</div>
<div className="flex gap-3 justify-end">
<button
onClick={() => setState({ status: "form" })}
className="px-4 py-2 bg-zinc-700 hover:bg-zinc-600 text-zinc-200 rounded transition-colors"
>
Try Again
</button>
</div>
</>
)}
</div>
</div>
);
}
124 changes: 73 additions & 51 deletions src/pages/admin/pages/AdminTokensPage.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -25,7 +26,7 @@ function formatMaybeDate(iso: string | null) {
}

function parseTokensPayload(json: any): TokenRow[] {
// Accept a few shapes so the page doesnt break when backend evolves:
// Accept a few shapes so the page doesn't break when backend evolves:
// { data: [...] }
// { tokens: [...] }
// { data: { tokens: [...] } }
Expand All @@ -38,63 +39,68 @@ export function AdminTokensPage() {
const API = apiBaseUrl;
const [tokens, setTokens] = useState<TokenRow[]>([]);
const [state, setState] = useState<LoadState>({ 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 <div className="p-6 text-zinc-300">Loading API tokens…</div>;
}
Expand All @@ -108,19 +114,29 @@ export function AdminTokensPage() {
{state.message}
</div>
<div className="text-zinc-500 text-xs mt-3">
If this is a 401/403, its an auth/permissions issue. If its 5xx,
its backend.
If this is a 401/403, it's an auth/permissions issue. If it's 5xx,
it's backend.
</div>
</div>
);
}

return (
<div className="p-6">
<h2 className="text-xl font-semibold text-zinc-100 mb-4">API Tokens</h2>
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-semibold text-zinc-100">API Tokens</h2>
<button
onClick={() => setIsModalOpen(true)}
className="px-4 py-2 bg-teal-600 hover:bg-teal-500 text-white rounded transition-colors font-medium"
>
+ Create Token
</button>
</div>

{tokens.length === 0 ? (
<div className="text-zinc-400">No tokens minted yet.</div>
<div className="text-zinc-400">
No tokens minted yet. Create one to get started!
</div>
) : (
<table className="w-full text-sm text-zinc-300 border border-zinc-800">
<thead className="bg-zinc-900 text-zinc-400">
Expand Down Expand Up @@ -149,6 +165,12 @@ export function AdminTokensPage() {
</tbody>
</table>
)}

<CreateTokenModal
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
onTokenCreated={handleTokenCreated}
/>
</div>
);
}
}