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