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.tsxBlame1038 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";
9b776daccantynz-alt10import { SettingsNav, settingsNavStyles } from "../views/components";
c81ab7aClaude11import { softAuth, requireAuth } from "../middleware/auth";
12import type { AuthEnv } from "../middleware/auth";
13
14const tokens = new Hono<AuthEnv>();
15
03e6f9bccantynz-alt16// SECURITY: "path*" (no slash before the *) doesn't match the bare path in
17// this Hono version -- see the admin-security.tsx fix for the confirmed
18// repro. Registering on the exact path AND "/path/*" is the verified fix.
19tokens.use("/settings/tokens", softAuth, requireAuth);
20tokens.use("/settings/tokens/*", softAuth, requireAuth);
21tokens.use("/api/user/tokens", softAuth, requireAuth);
22tokens.use("/api/user/tokens/*", softAuth, requireAuth);
c81ab7aClaude23
24function generateToken(): string {
25 const bytes = crypto.getRandomValues(new Uint8Array(32));
26 return (
27 "glc_" +
28 Array.from(bytes)
29 .map((b) => b.toString(16).padStart(2, "0"))
30 .join("")
31 );
32}
33
34async function hashToken(token: string): Promise<string> {
35 const data = new TextEncoder().encode(token);
36 const hash = await crypto.subtle.digest("SHA-256", data);
37 return Array.from(new Uint8Array(hash))
38 .map((b) => b.toString(16).padStart(2, "0"))
39 .join("");
40}
41
02c53b7Claude42// Inline, scoped CSS — every class prefixed with `.tokens-` so the block
43// cannot bleed into other surfaces. Pattern mirrors the settings polish
44// (commit 98eb360) and repo-settings danger-zone (commit 58307ae).
45const tokensStyles = `
46 .tokens-wrap {
47 max-width: 920px;
48 margin: 0 auto;
49 padding: var(--space-6) var(--space-4);
50 }
51
52 /* ─── Hero ─── */
53 .tokens-hero {
54 position: relative;
55 margin-bottom: var(--space-6);
56 padding: var(--space-5) var(--space-6);
57 background: var(--bg-elevated);
58 border: 1px solid var(--border);
59 border-radius: 16px;
60 overflow: hidden;
61 }
62 .tokens-hero::before {
63 content: '';
64 position: absolute;
65 top: 0; left: 0; right: 0;
66 height: 2px;
6fd5915Claude67 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
02c53b7Claude68 opacity: 0.7;
69 pointer-events: none;
70 }
71 .tokens-hero-bg {
72 position: absolute;
73 inset: -20% -10% auto auto;
74 width: 360px; height: 360px;
75 pointer-events: none;
76 z-index: 0;
77 }
78 .tokens-hero-orb {
79 position: absolute;
80 inset: 0;
6fd5915Claude81 background: radial-gradient(circle, rgba(91,110,232,0.18), rgba(95,143,160,0.09) 45%, transparent 70%);
02c53b7Claude82 filter: blur(80px);
83 opacity: 0.65;
84 animation: tokensHeroOrb 14s ease-in-out infinite;
85 }
86 @keyframes tokensHeroOrb {
87 0%, 100% { transform: scale(1) translate(0, 0); opacity: 0.55; }
88 50% { transform: scale(1.08) translate(-8px, 6px); opacity: 0.78; }
89 }
90 @media (prefers-reduced-motion: reduce) {
91 .tokens-hero-orb { animation: none; }
92 }
93 .tokens-hero-inner {
94 position: relative;
95 z-index: 1;
96 max-width: 640px;
97 }
98 .tokens-hero-eyebrow {
99 display: inline-flex;
100 align-items: center;
101 gap: 8px;
102 font-size: 13px;
103 color: var(--text-muted);
104 margin-bottom: var(--space-2);
105 letter-spacing: -0.005em;
106 }
107 .tokens-hero-eyebrow-icon {
108 display: inline-flex;
109 align-items: center;
110 justify-content: center;
111 width: 22px;
112 height: 22px;
113 border-radius: 7px;
6fd5915Claude114 background: rgba(91,110,232,0.14);
115 color: #5b6ee8;
116 box-shadow: inset 0 0 0 1px rgba(91,110,232,0.30);
02c53b7Claude117 flex-shrink: 0;
118 }
119 .tokens-hero-eyebrow-icon svg { width: 12px; height: 12px; display: block; }
120 .tokens-hero-eyebrow-sep { opacity: 0.45; }
121 .tokens-hero-username {
122 color: var(--accent);
123 font-weight: 600;
124 }
125 .tokens-hero-title {
126 font-size: clamp(28px, 4vw, 40px);
127 font-family: var(--font-display);
128 font-weight: 800;
129 letter-spacing: -0.028em;
130 line-height: 1.05;
131 margin: 0 0 var(--space-2);
132 color: var(--text-strong);
133 }
134 .tokens-hero-title .tokens-gradient-text {
6fd5915Claude135 background-image: linear-gradient(135deg, #5b6ee8 0%, #5b6ee8 50%, #5f8fa0 100%);
02c53b7Claude136 -webkit-background-clip: text;
137 background-clip: text;
138 -webkit-text-fill-color: transparent;
139 color: transparent;
140 }
141 .tokens-hero-sub {
142 font-size: 15px;
143 color: var(--text-muted);
144 margin: 0;
145 max-width: 620px;
146 line-height: 1.5;
147 }
148
149 /* ─── Banner: success / revealed-once token ─── */
150 .tokens-banner {
151 display: flex;
152 align-items: flex-start;
153 gap: 12px;
154 padding: 12px 16px;
155 border-radius: 12px;
156 font-size: 13.5px;
157 margin-bottom: var(--space-4);
158 line-height: 1.5;
159 }
160 .tokens-banner-success {
161 background: rgba(52,211,153,0.08);
162 color: #6ee7b7;
163 box-shadow: inset 0 0 0 1px rgba(52,211,153,0.30);
164 }
165 .tokens-banner-icon {
166 width: 20px; height: 20px;
167 border-radius: 9999px;
168 flex-shrink: 0;
169 display: inline-flex;
170 align-items: center;
171 justify-content: center;
172 font-size: 13px;
173 font-weight: 700;
174 margin-top: 1px;
175 }
176 .tokens-banner-success .tokens-banner-icon {
177 background: rgba(52,211,153,0.18);
178 color: #34d399;
179 }
180 .tokens-banner-body { flex: 1; min-width: 0; }
181
182 /* ─── Revealed-once token card ─── */
183 .tokens-reveal {
184 position: relative;
185 padding: var(--space-4) var(--space-5);
186 border-radius: 14px;
187 margin-bottom: var(--space-5);
188 background:
6fd5915Claude189 linear-gradient(180deg, rgba(91,110,232,0.06) 0%, rgba(95,143,160,0.03) 100%),
02c53b7Claude190 var(--bg-elevated);
6fd5915Claude191 border: 1px solid rgba(91,110,232,0.30);
02c53b7Claude192 overflow: hidden;
193 }
194 .tokens-reveal::before {
195 content: '';
196 position: absolute;
197 top: 0; left: 0; right: 0;
198 height: 2px;
6fd5915Claude199 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
02c53b7Claude200 opacity: 0.85;
201 pointer-events: none;
202 }
203 .tokens-reveal-eyebrow {
204 font-size: 11px;
205 font-weight: 600;
206 letter-spacing: 0.08em;
207 text-transform: uppercase;
6fd5915Claude208 color: #5b6ee8;
02c53b7Claude209 margin-bottom: 6px;
210 }
211 .tokens-reveal-title {
212 font-family: var(--font-display);
213 font-size: 16px;
214 font-weight: 700;
215 letter-spacing: -0.018em;
216 margin: 0 0 var(--space-3);
217 color: var(--text-strong);
218 }
219 .tokens-reveal-value {
220 display: block;
221 font-family: var(--font-mono);
222 font-size: 13px;
223 padding: 12px 14px;
224 border-radius: 10px;
225 background: var(--bg);
226 color: var(--text-strong);
227 box-shadow: inset 0 0 0 1px var(--border-strong);
228 word-break: break-all;
229 user-select: all;
230 -webkit-user-select: all;
231 }
232 .tokens-reveal-hint {
233 margin: var(--space-3) 0 0;
234 font-size: 12.5px;
235 color: var(--text-muted);
236 }
237
238 /* ─── Section cards ─── */
239 .tokens-section {
240 background: var(--bg-elevated);
241 border: 1px solid var(--border);
242 border-radius: 14px;
243 margin-bottom: var(--space-5);
244 overflow: hidden;
245 }
246 .tokens-section-head {
247 padding: var(--space-4) var(--space-5) var(--space-3);
248 border-bottom: 1px solid var(--border);
249 }
250 .tokens-section-eyebrow {
251 font-size: 11px;
252 font-weight: 600;
253 letter-spacing: 0.08em;
254 text-transform: uppercase;
255 color: var(--accent);
256 margin-bottom: 6px;
257 }
258 .tokens-section-title {
259 font-family: var(--font-display);
260 font-size: 18px;
261 font-weight: 700;
262 letter-spacing: -0.018em;
263 margin: 0 0 4px;
264 color: var(--text-strong);
265 }
266 .tokens-section-desc {
267 font-size: 13.5px;
268 color: var(--text-muted);
269 margin: 0;
270 line-height: 1.5;
271 }
272 .tokens-section-body { padding: var(--space-4) var(--space-5); }
273
274 /* ─── Token list / cards ─── */
275 .tokens-list {
276 display: flex;
277 flex-direction: column;
278 gap: 10px;
279 }
280 .tokens-card {
281 display: flex;
282 justify-content: space-between;
283 align-items: flex-start;
284 gap: var(--space-3);
285 padding: 14px 16px;
286 border: 1px solid var(--border);
287 border-radius: 12px;
288 background: var(--bg-secondary);
289 transition: border-color 120ms ease, background 120ms ease, transform 120ms ease;
290 }
291 .tokens-card:hover {
292 border-color: var(--border-strong);
293 background: rgba(255,255,255,0.02);
294 }
295 .tokens-card-main { flex: 1; min-width: 0; }
296 .tokens-card-name {
297 font-size: 14.5px;
298 font-weight: 600;
299 color: var(--text-strong);
300 margin: 0 0 6px;
301 word-break: break-word;
302 }
303 .tokens-card-meta {
304 display: flex;
305 flex-wrap: wrap;
306 align-items: center;
307 gap: 6px 10px;
308 font-size: 12.5px;
309 color: var(--text-muted);
310 }
311 .tokens-prefix-pill {
312 display: inline-flex;
313 align-items: center;
314 font-family: var(--font-mono);
315 font-size: 12px;
316 color: var(--text-strong);
317 background: var(--bg-tertiary);
318 padding: 2px 8px;
319 border-radius: 6px;
320 box-shadow: inset 0 0 0 1px var(--border);
321 word-break: break-all;
322 }
323 .tokens-scope-chips {
324 display: inline-flex;
325 flex-wrap: wrap;
326 gap: 4px;
327 }
328 .tokens-scope-chip {
329 display: inline-flex;
330 align-items: center;
331 font-size: 11.5px;
332 font-weight: 600;
333 letter-spacing: 0.01em;
334 padding: 2px 8px;
335 border-radius: 9999px;
6fd5915Claude336 background: rgba(91,110,232,0.12);
337 color: #5b6ee8;
338 box-shadow: inset 0 0 0 1px rgba(91,110,232,0.28);
02c53b7Claude339 }
340 .tokens-scope-chip.is-admin {
341 background: rgba(248,113,113,0.10);
342 color: #fca5a5;
343 box-shadow: inset 0 0 0 1px rgba(248,113,113,0.30);
344 }
345 .tokens-scope-chip.is-user {
6fd5915Claude346 background: rgba(95,143,160,0.12);
02c53b7Claude347 color: #7adfe9;
6fd5915Claude348 box-shadow: inset 0 0 0 1px rgba(95,143,160,0.30);
02c53b7Claude349 }
350 .tokens-meta-sep {
351 opacity: 0.4;
352 }
353 .tokens-meta-time {
354 font-variant-numeric: tabular-nums;
355 }
356 .tokens-card-action { flex-shrink: 0; }
357 .tokens-revoke-btn {
358 display: inline-flex;
359 align-items: center;
360 gap: 6px;
361 padding: 6px 12px;
362 font-size: 12.5px;
363 font-weight: 600;
364 color: #fca5a5;
365 background: rgba(248,113,113,0.06);
366 border: 1px solid rgba(248,113,113,0.30);
367 border-radius: 8px;
368 cursor: pointer;
369 font-family: inherit;
370 transition: background 120ms ease, border-color 120ms ease, color 120ms ease;
371 }
372 .tokens-revoke-btn:hover {
373 background: rgba(248,113,113,0.14);
374 border-color: rgba(248,113,113,0.55);
375 color: #fecaca;
376 }
377 .tokens-revoke-btn:focus-visible {
378 outline: none;
379 box-shadow: 0 0 0 3px rgba(248,113,113,0.25);
380 }
381
382 /* ─── Empty state ─── */
383 .tokens-empty {
384 position: relative;
385 padding: var(--space-6) var(--space-5);
386 border: 1px dashed var(--border-strong);
387 border-radius: 14px;
388 background: var(--bg-secondary);
389 text-align: center;
390 overflow: hidden;
391 }
392 .tokens-empty-orb {
393 position: absolute;
394 inset: -30% 50% auto auto;
395 width: 220px; height: 220px;
6fd5915Claude396 background: radial-gradient(circle, rgba(91,110,232,0.16), rgba(95,143,160,0.06) 45%, transparent 70%);
02c53b7Claude397 filter: blur(60px);
398 opacity: 0.7;
399 pointer-events: none;
400 }
401 .tokens-empty-inner { position: relative; z-index: 1; }
402 .tokens-empty-icon {
403 display: inline-flex;
404 align-items: center;
405 justify-content: center;
406 width: 44px;
407 height: 44px;
408 margin-bottom: 12px;
409 border-radius: 12px;
6fd5915Claude410 background: rgba(91,110,232,0.14);
411 color: #5b6ee8;
412 box-shadow: inset 0 0 0 1px rgba(91,110,232,0.30);
02c53b7Claude413 }
414 .tokens-empty-icon svg { width: 22px; height: 22px; display: block; }
415 .tokens-empty-title {
416 font-family: var(--font-display);
417 font-size: 16px;
418 font-weight: 700;
419 color: var(--text-strong);
420 margin: 0 0 6px;
421 }
422 .tokens-empty-desc {
423 font-size: 13.5px;
424 color: var(--text-muted);
425 margin: 0 auto;
426 max-width: 420px;
427 line-height: 1.5;
428 }
429
430 /* ─── Form fields ─── */
431 .tokens-field { margin-bottom: var(--space-4); }
432 .tokens-field:last-child { margin-bottom: 0; }
433 .tokens-field-label {
434 display: block;
435 font-size: 13px;
436 font-weight: 600;
437 color: var(--text-strong);
438 margin-bottom: 6px;
439 letter-spacing: -0.005em;
440 }
441 .tokens-field-hint {
442 font-size: 12.5px;
443 color: var(--text-muted);
444 margin-top: 6px;
445 line-height: 1.45;
446 }
447 .tokens-input {
448 width: 100%;
449 padding: 9px 12px;
450 font-size: 14px;
451 color: var(--text);
452 background: var(--bg);
453 border: 1px solid var(--border-strong);
454 border-radius: 8px;
455 outline: none;
456 transition: border-color 120ms ease, box-shadow 120ms ease;
457 font-family: var(--font-sans);
458 }
459 .tokens-input:focus {
460 border-color: var(--border-focus);
6fd5915Claude461 box-shadow: 0 0 0 3px rgba(91,110,232,0.18);
02c53b7Claude462 }
463
464 /* ─── Scope picker ─── */
465 .tokens-scope-grid {
466 display: flex;
467 flex-wrap: wrap;
468 gap: 8px;
469 }
470 .tokens-scope-option {
471 display: inline-flex;
472 align-items: center;
473 gap: 8px;
474 padding: 8px 12px;
475 border: 1px solid var(--border-subtle);
476 background: var(--bg-secondary);
477 border-radius: 10px;
478 font-size: 13px;
479 color: var(--text);
480 cursor: pointer;
481 transition: border-color 120ms ease, background 120ms ease;
482 }
483 .tokens-scope-option:hover {
484 border-color: var(--border);
485 background: rgba(255,255,255,0.02);
486 }
487 .tokens-scope-option input[type="checkbox"] {
488 margin: 0;
489 width: 14px; height: 14px;
490 accent-color: var(--accent);
491 cursor: pointer;
492 }
493 .tokens-scope-option-label {
494 font-family: var(--font-mono);
495 font-size: 12.5px;
496 font-weight: 600;
497 color: var(--text-strong);
498 }
499 .tokens-scope-option:has(input:checked) {
6fd5915Claude500 border-color: rgba(91,110,232,0.45);
501 background: rgba(91,110,232,0.08);
02c53b7Claude502 }
503
504 /* ─── Primary action button ─── */
505 .tokens-submit {
506 display: inline-flex;
507 align-items: center;
508 gap: 8px;
509 padding: 9px 16px;
510 font-size: 13.5px;
511 font-weight: 600;
512 color: #fff;
6fd5915Claude513 background: linear-gradient(135deg, #5b6ee8 0%, #6e54e0 100%);
514 border: 1px solid rgba(91,110,232,0.50);
02c53b7Claude515 border-radius: 10px;
516 cursor: pointer;
517 font-family: inherit;
518 box-shadow:
519 0 1px 0 rgba(255,255,255,0.10) inset,
6fd5915Claude520 0 4px 16px rgba(91,110,232,0.25);
02c53b7Claude521 transition: transform 120ms ease, box-shadow 120ms ease, filter 120ms ease;
522 }
523 .tokens-submit:hover {
524 filter: brightness(1.06);
525 box-shadow:
526 0 1px 0 rgba(255,255,255,0.12) inset,
6fd5915Claude527 0 6px 20px rgba(91,110,232,0.32);
02c53b7Claude528 }
529 .tokens-submit:active { transform: translateY(1px); }
530 .tokens-submit:focus-visible {
531 outline: none;
532 box-shadow:
6fd5915Claude533 0 0 0 3px rgba(91,110,232,0.30),
534 0 4px 16px rgba(91,110,232,0.25);
02c53b7Claude535 }
536
537 /* ─── Responsive ─── */
538 @media (max-width: 720px) {
539 .tokens-hero { padding: var(--space-4) var(--space-4); }
540 .tokens-section-head,
541 .tokens-section-body { padding-left: var(--space-4); padding-right: var(--space-4); }
542 .tokens-card {
543 flex-direction: column;
544 align-items: stretch;
545 }
546 .tokens-card-action { align-self: flex-end; }
547 }
548`;
549
550function ShieldIcon() {
551 return (
552 <svg
553 viewBox="0 0 24 24"
554 fill="none"
555 stroke="currentColor"
556 stroke-width="2"
557 stroke-linecap="round"
558 stroke-linejoin="round"
559 aria-hidden="true"
560 >
561 <path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
562 </svg>
563 );
564}
565
566function KeyIcon() {
567 return (
568 <svg
569 viewBox="0 0 24 24"
570 fill="none"
571 stroke="currentColor"
572 stroke-width="2"
573 stroke-linecap="round"
574 stroke-linejoin="round"
575 aria-hidden="true"
576 >
577 <circle cx="7.5" cy="15.5" r="3.5" />
578 <path d="M10 13l9-9" />
579 <path d="M15 8l3 3" />
580 <path d="M18 5l3 3" />
581 </svg>
582 );
583}
584
585/** Format a date as a friendly relative time, with absolute fallback. */
586function relativeTime(date: Date | string | null | undefined): string {
587 if (!date) return "";
588 const d = typeof date === "string" ? new Date(date) : date;
589 const ms = Date.now() - d.getTime();
590 if (Number.isNaN(ms)) return "";
591 const sec = Math.max(0, Math.floor(ms / 1000));
592 if (sec < 60) return "just now";
593 const min = Math.floor(sec / 60);
594 if (min < 60) return `${min} minute${min === 1 ? "" : "s"} ago`;
595 const hr = Math.floor(min / 60);
596 if (hr < 24) return `${hr} hour${hr === 1 ? "" : "s"} ago`;
597 const day = Math.floor(hr / 24);
598 if (day < 30) return `${day} day${day === 1 ? "" : "s"} ago`;
599 const mo = Math.floor(day / 30);
600 if (mo < 12) return `${mo} month${mo === 1 ? "" : "s"} ago`;
601 const yr = Math.floor(mo / 12);
602 return `${yr} year${yr === 1 ? "" : "s"} ago`;
603}
604
c81ab7aClaude605// Token settings page
606tokens.get("/settings/tokens", async (c) => {
607 const user = c.get("user")!;
608 const success = c.req.query("success");
609 const newToken = c.req.query("new_token");
02c53b7Claude610 const error = c.req.query("error");
c81ab7aClaude611
612 const userTokens = await db
613 .select()
614 .from(apiTokens)
615 .where(eq(apiTokens.userId, user.id));
616
617 return c.html(
618 <Layout title="API Tokens" user={user}>
02c53b7Claude619 <style dangerouslySetInnerHTML={{ __html: tokensStyles }} />
9b776daccantynz-alt620 <style dangerouslySetInnerHTML={{ __html: settingsNavStyles }} />
621 <div class="settings-shell" style="max-width:1500px;margin:0 auto;padding:var(--space-4)">
622 <SettingsNav active="tokens" />
623 <div class="tokens-wrap settings-content" style="max-width:none;margin:0;padding:0">
02c53b7Claude624 {/* ─── Hero ─── */}
625 <div class="tokens-hero">
626 <div class="tokens-hero-bg" aria-hidden="true">
627 <div class="tokens-hero-orb" />
628 </div>
629 <div class="tokens-hero-inner">
630 <div class="tokens-hero-eyebrow">
631 <span class="tokens-hero-eyebrow-icon">
632 <ShieldIcon />
633 </span>
634 <span>Personal access tokens</span>
635 <span class="tokens-hero-eyebrow-sep">·</span>
636 <span>Settings</span>
637 <span class="tokens-hero-eyebrow-sep">·</span>
638 <span class="tokens-hero-username">{user.username}</span>
639 </div>
640 <h1 class="tokens-hero-title">
641 Tokens for{" "}
642 <span class="tokens-gradient-text">automation</span>.
643 </h1>
644 <p class="tokens-hero-sub">
645 Issue scoped credentials for CI, scripts, and the
646 Gluecron MCP. Tokens are shown once, hashed at rest, and
647 can be revoked anytime.
648 </p>
649 </div>
650 </div>
651
652 {/* ─── Banners ─── */}
c81ab7aClaude653 {success && (
02c53b7Claude654 <div class="tokens-banner tokens-banner-success" role="status">
655 <span class="tokens-banner-icon" aria-hidden="true">✓</span>
656 <div class="tokens-banner-body">{decodeURIComponent(success)}</div>
657 </div>
c81ab7aClaude658 )}
02c53b7Claude659 {error && (
660 <div
661 class="tokens-banner tokens-banner-success"
662 style="background: rgba(248,113,113,0.08); color: #fca5a5; box-shadow: inset 0 0 0 1px rgba(248,113,113,0.30);"
663 role="alert"
664 >
665 <span
666 class="tokens-banner-icon"
667 style="background: rgba(248,113,113,0.18); color: #f87171;"
668 aria-hidden="true"
669 >
670 !
3e8f8e8Claude671 </span>
02c53b7Claude672 <div class="tokens-banner-body">{decodeURIComponent(error)}</div>
673 </div>
674 )}
675
676 {/* ─── Revealed-once new token ─── */}
677 {newToken && (
678 <div class="tokens-reveal" role="status" aria-live="polite">
679 <div class="tokens-reveal-eyebrow">New token · copy now</div>
680 <h2 class="tokens-reveal-title">
681 This token will only be shown once
682 </h2>
683 <code class="tokens-reveal-value">
684 {decodeURIComponent(newToken)}
685 </code>
686 <p class="tokens-reveal-hint">
687 Store it somewhere safe — your password manager, CI
688 secret store, or the GLUECRON_PAT env var. We can't
689 recover it for you.
690 </p>
691 </div>
c81ab7aClaude692 )}
02c53b7Claude693
694 {/* ─── Existing tokens ─── */}
695 <div class="tokens-section">
696 <div class="tokens-section-head">
697 <div class="tokens-section-eyebrow">Active tokens</div>
698 <h2 class="tokens-section-title">Your tokens</h2>
699 <p class="tokens-section-desc">
700 Each token carries its own scopes and can be revoked
701 independently. The 12-character prefix is safe to log;
702 the full value is not.
703 </p>
704 </div>
705 <div class="tokens-section-body">
706 {userTokens.length === 0 ? (
707 <div class="tokens-empty">
708 <div class="tokens-empty-orb" aria-hidden="true" />
709 <div class="tokens-empty-inner">
710 <div class="tokens-empty-icon" aria-hidden="true">
711 <KeyIcon />
c81ab7aClaude712 </div>
02c53b7Claude713 <h3 class="tokens-empty-title">No tokens yet</h3>
714 <p class="tokens-empty-desc">
715 Generate one below to authenticate against the
716 Gluecron API, MCP server, or your CI pipeline.
717 </p>
c81ab7aClaude718 </div>
02c53b7Claude719 </div>
720 ) : (
721 <div class="tokens-list">
722 {userTokens.map((token) => {
723 const scopes = (token.scopes || "")
724 .split(",")
725 .map((s) => s.trim())
726 .filter(Boolean);
727 return (
728 <div class="tokens-card">
729 <div class="tokens-card-main">
730 <div class="tokens-card-name">{token.name}</div>
731 <div class="tokens-card-meta">
732 <span class="tokens-prefix-pill">
733 {token.tokenPrefix}…
734 </span>
735 {scopes.length > 0 && (
736 <span class="tokens-scope-chips">
737 {scopes.map((scope) => (
738 <span
739 class={`tokens-scope-chip${
740 scope === "admin"
741 ? " is-admin"
742 : scope === "user"
743 ? " is-user"
744 : ""
745 }`}
746 >
747 {scope}
748 </span>
749 ))}
750 </span>
751 )}
752 {token.lastUsedAt ? (
753 <>
754 <span class="tokens-meta-sep">·</span>
755 <span class="tokens-meta-time">
756 Last used {relativeTime(token.lastUsedAt)}
757 </span>
758 </>
759 ) : (
760 <>
761 <span class="tokens-meta-sep">·</span>
762 <span class="tokens-meta-time">Never used</span>
763 </>
764 )}
765 </div>
766 </div>
767 <div class="tokens-card-action">
768 <form
769 method="post"
770 action={`/settings/tokens/${token.id}/delete`}
771 >
772 <button type="submit" class="tokens-revoke-btn">
773 Revoke
774 </button>
775 </form>
776 </div>
777 </div>
778 );
779 })}
780 </div>
781 )}
782 </div>
c81ab7aClaude783 </div>
784
02c53b7Claude785 {/* ─── New token form ─── */}
786 <div class="tokens-section">
787 <div class="tokens-section-head">
788 <div class="tokens-section-eyebrow">Generate</div>
789 <h2 class="tokens-section-title">New token</h2>
790 <p class="tokens-section-desc">
791 Pick a memorable name and the minimum scopes you need.
792 The token is generated, hashed, and surfaced exactly
793 once — paste it into your secret store immediately.
794 </p>
c81ab7aClaude795 </div>
02c53b7Claude796 <div class="tokens-section-body">
797 <form method="post" action="/settings/tokens">
798 <div class="tokens-field">
799 <label class="tokens-field-label" for="name">
800 Token name
c81ab7aClaude801 </label>
02c53b7Claude802 <input
803 type="text"
804 id="name"
805 name="name"
806 required
807 placeholder="e.g. CI/CD pipeline"
808 class="tokens-input"
809 />
810 <div class="tokens-field-hint">
811 A label only you see — helps you remember which
812 machine or workflow this token is for.
813 </div>
814 </div>
815 <div class="tokens-field">
816 <label class="tokens-field-label">Scopes</label>
817 <div class="tokens-scope-grid">
818 {["repo", "user", "admin"].map((scope) => (
819 <label class="tokens-scope-option">
820 <input
821 type="checkbox"
822 name="scopes"
823 value={scope}
824 checked={scope === "repo"}
825 aria-label={scope}
826 />
827 <span class="tokens-scope-option-label">{scope}</span>
828 </label>
829 ))}
830 </div>
831 <div class="tokens-field-hint">
832 <code style="font-family: var(--font-mono); font-size: 12px;">repo</code>{" "}
833 reads &amp; writes repository contents.{" "}
834 <code style="font-family: var(--font-mono); font-size: 12px;">user</code>{" "}
835 edits your profile.{" "}
836 <code style="font-family: var(--font-mono); font-size: 12px;">admin</code>{" "}
837 is required for site-wide actions like merging PRs
838 via the MCP server.
839 </div>
840 </div>
841 <button type="submit" class="tokens-submit">
842 Generate token
843 </button>
844 </form>
c81ab7aClaude845 </div>
02c53b7Claude846 </div>
847 </div>
9b776daccantynz-alt848 </div>
c81ab7aClaude849 </Layout>
850 );
851});
852
853// Create token
854tokens.post("/settings/tokens", async (c) => {
855 const user = c.get("user")!;
856 const body = await c.req.parseBody();
857 const name = String(body.name || "").trim();
858
859 let scopes: string;
860 const rawScopes = body.scopes;
861 if (Array.isArray(rawScopes)) {
862 scopes = rawScopes.join(",");
863 } else {
864 scopes = String(rawScopes || "repo");
865 }
866
867 if (!name) {
868 return c.redirect("/settings/tokens?error=Name+is+required");
869 }
870
871 const token = generateToken();
872 const tokenH = await hashToken(token);
873
874 await db.insert(apiTokens).values({
875 userId: user.id,
876 name,
877 tokenHash: tokenH,
878 tokenPrefix: token.slice(0, 12),
879 scopes,
880 });
881
882 return c.redirect(
883 `/settings/tokens?new_token=${encodeURIComponent(token)}`
884 );
885});
886
887// Delete token
888tokens.post("/settings/tokens/:id/delete", async (c) => {
889 const user = c.get("user")!;
890 const tokenId = c.req.param("id");
891
892 const [token] = await db
893 .select()
894 .from(apiTokens)
895 .where(eq(apiTokens.id, tokenId))
896 .limit(1);
897
898 if (!token || token.userId !== user.id) {
899 return c.redirect("/settings/tokens");
900 }
901
902 await db.delete(apiTokens).where(eq(apiTokens.id, tokenId));
903 return c.redirect("/settings/tokens?success=Token+revoked");
904});
905
96942a6Test User906/**
907 * Emergency PAT issuance — break-glass for when the web UI is broken
908 * (service-worker loop, css busted, whatever) and an operator needs
909 * a token to push a fix.
910 *
911 * Auth: bearer of the `EMERGENCY_PAT_SECRET` env var (set on the host).
912 * If the env var is unset, the endpoint returns 503 — we don't want it
913 * silently usable with an empty secret. This is the ONLY token route
914 * that isn't behind a normal session, by design.
915 *
916 * Issues a PAT for the user named in the JSON body's `username` field,
917 * defaulting to the site admin / oldest user (same heuristic the
918 * self-host bootstrap uses).
919 *
920 * Returns JSON: { user, token } — the token is shown ONCE.
921 *
922 * Use:
923 * curl -X POST https://gluecron.com/api/admin/emergency-pat \
924 * -H "Authorization: Bearer $EMERGENCY_PAT_SECRET" \
925 * -H "content-type: application/json" \
926 * -d '{"name":"break-glass","scopes":"admin"}'
927 */
928tokens.post("/api/admin/emergency-pat", async (c) => {
929 const secret = process.env.EMERGENCY_PAT_SECRET;
930 if (!secret) {
931 return c.json(
932 { error: "emergency PAT endpoint not configured (EMERGENCY_PAT_SECRET unset)" },
933 503
934 );
935 }
936 const provided = (c.req.header("authorization") || "").replace(/^Bearer\s+/i, "").trim();
937 if (provided !== secret) {
938 return c.json({ error: "invalid emergency secret" }, 401);
939 }
940
941 let body: { username?: string; name?: string; scopes?: string };
942 try {
943 body = await c.req.json();
944 } catch {
945 body = {};
946 }
947 const name = (body.name || "emergency-pat").trim();
948 const scopes = (body.scopes || "admin").trim();
949
950 // Resolve target user: explicit username → site admin → oldest user.
951 const { users, siteAdmins } = await import("../db/schema");
952 const { eq: eqOp, asc } = await import("drizzle-orm");
953 let target:
954 | { id: string; username: string }
955 | undefined;
956
957 if (body.username) {
958 const [u] = await db
959 .select({ id: users.id, username: users.username })
960 .from(users)
961 .where(eqOp(users.username, body.username))
962 .limit(1);
963 target = u;
964 }
965 if (!target) {
966 try {
967 const [u] = await db
968 .select({ id: users.id, username: users.username })
969 .from(siteAdmins)
970 .innerJoin(users, eqOp(siteAdmins.userId, users.id))
971 .limit(1);
972 target = u;
973 } catch {
974 // siteAdmins table may not exist on stale schemas — fall through.
975 }
976 }
977 if (!target) {
978 const [u] = await db
979 .select({ id: users.id, username: users.username })
980 .from(users)
981 .orderBy(asc(users.createdAt))
982 .limit(1);
983 target = u;
984 }
985 if (!target) {
986 return c.json({ error: "no user available to issue PAT for" }, 404);
987 }
988
989 // Token + hash — same algorithm the web flow uses.
990 const tokenBytes = crypto.getRandomValues(new Uint8Array(32));
991 const token =
992 "glc_" +
993 Array.from(tokenBytes)
994 .map((b) => b.toString(16).padStart(2, "0"))
995 .join("");
996 const hashBuf = await crypto.subtle.digest(
997 "SHA-256",
998 new TextEncoder().encode(token)
999 );
1000 const tokenHash = Array.from(new Uint8Array(hashBuf))
1001 .map((b) => b.toString(16).padStart(2, "0"))
1002 .join("");
1003
1004 await db.insert(apiTokens).values({
1005 userId: target.id,
1006 name,
1007 tokenHash,
1008 tokenPrefix: token.slice(0, 12),
1009 scopes,
1010 });
1011
1012 return c.json({
1013 user: { id: target.id, username: target.username },
1014 token,
1015 name,
1016 scopes,
1017 note: "Token is shown once. Store it now.",
1018 });
1019});
1020
c81ab7aClaude1021// API endpoint
1022tokens.get("/api/user/tokens", async (c) => {
1023 const user = c.get("user")!;
1024 const userTokens = await db
1025 .select({
1026 id: apiTokens.id,
1027 name: apiTokens.name,
1028 tokenPrefix: apiTokens.tokenPrefix,
1029 scopes: apiTokens.scopes,
1030 lastUsedAt: apiTokens.lastUsedAt,
1031 createdAt: apiTokens.createdAt,
1032 })
1033 .from(apiTokens)
1034 .where(eq(apiTokens.userId, user.id));
1035 return c.json(userTokens);
1036});
1037
1038export default tokens;