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.tsxBlame1177 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);
e589f77ccantynz-alt115 color: var(--accent);
6fd5915Claude116 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);
e589f77ccantynz-alt162 color: var(--green);
02c53b7Claude163 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);
e589f77ccantynz-alt178 color: var(--green);
02c53b7Claude179 }
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;
e589f77ccantynz-alt208 color: var(--accent);
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);
e589f77ccantynz-alt337 color: var(--accent);
6fd5915Claude338 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);
e589f77ccantynz-alt342 color: var(--red);
02c53b7Claude343 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;
e589f77ccantynz-alt364 color: var(--red);
02c53b7Claude365 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);
e589f77ccantynz-alt411 color: var(--accent);
6fd5915Claude412 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. */
b9b9f22ccantynz-alt586/**
587 * Absolute date for a future expiry ("Expires 26 Aug 2026"). Relative phrasing
588 * is fine for the past ("Expired 3 days ago") but vague forwards — "expires in
589 * 2 months" is not something you can plan a rotation around.
590 */
591function formatExpiry(date: Date | string): string {
592 const d = typeof date === "string" ? new Date(date) : date;
593 if (Number.isNaN(d.getTime())) return "";
594 return d.toLocaleDateString("en-GB", {
595 day: "numeric",
596 month: "short",
597 year: "numeric",
598 timeZone: "UTC",
599 });
600}
601
02c53b7Claude602function relativeTime(date: Date | string | null | undefined): string {
603 if (!date) return "";
604 const d = typeof date === "string" ? new Date(date) : date;
605 const ms = Date.now() - d.getTime();
606 if (Number.isNaN(ms)) return "";
607 const sec = Math.max(0, Math.floor(ms / 1000));
608 if (sec < 60) return "just now";
609 const min = Math.floor(sec / 60);
610 if (min < 60) return `${min} minute${min === 1 ? "" : "s"} ago`;
611 const hr = Math.floor(min / 60);
612 if (hr < 24) return `${hr} hour${hr === 1 ? "" : "s"} ago`;
613 const day = Math.floor(hr / 24);
614 if (day < 30) return `${day} day${day === 1 ? "" : "s"} ago`;
615 const mo = Math.floor(day / 30);
616 if (mo < 12) return `${mo} month${mo === 1 ? "" : "s"} ago`;
617 const yr = Math.floor(mo / 12);
618 return `${yr} year${yr === 1 ? "" : "s"} ago`;
619}
620
c81ab7aClaude621// Token settings page
622tokens.get("/settings/tokens", async (c) => {
623 const user = c.get("user")!;
624 const success = c.req.query("success");
625 const newToken = c.req.query("new_token");
02c53b7Claude626 const error = c.req.query("error");
c81ab7aClaude627
628 const userTokens = await db
629 .select()
630 .from(apiTokens)
631 .where(eq(apiTokens.userId, user.id));
632
633 return c.html(
634 <Layout title="API Tokens" user={user}>
02c53b7Claude635 <style dangerouslySetInnerHTML={{ __html: tokensStyles }} />
9b776daccantynz-alt636 <style dangerouslySetInnerHTML={{ __html: settingsNavStyles }} />
637 <div class="settings-shell" style="max-width:1500px;margin:0 auto;padding:var(--space-4)">
638 <SettingsNav active="tokens" />
639 <div class="tokens-wrap settings-content" style="max-width:none;margin:0;padding:0">
02c53b7Claude640 {/* ─── Hero ─── */}
641 <div class="tokens-hero">
642 <div class="tokens-hero-bg" aria-hidden="true">
643 <div class="tokens-hero-orb" />
644 </div>
645 <div class="tokens-hero-inner">
646 <div class="tokens-hero-eyebrow">
647 <span class="tokens-hero-eyebrow-icon">
648 <ShieldIcon />
649 </span>
650 <span>Personal access tokens</span>
651 <span class="tokens-hero-eyebrow-sep">·</span>
652 <span>Settings</span>
653 <span class="tokens-hero-eyebrow-sep">·</span>
654 <span class="tokens-hero-username">{user.username}</span>
655 </div>
656 <h1 class="tokens-hero-title">
657 Tokens for{" "}
658 <span class="tokens-gradient-text">automation</span>.
659 </h1>
660 <p class="tokens-hero-sub">
661 Issue scoped credentials for CI, scripts, and the
662 Gluecron MCP. Tokens are shown once, hashed at rest, and
663 can be revoked anytime.
664 </p>
665 </div>
666 </div>
667
668 {/* ─── Banners ─── */}
c81ab7aClaude669 {success && (
02c53b7Claude670 <div class="tokens-banner tokens-banner-success" role="status">
671 <span class="tokens-banner-icon" aria-hidden="true">✓</span>
672 <div class="tokens-banner-body">{decodeURIComponent(success)}</div>
673 </div>
c81ab7aClaude674 )}
02c53b7Claude675 {error && (
676 <div
677 class="tokens-banner tokens-banner-success"
e589f77ccantynz-alt678 style="background: rgba(248,113,113,0.08); color: var(--red); box-shadow: inset 0 0 0 1px rgba(248,113,113,0.30);"
02c53b7Claude679 role="alert"
680 >
681 <span
682 class="tokens-banner-icon"
e589f77ccantynz-alt683 style="background: rgba(248,113,113,0.18); color: var(--red);"
02c53b7Claude684 aria-hidden="true"
685 >
686 !
3e8f8e8Claude687 </span>
02c53b7Claude688 <div class="tokens-banner-body">{decodeURIComponent(error)}</div>
689 </div>
690 )}
691
692 {/* ─── Revealed-once new token ─── */}
693 {newToken && (
694 <div class="tokens-reveal" role="status" aria-live="polite">
695 <div class="tokens-reveal-eyebrow">New token · copy now</div>
696 <h2 class="tokens-reveal-title">
697 This token will only be shown once
698 </h2>
699 <code class="tokens-reveal-value">
700 {decodeURIComponent(newToken)}
701 </code>
702 <p class="tokens-reveal-hint">
703 Store it somewhere safe — your password manager, CI
704 secret store, or the GLUECRON_PAT env var. We can't
705 recover it for you.
706 </p>
707 </div>
c81ab7aClaude708 )}
02c53b7Claude709
710 {/* ─── Existing tokens ─── */}
711 <div class="tokens-section">
712 <div class="tokens-section-head">
713 <div class="tokens-section-eyebrow">Active tokens</div>
714 <h2 class="tokens-section-title">Your tokens</h2>
715 <p class="tokens-section-desc">
716 Each token carries its own scopes and can be revoked
717 independently. The 12-character prefix is safe to log;
718 the full value is not.
719 </p>
720 </div>
721 <div class="tokens-section-body">
722 {userTokens.length === 0 ? (
723 <div class="tokens-empty">
724 <div class="tokens-empty-orb" aria-hidden="true" />
725 <div class="tokens-empty-inner">
726 <div class="tokens-empty-icon" aria-hidden="true">
727 <KeyIcon />
c81ab7aClaude728 </div>
02c53b7Claude729 <h3 class="tokens-empty-title">No tokens yet</h3>
730 <p class="tokens-empty-desc">
731 Generate one below to authenticate against the
732 Gluecron API, MCP server, or your CI pipeline.
733 </p>
c81ab7aClaude734 </div>
02c53b7Claude735 </div>
736 ) : (
737 <div class="tokens-list">
738 {userTokens.map((token) => {
739 const scopes = (token.scopes || "")
740 .split(",")
741 .map((s) => s.trim())
742 .filter(Boolean);
743 return (
744 <div class="tokens-card">
745 <div class="tokens-card-main">
746 <div class="tokens-card-name">{token.name}</div>
747 <div class="tokens-card-meta">
748 <span class="tokens-prefix-pill">
749 {token.tokenPrefix}…
750 </span>
751 {scopes.length > 0 && (
752 <span class="tokens-scope-chips">
753 {scopes.map((scope) => (
754 <span
755 class={`tokens-scope-chip${
756 scope === "admin"
757 ? " is-admin"
758 : scope === "user"
759 ? " is-user"
760 : ""
761 }`}
762 >
763 {scope}
764 </span>
765 ))}
766 </span>
767 )}
768 {token.lastUsedAt ? (
769 <>
770 <span class="tokens-meta-sep">·</span>
771 <span class="tokens-meta-time">
772 Last used {relativeTime(token.lastUsedAt)}
773 </span>
774 </>
775 ) : (
776 <>
777 <span class="tokens-meta-sep">·</span>
778 <span class="tokens-meta-time">Never used</span>
779 </>
780 )}
b9b9f22ccantynz-alt781 <span class="tokens-meta-sep">·</span>
782 {!token.expiresAt ? (
783 <span class="tokens-meta-time">No expiration</span>
784 ) : new Date(token.expiresAt) < new Date() ? (
785 <span
786 class="tokens-meta-time"
787 style="color: var(--red); font-weight: 600;"
788 >
789 Expired {relativeTime(token.expiresAt)}
790 </span>
791 ) : (
792 <span class="tokens-meta-time">
793 Expires {formatExpiry(token.expiresAt)}
794 </span>
795 )}
02c53b7Claude796 </div>
797 </div>
798 <div class="tokens-card-action">
799 <form
800 method="post"
801 action={`/settings/tokens/${token.id}/delete`}
802 >
803 <button type="submit" class="tokens-revoke-btn">
804 Revoke
805 </button>
806 </form>
807 </div>
808 </div>
809 );
810 })}
811 </div>
812 )}
813 </div>
c81ab7aClaude814 </div>
815
02c53b7Claude816 {/* ─── New token form ─── */}
817 <div class="tokens-section">
818 <div class="tokens-section-head">
819 <div class="tokens-section-eyebrow">Generate</div>
820 <h2 class="tokens-section-title">New token</h2>
821 <p class="tokens-section-desc">
822 Pick a memorable name and the minimum scopes you need.
823 The token is generated, hashed, and surfaced exactly
824 once — paste it into your secret store immediately.
825 </p>
c81ab7aClaude826 </div>
02c53b7Claude827 <div class="tokens-section-body">
828 <form method="post" action="/settings/tokens">
829 <div class="tokens-field">
830 <label class="tokens-field-label" for="name">
831 Token name
c81ab7aClaude832 </label>
02c53b7Claude833 <input
834 type="text"
835 id="name"
836 name="name"
837 required
838 placeholder="e.g. CI/CD pipeline"
839 class="tokens-input"
840 />
841 <div class="tokens-field-hint">
842 A label only you see — helps you remember which
843 machine or workflow this token is for.
844 </div>
845 </div>
846 <div class="tokens-field">
847 <label class="tokens-field-label">Scopes</label>
848 <div class="tokens-scope-grid">
849 {["repo", "user", "admin"].map((scope) => (
850 <label class="tokens-scope-option">
851 <input
852 type="checkbox"
853 name="scopes"
854 value={scope}
855 checked={scope === "repo"}
856 aria-label={scope}
857 />
858 <span class="tokens-scope-option-label">{scope}</span>
859 </label>
860 ))}
861 </div>
862 <div class="tokens-field-hint">
863 <code style="font-family: var(--font-mono); font-size: 12px;">repo</code>{" "}
864 reads &amp; writes repository contents.{" "}
865 <code style="font-family: var(--font-mono); font-size: 12px;">user</code>{" "}
866 edits your profile.{" "}
867 <code style="font-family: var(--font-mono); font-size: 12px;">admin</code>{" "}
868 is required for site-wide actions like merging PRs
869 via the MCP server.
870 </div>
871 </div>
b9b9f22ccantynz-alt872 <div class="tokens-field">
873 <label class="tokens-field-label" for="expires_in">
874 Expiration
875 </label>
876 <select id="expires_in" name="expires_in" class="tokens-input">
877 <option value="30" selected>
878 30 days
879 </option>
880 <option value="7">7 days</option>
881 <option value="60">60 days</option>
882 <option value="90">90 days</option>
883 <option value="custom">Custom…</option>
884 <option value="never">No expiration</option>
885 </select>
886 <input
887 type="date"
888 id="expires_at"
889 name="expires_at"
890 class="tokens-input"
891 style="display: none; margin-top: 8px;"
892 aria-label="Custom expiration date"
893 />
894 <div class="tokens-field-hint">
895 An expiring token limits the damage if it leaks. Pick{" "}
896 <strong>No expiration</strong> only for long-lived automation
897 you actively monitor.
898 </div>
899 </div>
02c53b7Claude900 <button type="submit" class="tokens-submit">
901 Generate token
902 </button>
903 </form>
b9b9f22ccantynz-alt904 {/* Reveal the date picker only for the "Custom…" choice. Progressive
905 enhancement: with JS off the select still submits a valid preset,
906 and the server ignores an empty expires_at. */}
907 <script
908 dangerouslySetInnerHTML={{
909 __html: `(function(){
910 var sel = document.getElementById('expires_in');
911 var day = document.getElementById('expires_at');
912 if (!sel || !day) return;
913 function sync(){
914 var custom = sel.value === 'custom';
915 day.style.display = custom ? 'block' : 'none';
916 day.required = custom;
917 if (custom && !day.value) {
918 var d = new Date(Date.now() + 30 * 86400000);
919 day.min = new Date(Date.now() + 86400000).toISOString().slice(0, 10);
920 day.value = d.toISOString().slice(0, 10);
921 }
922 }
923 sel.addEventListener('change', sync);
924 sync();
925})();`,
926 }}
927 />
c81ab7aClaude928 </div>
02c53b7Claude929 </div>
930 </div>
9b776daccantynz-alt931 </div>
c81ab7aClaude932 </Layout>
933 );
934});
935
936// Create token
b9b9f22ccantynz-alt937/**
938 * Sentinel for "the user asked for an expiry we can't honour". Distinct from
939 * `null`, which legitimately means "no expiration" — conflating the two is how
940 * a rejected date would silently become a token that never expires.
941 */
942export const INVALID_EXPIRY = Symbol("invalid-expiry");
943
944/**
945 * Resolve the create-token form's expiry controls to a Date (or null for
946 * "no expiration").
947 *
948 * `expires_in` is a preset in days, the literal "never", or "custom" — in
949 * which case `expires_at` carries a yyyy-mm-dd from the date picker. An
950 * absent/blank `expires_in` means the request predates this field (e.g. an
951 * older client or a script posting the form); those default to 30 days rather
952 * than to a token that lives forever.
953 */
954export function parseExpiry(
955 expiresIn: string,
956 expiresAtRaw: string,
957 now: Date = new Date()
958): Date | null | typeof INVALID_EXPIRY {
959 const choice = expiresIn.trim() || "30";
960
961 if (choice === "never") return null;
962
963 if (choice === "custom") {
964 const raw = expiresAtRaw.trim();
965 if (!raw) return INVALID_EXPIRY;
966 // Anchor to end-of-day UTC so "expires on the 5th" stays usable through
967 // the 5th in every timezone, rather than dying at 00:00 UTC.
968 const parsed = new Date(`${raw}T23:59:59.999Z`);
969 if (Number.isNaN(parsed.getTime())) return INVALID_EXPIRY;
970 if (parsed.getTime() <= now.getTime()) return INVALID_EXPIRY;
971 return parsed;
972 }
973
974 const days = Number(choice);
975 if (!Number.isFinite(days) || !Number.isInteger(days) || days <= 0) {
976 return INVALID_EXPIRY;
977 }
978 return new Date(now.getTime() + days * 86_400_000);
979}
980
c81ab7aClaude981tokens.post("/settings/tokens", async (c) => {
982 const user = c.get("user")!;
983 const body = await c.req.parseBody();
984 const name = String(body.name || "").trim();
985
986 let scopes: string;
987 const rawScopes = body.scopes;
988 if (Array.isArray(rawScopes)) {
989 scopes = rawScopes.join(",");
990 } else {
991 scopes = String(rawScopes || "repo");
992 }
993
994 if (!name) {
995 return c.redirect("/settings/tokens?error=Name+is+required");
996 }
997
b9b9f22ccantynz-alt998 const expiresAt = parseExpiry(
999 String(body.expires_in ?? ""),
1000 String(body.expires_at ?? "")
1001 );
1002 if (expiresAt === INVALID_EXPIRY) {
1003 return c.redirect(
1004 "/settings/tokens?error=Expiration+must+be+a+future+date"
1005 );
1006 }
1007
c81ab7aClaude1008 const token = generateToken();
1009 const tokenH = await hashToken(token);
1010
1011 await db.insert(apiTokens).values({
1012 userId: user.id,
1013 name,
1014 tokenHash: tokenH,
1015 tokenPrefix: token.slice(0, 12),
1016 scopes,
b9b9f22ccantynz-alt1017 expiresAt,
c81ab7aClaude1018 });
1019
1020 return c.redirect(
1021 `/settings/tokens?new_token=${encodeURIComponent(token)}`
1022 );
1023});
1024
1025// Delete token
1026tokens.post("/settings/tokens/:id/delete", async (c) => {
1027 const user = c.get("user")!;
1028 const tokenId = c.req.param("id");
1029
1030 const [token] = await db
1031 .select()
1032 .from(apiTokens)
1033 .where(eq(apiTokens.id, tokenId))
1034 .limit(1);
1035
1036 if (!token || token.userId !== user.id) {
1037 return c.redirect("/settings/tokens");
1038 }
1039
1040 await db.delete(apiTokens).where(eq(apiTokens.id, tokenId));
1041 return c.redirect("/settings/tokens?success=Token+revoked");
1042});
1043
96942a6Test User1044/**
1045 * Emergency PAT issuance — break-glass for when the web UI is broken
1046 * (service-worker loop, css busted, whatever) and an operator needs
1047 * a token to push a fix.
1048 *
1049 * Auth: bearer of the `EMERGENCY_PAT_SECRET` env var (set on the host).
1050 * If the env var is unset, the endpoint returns 503 — we don't want it
1051 * silently usable with an empty secret. This is the ONLY token route
1052 * that isn't behind a normal session, by design.
1053 *
1054 * Issues a PAT for the user named in the JSON body's `username` field,
1055 * defaulting to the site admin / oldest user (same heuristic the
1056 * self-host bootstrap uses).
1057 *
1058 * Returns JSON: { user, token } — the token is shown ONCE.
1059 *
1060 * Use:
1061 * curl -X POST https://gluecron.com/api/admin/emergency-pat \
1062 * -H "Authorization: Bearer $EMERGENCY_PAT_SECRET" \
1063 * -H "content-type: application/json" \
1064 * -d '{"name":"break-glass","scopes":"admin"}'
1065 */
1066tokens.post("/api/admin/emergency-pat", async (c) => {
1067 const secret = process.env.EMERGENCY_PAT_SECRET;
1068 if (!secret) {
1069 return c.json(
1070 { error: "emergency PAT endpoint not configured (EMERGENCY_PAT_SECRET unset)" },
1071 503
1072 );
1073 }
1074 const provided = (c.req.header("authorization") || "").replace(/^Bearer\s+/i, "").trim();
1075 if (provided !== secret) {
1076 return c.json({ error: "invalid emergency secret" }, 401);
1077 }
1078
1079 let body: { username?: string; name?: string; scopes?: string };
1080 try {
1081 body = await c.req.json();
1082 } catch {
1083 body = {};
1084 }
1085 const name = (body.name || "emergency-pat").trim();
1086 const scopes = (body.scopes || "admin").trim();
1087
1088 // Resolve target user: explicit username → site admin → oldest user.
1089 const { users, siteAdmins } = await import("../db/schema");
1090 const { eq: eqOp, asc } = await import("drizzle-orm");
1091 let target:
1092 | { id: string; username: string }
1093 | undefined;
1094
1095 if (body.username) {
1096 const [u] = await db
1097 .select({ id: users.id, username: users.username })
1098 .from(users)
1099 .where(eqOp(users.username, body.username))
1100 .limit(1);
1101 target = u;
1102 }
1103 if (!target) {
1104 try {
1105 const [u] = await db
1106 .select({ id: users.id, username: users.username })
1107 .from(siteAdmins)
1108 .innerJoin(users, eqOp(siteAdmins.userId, users.id))
1109 .limit(1);
1110 target = u;
1111 } catch {
1112 // siteAdmins table may not exist on stale schemas — fall through.
1113 }
1114 }
1115 if (!target) {
1116 const [u] = await db
1117 .select({ id: users.id, username: users.username })
1118 .from(users)
1119 .orderBy(asc(users.createdAt))
1120 .limit(1);
1121 target = u;
1122 }
1123 if (!target) {
1124 return c.json({ error: "no user available to issue PAT for" }, 404);
1125 }
1126
1127 // Token + hash — same algorithm the web flow uses.
1128 const tokenBytes = crypto.getRandomValues(new Uint8Array(32));
1129 const token =
1130 "glc_" +
1131 Array.from(tokenBytes)
1132 .map((b) => b.toString(16).padStart(2, "0"))
1133 .join("");
1134 const hashBuf = await crypto.subtle.digest(
1135 "SHA-256",
1136 new TextEncoder().encode(token)
1137 );
1138 const tokenHash = Array.from(new Uint8Array(hashBuf))
1139 .map((b) => b.toString(16).padStart(2, "0"))
1140 .join("");
1141
1142 await db.insert(apiTokens).values({
1143 userId: target.id,
1144 name,
1145 tokenHash,
1146 tokenPrefix: token.slice(0, 12),
1147 scopes,
1148 });
1149
1150 return c.json({
1151 user: { id: target.id, username: target.username },
1152 token,
1153 name,
1154 scopes,
1155 note: "Token is shown once. Store it now.",
1156 });
1157});
1158
c81ab7aClaude1159// API endpoint
1160tokens.get("/api/user/tokens", async (c) => {
1161 const user = c.get("user")!;
1162 const userTokens = await db
1163 .select({
1164 id: apiTokens.id,
1165 name: apiTokens.name,
1166 tokenPrefix: apiTokens.tokenPrefix,
1167 scopes: apiTokens.scopes,
1168 lastUsedAt: apiTokens.lastUsedAt,
1169 createdAt: apiTokens.createdAt,
b9b9f22ccantynz-alt1170 expiresAt: apiTokens.expiresAt,
c81ab7aClaude1171 })
1172 .from(apiTokens)
1173 .where(eq(apiTokens.userId, user.id));
1174 return c.json(userTokens);
1175});
1176
1177export default tokens;