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