Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
Commit02c53b7unknown_key

polish(tokens): 2026 hero + token cards + danger treatment (parallel session)

Claude committed on May 20, 2026Parent: bdb51e4
1 file changed+7799202c53b7f984d52f6c289f664e587d7f0e0f7b3f5
1 changed file+779−92
Modifiedsrc/routes/tokens.tsx+779−92View fileUnifiedSplit
99import { Layout } from "../views/layout";
1010import { softAuth, requireAuth } from "../middleware/auth";
1111import type { AuthEnv } from "../middleware/auth";
12import {
13 Container,
14 PageHeader,
15 Section,
16 Alert,
17 EmptyState,
18 ListItem,
19 Flex,
20 Form,
21 FormGroup,
22 Input,
23 Button,
24 InlineCode,
25 Text,
26} from "../views/ui";
2712
2813const tokens = new Hono<AuthEnv>();
2914
4833 .join("");
4934}
5035
36// 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;
61 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
62 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;
75 background: radial-gradient(circle, rgba(140,109,255,0.18), rgba(54,197,214,0.09) 45%, transparent 70%);
76 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;
108 background: rgba(140,109,255,0.14);
109 color: #b69dff;
110 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.30);
111 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 {
129 background-image: linear-gradient(135deg, #a48bff 0%, #8c6dff 50%, #36c5d6 100%);
130 -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:
183 linear-gradient(180deg, rgba(140,109,255,0.06) 0%, rgba(54,197,214,0.03) 100%),
184 var(--bg-elevated);
185 border: 1px solid rgba(140,109,255,0.30);
186 overflow: hidden;
187 }
188 .tokens-reveal::before {
189 content: '';
190 position: absolute;
191 top: 0; left: 0; right: 0;
192 height: 2px;
193 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
194 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;
202 color: #b69dff;
203 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;
330 background: rgba(140,109,255,0.12);
331 color: #b69dff;
332 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.28);
333 }
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 {
340 background: rgba(54,197,214,0.12);
341 color: #7adfe9;
342 box-shadow: inset 0 0 0 1px rgba(54,197,214,0.30);
343 }
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;
390 background: radial-gradient(circle, rgba(140,109,255,0.16), rgba(54,197,214,0.06) 45%, transparent 70%);
391 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;
404 background: rgba(140,109,255,0.14);
405 color: #b69dff;
406 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.30);
407 }
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);
455 box-shadow: 0 0 0 3px rgba(140,109,255,0.18);
456 }
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) {
494 border-color: rgba(140,109,255,0.45);
495 background: rgba(140,109,255,0.08);
496 }
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;
507 background: linear-gradient(135deg, #8c6dff 0%, #6e54e0 100%);
508 border: 1px solid rgba(140,109,255,0.50);
509 border-radius: 10px;
510 cursor: pointer;
511 font-family: inherit;
512 box-shadow:
513 0 1px 0 rgba(255,255,255,0.10) inset,
514 0 4px 16px rgba(140,109,255,0.25);
515 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,
521 0 6px 20px rgba(140,109,255,0.32);
522 }
523 .tokens-submit:active { transform: translateY(1px); }
524 .tokens-submit:focus-visible {
525 outline: none;
526 box-shadow:
527 0 0 0 3px rgba(140,109,255,0.30),
528 0 4px 16px rgba(140,109,255,0.25);
529 }
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
51599// Token settings page
52600tokens.get("/settings/tokens", async (c) => {
53601 const user = c.get("user")!;
54602 const success = c.req.query("success");
55603 const newToken = c.req.query("new_token");
604 const error = c.req.query("error");
56605
57606 const userTokens = await db
58607 .select()
61610
62611 return c.html(
63612 <Layout title="API Tokens" user={user}>
64 <Container class="settings-container">
65 <PageHeader title="Personal access tokens" />
613 <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 ─── */}
66644 {success && (
67 <Alert variant="success">
68 {decodeURIComponent(success)}
69 </Alert>
645 <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>
70649 )}
71 {newToken && (
72 <Alert variant="success">
73 <span style="font-family: var(--font-mono); word-break: break-all">
74 New token (copy now — it won't be shown again):{" "}
75 <strong>{decodeURIComponent(newToken)}</strong>
650 {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 !
76662 </span>
77 </Alert>
663 <div class="tokens-banner-body">{decodeURIComponent(error)}</div>
664 </div>
78665 )}
79 <div style="margin-top: 16px">
80 {userTokens.length === 0 ? (
81 <EmptyState>
82 <Text muted>No tokens yet.</Text>
83 </EmptyState>
84 ) : (
85 userTokens.map((token) => (
86 <ListItem>
87 <div>
88 <strong>{token.name}</strong>
89 <div class="ssh-key-meta">
90 <InlineCode>{token.tokenPrefix}...</InlineCode>
91 <span style="margin-left: 8px">
92 Scopes: {token.scopes}
93 </span>
94 {token.lastUsedAt && (
95 <span>
96 {" "}
97 | Last used{" "}
98 {new Date(token.lastUsedAt).toLocaleDateString()}
99 </span>
100 )}
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>
683 )}
684
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 />
101703 </div>
704 <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>
102709 </div>
103 <form
104 method="post"
105 action={`/settings/tokens/${token.id}/delete`}
106 >
107 <Button type="submit" variant="danger" size="sm">
108 Revoke
109 </Button>
110 </form>
111 </ListItem>
112 ))
113 )}
710 </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>
114774 </div>
115775
116 <h3 style="margin-top: 24px; margin-bottom: 12px">
117 Generate new token
118 </h3>
119 <form method="post" action="/settings/tokens">
120 <div class="form-group">
121 <label for="name">Token name</label>
122 <input
123 type="text"
124 id="name"
125 name="name"
126 required
127 placeholder="e.g. CI/CD pipeline"
128 />
776 {/* ─── 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>
129786 </div>
130 <div class="form-group">
131 <label>Scopes</label>
132 <div style="display: flex; gap: 16px; flex-wrap: wrap">
133 {["repo", "user", "admin"].map((scope) => (
134 <label style="display: flex; align-items: center; gap: 4px; font-size: 14px; cursor: pointer">
135 <input
136 type="checkbox"
137 name="scopes"
138 value={scope}
139 checked={scope === "repo"}
140 aria-label={scope}
141 />{" "}
142 {scope}
787 <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
143792 </label>
144 ))}
145 </div>
793 <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>
146836 </div>
147 <button type="submit" class="btn btn-primary">
148 Generate token
149 </button>
150 </form>
151 </Container>
837 </div>
838 </div>
152839 </Layout>
153840 );
154841});
155842