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

admin.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.

admin.tsxBlame1787 lines · 1 contributor
8f50ed0Claude1/**
2 * Block F3 — Site admin panel.
3 *
4 * GET /admin — dashboard (counts + recent users)
5 * GET /admin/users — user list + search
6 * POST /admin/users/:id/admin — toggle site-admin flag
7 * GET /admin/repos — repo list (including private)
8 * POST /admin/repos/:id/delete — nuclear delete (audit-logged)
9 * GET /admin/flags — site flags CRUD
10 * POST /admin/flags — set flag
11 *
12 * All routes gated by `isSiteAdmin`. First registered user is the bootstrap
13 * admin. Site banner + registration lock are surfaced to the rest of the app
14 * via `getFlag`.
07f4b70Claude15 *
16 * Visual polish (parallel session 3.I): adopts the 2026 design language —
17 * gradient-hairline hero, animated orb, stat cards with display font, and a
18 * polished action grid with inline-SVG glyphs. Logic, auth gates, redirects,
19 * and audit emissions are unchanged from the pre-polish version. All scoped
20 * CSS classes are prefixed `.admin-`.
8f50ed0Claude21 */
22
23import { Hono } from "hono";
24import { and, desc, eq, ilike, or, sql } from "drizzle-orm";
25import { db } from "../db";
26import { repositories, users } from "../db/schema";
27import { Layout } from "../views/layout";
5618f9aClaude28import { softAuth } from "../middleware/auth";
8f50ed0Claude29import type { AuthEnv } from "../middleware/auth";
30import {
31 grantSiteAdmin,
32 isSiteAdmin,
33 KNOWN_FLAGS,
34 listFlags,
35 listSiteAdmins,
36 revokeSiteAdmin,
37 setFlag,
38} from "../lib/admin";
39import { audit } from "../lib/notify";
08420cdClaude40import { sendDigestsToAll, sendDigestForUser } from "../lib/email-digest";
8e9f1d9Claude41import {
42 getLastTick,
43 getTickCount,
44 runAutopilotTick,
45} from "../lib/autopilot";
988380aClaude46import { ensureDemoContent, DEMO_USERNAME } from "../lib/demo-seed";
8f50ed0Claude47
48const admin = new Hono<AuthEnv>();
49admin.use("*", softAuth);
50
07f4b70Claude51/* ─────────────────────────────────────────────────────────────────────────
52 * Scoped CSS — every class prefixed `.admin-` so the block cannot bleed
53 * into other surfaces. Pattern mirrors dashboard-hero (commit a004c46),
54 * repo-home (commit 544d842), and settings polish (commit 98eb360).
55 * ───────────────────────────────────────────────────────────────────── */
56const adminStyles = `
57 .admin-wrap { max-width: 1080px; margin: 0 auto; }
58
59 /* ─── Hero (main dashboard) ─── */
60 .admin-hero {
61 position: relative;
62 margin-bottom: var(--space-6);
63 padding: var(--space-5) var(--space-6);
64 background: var(--bg-elevated);
65 border: 1px solid var(--border);
66 border-radius: 16px;
67 overflow: hidden;
68 }
69 .admin-hero::before {
70 content: '';
71 position: absolute;
72 top: 0; left: 0; right: 0;
73 height: 2px;
74 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
75 opacity: 0.7;
76 pointer-events: none;
77 }
78 .admin-hero-bg {
79 position: absolute;
80 inset: -20% -10% auto auto;
81 width: 380px; height: 380px;
82 pointer-events: none;
83 z-index: 0;
84 }
85 .admin-hero-orb {
86 position: absolute;
87 inset: 0;
88 background: radial-gradient(circle, rgba(140,109,255,0.20), rgba(54,197,214,0.10) 45%, transparent 70%);
89 filter: blur(80px);
90 opacity: 0.7;
91 animation: adminHeroOrb 14s ease-in-out infinite;
92 }
93 @keyframes adminHeroOrb {
94 0%, 100% { transform: scale(1) translate(0, 0); opacity: 0.6; }
95 50% { transform: scale(1.1) translate(-10px, 8px); opacity: 0.85; }
96 }
97 @media (prefers-reduced-motion: reduce) {
98 .admin-hero-orb { animation: none; }
99 }
100 .admin-hero-inner {
101 position: relative;
102 z-index: 1;
103 max-width: 720px;
104 }
105 .admin-hero-eyebrow {
106 font-size: 12px;
107 color: var(--text-muted);
108 margin-bottom: var(--space-2);
109 letter-spacing: 0.02em;
110 text-transform: none;
111 display: inline-flex;
112 align-items: center;
113 gap: 8px;
114 }
115 .admin-hero-eyebrow .admin-shield {
116 display: inline-flex;
117 align-items: center;
118 justify-content: center;
119 width: 18px; height: 18px;
120 border-radius: 6px;
121 background: rgba(140,109,255,0.14);
122 color: #b69dff;
123 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.35);
124 }
125 .admin-hero-eyebrow .admin-who {
126 color: var(--accent);
127 font-weight: 600;
128 }
129 .admin-hero-title {
130 font-size: clamp(28px, 4vw, 40px);
131 font-family: var(--font-display);
132 font-weight: 800;
133 letter-spacing: -0.028em;
134 line-height: 1.05;
135 margin: 0 0 var(--space-2);
136 color: var(--text-strong);
137 }
138 .admin-hero-title .gradient-text,
139 .admin-hero-title-grad {
140 background-image: linear-gradient(135deg, #a48bff 0%, #8c6dff 50%, #36c5d6 100%);
141 -webkit-background-clip: text;
142 background-clip: text;
143 -webkit-text-fill-color: transparent;
144 color: transparent;
145 }
146 .admin-hero-sub {
147 font-size: 15px;
148 color: var(--text-muted);
149 margin: 0;
150 line-height: 1.5;
151 max-width: 620px;
152 }
153
154 /* ─── Section hero (sub-pages) ─── */
155 .admin-sec-hero {
156 position: relative;
157 margin-bottom: var(--space-5);
158 padding: var(--space-4) var(--space-5);
159 background: var(--bg-elevated);
160 border: 1px solid var(--border);
161 border-radius: 14px;
162 overflow: hidden;
163 display: flex;
164 align-items: flex-end;
165 justify-content: space-between;
166 gap: var(--space-4);
167 flex-wrap: wrap;
168 }
169 .admin-sec-hero::before {
170 content: '';
171 position: absolute;
172 top: 0; left: 0; right: 0;
173 height: 2px;
174 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
175 opacity: 0.6;
176 pointer-events: none;
177 }
178 .admin-sec-hero-text { flex: 1; min-width: 240px; }
179 .admin-sec-eyebrow {
180 font-size: 11px;
181 font-weight: 600;
182 letter-spacing: 0.08em;
183 text-transform: uppercase;
184 color: var(--accent);
185 margin-bottom: 6px;
186 }
187 .admin-sec-title {
188 font-family: var(--font-display);
189 font-size: clamp(22px, 2.8vw, 28px);
190 font-weight: 800;
191 letter-spacing: -0.022em;
192 line-height: 1.1;
193 margin: 0 0 4px;
194 color: var(--text-strong);
195 }
196 .admin-sec-sub {
197 font-size: 13.5px;
198 color: var(--text-muted);
199 margin: 0;
200 line-height: 1.5;
201 max-width: 620px;
202 }
203 .admin-sec-hero-actions {
204 display: flex;
205 gap: var(--space-2);
206 flex-wrap: wrap;
207 }
208
209 /* ─── Banners ─── */
210 .admin-banner {
211 margin-bottom: var(--space-4);
212 padding: 10px 14px;
213 border-radius: 10px;
214 font-size: 13.5px;
215 border: 1px solid var(--border);
216 background: rgba(255,255,255,0.025);
217 color: var(--text);
218 }
219 .admin-banner.is-error {
220 border-color: rgba(248,113,113,0.40);
221 background: rgba(248,113,113,0.08);
222 color: #fecaca;
223 }
224 .admin-banner.is-ok {
225 border-color: rgba(52,211,153,0.40);
226 background: rgba(52,211,153,0.08);
227 color: #bbf7d0;
228 }
229
230 /* ─── Stat grid ─── */
231 .admin-stat-grid {
232 display: grid;
233 grid-template-columns: repeat(3, 1fr);
234 gap: var(--space-3);
235 margin-bottom: var(--space-5);
236 }
237 @media (max-width: 720px) {
238 .admin-stat-grid { grid-template-columns: 1fr; }
239 }
240 .admin-stat {
241 position: relative;
242 padding: var(--space-4) var(--space-4);
243 background: var(--bg-elevated);
244 border: 1px solid var(--border);
245 border-radius: 14px;
246 overflow: hidden;
247 transition: transform 160ms ease, border-color 160ms ease, box-shadow 160ms ease;
248 }
249 .admin-stat::after {
250 content: '';
251 position: absolute;
252 inset: 0;
253 border-radius: inherit;
254 background: linear-gradient(135deg, rgba(140,109,255,0.05), rgba(54,197,214,0.04));
255 opacity: 0;
256 pointer-events: none;
257 transition: opacity 200ms ease;
258 }
259 .admin-stat:hover {
260 transform: translateY(-2px);
261 border-color: var(--border-strong);
262 box-shadow: 0 10px 28px -16px rgba(0,0,0,0.55);
263 }
264 .admin-stat:hover::after { opacity: 1; }
265 .admin-stat-head {
266 display: flex;
267 align-items: center;
268 justify-content: space-between;
269 margin-bottom: var(--space-2);
270 position: relative;
271 z-index: 1;
272 }
273 .admin-stat-label {
274 font-size: 11px;
275 font-weight: 600;
276 letter-spacing: 0.08em;
277 text-transform: uppercase;
278 color: var(--text-muted);
279 }
280 .admin-stat-icon {
281 display: inline-flex;
282 align-items: center;
283 justify-content: center;
284 width: 26px; height: 26px;
285 border-radius: 8px;
286 background: rgba(140,109,255,0.12);
287 color: #b69dff;
288 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.28);
289 }
290 .admin-stat-value {
291 position: relative;
292 z-index: 1;
293 font-family: var(--font-display);
294 font-size: 36px;
295 font-weight: 800;
296 letter-spacing: -0.028em;
297 line-height: 1;
298 color: var(--text-strong);
299 }
300 .admin-stat-hint {
301 margin-top: 6px;
302 font-size: 12px;
303 color: var(--text-faint);
304 position: relative;
305 z-index: 1;
306 }
307
308 /* ─── Action grid ─── */
309 .admin-actions {
310 display: grid;
311 grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
312 gap: var(--space-2);
313 margin-bottom: var(--space-6);
314 }
315 .admin-action {
316 display: flex;
317 align-items: center;
318 gap: 10px;
319 padding: 12px 14px;
320 background: var(--bg-elevated);
321 border: 1px solid var(--border);
322 border-radius: 12px;
323 color: var(--text);
324 text-decoration: none;
325 font-size: 13.5px;
326 font-weight: 500;
327 line-height: 1.25;
328 transition: border-color 150ms ease, background 150ms ease, transform 150ms ease;
329 cursor: pointer;
330 text-align: left;
331 font-family: inherit;
332 }
333 .admin-action:hover {
334 border-color: var(--border-strong);
335 background: rgba(255,255,255,0.025);
336 transform: translateY(-1px);
337 }
338 .admin-action:focus-visible {
339 outline: none;
340 border-color: var(--border-focus);
341 box-shadow: 0 0 0 3px rgba(140,109,255,0.18);
342 }
343 .admin-action .admin-action-icon {
344 display: inline-flex;
345 align-items: center;
346 justify-content: center;
347 width: 30px; height: 30px;
348 border-radius: 8px;
349 background: rgba(255,255,255,0.03);
350 color: var(--text-muted);
351 flex-shrink: 0;
352 box-shadow: inset 0 0 0 1px var(--border);
353 }
354 .admin-action.is-primary {
355 border-color: rgba(140,109,255,0.35);
356 background: linear-gradient(135deg, rgba(140,109,255,0.14), rgba(54,197,214,0.10));
357 color: var(--text-strong);
358 }
359 .admin-action.is-primary:hover {
360 border-color: rgba(140,109,255,0.55);
361 background: linear-gradient(135deg, rgba(140,109,255,0.20), rgba(54,197,214,0.14));
362 }
363 .admin-action.is-primary .admin-action-icon {
364 background: rgba(140,109,255,0.18);
365 color: #c5b3ff;
366 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.40);
367 }
368 .admin-action-form { display: contents; }
369
370 /* ─── Section header (h3 replacement) ─── */
371 .admin-h3 {
372 display: flex;
373 align-items: baseline;
374 justify-content: space-between;
375 gap: var(--space-3);
376 margin: var(--space-5) 0 var(--space-3);
377 }
378 .admin-h3 h3 {
379 font-family: var(--font-display);
380 font-size: 16px;
381 font-weight: 700;
382 letter-spacing: -0.014em;
383 margin: 0;
384 color: var(--text-strong);
385 }
386 .admin-h3-meta {
387 font-size: 12px;
388 color: var(--text-muted);
389 }
390
391 /* ─── Lists / cards ─── */
392 .admin-list {
393 background: var(--bg-elevated);
394 border: 1px solid var(--border);
395 border-radius: 14px;
396 overflow: hidden;
397 }
398 .admin-list-row {
399 display: flex;
400 align-items: center;
401 justify-content: space-between;
402 gap: var(--space-3);
403 padding: 12px 16px;
404 border-bottom: 1px solid var(--border-subtle);
405 transition: background 120ms ease;
406 }
407 .admin-list-row:last-child { border-bottom: none; }
408 .admin-list-row:hover { background: rgba(255,255,255,0.018); }
409 .admin-list-empty {
410 padding: var(--space-5);
411 text-align: center;
412 color: var(--text-muted);
413 font-size: 13.5px;
414 }
415 .admin-list-main { display: flex; align-items: center; gap: 12px; min-width: 0; flex: 1; }
416 .admin-avatar {
417 width: 30px; height: 30px;
418 border-radius: 9999px;
419 background: linear-gradient(135deg, rgba(140,109,255,0.30), rgba(54,197,214,0.22));
420 color: var(--text-strong);
421 display: inline-flex;
422 align-items: center;
423 justify-content: center;
424 font-size: 12px;
425 font-weight: 700;
426 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.30);
427 flex-shrink: 0;
428 text-transform: uppercase;
429 }
430 .admin-avatar.is-admin {
431 background: linear-gradient(135deg, rgba(140,109,255,0.50), rgba(54,197,214,0.35));
432 color: #fff;
433 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.55), 0 0 12px rgba(140,109,255,0.25);
434 }
435 .admin-row-text { min-width: 0; }
436 .admin-row-title {
437 font-size: 14px;
438 font-weight: 600;
439 color: var(--text-strong);
440 text-decoration: none;
441 }
442 .admin-row-title:hover { color: var(--accent-hover); }
443 .admin-row-sub {
444 margin-top: 2px;
445 font-size: 12px;
446 color: var(--text-muted);
447 display: flex;
448 gap: 8px;
449 flex-wrap: wrap;
450 align-items: center;
451 }
452 .admin-pill {
453 display: inline-flex;
454 align-items: center;
455 gap: 4px;
456 padding: 2px 8px;
457 border-radius: 9999px;
458 font-size: 10.5px;
459 font-weight: 600;
460 letter-spacing: 0.04em;
461 text-transform: uppercase;
462 }
463 .admin-pill.is-admin {
464 background: rgba(140,109,255,0.16);
465 color: #c5b3ff;
466 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.35);
467 }
468 .admin-pill.is-private {
469 background: rgba(251,191,36,0.10);
470 color: #fde68a;
471 box-shadow: inset 0 0 0 1px rgba(251,191,36,0.30);
472 }
473 .admin-pill.is-public {
474 background: rgba(255,255,255,0.04);
475 color: var(--text-muted);
476 box-shadow: inset 0 0 0 1px var(--border);
477 }
478 .admin-pill.is-on {
479 background: rgba(52,211,153,0.14);
480 color: #6ee7b7;
481 box-shadow: inset 0 0 0 1px rgba(52,211,153,0.32);
482 }
483 .admin-pill.is-off {
484 background: rgba(255,255,255,0.04);
485 color: var(--text-muted);
486 box-shadow: inset 0 0 0 1px var(--border);
487 }
488 .admin-pill .dot {
489 width: 6px; height: 6px;
490 border-radius: 9999px;
491 background: currentColor;
492 }
493
494 /* ─── Inline forms / search ─── */
495 .admin-search {
496 display: flex;
497 gap: 8px;
498 flex-wrap: wrap;
499 align-items: center;
500 margin-bottom: var(--space-4);
501 }
502 .admin-input {
503 width: 320px;
504 max-width: 100%;
505 padding: 9px 12px;
506 font-size: 14px;
507 color: var(--text);
508 background: var(--bg);
509 border: 1px solid var(--border-strong);
510 border-radius: 8px;
511 outline: none;
512 font-family: var(--font-sans);
513 transition: border-color 120ms ease, box-shadow 120ms ease;
514 }
515 .admin-input:focus {
516 border-color: var(--border-focus);
517 box-shadow: 0 0 0 3px rgba(140,109,255,0.18);
518 }
519
520 /* ─── Flags form ─── */
521 .admin-card {
522 background: var(--bg-elevated);
523 border: 1px solid var(--border);
524 border-radius: 14px;
525 overflow: hidden;
526 }
527 .admin-card-body { padding: var(--space-5); }
528 .admin-card-foot {
529 padding: var(--space-3) var(--space-5);
530 border-top: 1px solid var(--border);
531 background: rgba(255,255,255,0.012);
532 display: flex;
533 justify-content: flex-end;
534 gap: var(--space-2);
535 align-items: center;
536 flex-wrap: wrap;
537 }
538 .admin-card-foot .admin-foot-hint {
539 margin-right: auto;
540 font-size: 12.5px;
541 color: var(--text-muted);
542 }
543 .admin-field { margin-bottom: var(--space-4); }
544 .admin-field:last-child { margin-bottom: 0; }
545 .admin-field label {
546 display: block;
547 font-family: var(--font-mono);
548 font-size: 12.5px;
549 font-weight: 600;
550 color: var(--text-strong);
551 margin-bottom: 6px;
552 letter-spacing: -0.005em;
553 }
554 .admin-field .admin-input-mono {
555 width: 100%;
556 padding: 9px 12px;
557 font-size: 13.5px;
558 color: var(--text);
559 background: var(--bg);
560 border: 1px solid var(--border-strong);
561 border-radius: 8px;
562 outline: none;
563 font-family: var(--font-mono);
564 transition: border-color 120ms ease, box-shadow 120ms ease;
565 }
566 .admin-field .admin-input-mono:focus {
567 border-color: var(--border-focus);
568 box-shadow: 0 0 0 3px rgba(140,109,255,0.18);
569 }
570 .admin-field-hint {
571 font-size: 11.5px;
572 color: var(--text-muted);
573 margin-top: 6px;
574 line-height: 1.45;
575 }
576 .admin-field-hint code {
577 font-family: var(--font-mono);
578 font-size: 11.5px;
579 background: var(--bg-tertiary);
580 padding: 1px 5px;
581 border-radius: 4px;
582 }
583
584 /* ─── Digest forms ─── */
585 .admin-digest-row {
586 display: flex;
587 gap: 8px;
588 align-items: center;
589 flex-wrap: wrap;
590 }
591
592 /* ─── Autopilot specific ─── */
593 .admin-ap-table {
594 width: 100%;
595 border-collapse: collapse;
596 background: var(--bg-elevated);
597 border: 1px solid var(--border);
598 border-radius: 14px;
599 overflow: hidden;
600 }
601 .admin-ap-table thead th {
602 text-align: left;
603 font-size: 11px;
604 font-weight: 600;
605 letter-spacing: 0.08em;
606 text-transform: uppercase;
607 color: var(--text-muted);
608 padding: 10px 14px;
609 background: rgba(255,255,255,0.015);
610 border-bottom: 1px solid var(--border);
611 }
612 .admin-ap-table tbody td {
613 padding: 10px 14px;
614 border-bottom: 1px solid var(--border-subtle);
615 font-size: 13px;
616 color: var(--text);
617 vertical-align: top;
618 }
619 .admin-ap-table tbody tr:last-child td { border-bottom: none; }
620 .admin-ap-table code {
621 font-family: var(--font-mono);
622 font-size: 12px;
623 color: var(--text-strong);
624 }
625 .admin-ap-status-ok { color: var(--green); font-weight: 600; }
626 .admin-ap-status-fail { color: var(--red); font-weight: 600; }
627 .admin-ap-empty {
628 padding: var(--space-5);
629 text-align: center;
630 color: var(--text-muted);
631 font-size: 13.5px;
632 background: var(--bg-elevated);
633 border: 1px dashed var(--border);
634 border-radius: 14px;
635 }
636 .admin-ap-foot {
637 margin-top: var(--space-5);
638 padding: var(--space-3) var(--space-4);
639 border: 1px solid var(--border-subtle);
640 background: rgba(255,255,255,0.015);
641 border-radius: 10px;
642 color: var(--text-muted);
643 font-size: 12.5px;
644 }
645 .admin-ap-foot code {
646 font-family: var(--font-mono);
647 font-size: 12px;
648 background: var(--bg-tertiary);
649 padding: 1px 5px;
650 border-radius: 4px;
651 color: var(--text);
652 }
653
654 /* ─── Misc ─── */
655 .admin-403 {
656 max-width: 540px;
657 margin: var(--space-12) auto;
658 padding: var(--space-6);
659 text-align: center;
660 background: var(--bg-elevated);
661 border: 1px solid var(--border);
662 border-radius: 16px;
663 }
664 .admin-403 h2 {
665 font-family: var(--font-display);
666 font-size: 22px;
667 margin: 0 0 8px;
668 color: var(--text-strong);
669 }
670 .admin-403 p { color: var(--text-muted); margin: 0; font-size: 14px; }
671`;
672
673/** Inline-SVG icons (no external deps). Stroke-based, currentColor. */
674const Icons = {
675 shield: (
676 <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
677 <path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
678 </svg>
679 ),
680 users: (
681 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
682 <path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
683 <circle cx="9" cy="7" r="4" />
684 <path d="M23 21v-2a4 4 0 0 0-3-3.87" />
685 <path d="M16 3.13a4 4 0 0 1 0 7.75" />
686 </svg>
687 ),
688 repo: (
689 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
690 <path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20" />
691 <path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z" />
692 </svg>
693 ),
694 starShield: (
695 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
696 <path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
697 <path d="m9 12 2 2 4-4" />
698 </svg>
699 ),
700 ops: (
701 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
702 <circle cx="12" cy="12" r="3" />
703 <path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.6 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
704 </svg>
705 ),
706 pulse: (
707 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
708 <path d="M22 12h-4l-3 9L9 3l-3 9H2" />
709 </svg>
710 ),
711 flag: (
712 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
713 <path d="M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1z" />
714 <line x1="4" y1="22" x2="4" y2="15" />
715 </svg>
716 ),
717 mail: (
718 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
719 <path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z" />
720 <polyline points="22,6 12,13 2,6" />
721 </svg>
722 ),
723 google: (
724 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
725 <circle cx="12" cy="12" r="10" />
726 <path d="M12 8v8" /><path d="M8 12h8" />
727 </svg>
728 ),
729 github: (
730 <svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
731 <path d="M12 .5C5.65.5.5 5.65.5 12c0 5.08 3.29 9.39 7.86 10.91.58.11.79-.25.79-.56 0-.28-.01-1.02-.02-2-3.2.69-3.88-1.54-3.88-1.54-.52-1.33-1.28-1.68-1.28-1.68-1.05-.72.08-.71.08-.71 1.16.08 1.77 1.19 1.77 1.19 1.03 1.77 2.7 1.26 3.36.96.1-.74.4-1.26.73-1.55-2.55-.29-5.23-1.28-5.23-5.69 0-1.26.45-2.29 1.19-3.1-.12-.29-.51-1.47.11-3.06 0 0 .97-.31 3.18 1.18a11 11 0 0 1 2.9-.39c.98 0 1.97.13 2.9.39 2.2-1.49 3.17-1.18 3.17-1.18.63 1.59.23 2.77.11 3.06.74.81 1.19 1.84 1.19 3.1 0 4.42-2.69 5.39-5.25 5.68.41.35.78 1.04.78 2.1 0 1.52-.01 2.74-.01 3.11 0 .31.21.68.8.56C20.21 21.39 23.5 17.08 23.5 12 23.5 5.65 18.35.5 12 .5z" />
732 </svg>
733 ),
734 sso: (
735 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
736 <rect x="3" y="11" width="18" height="11" rx="2" />
737 <path d="M7 11V7a5 5 0 0 1 10 0v4" />
738 </svg>
739 ),
740 bot: (
741 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
742 <rect x="3" y="7" width="18" height="13" rx="2" />
743 <circle cx="9" cy="13" r="1" />
744 <circle cx="15" cy="13" r="1" />
745 <path d="M12 3v4" />
746 </svg>
747 ),
748 refresh: (
749 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
750 <polyline points="23 4 23 10 17 10" />
751 <polyline points="1 20 1 14 7 14" />
752 <path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10" />
753 <path d="M20.49 15a9 9 0 0 1-14.85 3.36L1 14" />
754 </svg>
755 ),
756 arrowLeft: (
757 <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
758 <line x1="19" y1="12" x2="5" y2="12" />
759 <polyline points="12 19 5 12 12 5" />
760 </svg>
761 ),
509c376Claude762 key: (
763 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
764 <path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4" />
765 </svg>
766 ),
07f4b70Claude767};
768
769/** First-letter avatar helper. */
770function initials(s: string): string {
771 return (s || "?").trim().charAt(0).toUpperCase() || "?";
772}
773
8f50ed0Claude774async function gate(c: any): Promise<{ user: any } | Response> {
775 const user = c.get("user");
776 if (!user) return c.redirect("/login?next=/admin");
777 if (!(await isSiteAdmin(user.id))) {
778 return c.html(
779 <Layout title="Forbidden" user={user}>
07f4b70Claude780 <div class="admin-403">
8f50ed0Claude781 <h2>403 — Not a site admin</h2>
782 <p>You don't have permission to view this page.</p>
783 </div>
07f4b70Claude784 <style dangerouslySetInnerHTML={{ __html: adminStyles }} />
8f50ed0Claude785 </Layout>,
786 403
787 );
788 }
789 return { user };
790}
791
792admin.get("/admin", async (c) => {
793 const g = await gate(c);
794 if (g instanceof Response) return g;
795 const { user } = g;
796
797 const [uc] = await db.select({ n: sql<number>`count(*)::int` }).from(users);
798 const [rc] = await db
799 .select({ n: sql<number>`count(*)::int` })
800 .from(repositories);
801
802 const recent = await db
803 .select({
804 id: users.id,
805 username: users.username,
806 createdAt: users.createdAt,
807 })
808 .from(users)
809 .orderBy(desc(users.createdAt))
810 .limit(10);
811
812 const admins = await listSiteAdmins();
813
988380aClaude814 const msg = c.req.query("result") || c.req.query("error");
815 const isErr = !!c.req.query("error");
816
07f4b70Claude817 const userCount = Number(uc?.n || 0);
818 const repoCount = Number(rc?.n || 0);
819 const adminCount = admins.length;
820
8f50ed0Claude821 return c.html(
822 <Layout title="Admin — Gluecron" user={user}>
07f4b70Claude823 <div class="admin-wrap">
824 <section class="admin-hero">
825 <div class="admin-hero-bg" aria-hidden="true">
826 <div class="admin-hero-orb" />
827 </div>
828 <div class="admin-hero-inner">
829 <div class="admin-hero-eyebrow">
830 <span class="admin-shield" aria-hidden="true">{Icons.shield}</span>
831 Site administration ·{" "}
832 <span class="admin-who">{user.username}</span>
833 </div>
834 <h2 class="admin-hero-title">
835 <span class="admin-hero-title-grad">Site admin</span>.
836 </h2>
837 <p class="admin-hero-sub">
838 {userCount} user{userCount === 1 ? "" : "s"} ·{" "}
839 {repoCount} repo{repoCount === 1 ? "" : "s"} ·{" "}
840 {adminCount} site admin{adminCount === 1 ? "" : "s"}.{" "}
841 Operations, flags, digests, and autopilot — all in one place.
842 </p>
843 </div>
844 </section>
8f50ed0Claude845
07f4b70Claude846 {msg && (
847 <div class={"admin-banner " + (isErr ? "is-error" : "is-ok")}>
848 {decodeURIComponent(msg)}
849 </div>
850 )}
988380aClaude851
07f4b70Claude852 <div class="admin-stat-grid">
853 <div class="admin-stat">
854 <div class="admin-stat-head">
855 <span class="admin-stat-label">Users</span>
856 <span class="admin-stat-icon">{Icons.users}</span>
857 </div>
858 <div class="admin-stat-value">{userCount}</div>
859 <div class="admin-stat-hint">Registered accounts</div>
8f50ed0Claude860 </div>
07f4b70Claude861 <div class="admin-stat">
862 <div class="admin-stat-head">
863 <span class="admin-stat-label">Repos</span>
864 <span class="admin-stat-icon">{Icons.repo}</span>
865 </div>
866 <div class="admin-stat-value">{repoCount}</div>
867 <div class="admin-stat-hint">Public + private</div>
8f50ed0Claude868 </div>
07f4b70Claude869 <div class="admin-stat">
870 <div class="admin-stat-head">
871 <span class="admin-stat-label">Admins</span>
872 <span class="admin-stat-icon">{Icons.starShield}</span>
873 </div>
874 <div class="admin-stat-value">{adminCount}</div>
875 <div class="admin-stat-hint">Site admins</div>
8f50ed0Claude876 </div>
877 </div>
878
07f4b70Claude879 <div class="admin-actions">
880 <a href="/admin/ops" class="admin-action is-primary">
881 <span class="admin-action-icon">{Icons.ops}</span>
882 Operations
883 </a>
509c376Claude884 <a href="/admin/integrations" class="admin-action is-primary">
885 <span class="admin-action-icon">{Icons.key}</span>
886 Integrations
887 </a>
cf793f9Claude888 <a href="/admin/health" class="admin-action is-primary">
07f4b70Claude889 <span class="admin-action-icon">{Icons.pulse}</span>
cf793f9Claude890 Health (traffic lights)
891 </a>
892 <a href="/admin/deploys" class="admin-action is-primary">
893 <span class="admin-action-icon">{Icons.ops}</span>
894 Deploys
895 </a>
896 <a href="/admin/diagnose" class="admin-action">
897 <span class="admin-action-icon">{Icons.pulse}</span>
898 Diagnose
899 </a>
900 <a href="/admin/self-host" class="admin-action">
901 <span class="admin-action-icon">{Icons.ops}</span>
902 Self-host status
903 </a>
904 <a href="/admin/status" class="admin-action">
905 <span class="admin-action-icon">{Icons.pulse}</span>
906 Live activity stream
07f4b70Claude907 </a>
908 <a href="/admin/users" class="admin-action">
909 <span class="admin-action-icon">{Icons.users}</span>
910 Manage users
911 </a>
912 <a href="/admin/repos" class="admin-action">
913 <span class="admin-action-icon">{Icons.repo}</span>
914 Manage repos
915 </a>
916 <a href="/admin/flags" class="admin-action">
917 <span class="admin-action-icon">{Icons.flag}</span>
918 Site flags
919 </a>
920 <a href="/admin/digests" class="admin-action">
921 <span class="admin-action-icon">{Icons.mail}</span>
922 Email digests
923 </a>
924 <a href="/admin/google-oauth" class="admin-action">
925 <span class="admin-action-icon">{Icons.google}</span>
926 Sign in with Google
927 </a>
928 <a href="/admin/github-oauth" class="admin-action">
929 <span class="admin-action-icon">{Icons.github}</span>
930 Sign in with GitHub
931 </a>
932 <a href="/admin/sso" class="admin-action">
933 <span class="admin-action-icon">{Icons.sso}</span>
934 Enterprise SSO
935 </a>
936 <a href="/admin/autopilot" class="admin-action">
937 <span class="admin-action-icon">{Icons.bot}</span>
938 Autopilot
939 </a>
662ce86Claude940 <a href="/connect/claude" class="admin-action is-primary">
941 <span class="admin-action-icon">{Icons.bot}</span>
942 Connect Claude
943 </a>
07f4b70Claude944 <form
945 method="post"
946 action="/admin/demo/reseed"
947 class="admin-action-form"
948 >
949 <button
950 class="admin-action"
951 type="submit"
952 title="Idempotently (re)create demo user + 3 sample repos"
953 >
954 <span class="admin-action-icon">{Icons.refresh}</span>
955 Reseed demo
956 </button>
957 </form>
958 </div>
8f50ed0Claude959
07f4b70Claude960 <div class="admin-h3">
961 <h3>Recent signups</h3>
962 <span class="admin-h3-meta">
963 {recent.length} most-recent
964 </span>
965 </div>
966 <div class="admin-list" style="margin-bottom:20px">
967 {recent.length === 0 ? (
968 <div class="admin-list-empty">No users yet.</div>
969 ) : (
970 recent.map((u) => (
971 <div class="admin-list-row">
972 <div class="admin-list-main">
973 <span class="admin-avatar" aria-hidden="true">{initials(u.username)}</span>
974 <div class="admin-row-text">
975 <a href={`/${u.username}`} class="admin-row-title">
976 {u.username}
977 </a>
978 <div class="admin-row-sub">
979 <span>Joined</span>
980 <span>
981 {u.createdAt
982 ? new Date(u.createdAt as unknown as string).toLocaleString()
983 : ""}
984 </span>
985 </div>
986 </div>
987 </div>
988 </div>
989 ))
990 )}
991 </div>
8f50ed0Claude992
07f4b70Claude993 <div class="admin-h3">
994 <h3>Site admins</h3>
995 <span class="admin-h3-meta">
996 {adminCount} active
997 </span>
998 </div>
999 <div class="admin-list">
1000 {admins.length === 0 ? (
1001 <div class="admin-list-empty">
1002 No admins (bootstrap mode — oldest user is admin).
8f50ed0Claude1003 </div>
07f4b70Claude1004 ) : (
1005 admins.map((a) => (
1006 <div class="admin-list-row">
1007 <div class="admin-list-main">
1008 <span class="admin-avatar is-admin" aria-hidden="true">{initials(a.username)}</span>
1009 <div class="admin-row-text">
1010 <a href={`/${a.username}`} class="admin-row-title">
1011 {a.username}
1012 </a>
1013 <div class="admin-row-sub">
1014 <span class="admin-pill is-admin">
1015 <span class="dot" aria-hidden="true" /> Site admin
1016 </span>
1017 <span>
1018 Granted{" "}
1019 {a.grantedAt
1020 ? new Date(a.grantedAt as unknown as string).toLocaleDateString()
1021 : "—"}
1022 </span>
1023 </div>
1024 </div>
1025 </div>
1026 </div>
1027 ))
1028 )}
1029 </div>
8f50ed0Claude1030 </div>
07f4b70Claude1031 <style dangerouslySetInnerHTML={{ __html: adminStyles }} />
8f50ed0Claude1032 </Layout>
1033 );
1034});
1035
1036// ----- Users -----
1037
1038admin.get("/admin/users", async (c) => {
1039 const g = await gate(c);
1040 if (g instanceof Response) return g;
1041 const { user } = g;
1042 const q = c.req.query("q") || "";
1043 const rows = await db
1044 .select({
1045 id: users.id,
1046 username: users.username,
1047 email: users.email,
1048 createdAt: users.createdAt,
1049 })
1050 .from(users)
1051 .where(
1052 q
1053 ? or(ilike(users.username, `%${q}%`), ilike(users.email, `%${q}%`))!
1054 : sql`1=1`
1055 )
1056 .orderBy(desc(users.createdAt))
1057 .limit(200);
1058
1059 const adminIds = new Set((await listSiteAdmins()).map((a) => a.userId));
1060
1061 return c.html(
1062 <Layout title="Admin — Users" user={user}>
07f4b70Claude1063 <div class="admin-wrap">
1064 <section class="admin-sec-hero">
1065 <div class="admin-sec-hero-text">
1066 <div class="admin-sec-eyebrow">Site admin</div>
1067 <h2 class="admin-sec-title">Users</h2>
1068 <p class="admin-sec-sub">
1069 Search, audit, and grant or revoke the site-admin flag.
1070 Showing up to 200 accounts ordered by signup recency.
1071 </p>
1072 </div>
1073 <div class="admin-sec-hero-actions">
1074 <a href="/admin" class="btn btn-sm">
1075 {Icons.arrowLeft} Back
1076 </a>
1077 </div>
1078 </section>
1079
1080 <form method="get" action="/admin/users" class="admin-search">
1081 <input
1082 type="text"
1083 name="q"
1084 value={q}
1085 placeholder="Search username or email"
1086 aria-label="Search username or email"
1087 class="admin-input"
1088 />
1089 <button type="submit" class="btn">
1090 Search
1091 </button>
1092 {q && (
1093 <a href="/admin/users" class="btn btn-sm">
1094 Clear
1095 </a>
1096 )}
1097 </form>
1098
1099 <div class="admin-list">
1100 {rows.length === 0 ? (
1101 <div class="admin-list-empty">No users found.</div>
1102 ) : (
1103 rows.map((u) => {
1104 const isAdmin = adminIds.has(u.id);
1105 return (
1106 <div class="admin-list-row">
1107 <div class="admin-list-main">
1108 <span class={"admin-avatar" + (isAdmin ? " is-admin" : "")} aria-hidden="true">
1109 {initials(u.username)}
8f50ed0Claude1110 </span>
07f4b70Claude1111 <div class="admin-row-text">
1112 <a href={`/${u.username}`} class="admin-row-title">
1113 {u.username}
1114 </a>
1115 {isAdmin && (
1116 <span class="admin-pill is-admin" style="margin-left:8px">
1117 <span class="dot" aria-hidden="true" /> Admin
1118 </span>
1119 )}
1120 <div class="admin-row-sub">
1121 <span>{u.email}</span>
1122 {u.createdAt && (
1123 <span>
1124 ·{" "}
1125 {new Date(
1126 u.createdAt as unknown as string
1127 ).toLocaleDateString()}
1128 </span>
1129 )}
1130 </div>
1131 </div>
1132 </div>
1133 <form
1134 method="post"
1135 action={`/admin/users/${u.id}/admin`}
1136 onsubmit={
1137 isAdmin
1138 ? "return confirm('Revoke site admin?')"
1139 : "return confirm('Grant site admin?')"
1140 }
1141 >
1142 <button type="submit" class="btn btn-sm">
1143 {isAdmin ? "Revoke admin" : "Grant admin"}
1144 </button>
1145 </form>
8f50ed0Claude1146 </div>
07f4b70Claude1147 );
1148 })
1149 )}
1150 </div>
8f50ed0Claude1151 </div>
07f4b70Claude1152 <style dangerouslySetInnerHTML={{ __html: adminStyles }} />
8f50ed0Claude1153 </Layout>
1154 );
1155});
1156
1157admin.post("/admin/users/:id/admin", async (c) => {
1158 const g = await gate(c);
1159 if (g instanceof Response) return g;
1160 const { user } = g;
1161 const id = c.req.param("id");
1162 const admins = await listSiteAdmins();
1163 const isAlready = admins.some((a) => a.userId === id);
1164 if (isAlready) {
1165 await revokeSiteAdmin(id);
1166 await audit({
1167 userId: user.id,
1168 action: "site_admin.revoke",
1169 targetType: "user",
1170 targetId: id,
1171 });
1172 } else {
1173 await grantSiteAdmin(id, user.id);
1174 await audit({
1175 userId: user.id,
1176 action: "site_admin.grant",
1177 targetType: "user",
1178 targetId: id,
1179 });
1180 }
1181 return c.redirect("/admin/users");
1182});
1183
1184// ----- Repos -----
1185
1186admin.get("/admin/repos", async (c) => {
1187 const g = await gate(c);
1188 if (g instanceof Response) return g;
1189 const { user } = g;
1190 const rows = await db
1191 .select({
1192 id: repositories.id,
1193 name: repositories.name,
1194 ownerUsername: users.username,
0316dbbClaude1195 isPrivate: repositories.isPrivate,
8f50ed0Claude1196 createdAt: repositories.createdAt,
1197 starCount: repositories.starCount,
1198 })
1199 .from(repositories)
1200 .innerJoin(users, eq(repositories.ownerId, users.id))
1201 .orderBy(desc(repositories.createdAt))
1202 .limit(200);
1203
1204 return c.html(
1205 <Layout title="Admin — Repos" user={user}>
07f4b70Claude1206 <div class="admin-wrap">
1207 <section class="admin-sec-hero">
1208 <div class="admin-sec-hero-text">
1209 <div class="admin-sec-eyebrow">Site admin</div>
1210 <h2 class="admin-sec-title">Repositories</h2>
1211 <p class="admin-sec-sub">
1212 Every repository on the platform — public and private.
1213 Delete is irreversible and audit-logged.
1214 </p>
1215 </div>
1216 <div class="admin-sec-hero-actions">
1217 <a href="/admin" class="btn btn-sm">
1218 {Icons.arrowLeft} Back
1219 </a>
1220 </div>
1221 </section>
1222
1223 <div class="admin-list">
1224 {rows.length === 0 ? (
1225 <div class="admin-list-empty">No repositories.</div>
1226 ) : (
1227 rows.map((r) => (
1228 <div class="admin-list-row">
1229 <div class="admin-list-main">
1230 <span class="admin-avatar" aria-hidden="true">
1231 {initials(r.ownerUsername)}
1232 </span>
1233 <div class="admin-row-text">
1234 <a
1235 href={`/${r.ownerUsername}/${r.name}`}
1236 class="admin-row-title"
1237 >
1238 {r.ownerUsername}/{r.name}
1239 </a>
1240 <span
1241 class={
1242 "admin-pill " +
1243 (r.isPrivate ? "is-private" : "is-public")
1244 }
1245 style="margin-left:8px"
1246 >
1247 {r.isPrivate ? "private" : "public"}
1248 </span>
1249 <div class="admin-row-sub">
1250 <span>{r.starCount} star{r.starCount === 1 ? "" : "s"}</span>
1251 <span>·</span>
1252 <span>
1253 {r.createdAt
1254 ? new Date(r.createdAt as unknown as string).toLocaleDateString()
1255 : ""}
1256 </span>
1257 </div>
1258 </div>
8f50ed0Claude1259 </div>
07f4b70Claude1260 <form
1261 method="post"
1262 action={`/admin/repos/${r.id}/delete`}
1263 onsubmit="return confirm('Delete repository permanently? This cannot be undone.')"
1264 >
1265 <button type="submit" class="btn btn-sm btn-danger">
1266 Delete
1267 </button>
1268 </form>
8f50ed0Claude1269 </div>
07f4b70Claude1270 ))
1271 )}
1272 </div>
8f50ed0Claude1273 </div>
07f4b70Claude1274 <style dangerouslySetInnerHTML={{ __html: adminStyles }} />
8f50ed0Claude1275 </Layout>
1276 );
1277});
1278
1279admin.post("/admin/repos/:id/delete", async (c) => {
1280 const g = await gate(c);
1281 if (g instanceof Response) return g;
1282 const { user } = g;
1283 const id = c.req.param("id");
1284 try {
1285 await db.delete(repositories).where(eq(repositories.id, id));
1286 } catch (err) {
1287 console.error("[admin] repo delete:", err);
1288 }
1289 await audit({
1290 userId: user.id,
1291 action: "admin.repo.delete",
1292 targetType: "repository",
1293 targetId: id,
1294 });
1295 return c.redirect("/admin/repos");
1296});
1297
1298// ----- Flags -----
1299
1300admin.get("/admin/flags", async (c) => {
1301 const g = await gate(c);
1302 if (g instanceof Response) return g;
1303 const { user } = g;
1304
1305 const existing = await listFlags();
1306 const existingMap = new Map(existing.map((f) => [f.key, f.value]));
1307 const keys = Object.keys(KNOWN_FLAGS) as Array<keyof typeof KNOWN_FLAGS>;
1308
1309 return c.html(
1310 <Layout title="Admin — Flags" user={user}>
07f4b70Claude1311 <div class="admin-wrap">
1312 <section class="admin-sec-hero">
1313 <div class="admin-sec-hero-text">
1314 <div class="admin-sec-eyebrow">Site admin</div>
1315 <h2 class="admin-sec-title">Site flags</h2>
1316 <p class="admin-sec-sub">
1317 Runtime feature flags surfaced to the rest of the app via
1318 <code style="margin:0 4px;padding:1px 5px;border-radius:4px;background:var(--bg-tertiary);font-family:var(--font-mono);font-size:12px">
1319 getFlag()
1320 </code>
1321 — registration lock, site banner, read-only mode, and more.
1322 </p>
1323 </div>
1324 <div class="admin-sec-hero-actions">
1325 <a href="/admin" class="btn btn-sm">
1326 {Icons.arrowLeft} Back
1327 </a>
1328 </div>
1329 </section>
1330
1331 <form method="post" action="/admin/flags" class="admin-card">
1332 <div class="admin-card-body">
1333 {keys.map((k) => {
1334 const current = existingMap.get(k) ?? (KNOWN_FLAGS as any)[k];
1335 return (
1336 <div class="admin-field">
1337 <label>{k}</label>
1338 <input
1339 type="text"
1340 name={k}
1341 value={current}
1342 aria-label={k}
1343 class="admin-input-mono"
1344 />
1345 <div class="admin-field-hint">
1346 default: <code>{(KNOWN_FLAGS as any)[k] || "(empty)"}</code>
1347 </div>
1348 </div>
1349 );
1350 })}
1351 </div>
1352 <div class="admin-card-foot">
1353 <span class="admin-foot-hint">
1354 Saved values overwrite the defaults at runtime.
1355 </span>
1356 <button type="submit" class="btn btn-primary">
1357 Save
1358 </button>
1359 </div>
1360 </form>
8f50ed0Claude1361 </div>
07f4b70Claude1362 <style dangerouslySetInnerHTML={{ __html: adminStyles }} />
8f50ed0Claude1363 </Layout>
1364 );
1365});
1366
1367admin.post("/admin/flags", async (c) => {
1368 const g = await gate(c);
1369 if (g instanceof Response) return g;
1370 const { user } = g;
1371 const body = await c.req.parseBody();
1372 const keys = Object.keys(KNOWN_FLAGS) as Array<keyof typeof KNOWN_FLAGS>;
1373 for (const k of keys) {
1374 const v = String(body[k] ?? "");
1375 await setFlag(k, v, user.id);
1376 }
1377 await audit({ userId: user.id, action: "admin.flags.save" });
1378 return c.redirect("/admin/flags");
1379});
1380
08420cdClaude1381// ----- Email digests (Block I7) -----
1382
1383admin.get("/admin/digests", async (c) => {
1384 const g = await gate(c);
1385 if (g instanceof Response) return g;
1386 const { user } = g;
1387
1388 const [optedRow] = await db
1389 .select({ n: sql<number>`count(*)::int` })
1390 .from(users)
1391 .where(eq(users.notifyEmailDigestWeekly, true));
1392 const opted = Number(optedRow?.n || 0);
1393
1394 const recentlySent = await db
1395 .select({
1396 id: users.id,
1397 username: users.username,
1398 lastDigestSentAt: users.lastDigestSentAt,
1399 })
1400 .from(users)
1401 .where(sql`${users.lastDigestSentAt} is not null`)
1402 .orderBy(desc(users.lastDigestSentAt))
1403 .limit(20);
1404
1405 const result = c.req.query("result");
1406 const error = c.req.query("error");
1407
1408 return c.html(
1409 <Layout title="Admin — Digests" user={user}>
07f4b70Claude1410 <div class="admin-wrap">
1411 <section class="admin-sec-hero">
1412 <div class="admin-sec-hero-text">
1413 <div class="admin-sec-eyebrow">Site admin</div>
1414 <h2 class="admin-sec-title">Email digests</h2>
1415 <p class="admin-sec-sub">
1416 Manually trigger the weekly digest for every opted-in user
1417 or preview the email for a single account.
1418 </p>
1419 </div>
1420 <div class="admin-sec-hero-actions">
1421 <a href="/admin" class="btn btn-sm">
1422 {Icons.arrowLeft} Back
1423 </a>
1424 </div>
1425 </section>
08420cdClaude1426
07f4b70Claude1427 {result && (
1428 <div class="admin-banner is-ok">{decodeURIComponent(result)}</div>
1429 )}
1430 {error && (
1431 <div class="admin-banner is-error">{decodeURIComponent(error)}</div>
1432 )}
08420cdClaude1433
07f4b70Claude1434 <div class="admin-card" style="margin-bottom:var(--space-5)">
1435 <div class="admin-card-body">
1436 <div style="display:flex;align-items:center;gap:10px;margin-bottom:var(--space-3);flex-wrap:wrap">
1437 <span class="admin-pill is-on">
1438 <span class="dot" aria-hidden="true" />
1439 {opted} opted-in
1440 </span>
1441 <span style="font-size:13px;color:var(--text-muted)">
1442 user{opted === 1 ? "" : "s"} subscribed to the weekly digest.
08420cdClaude1443 </span>
1444 </div>
07f4b70Claude1445 <form method="post" action="/admin/digests/run" style="margin-bottom:var(--space-4)">
1446 <button
1447 type="submit"
1448 class="btn btn-primary"
1449 onclick="return confirm('Send weekly digest to all opted-in users now?')"
1450 >
1451 Send digests now
1452 </button>
1453 </form>
1454 <div style="border-top:1px solid var(--border-subtle);padding-top:var(--space-4)">
1455 <div style="font-size:12.5px;color:var(--text-muted);margin-bottom:8px">
1456 Preview / one-off — send the digest to a single user.
1457 </div>
1458 <form
1459 method="post"
1460 action="/admin/digests/preview"
1461 class="admin-digest-row"
1462 >
1463 <input
1464 type="text"
1465 name="username"
1466 placeholder="username"
1467 required
1468 aria-label="Username"
1469 class="admin-input"
1470 style="width:240px"
1471 />
1472 <button type="submit" class="btn btn-sm">
1473 Send to one user
1474 </button>
1475 </form>
1476 </div>
1477 </div>
1478 </div>
1479
1480 <div class="admin-h3">
1481 <h3>Recently sent</h3>
1482 <span class="admin-h3-meta">
1483 last {recentlySent.length}
1484 </span>
1485 </div>
1486 <div class="admin-list">
1487 {recentlySent.length === 0 ? (
1488 <div class="admin-list-empty">No digests have been sent yet.</div>
1489 ) : (
1490 recentlySent.map((u) => (
1491 <div class="admin-list-row">
1492 <div class="admin-list-main">
1493 <span class="admin-avatar" aria-hidden="true">{initials(u.username)}</span>
1494 <div class="admin-row-text">
1495 <a href={`/${u.username}`} class="admin-row-title">
1496 {u.username}
1497 </a>
1498 <div class="admin-row-sub">
1499 <span>Sent</span>
1500 <span>
1501 {u.lastDigestSentAt
1502 ? new Date(
1503 u.lastDigestSentAt as unknown as string
1504 ).toLocaleString()
1505 : ""}
1506 </span>
1507 </div>
1508 </div>
1509 </div>
1510 </div>
1511 ))
1512 )}
1513 </div>
08420cdClaude1514 </div>
07f4b70Claude1515 <style dangerouslySetInnerHTML={{ __html: adminStyles }} />
08420cdClaude1516 </Layout>
1517 );
1518});
1519
1520admin.post("/admin/digests/run", async (c) => {
1521 const g = await gate(c);
1522 if (g instanceof Response) return g;
1523 const { user } = g;
1524 const results = await sendDigestsToAll();
1525 const sent = results.filter((r) => r.ok).length;
1526 const skipped = results.length - sent;
1527 await audit({
1528 userId: user.id,
1529 action: "admin.digests.run",
1530 metadata: { sent, skipped, total: results.length },
1531 });
1532 return c.redirect(
1533 `/admin/digests?result=${encodeURIComponent(
1534 `Processed ${results.length} opted-in users: ${sent} sent, ${skipped} skipped.`
1535 )}`
1536 );
1537});
1538
1539admin.post("/admin/digests/preview", async (c) => {
1540 const g = await gate(c);
1541 if (g instanceof Response) return g;
1542 const { user } = g;
1543 const body = await c.req.parseBody();
1544 const username = String(body.username || "").trim();
1545 if (!username) {
1546 return c.redirect("/admin/digests?error=Username+required");
1547 }
1548 const [target] = await db
1549 .select({ id: users.id, username: users.username })
1550 .from(users)
1551 .where(eq(users.username, username))
1552 .limit(1);
1553 if (!target) {
1554 return c.redirect("/admin/digests?error=User+not+found");
1555 }
1556 const result = await sendDigestForUser(target.id);
1557 await audit({
1558 userId: user.id,
1559 action: "admin.digests.preview",
1560 targetType: "user",
1561 targetId: target.id,
1562 metadata: {
1563 ok: result.ok,
1564 skipped: "skipped" in result ? result.skipped : null,
1565 },
1566 });
1567 if (result.ok) {
1568 return c.redirect(
1569 `/admin/digests?result=${encodeURIComponent(
1570 `Digest sent to ${target.username}.`
1571 )}`
1572 );
1573 }
1574 return c.redirect(
1575 `/admin/digests?error=${encodeURIComponent(
1576 `Not sent: ${"skipped" in result ? result.skipped : "unknown reason"}`
1577 )}`
1578 );
1579});
1580
8e9f1d9Claude1581admin.get("/admin/autopilot", async (c) => {
1582 const g = await gate(c);
1583 if (g instanceof Response) return g;
1584 const { user } = g;
1585 const tick = getLastTick();
1586 const total = getTickCount();
1587 const disabled = process.env.AUTOPILOT_DISABLED === "1";
1588 const intervalRaw = process.env.AUTOPILOT_INTERVAL_MS;
1589 const intervalMs =
1590 intervalRaw && Number.isFinite(Number(intervalRaw)) && Number(intervalRaw) > 0
1591 ? Number(intervalRaw)
1592 : 5 * 60 * 1000;
1593 const msg = c.req.query("result") || c.req.query("error");
1594 const isErr = !!c.req.query("error");
1595 return c.html(
1596 <Layout title="Autopilot — admin" user={user}>
07f4b70Claude1597 <div class="admin-wrap" style="padding: var(--space-6) var(--space-4)">
1598 <section class="admin-sec-hero">
1599 <div class="admin-sec-hero-text">
1600 <div class="admin-sec-eyebrow">Site admin</div>
1601 <h2 class="admin-sec-title">Autopilot</h2>
1602 <p class="admin-sec-sub">
1603 Periodic platform-maintenance loop — mirror sync, merge-queue
1604 progress, weekly digests, advisory rescans, environment
1605 wait-timer release, and scheduled workflow triggers (cron).
1606 </p>
1607 </div>
1608 <div class="admin-sec-hero-actions">
1609 <a href="/admin" class="btn btn-sm">
1610 {Icons.arrowLeft} Back
1611 </a>
1612 </div>
1613 </section>
1614
8e9f1d9Claude1615 {msg && (
07f4b70Claude1616 <div class={"admin-banner " + (isErr ? "is-error" : "is-ok")}>
8e9f1d9Claude1617 {decodeURIComponent(msg)}
1618 </div>
1619 )}
07f4b70Claude1620
1621 <div class="admin-stat-grid" style="grid-template-columns: repeat(auto-fit, minmax(180px, 1fr))">
1622 <div class="admin-stat">
1623 <div class="admin-stat-head">
1624 <span class="admin-stat-label">Status</span>
1625 <span class={"admin-pill " + (disabled ? "is-off" : "is-on")}>
1626 <span class="dot" aria-hidden="true" />
1627 {disabled ? "disabled" : "running"}
1628 </span>
1629 </div>
1630 <div class="admin-stat-value" style="font-size:22px">
8e9f1d9Claude1631 {disabled ? "disabled" : "running"}
1632 </div>
1633 </div>
07f4b70Claude1634 <div class="admin-stat">
1635 <div class="admin-stat-head">
1636 <span class="admin-stat-label">Interval</span>
1637 </div>
1638 <div class="admin-stat-value">{Math.round(intervalMs / 1000)}s</div>
1639 <div class="admin-stat-hint">between ticks</div>
8e9f1d9Claude1640 </div>
07f4b70Claude1641 <div class="admin-stat">
1642 <div class="admin-stat-head">
1643 <span class="admin-stat-label">Ticks this process</span>
1644 </div>
1645 <div class="admin-stat-value">{total}</div>
1646 <div class="admin-stat-hint">since boot</div>
8e9f1d9Claude1647 </div>
07f4b70Claude1648 <div class="admin-stat">
1649 <div class="admin-stat-head">
1650 <span class="admin-stat-label">Last tick</span>
1651 </div>
1652 <div
1653 class="admin-stat-value"
1654 style="font-size: 14px; font-family: var(--font-mono); line-height: 1.3"
1655 >
8e9f1d9Claude1656 {tick ? tick.finishedAt : "never"}
1657 </div>
1658 </div>
1659 </div>
07f4b70Claude1660
8e9f1d9Claude1661 <form
1662 method="post"
1663 action="/admin/autopilot/run"
07f4b70Claude1664 style="margin-bottom: var(--space-5); display:flex; align-items:center; gap:12px; flex-wrap:wrap"
8e9f1d9Claude1665 >
1666 <button class="btn btn-primary" type="submit">
1667 Run tick now
1668 </button>
07f4b70Claude1669 <span style="color: var(--text-muted); font-size: 13px">
8e9f1d9Claude1670 Executes all sub-tasks synchronously and records the result.
1671 </span>
1672 </form>
07f4b70Claude1673
1674 <div class="admin-h3">
1675 <h3>Last tick tasks</h3>
1676 {tick && (
1677 <span class="admin-h3-meta">
1678 {tick.tasks.filter((t) => t.ok).length}/{tick.tasks.length} ok
1679 </span>
1680 )}
1681 </div>
8e9f1d9Claude1682 {tick ? (
07f4b70Claude1683 <table class="admin-ap-table">
8e9f1d9Claude1684 <thead>
1685 <tr>
07f4b70Claude1686 <th>Task</th>
1687 <th>Status</th>
8e9f1d9Claude1688 <th style="text-align: right">Duration</th>
07f4b70Claude1689 <th>Error</th>
8e9f1d9Claude1690 </tr>
1691 </thead>
1692 <tbody>
1693 {tick.tasks.map((t) => (
1694 <tr>
1695 <td>
1696 <code>{t.name}</code>
1697 </td>
07f4b70Claude1698 <td class={t.ok ? "admin-ap-status-ok" : "admin-ap-status-fail"}>
8e9f1d9Claude1699 {t.ok ? "ok" : "failed"}
1700 </td>
1701 <td style="text-align: right">{t.durationMs}ms</td>
07f4b70Claude1702 <td style="color: var(--text-muted); font-size: 12.5px">
8e9f1d9Claude1703 {t.error || ""}
1704 </td>
1705 </tr>
1706 ))}
1707 </tbody>
1708 </table>
1709 ) : (
07f4b70Claude1710 <div class="admin-ap-empty">
8e9f1d9Claude1711 No ticks have run yet. The first tick fires after the interval
1712 elapses. Click "Run tick now" to fire one immediately.
07f4b70Claude1713 </div>
8e9f1d9Claude1714 )}
07f4b70Claude1715 <p class="admin-ap-foot">
8e9f1d9Claude1716 Opt out with env <code>AUTOPILOT_DISABLED=1</code>. Adjust cadence
1717 with <code>AUTOPILOT_INTERVAL_MS</code> (milliseconds).
1718 </p>
1719 </div>
07f4b70Claude1720 <style dangerouslySetInnerHTML={{ __html: adminStyles }} />
8e9f1d9Claude1721 </Layout>
1722 );
1723});
1724
988380aClaude1725admin.post("/admin/demo/reseed", async (c) => {
1726 const g = await gate(c);
1727 if (g instanceof Response) return g;
1728 const { user } = g;
1729 try {
1730 const result = await ensureDemoContent({ force: true });
1731 const summary = `Demo reseed: user=${result.created.user ? "created" : "existed"}, repos=${result.created.repos.length}, issues=${result.created.issues}, prs=${result.created.prs}${result.errors.length ? `, errors=${result.errors.length}` : ""}`;
1732 await audit({
1733 userId: user.id,
1734 action: "admin.demo.reseed",
1735 targetType: "user",
1736 targetId: result.demoUser?.id ?? "demo",
1737 metadata: {
1738 createdUser: result.created.user,
1739 createdRepos: result.created.repos,
1740 createdIssues: result.created.issues,
1741 createdPrs: result.created.prs,
1742 errors: result.errors.slice(0, 5),
1743 },
1744 });
1745 return c.redirect(`/admin?result=${encodeURIComponent(summary)}`);
1746 } catch (err) {
1747 const message = err instanceof Error ? err.message : String(err);
1748 return c.redirect(
1749 `/admin?error=${encodeURIComponent("Demo reseed failed: " + message)}`
1750 );
1751 }
1752});
1753
1754// Public jump-to-demo — redirects to the first demo repo if present,
1755// otherwise to /explore. Useful as a landing-page-linkable "try it" URL.
1756admin.get("/demo", (c) => {
1757 return c.redirect(`/${DEMO_USERNAME}/hello-python`);
1758});
1759
8e9f1d9Claude1760admin.post("/admin/autopilot/run", async (c) => {
1761 const g = await gate(c);
1762 if (g instanceof Response) return g;
1763 const { user } = g;
1764 let summary = "";
1765 try {
1766 const result = await runAutopilotTick();
1767 const ok = result.tasks.filter((t) => t.ok).length;
1768 summary = `Tick complete: ${ok}/${result.tasks.length} tasks ok.`;
1769 await audit({
1770 userId: user.id,
1771 action: "admin.autopilot.run",
1772 targetType: "system",
1773 targetId: "autopilot",
1774 metadata: { ok, total: result.tasks.length },
1775 });
1776 return c.redirect(
1777 `/admin/autopilot?result=${encodeURIComponent(summary)}`
1778 );
1779 } catch (err) {
1780 const message = err instanceof Error ? err.message : String(err);
1781 return c.redirect(
1782 `/admin/autopilot?error=${encodeURIComponent("Tick failed: " + message)}`
1783 );
1784 }
1785});
1786
8f50ed0Claude1787export default admin;