Skip to content

Commit 2c3c178

Browse files
authored
Merge pull request #35 from AdaInTheLab/feat/bearer-token
UI 🎨 add token creation interface for AI agents
2 parents 9a79acf + bc93103 commit 2c3c178

File tree

2 files changed

+285
-51
lines changed

2 files changed

+285
-51
lines changed
Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
// src/pages/admin/components/CreateTokenModal.tsx
2+
import { useState } from "react";
3+
import { apiBaseUrl } from "@/api/api";
4+
5+
type CreateTokenModalProps = {
6+
isOpen: boolean;
7+
onClose: () => void;
8+
onTokenCreated: () => void;
9+
};
10+
11+
type CreateState =
12+
| { status: "form" }
13+
| { status: "creating" }
14+
| { status: "success"; token: string; tokenId: string }
15+
| { status: "error"; message: string };
16+
17+
export function CreateTokenModal({ isOpen, onClose, onTokenCreated }: CreateTokenModalProps) {
18+
const [label, setLabel] = useState("");
19+
const [state, setState] = useState<CreateState>({ status: "form" });
20+
const [copied, setCopied] = useState(false);
21+
22+
if (!isOpen) return null;
23+
24+
const handleCreate = async () => {
25+
if (!label.trim()) {
26+
setState({ status: "error", message: "Label is required" });
27+
return;
28+
}
29+
30+
setState({ status: "creating" });
31+
32+
try {
33+
const res = await fetch(`${apiBaseUrl}/admin/tokens`, {
34+
method: "POST",
35+
credentials: "include",
36+
headers: {
37+
"Content-Type": "application/json",
38+
Accept: "application/json",
39+
},
40+
body: JSON.stringify({
41+
label: label.trim(),
42+
scopes: ["admin"],
43+
expires_at: null, // Never expires
44+
}),
45+
});
46+
47+
if (!res.ok) {
48+
const errorText = await res.text();
49+
throw new Error(`Failed to create token: ${res.status} ${errorText}`);
50+
}
51+
52+
const json = await res.json();
53+
54+
if (!json.ok || !json.data?.token) {
55+
throw new Error("Invalid response from server");
56+
}
57+
58+
setState({
59+
status: "success",
60+
token: json.data.token,
61+
tokenId: json.data.id,
62+
});
63+
} catch (e: any) {
64+
setState({
65+
status: "error",
66+
message: e?.message ?? "Failed to create token",
67+
});
68+
}
69+
};
70+
71+
const handleCopy = async () => {
72+
if (state.status === "success") {
73+
await navigator.clipboard.writeText(state.token);
74+
setCopied(true);
75+
setTimeout(() => setCopied(false), 2000);
76+
}
77+
};
78+
79+
const handleDone = () => {
80+
onTokenCreated();
81+
onClose();
82+
// Reset state for next time
83+
setState({ status: "form" });
84+
setLabel("");
85+
setCopied(false);
86+
};
87+
88+
return (
89+
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80">
90+
<div className="bg-zinc-900 border border-zinc-700 rounded-lg p-6 max-w-lg w-full mx-4 shadow-2xl">
91+
{/* Form State */}
92+
{state.status === "form" && (
93+
<>
94+
<h3 className="text-xl font-semibold text-zinc-100 mb-4">
95+
Create API Token
96+
</h3>
97+
<div className="mb-4">
98+
<label className="block text-sm text-zinc-400 mb-2">
99+
Token Label
100+
</label>
101+
<input
102+
type="text"
103+
value={label}
104+
onChange={(e) => setLabel(e.target.value)}
105+
placeholder="e.g., Claude AI Agent"
106+
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"
107+
autoFocus
108+
/>
109+
</div>
110+
<div className="text-xs text-zinc-500 mb-4">
111+
This token will have <code className="text-teal-400">admin</code> scope
112+
and never expire.
113+
</div>
114+
<div className="flex gap-3 justify-end">
115+
<button
116+
onClick={onClose}
117+
className="px-4 py-2 text-zinc-400 hover:text-zinc-200 transition-colors"
118+
>
119+
Cancel
120+
</button>
121+
<button
122+
onClick={handleCreate}
123+
className="px-4 py-2 bg-teal-600 hover:bg-teal-500 text-white rounded transition-colors"
124+
>
125+
Generate Token
126+
</button>
127+
</div>
128+
</>
129+
)}
130+
131+
{/* Creating State */}
132+
{state.status === "creating" && (
133+
<div className="text-center py-8">
134+
<div className="text-zinc-300 mb-2">Generating token...</div>
135+
<div className="text-zinc-500 text-sm">This will only take a moment</div>
136+
</div>
137+
)}
138+
139+
{/* Success State */}
140+
{state.status === "success" && (
141+
<>
142+
<h3 className="text-xl font-semibold text-teal-400 mb-4">
143+
✅ Token Created!
144+
</h3>
145+
<div className="bg-red-900/20 border border-red-700 rounded p-4 mb-4">
146+
<div className="text-red-400 font-semibold mb-2">
147+
⚠️ Copy this token now!
148+
</div>
149+
<div className="text-red-300 text-sm">
150+
For security reasons, this token will only be shown once.
151+
If you lose it, you'll need to create a new one.
152+
</div>
153+
</div>
154+
155+
<div className="mb-4">
156+
<label className="block text-sm text-zinc-400 mb-2">
157+
Your Bearer Token
158+
</label>
159+
<div className="bg-zinc-800 border border-zinc-700 rounded p-3 font-mono text-xs text-teal-300 break-all">
160+
{state.token}
161+
</div>
162+
</div>
163+
164+
<div className="bg-zinc-800 border border-zinc-700 rounded p-3 mb-4 text-xs text-zinc-400">
165+
<div className="font-semibold text-zinc-300 mb-2">
166+
💡 How to use this token:
167+
</div>
168+
<div className="space-y-1">
169+
<div>1. Copy the token above</div>
170+
<div>2. Give it to your AI agent (Claude, GPT, etc.)</div>
171+
<div>3. Agent can use it to create Lab Notes autonomously</div>
172+
</div>
173+
</div>
174+
175+
<div className="flex gap-3 justify-end">
176+
<button
177+
onClick={handleCopy}
178+
className="px-4 py-2 bg-zinc-700 hover:bg-zinc-600 text-zinc-200 rounded transition-colors"
179+
>
180+
{copied ? "✓ Copied!" : "Copy Token"}
181+
</button>
182+
<button
183+
onClick={handleDone}
184+
className="px-4 py-2 bg-teal-600 hover:bg-teal-500 text-white rounded transition-colors"
185+
>
186+
Done
187+
</button>
188+
</div>
189+
</>
190+
)}
191+
192+
{/* Error State */}
193+
{state.status === "error" && (
194+
<>
195+
<h3 className="text-xl font-semibold text-red-400 mb-4">Error</h3>
196+
<div className="bg-red-900/20 border border-red-700 rounded p-4 mb-4">
197+
<div className="text-red-300">{state.message}</div>
198+
</div>
199+
<div className="flex gap-3 justify-end">
200+
<button
201+
onClick={() => setState({ status: "form" })}
202+
className="px-4 py-2 bg-zinc-700 hover:bg-zinc-600 text-zinc-200 rounded transition-colors"
203+
>
204+
Try Again
205+
</button>
206+
</div>
207+
</>
208+
)}
209+
</div>
210+
</div>
211+
);
212+
}
Lines changed: 73 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// src/pages/admin/pages/AdminTokensPage.tsx
22
import { useEffect, useMemo, useState } from "react";
33
import { apiBaseUrl } from "@/api/api";
4+
import { CreateTokenModal } from "../components/CreateTokenModal";
45

