Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
Blame · Line-by-line history

tokens.tsx

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

tokens.tsxBlame341 lines · 3 contributors
c81ab7aClaude1/**
2 * API tokens — personal access tokens for automation.
3 */
4
5import { Hono } from "hono";
6import { eq } from "drizzle-orm";
7import { db } from "../db";
8import { apiTokens } from "../db/schema";
9import { Layout } from "../views/layout";
10import { softAuth, requireAuth } from "../middleware/auth";
11import type { AuthEnv } from "../middleware/auth";
3e8f8e8Claude12import {
13 Container,
14 PageHeader,
15 Section,
16 Alert,
17 EmptyState,
18 ListItem,
19 Flex,
20 Form,
21 FormGroup,
22 Input,
23 Button,
24 InlineCode,
25 Text,
26} from "../views/ui";
c81ab7aClaude27
28const tokens = new Hono<AuthEnv>();
29
30tokens.use("/settings/tokens*", softAuth, requireAuth);
31tokens.use("/api/user/tokens*", softAuth, requireAuth);
32
33function generateToken(): string {
34 const bytes = crypto.getRandomValues(new Uint8Array(32));
35 return (
36 "glc_" +
37 Array.from(bytes)
38 .map((b) => b.toString(16).padStart(2, "0"))
39 .join("")
40 );
41}
42
43async function hashToken(token: string): Promise<string> {
44 const data = new TextEncoder().encode(token);
45 const hash = await crypto.subtle.digest("SHA-256", data);
46 return Array.from(new Uint8Array(hash))
47 .map((b) => b.toString(16).padStart(2, "0"))
48 .join("");
49}
50
51// Token settings page
52tokens.get("/settings/tokens", async (c) => {
53 const user = c.get("user")!;
54 const success = c.req.query("success");
55 const newToken = c.req.query("new_token");
56
57 const userTokens = await db
58 .select()
59 .from(apiTokens)
60 .where(eq(apiTokens.userId, user.id));
61
62 return c.html(
63 <Layout title="API Tokens" user={user}>
3e8f8e8Claude64 <Container class="settings-container">
65 <PageHeader title="Personal access tokens" />
c81ab7aClaude66 {success && (
3e8f8e8Claude67 <Alert variant="success">
c81ab7aClaude68 {decodeURIComponent(success)}
3e8f8e8Claude69 </Alert>
c81ab7aClaude70 )}
71 {newToken && (
3e8f8e8Claude72 <Alert variant="success">
73 <span style="font-family: var(--font-mono); word-break: break-all">
74 New token (copy now — it won't be shown again):{" "}
75 <strong>{decodeURIComponent(newToken)}</strong>
76 </span>
77 </Alert>
c81ab7aClaude78 )}
79 <div style="margin-top: 16px">
80 {userTokens.length === 0 ? (
3e8f8e8Claude81 <EmptyState>
82 <Text muted>No tokens yet.</Text>
83 </EmptyState>
c81ab7aClaude84 ) : (
85 userTokens.map((token) => (
3e8f8e8Claude86 <ListItem>
c81ab7aClaude87 <div>
88 <strong>{token.name}</strong>
89 <div class="ssh-key-meta">
3e8f8e8Claude90 <InlineCode>{token.tokenPrefix}...</InlineCode>
c81ab7aClaude91 <span style="margin-left: 8px">
92 Scopes: {token.scopes}
93 </span>
94 {token.lastUsedAt && (
95 <span>
96 {" "}
97 | Last used{" "}
98 {new Date(token.lastUsedAt).toLocaleDateString()}
99 </span>
100 )}
101 </div>
102 </div>
103 <form
001af43Claude104 method="post"
c81ab7aClaude105 action={`/settings/tokens/${token.id}/delete`}
106 >
3e8f8e8Claude107 <Button type="submit" variant="danger" size="sm">
c81ab7aClaude108 Revoke
3e8f8e8Claude109 </Button>
0316dbbClaude110 </form>
3e8f8e8Claude111 </ListItem>
c81ab7aClaude112 ))
113 )}
114 </div>
115
116 <h3 style="margin-top: 24px; margin-bottom: 12px">
117 Generate new token
118 </h3>
001af43Claude119 <form method="post" action="/settings/tokens">
c81ab7aClaude120 <div class="form-group">
121 <label for="name">Token name</label>
122 <input
123 type="text"
124 id="name"
125 name="name"
126 required
127 placeholder="e.g. CI/CD pipeline"
128 />
129 </div>
130 <div class="form-group">
131 <label>Scopes</label>
132 <div style="display: flex; gap: 16px; flex-wrap: wrap">
133 {["repo", "user", "admin"].map((scope) => (
134 <label style="display: flex; align-items: center; gap: 4px; font-size: 14px; cursor: pointer">
135 <input
136 type="checkbox"
137 name="scopes"
138 value={scope}
139 checked={scope === "repo"}
2c3ba6ecopilot-swe-agent[bot]140 aria-label={scope}
c81ab7aClaude141 />{" "}
142 {scope}
143 </label>
144 ))}
145 </div>
146 </div>
147 <button type="submit" class="btn btn-primary">
148 Generate token
149 </button>
150 </form>
0316dbbClaude151 </Container>
c81ab7aClaude152 </Layout>
153 );
154});
155
156// Create token
157tokens.post("/settings/tokens", async (c) => {
158 const user = c.get("user")!;
159 const body = await c.req.parseBody();
160 const name = String(body.name || "").trim();
161
162 let scopes: string;
163 const rawScopes = body.scopes;
164 if (Array.isArray(rawScopes)) {
165 scopes = rawScopes.join(",");
166 } else {
167 scopes = String(rawScopes || "repo");
168 }
169
170 if (!name) {
171 return c.redirect("/settings/tokens?error=Name+is+required");
172 }
173
174 const token = generateToken();
175 const tokenH = await hashToken(token);
176
177 await db.insert(apiTokens).values({
178 userId: user.id,
179 name,
180 tokenHash: tokenH,
181 tokenPrefix: token.slice(0, 12),
182 scopes,
183 });
184
185 return c.redirect(
186 `/settings/tokens?new_token=${encodeURIComponent(token)}`
187 );
188});
189
190// Delete token
191tokens.post("/settings/tokens/:id/delete", async (c) => {
192 const user = c.get("user")!;
193 const tokenId = c.req.param("id");
194
195 const [token] = await db
196 .select()
197 .from(apiTokens)
198 .where(eq(apiTokens.id, tokenId))
199 .limit(1);
200
201 if (!token || token.userId !== user.id) {
202 return c.redirect("/settings/tokens");
203 }
204
205 await db.delete(apiTokens).where(eq(apiTokens.id, tokenId));
206 return c.redirect("/settings/tokens?success=Token+revoked");
207});
208
96942a6Test User209/**
210 * Emergency PAT issuance — break-glass for when the web UI is broken
211 * (service-worker loop, css busted, whatever) and an operator needs
212 * a token to push a fix.
213 *
214 * Auth: bearer of the `EMERGENCY_PAT_SECRET` env var (set on the host).
215 * If the env var is unset, the endpoint returns 503 — we don't want it
216 * silently usable with an empty secret. This is the ONLY token route
217 * that isn't behind a normal session, by design.
218 *
219 * Issues a PAT for the user named in the JSON body's `username` field,
220 * defaulting to the site admin / oldest user (same heuristic the
221 * self-host bootstrap uses).
222 *
223 * Returns JSON: { user, token } — the token is shown ONCE.
224 *
225 * Use:
226 * curl -X POST https://gluecron.com/api/admin/emergency-pat \
227 * -H "Authorization: Bearer $EMERGENCY_PAT_SECRET" \
228 * -H "content-type: application/json" \
229 * -d '{"name":"break-glass","scopes":"admin"}'
230 */
231tokens.post("/api/admin/emergency-pat", async (c) => {
232 const secret = process.env.EMERGENCY_PAT_SECRET;
233 if (!secret) {
234 return c.json(
235 { error: "emergency PAT endpoint not configured (EMERGENCY_PAT_SECRET unset)" },
236 503
237 );
238 }
239 const provided = (c.req.header("authorization") || "").replace(/^Bearer\s+/i, "").trim();
240 if (provided !== secret) {
241 return c.json({ error: "invalid emergency secret" }, 401);
242 }
243
244 let body: { username?: string; name?: string; scopes?: string };
245 try {
246 body = await c.req.json();
247 } catch {
248 body = {};
249 }
250 const name = (body.name || "emergency-pat").trim();
251 const scopes = (body.scopes || "admin").trim();
252
253 // Resolve target user: explicit username → site admin → oldest user.
254 const { users, siteAdmins } = await import("../db/schema");
255 const { eq: eqOp, asc } = await import("drizzle-orm");
256 let target:
257 | { id: string; username: string }
258 | undefined;
259
260 if (body.username) {
261 const [u] = await db
262 .select({ id: users.id, username: users.username })
263 .from(users)
264 .where(eqOp(users.username, body.username))
265 .limit(1);
266 target = u;
267 }
268 if (!target) {
269 try {
270 const [u] = await db
271 .select({ id: users.id, username: users.username })
272 .from(siteAdmins)
273 .innerJoin(users, eqOp(siteAdmins.userId, users.id))
274 .limit(1);
275 target = u;
276 } catch {
277 // siteAdmins table may not exist on stale schemas — fall through.
278 }
279 }
280 if (!target) {
281 const [u] = await db
282 .select({ id: users.id, username: users.username })
283 .from(users)
284 .orderBy(asc(users.createdAt))
285 .limit(1);
286 target = u;
287 }
288 if (!target) {
289 return c.json({ error: "no user available to issue PAT for" }, 404);
290 }
291
292 // Token + hash — same algorithm the web flow uses.
293 const tokenBytes = crypto.getRandomValues(new Uint8Array(32));
294 const token =
295 "glc_" +
296 Array.from(tokenBytes)
297 .map((b) => b.toString(16).padStart(2, "0"))
298 .join("");
299 const hashBuf = await crypto.subtle.digest(
300 "SHA-256",
301 new TextEncoder().encode(token)
302 );
303 const tokenHash = Array.from(new Uint8Array(hashBuf))
304 .map((b) => b.toString(16).padStart(2, "0"))
305 .join("");
306
307 await db.insert(apiTokens).values({
308 userId: target.id,
309 name,
310 tokenHash,
311 tokenPrefix: token.slice(0, 12),
312 scopes,
313 });
314
315 return c.json({
316 user: { id: target.id, username: target.username },
317 token,
318 name,
319 scopes,
320 note: "Token is shown once. Store it now.",
321 });
322});
323
c81ab7aClaude324// API endpoint
325tokens.get("/api/user/tokens", async (c) => {
326 const user = c.get("user")!;
327 const userTokens = await db
328 .select({
329 id: apiTokens.id,
330 name: apiTokens.name,
331 tokenPrefix: apiTokens.tokenPrefix,
332 scopes: apiTokens.scopes,
333 lastUsedAt: apiTokens.lastUsedAt,
334 createdAt: apiTokens.createdAt,
335 })
336 .from(apiTokens)
337 .where(eq(apiTokens.userId, user.id));
338 return c.json(userTokens);
339});
340
341export default tokens;