56
type TokenRow = {
67
id: string;
@@ -25,7 +26,7 @@ function formatMaybeDate(iso: string | null) {
2526
}
2627

2728
function parseTokensPayload(json: any): TokenRow[] {
28-
// Accept a few shapes so the page doesnt break when backend evolves:
29+
// Accept a few shapes so the page doesn't break when backend evolves:
2930
// { data: [...] }
3031
// { tokens: [...] }
3132
// { data: { tokens: [...] } }
@@ -38,63 +39,68 @@ export function AdminTokensPage() {
3839
const API = apiBaseUrl;
3940
const [tokens, setTokens] = useState<TokenRow[]>([]);
4041
const [state, setState] = useState<LoadState>({ status: "loading" });
42+
const [isModalOpen, setIsModalOpen] = useState(false);
4143

4244
// stable key in case apiBaseUrl is derived; avoids weird reruns
4345
const url = useMemo(() => `${API}/admin/tokens`, [API]);
4446

45-
useEffect(() => {
46-
const controller = new AbortController();
47-
48-
(async () => {
49-
setState({ status: "loading" });
50-
try {
51-
const res = await fetch(url, {
52-
credentials: "include",
53-
headers: { Accept: "application/json" },
54-
signal: controller.signal,
55-
});
56-
57-
if (!res.ok) {
58-
// Try to extract server-provided message
59-
let serverMsg = "";
60-
try {
61-
const maybeJson = await res.json();
62-
serverMsg = maybeJson?.error ?? maybeJson?.message ?? "";
63-
} catch {
64-
// ignore json parse errors; maybe non-json
65-
}
66-
67-
const hint =
68-
res.status === 401
69-
? "Not authenticated (401). Try logging in again."
70-
: res.status === 403
71-
? "Authenticated but not authorized (403)."
72-
: res.status >= 500
73-
? "Server error. Check API logs."
74-
: "Request failed.";
75-
76-
throw Object.assign(new Error(serverMsg || hint), { code: res.status });
47+
const loadTokens = async () => {
48+
setState({ status: "loading" });
49+
try {
50+
const res = await fetch(url, {
51+
credentials: "include",
52+
headers: { Accept: "application/json" },
53+
});
54+
55+
if (!res.ok) {
56+
// Try to extract server-provided message
57+
let serverMsg = "";
58+
try {
59+
const maybeJson = await res.json();
60+
serverMsg = maybeJson?.error ?? maybeJson?.message ?? "";
61+
} catch {
62+
// ignore json parse errors; maybe non-json
7763
}
7864

79-
const json = await res.json();
80-
const rows = parseTokensPayload(json);
81-
82-
setTokens(rows);
83-
setState({ status: "ready" });
84-
} catch (e: any) {
85-
if (e?.name === "AbortError") return;
65+
const hint =
66+
res.status === 401
67+
? "Not authenticated (401). Try logging in again."
68+
: res.status === 403
69+
? "Authenticated but not authorized (403)."
70+
: res.status >= 500
71+
? "Server error. Check API logs."
72+
: "Request failed.";
8673

87-
setState({
88-
status: "error",
89-
message: e?.message ?? "Failed to load tokens",
90-
code: e?.code,
91-
});
74+
throw Object.assign(new Error(serverMsg || hint), { code: res.status });
9275
}
93-
})();
9476

77+
const json = await res.json();
78+
const rows = parseTokensPayload(json);
79+
80+
setTokens(rows);
81+
setState({ status: "ready" });
82+
} catch (e: any) {
83+
if (e?.name === "AbortError") return;
84+
85+
setState({
86+
status: "error",
87+
message: e?.message ?? "Failed to load tokens",
88+
code: e?.code,
89+
});
90+
}
91+
};
92+
93+
useEffect(() => {
94+
const controller = new AbortController();
95+
loadTokens();
9596
return () => controller.abort();
9697
}, [url]);
9798

99+
const handleTokenCreated = () => {
100+
// Reload the token list
101+
loadTokens();
102+
};
103+
98104
if (state.status === "loading") {
99105
return <div className="p-6 text-zinc-300">Loading API tokens…</div>;
100106
}
@@ -108,19 +114,29 @@ export function AdminTokensPage() {
108114
{state.message}
109115
</div>
110116
<div className="text-zinc-500 text-xs mt-3">
111-
If this is a 401/403, its an auth/permissions issue. If its 5xx,
112-
its backend.
117+
If this is a 401/403, it's an auth/permissions issue. If it's 5xx,
118+
it's backend.
113119
</div>
114120
</div>
115121
);
116122
}
117123

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

122136
{tokens.length === 0 ? (
123-
<div className="text-zinc-400">No tokens minted yet.</div>
137+
<div className="text-zinc-400">
138+
No tokens minted yet. Create one to get started!
139+
</div>
124140
) : (
125141
<table className="w-full text-sm text-zinc-300 border border-zinc-800">
126142
<thead className="bg-zinc-900 text-zinc-400">
@@ -149,6 +165,12 @@ export function AdminTokensPage() {
149165
</tbody>
150166
</table>
151167
)}
168+
169+
<CreateTokenModal
170+
isOpen={isModalOpen}
171+
onClose={() => setIsModalOpen(false)}
172+
onTokenCreated={handleTokenCreated}
173+
/>
152174
</div>
153175
);
154-
}
176+
}

0 commit comments

Comments
 (0)