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.tsxBlame1762 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 ),
762};
763
764/** First-letter avatar helper. */
765function initials(s: string): string {
766 return (s || "?").trim().charAt(0).toUpperCase() || "?";
767}
768
8f50ed0Claude769async function gate(c: any): Promise<{ user: any } | Response> {
770 const user = c.get("user");
771 if (!user) return c.redirect("/login?next=/admin");
772 if (!(await isSiteAdmin(user.id))) {
773 return c.html(
774 <Layout title="Forbidden" user={user}>
07f4b70Claude775 <div class="admin-403">
8f50ed0Claude776 <h2>403 — Not a site admin</h2>
777 <p>You don't have permission to view this page.</p>
778 </div>
07f4b70Claude779 <style dangerouslySetInnerHTML={{ __html: adminStyles }} />
8f50ed0Claude780 </Layout>,
781 403
782 );
783 }
784 return { user };
785}
786
787admin.get("/admin", async (c) => {
788 const g = await gate(c);
789 if (g instanceof Response) return g;
790 const { user } = g;
791
792 const [uc] = await db.select({ n: sql<number>`count(*)::int` }).from(users);
793 const [rc] = await db
794 .select({ n: sql<number>`count(*)::int` })
795 .from(repositories);
796
797 const recent = await db
798 .select({
799 id: users.id,
800 username: users.username,
801 createdAt: users.createdAt,
802 })
803 .from(users)
804 .orderBy(desc(users.createdAt))
805 .limit(10);
806
807 const admins = await listSiteAdmins();
808
988380aClaude809 const msg = c.req.query("result") || c.req.query("error");
810 const isErr = !!c.req.query("error");
811
07f4b70Claude812 const userCount = Number(uc?.n || 0);
813 const repoCount = Number(rc?.n || 0);
814 const adminCount = admins.length;
815
8f50ed0Claude816 return c.html(
817 <Layout title="Admin — Gluecron" user={user}>
07f4b70Claude818 <div class="admin-wrap">
819 <section class="admin-hero">
820 <div class="admin-hero-bg" aria-hidden="true">
821 <div class="admin-hero-orb" />
822 </div>
823 <div class="admin-hero-inner">
824 <div class="admin-hero-eyebrow">
825 <span class="admin-shield" aria-hidden="true">{Icons.shield}</span>
826 Site administration ·{" "}
827 <span class="admin-who">{user.username}</span>
828 </div>
829 <h2 class="admin-hero-title">
830 <span class="admin-hero-title-grad">Site admin</span>.
831 </h2>
832 <p class="admin-hero-sub">
833 {userCount} user{userCount === 1 ? "" : "s"} ·{" "}
834 {repoCount} repo{repoCount === 1 ? "" : "s"} ·{" "}
835 {adminCount} site admin{adminCount === 1 ? "" : "s"}.{" "}
836 Operations, flags, digests, and autopilot — all in one place.
837 </p>
838 </div>
839 </section>
8f50ed0Claude840
07f4b70Claude841 {msg && (
842 <div class={"admin-banner " + (isErr ? "is-error" : "is-ok")}>
843 {decodeURIComponent(msg)}
844 </div>
845 )}
988380aClaude846
07f4b70Claude847 <div class="admin-stat-grid">
848 <div class="admin-stat">
849 <div class="admin-stat-head">
850 <span class="admin-stat-label">Users</span>
851 <span class="admin-stat-icon">{Icons.users}</span>
852 </div>
853 <div class="admin-stat-value">{userCount}</div>
854 <div class="admin-stat-hint">Registered accounts</div>
8f50ed0Claude855 </div>
07f4b70Claude856 <div class="admin-stat">
857 <div class="admin-stat-head">
858 <span class="admin-stat-label">Repos</span>
859 <span class="admin-stat-icon">{Icons.repo}</span>
860 </div>
861 <div class="admin-stat-value">{repoCount}</div>
862 <div class="admin-stat-hint">Public + private</div>
8f50ed0Claude863 </div>
07f4b70Claude864 <div class="admin-stat">
865 <div class="admin-stat-head">
866 <span class="admin-stat-label">Admins</span>
867 <span class="admin-stat-icon">{Icons.starShield}</span>
868 </div>
869 <div class="admin-stat-value">{adminCount}</div>
870 <div class="admin-stat-hint">Site admins</div>
8f50ed0Claude871 </div>
872 </div>
873
07f4b70Claude874 <div class="admin-actions">
875 <a href="/admin/ops" class="admin-action is-primary">
876 <span class="admin-action-icon">{Icons.ops}</span>
877 Operations
878 </a>
879 <a href="/admin/diagnose" class="admin-action is-primary">
880 <span class="admin-action-icon">{Icons.pulse}</span>
881 Health / Diagnose
882 </a>
883 <a href="/admin/users" class="admin-action">
884 <span class="admin-action-icon">{Icons.users}</span>
885 Manage users
886 </a>
887 <a href="/admin/repos" class="admin-action">
888 <span class="admin-action-icon">{Icons.repo}</span>
889 Manage repos
890 </a>
891 <a href="/admin/flags" class="admin-action">
892 <span class="admin-action-icon">{Icons.flag}</span>
893 Site flags
894 </a>
895 <a href="/admin/digests" class="admin-action">
896 <span class="admin-action-icon">{Icons.mail}</span>
897 Email digests
898 </a>
899 <a href="/admin/google-oauth" class="admin-action">
900 <span class="admin-action-icon">{Icons.google}</span>
901 Sign in with Google
902 </a>
903 <a href="/admin/github-oauth" class="admin-action">
904 <span class="admin-action-icon">{Icons.github}</span>
905 Sign in with GitHub
906 </a>
907 <a href="/admin/sso" class="admin-action">
908 <span class="admin-action-icon">{Icons.sso}</span>
909 Enterprise SSO
910 </a>
911 <a href="/admin/autopilot" class="admin-action">
912 <span class="admin-action-icon">{Icons.bot}</span>
913 Autopilot
914 </a>
662ce86Claude915 <a href="/connect/claude" class="admin-action is-primary">
916 <span class="admin-action-icon">{Icons.bot}</span>
917 Connect Claude
918 </a>
07f4b70Claude919 <form
920 method="post"
921 action="/admin/demo/reseed"
922 class="admin-action-form"
923 >
924 <button
925 class="admin-action"
926 type="submit"
927 title="Idempotently (re)create demo user + 3 sample repos"
928 >
929 <span class="admin-action-icon">{Icons.refresh}</span>
930 Reseed demo
931 </button>
932 </form>
933 </div>
8f50ed0Claude934
07f4b70Claude935 <div class="admin-h3">
936 <h3>Recent signups</h3>
937 <span class="admin-h3-meta">
938 {recent.length} most-recent
939 </span>
940 </div>
941 <div class="admin-list" style="margin-bottom:20px">
942 {recent.length === 0 ? (
943 <div class="admin-list-empty">No users yet.</div>
944 ) : (
945 recent.map((u) => (
946 <div class="admin-list-row">
947 <div class="admin-list-main">
948 <span class="admin-avatar" aria-hidden="true">{initials(u.username)}</span>
949 <div class="admin-row-text">
950 <a href={`/${u.username}`} class="admin-row-title">
951 {u.username}
952 </a>
953 <div class="admin-row-sub">
954 <span>Joined</span>
955 <span>
956 {u.createdAt
957 ? new Date(u.createdAt as unknown as string).toLocaleString()
958 : ""}
959 </span>
960 </div>
961 </div>
962 </div>
963 </div>
964 ))
965 )}
966 </div>
8f50ed0Claude967
07f4b70Claude968 <div class="admin-h3">
969 <h3>Site admins</h3>
970 <span class="admin-h3-meta">
971 {adminCount} active
972 </span>
973 </div>
974 <div class="admin-list">
975 {admins.length === 0 ? (
976 <div class="admin-list-empty">
977 No admins (bootstrap mode — oldest user is admin).
8f50ed0Claude978 </div>
07f4b70Claude979 ) : (
980 admins.map((a) => (
981 <div class="admin-list-row">
982 <div class="admin-list-main">
983 <span class="admin-avatar is-admin" aria-hidden="true">{initials(a.username)}</span>
984 <div class="admin-row-text">
985 <a href={`/${a.username}`} class="admin-row-title">
986 {a.username}
987 </a>
988 <div class="admin-row-sub">
989 <span class="admin-pill is-admin">
990 <span class="dot" aria-hidden="true" /> Site admin
991 </span>
992 <span>
993 Granted{" "}
994 {a.grantedAt
995 ? new Date(a.grantedAt as unknown as string).toLocaleDateString()
996 : "—"}
997 </span>
998 </div>
999 </div>
1000 </div>
1001 </div>
1002 ))
1003 )}
1004 </div>
8f50ed0Claude1005 </div>
07f4b70Claude1006 <style dangerouslySetInnerHTML={{ __html: adminStyles }} />
8f50ed0Claude1007 </Layout>
1008 );
1009});
1010
1011// ----- Users -----
1012
1013admin.get("/admin/users", async (c) => {
1014 const g = await gate(c);
1015 if (g instanceof Response) return g;
1016 const { user } = g;
1017 const q = c.req.query("q") || "";
1018 const rows = await db
1019 .select({
1020 id: users.id,
1021 username: users.username,
1022 email: users.email,
1023 createdAt: users.createdAt,
1024 })
1025 .from(users)
1026 .where(
1027 q
1028 ? or(ilike(users.username, `%${q}%`), ilike(users.email, `%${q}%`))!
1029 : sql`1=1`
1030 )
1031 .orderBy(desc(users.createdAt))
1032 .limit(200);
1033
1034 const adminIds = new Set((await listSiteAdmins()).map((a) => a.userId));
1035
1036 return c.html(
1037 <Layout title="Admin — Users" user={user}>
07f4b70Claude1038 <div class="admin-wrap">
1039 <section class="admin-sec-hero">
1040 <div class="admin-sec-hero-text">
1041 <div class="admin-sec-eyebrow">Site admin</div>
1042 <h2 class="admin-sec-title">Users</h2>
1043 <p class="admin-sec-sub">
1044 Search, audit, and grant or revoke the site-admin flag.
1045 Showing up to 200 accounts ordered by signup recency.
1046 </p>
1047 </div>
1048 <div class="admin-sec-hero-actions">
1049 <a href="/admin" class="btn btn-sm">
1050 {Icons.arrowLeft} Back
1051 </a>
1052 </div>
1053 </section>
1054
1055 <form method="get" action="/admin/users" class="admin-search">
1056 <input
1057 type="text"
1058 name="q"
1059 value={q}
1060 placeholder="Search username or email"
1061 aria-label="Search username or email"
1062 class="admin-input"
1063 />
1064 <button type="submit" class="btn">
1065 Search
1066 </button>
1067 {q && (
1068 <a href="/admin/users" class="btn btn-sm">
1069 Clear
1070 </a>
1071 )}
1072 </form>
1073
1074 <div class="admin-list">
1075 {rows.length === 0 ? (
1076 <div class="admin-list-empty">No users found.</div>
1077 ) : (
1078 rows.map((u) => {
1079 const isAdmin = adminIds.has(u.id);
1080 return (
1081 <div class="admin-list-row">
1082 <div class="admin-list-main">
1083 <span class={"admin-avatar" + (isAdmin ? " is-admin" : "")} aria-hidden="true">
1084 {initials(u.username)}
8f50ed0Claude1085 </span>
07f4b70Claude1086 <div class="admin-row-text">
1087 <a href={`/${u.username}`} class="admin-row-title">
1088 {u.username}
1089 </a>
1090 {isAdmin && (
1091 <span class="admin-pill is-admin" style="margin-left:8px">
1092 <span class="dot" aria-hidden="true" /> Admin
1093 </span>
1094 )}
1095 <div class="admin-row-sub">
1096 <span>{u.email}</span>
1097 {u.createdAt && (
1098 <span>
1099 ·{" "}
1100 {new Date(
1101 u.createdAt as unknown as string
1102 ).toLocaleDateString()}
1103 </span>
1104 )}
1105 </div>
1106 </div>
1107 </div>
1108 <form
1109 method="post"
1110 action={`/admin/users/${u.id}/admin`}
1111 onsubmit={
1112 isAdmin
1113 ? "return confirm('Revoke site admin?')"
1114 : "return confirm('Grant site admin?')"
1115 }
1116 >
1117 <button type="submit" class="btn btn-sm">
1118 {isAdmin ? "Revoke admin" : "Grant admin"}
1119 </button>
1120 </form>
8f50ed0Claude1121 </div>
07f4b70Claude1122 );
1123 })
1124 )}
1125 </div>
8f50ed0Claude1126 </div>
07f4b70Claude1127 <style dangerouslySetInnerHTML={{ __html: adminStyles }} />
8f50ed0Claude1128 </Layout>
1129 );
1130});
1131
1132admin.post("/admin/users/:id/admin", async (c) => {
1133 const g = await gate(c);
1134 if (g instanceof Response) return g;
1135 const { user } = g;
1136 const id = c.req.param("id");
1137 const admins = await listSiteAdmins();
1138 const isAlready = admins.some((a) => a.userId === id);
1139 if (isAlready) {
1140 await revokeSiteAdmin(id);
1141 await audit({
1142 userId: user.id,
1143 action: "site_admin.revoke",
1144 targetType: "user",
1145 targetId: id,
1146 });
1147 } else {
1148 await grantSiteAdmin(id, user.id);
1149 await audit({
1150 userId: user.id,
1151 action: "site_admin.grant",
1152 targetType: "user",
1153 targetId: id,
1154 });
1155 }
1156 return c.redirect("/admin/users");
1157});
1158
1159// ----- Repos -----
1160
1161admin.get("/admin/repos", async (c) => {
1162 const g = await gate(c);
1163 if (g instanceof Response) return g;
1164 const { user } = g;
1165 const rows = await db
1166 .select({
1167 id: repositories.id,
1168 name: repositories.name,
1169 ownerUsername: users.username,
0316dbbClaude1170 isPrivate: repositories.isPrivate,
8f50ed0Claude1171 createdAt: repositories.createdAt,
1172 starCount: repositories.starCount,
1173 })
1174 .from(repositories)
1175 .innerJoin(users, eq(repositories.ownerId, users.id))
1176 .orderBy(desc(repositories.createdAt))
1177 .limit(200);
1178
1179 return c.html(
1180 <Layout title="Admin — Repos" user={user}>
07f4b70Claude1181 <div class="admin-wrap">
1182 <section class="admin-sec-hero">
1183 <div class="admin-sec-hero-text">
1184 <div class="admin-sec-eyebrow">Site admin</div>
1185 <h2 class="admin-sec-title">Repositories</h2>
1186 <p class="admin-sec-sub">
1187 Every repository on the platform — public and private.
1188 Delete is irreversible and audit-logged.
1189 </p>
1190 </div>
1191 <div class="admin-sec-hero-actions">
1192 <a href="/admin" class="btn btn-sm">
1193 {Icons.arrowLeft} Back
1194 </a>
1195 </div>
1196 </section>
1197
1198 <div class="admin-list">
1199 {rows.length === 0 ? (
1200 <div class="admin-list-empty">No repositories.</div>
1201 ) : (
1202 rows.map((r) => (
1203 <div class="admin-list-row">
1204 <div class="admin-list-main">
1205 <span class="admin-avatar" aria-hidden="true">
1206 {initials(r.ownerUsername)}
1207 </span>
1208 <div class="admin-row-text">
1209 <a
1210 href={`/${r.ownerUsername}/${r.name}`}
1211 class="admin-row-title"
1212 >
1213 {r.ownerUsername}/{r.name}
1214 </a>
1215 <span
1216 class={
1217 "admin-pill " +
1218 (r.isPrivate ? "is-private" : "is-public")
1219 }
1220 style="margin-left:8px"
1221 >
1222 {r.isPrivate ? "private" : "public"}
1223 </span>
1224 <div class="admin-row-sub">
1225 <span>{r.starCount} star{r.starCount === 1 ? "" : "s"}</span>
1226 <span>·</span>
1227 <span>
1228 {r.createdAt
1229 ? new Date(r.createdAt as unknown as string).toLocaleDateString()
1230 : ""}
1231 </span>
1232 </div>
1233 </div>
8f50ed0Claude1234 </div>
07f4b70Claude1235 <form
1236 method="post"
1237 action={`/admin/repos/${r.id}/delete`}
1238 onsubmit="return confirm('Delete repository permanently? This cannot be undone.')"
1239 >
1240 <button type="submit" class="btn btn-sm btn-danger">
1241 Delete
1242 </button>
1243 </form>
8f50ed0Claude1244 </div>
07f4b70Claude1245 ))
1246 )}
1247 </div>
8f50ed0Claude1248 </div>
07f4b70Claude1249 <style dangerouslySetInnerHTML={{ __html: adminStyles }} />
8f50ed0Claude1250 </Layout>
1251 );
1252});
1253
1254admin.post("/admin/repos/:id/delete", async (c) => {
1255 const g = await gate(c);
1256 if (g instanceof Response) return g;
1257 const { user } = g;
1258 const id = c.req.param("id");
1259 try {
1260 await db.delete(repositories).where(eq(repositories.id, id));
1261 } catch (err) {
1262 console.error("[admin] repo delete:", err);
1263 }
1264 await audit({
1265 userId: user.id,
1266 action: "admin.repo.delete",
1267 targetType: "repository",
1268 targetId: id,
1269 });
1270 return c.redirect("/admin/repos");
1271});
1272
1273// ----- Flags -----
1274
1275admin.get("/admin/flags", async (c) => {
1276 const g = await gate(c);
1277 if (g instanceof Response) return g;
1278 const { user } = g;
1279
1280 const existing = await listFlags();
1281 const existingMap = new Map(existing.map((f) => [f.key, f.value]));
1282 const keys = Object.keys(KNOWN_FLAGS) as Array<keyof typeof KNOWN_FLAGS>;
1283
1284 return c.html(
1285 <Layout title="Admin — Flags" user={user}>
07f4b70Claude1286 <div class="admin-wrap">
1287 <section class="admin-sec-hero">
1288 <div class="admin-sec-hero-text">
1289 <div class="admin-sec-eyebrow">Site admin</div>
1290 <h2 class="admin-sec-title">Site flags</h2>
1291 <p class="admin-sec-sub">
1292 Runtime feature flags surfaced to the rest of the app via
1293 <code style="margin:0 4px;padding:1px 5px;border-radius:4px;background:var(--bg-tertiary);font-family:var(--font-mono);font-size:12px">
1294 getFlag()
1295 </code>
1296 — registration lock, site banner, read-only mode, and more.
1297 </p>
1298 </div>
1299 <div class="admin-sec-hero-actions">
1300 <a href="/admin" class="btn btn-sm">
1301 {Icons.arrowLeft} Back
1302 </a>
1303 </div>
1304 </section>
1305
1306 <form method="post" action="/admin/flags" class="admin-card">
1307 <div class="admin-card-body">
1308 {keys.map((k) => {
1309 const current = existingMap.get(k) ?? (KNOWN_FLAGS as any)[k];
1310 return (
1311 <div class="admin-field">
1312 <label>{k}</label>
1313 <input
1314 type="text"
1315 name={k}
1316 value={current}
1317 aria-label={k}
1318 class="admin-input-mono"
1319 />
1320 <div class="admin-field-hint">
1321 default: <code>{(KNOWN_FLAGS as any)[k] || "(empty)"}</code>
1322 </div>
1323 </div>
1324 );
1325 })}
1326 </div>
1327 <div class="admin-card-foot">
1328 <span class="admin-foot-hint">
1329 Saved values overwrite the defaults at runtime.
1330 </span>
1331 <button type="submit" class="btn btn-primary">
1332 Save
1333 </button>
1334 </div>
1335 </form>
8f50ed0Claude1336 </div>
07f4b70Claude1337 <style dangerouslySetInnerHTML={{ __html: adminStyles }} />
8f50ed0Claude1338 </Layout>
1339 );
1340});
1341
1342admin.post("/admin/flags", async (c) => {
1343 const g = await gate(c);
1344 if (g instanceof Response) return g;
1345 const { user } = g;
1346 const body = await c.req.parseBody();
1347 const keys = Object.keys(KNOWN_FLAGS) as Array<keyof typeof KNOWN_FLAGS>;
1348 for (const k of keys) {
1349 const v = String(body[k] ?? "");
1350 await setFlag(k, v, user.id);
1351 }
1352 await audit({ userId: user.id, action: "admin.flags.save" });
1353 return c.redirect("/admin/flags");
1354});
1355
08420cdClaude1356// ----- Email digests (Block I7) -----
1357
1358admin.get("/admin/digests", async (c) => {
1359 const g = await gate(c);
1360 if (g instanceof Response) return g;
1361 const { user } = g;
1362
1363 const [optedRow] = await db
1364 .select({ n: sql<number>`count(*)::int` })
1365 .from(users)
1366 .where(eq(users.notifyEmailDigestWeekly, true));
1367 const opted = Number(optedRow?.n || 0);
1368
1369 const recentlySent = await db
1370 .select({
1371 id: users.id,
1372 username: users.username,
1373 lastDigestSentAt: users.lastDigestSentAt,
1374 })
1375 .from(users)
1376 .where(sql`${users.lastDigestSentAt} is not null`)
1377 .orderBy(desc(users.lastDigestSentAt))
1378 .limit(20);
1379
1380 const result = c.req.query("result");
1381 const error = c.req.query("error");
1382
1383 return c.html(
1384 <Layout title="Admin — Digests" user={user}>
07f4b70Claude1385 <div class="admin-wrap">
1386 <section class="admin-sec-hero">
1387 <div class="admin-sec-hero-text">
1388 <div class="admin-sec-eyebrow">Site admin</div>
1389 <h2 class="admin-sec-title">Email digests</h2>
1390 <p class="admin-sec-sub">
1391 Manually trigger the weekly digest for every opted-in user
1392 or preview the email for a single account.
1393 </p>
1394 </div>
1395 <div class="admin-sec-hero-actions">
1396 <a href="/admin" class="btn btn-sm">
1397 {Icons.arrowLeft} Back
1398 </a>
1399 </div>
1400 </section>
08420cdClaude1401
07f4b70Claude1402 {result && (
1403 <div class="admin-banner is-ok">{decodeURIComponent(result)}</div>
1404 )}
1405 {error && (
1406 <div class="admin-banner is-error">{decodeURIComponent(error)}</div>
1407 )}
08420cdClaude1408
07f4b70Claude1409 <div class="admin-card" style="margin-bottom:var(--space-5)">
1410 <div class="admin-card-body">
1411 <div style="display:flex;align-items:center;gap:10px;margin-bottom:var(--space-3);flex-wrap:wrap">
1412 <span class="admin-pill is-on">
1413 <span class="dot" aria-hidden="true" />
1414 {opted} opted-in
1415 </span>
1416 <span style="font-size:13px;color:var(--text-muted)">
1417 user{opted === 1 ? "" : "s"} subscribed to the weekly digest.
08420cdClaude1418 </span>
1419 </div>
07f4b70Claude1420 <form method="post" action="/admin/digests/run" style="margin-bottom:var(--space-4)">
1421 <button
1422 type="submit"
1423 class="btn btn-primary"
1424 onclick="return confirm('Send weekly digest to all opted-in users now?')"
1425 >
1426 Send digests now
1427 </button>
1428 </form>
1429 <div style="border-top:1px solid var(--border-subtle);padding-top:var(--space-4)">
1430 <div style="font-size:12.5px;color:var(--text-muted);margin-bottom:8px">
1431 Preview / one-off — send the digest to a single user.
1432 </div>
1433 <form
1434 method="post"
1435 action="/admin/digests/preview"
1436 class="admin-digest-row"
1437 >
1438 <input
1439 type="text"
1440 name="username"
1441 placeholder="username"
1442 required
1443 aria-label="Username"
1444 class="admin-input"
1445 style="width:240px"
1446 />
1447 <button type="submit" class="btn btn-sm">
1448 Send to one user
1449 </button>
1450 </form>
1451 </div>
1452 </div>
1453 </div>
1454
1455 <div class="admin-h3">
1456 <h3>Recently sent</h3>
1457 <span class="admin-h3-meta">
1458 last {recentlySent.length}
1459 </span>
1460 </div>
1461 <div class="admin-list">
1462 {recentlySent.length === 0 ? (
1463 <div class="admin-list-empty">No digests have been sent yet.</div>
1464 ) : (
1465 recentlySent.map((u) => (
1466 <div class="admin-list-row">
1467 <div class="admin-list-main">
1468 <span class="admin-avatar" aria-hidden="true">{initials(u.username)}</span>
1469 <div class="admin-row-text">
1470 <a href={`/${u.username}`} class="admin-row-title">
1471 {u.username}
1472 </a>
1473 <div class="admin-row-sub">
1474 <span>Sent</span>
1475 <span>
1476 {u.lastDigestSentAt
1477 ? new Date(
1478 u.lastDigestSentAt as unknown as string
1479 ).toLocaleString()
1480 : ""}
1481 </span>
1482 </div>
1483 </div>
1484 </div>
1485 </div>
1486 ))
1487 )}
1488 </div>
08420cdClaude1489 </div>
07f4b70Claude1490 <style dangerouslySetInnerHTML={{ __html: adminStyles }} />
08420cdClaude1491 </Layout>
1492 );
1493});
1494
1495admin.post("/admin/digests/run", async (c) => {
1496 const g = await gate(c);
1497 if (g instanceof Response) return g;
1498 const { user } = g;
1499 const results = await sendDigestsToAll();
1500 const sent = results.filter((r) => r.ok).length;
1501 const skipped = results.length - sent;
1502 await audit({
1503 userId: user.id,
1504 action: "admin.digests.run",
1505 metadata: { sent, skipped, total: results.length },
1506 });
1507 return c.redirect(
1508 `/admin/digests?result=${encodeURIComponent(
1509 `Processed ${results.length} opted-in users: ${sent} sent, ${skipped} skipped.`
1510 )}`
1511 );
1512});
1513
1514admin.post("/admin/digests/preview", async (c) => {
1515 const g = await gate(c);
1516 if (g instanceof Response) return g;
1517 const { user } = g;
1518 const body = await c.req.parseBody();
1519 const username = String(body.username || "").trim();
1520 if (!username) {
1521 return c.redirect("/admin/digests?error=Username+required");
1522 }
1523 const [target] = await db
1524 .select({ id: users.id, username: users.username })
1525 .from(users)
1526 .where(eq(users.username, username))
1527 .limit(1);
1528 if (!target) {
1529 return c.redirect("/admin/digests?error=User+not+found");
1530 }
1531 const result = await sendDigestForUser(target.id);
1532 await audit({
1533 userId: user.id,
1534 action: "admin.digests.preview",
1535 targetType: "user",
1536 targetId: target.id,
1537 metadata: {
1538 ok: result.ok,
1539 skipped: "skipped" in result ? result.skipped : null,
1540 },
1541 });
1542 if (result.ok) {
1543 return c.redirect(
1544 `/admin/digests?result=${encodeURIComponent(
1545 `Digest sent to ${target.username}.`
1546 )}`
1547 );
1548 }
1549 return c.redirect(
1550 `/admin/digests?error=${encodeURIComponent(
1551 `Not sent: ${"skipped" in result ? result.skipped : "unknown reason"}`
1552 )}`
1553 );
1554});
1555
8e9f1d9Claude1556admin.get("/admin/autopilot", async (c) => {
1557 const g = await gate(c);
1558 if (g instanceof Response) return g;
1559 const { user } = g;
1560 const tick = getLastTick();
1561 const total = getTickCount();
1562 const disabled = process.env.AUTOPILOT_DISABLED === "1";
1563 const intervalRaw = process.env.AUTOPILOT_INTERVAL_MS;
1564 const intervalMs =
1565 intervalRaw && Number.isFinite(Number(intervalRaw)) && Number(intervalRaw) > 0
1566 ? Number(intervalRaw)
1567 : 5 * 60 * 1000;
1568 const msg = c.req.query("result") || c.req.query("error");
1569 const isErr = !!c.req.query("error");
1570 return c.html(
1571 <Layout title="Autopilot — admin" user={user}>
07f4b70Claude1572 <div class="admin-wrap" style="padding: var(--space-6) var(--space-4)">
1573 <section class="admin-sec-hero">
1574 <div class="admin-sec-hero-text">
1575 <div class="admin-sec-eyebrow">Site admin</div>
1576 <h2 class="admin-sec-title">Autopilot</h2>
1577 <p class="admin-sec-sub">
1578 Periodic platform-maintenance loop — mirror sync, merge-queue
1579 progress, weekly digests, advisory rescans, environment
1580 wait-timer release, and scheduled workflow triggers (cron).
1581 </p>
1582 </div>
1583 <div class="admin-sec-hero-actions">
1584 <a href="/admin" class="btn btn-sm">
1585 {Icons.arrowLeft} Back
1586 </a>
1587 </div>
1588 </section>
1589
8e9f1d9Claude1590 {msg && (
07f4b70Claude1591 <div class={"admin-banner " + (isErr ? "is-error" : "is-ok")}>
8e9f1d9Claude1592 {decodeURIComponent(msg)}
1593 </div>
1594 )}
07f4b70Claude1595
1596 <div class="admin-stat-grid" style="grid-template-columns: repeat(auto-fit, minmax(180px, 1fr))">
1597 <div class="admin-stat">
1598 <div class="admin-stat-head">
1599 <span class="admin-stat-label">Status</span>
1600 <span class={"admin-pill " + (disabled ? "is-off" : "is-on")}>
1601 <span class="dot" aria-hidden="true" />
1602 {disabled ? "disabled" : "running"}
1603 </span>
1604 </div>
1605 <div class="admin-stat-value" style="font-size:22px">
8e9f1d9Claude1606 {disabled ? "disabled" : "running"}
1607 </div>
1608 </div>
07f4b70Claude1609 <div class="admin-stat">
1610 <div class="admin-stat-head">
1611 <span class="admin-stat-label">Interval</span>
1612 </div>
1613 <div class="admin-stat-value">{Math.round(intervalMs / 1000)}s</div>
1614 <div class="admin-stat-hint">between ticks</div>
8e9f1d9Claude1615 </div>
07f4b70Claude1616 <div class="admin-stat">
1617 <div class="admin-stat-head">
1618 <span class="admin-stat-label">Ticks this process</span>
1619 </div>
1620 <div class="admin-stat-value">{total}</div>
1621 <div class="admin-stat-hint">since boot</div>
8e9f1d9Claude1622 </div>
07f4b70Claude1623 <div class="admin-stat">
1624 <div class="admin-stat-head">
1625 <span class="admin-stat-label">Last tick</span>
1626 </div>
1627 <div
1628 class="admin-stat-value"
1629 style="font-size: 14px; font-family: var(--font-mono); line-height: 1.3"
1630 >
8e9f1d9Claude1631 {tick ? tick.finishedAt : "never"}
1632 </div>
1633 </div>
1634 </div>
07f4b70Claude1635
8e9f1d9Claude1636 <form
1637 method="post"
1638 action="/admin/autopilot/run"
07f4b70Claude1639 style="margin-bottom: var(--space-5); display:flex; align-items:center; gap:12px; flex-wrap:wrap"
8e9f1d9Claude1640 >
1641 <button class="btn btn-primary" type="submit">
1642 Run tick now
1643 </button>
07f4b70Claude1644 <span style="color: var(--text-muted); font-size: 13px">
8e9f1d9Claude1645 Executes all sub-tasks synchronously and records the result.
1646 </span>
1647 </form>
07f4b70Claude1648
1649 <div class="admin-h3">
1650 <h3>Last tick tasks</h3>
1651 {tick && (
1652 <span class="admin-h3-meta">
1653 {tick.tasks.filter((t) => t.ok).length}/{tick.tasks.length} ok
1654 </span>
1655 )}
1656 </div>
8e9f1d9Claude1657 {tick ? (
07f4b70Claude1658 <table class="admin-ap-table">
8e9f1d9Claude1659 <thead>
1660 <tr>
07f4b70Claude1661 <th>Task</th>
1662 <th>Status</th>
8e9f1d9Claude1663 <th style="text-align: right">Duration</th>
07f4b70Claude1664 <th>Error</th>
8e9f1d9Claude1665 </tr>
1666 </thead>
1667 <tbody>
1668 {tick.tasks.map((t) => (
1669 <tr>
1670 <td>
1671 <code>{t.name}</code>
1672 </td>
07f4b70Claude1673 <td class={t.ok ? "admin-ap-status-ok" : "admin-ap-status-fail"}>
8e9f1d9Claude1674 {t.ok ? "ok" : "failed"}
1675 </td>
1676 <td style="text-align: right">{t.durationMs}ms</td>
07f4b70Claude1677 <td style="color: var(--text-muted); font-size: 12.5px">
8e9f1d9Claude1678 {t.error || ""}
1679 </td>
1680 </tr>
1681 ))}
1682 </tbody>
1683 </table>
1684 ) : (
07f4b70Claude1685 <div class="admin-ap-empty">
8e9f1d9Claude1686 No ticks have run yet. The first tick fires after the interval
1687 elapses. Click "Run tick now" to fire one immediately.
07f4b70Claude1688 </div>
8e9f1d9Claude1689 )}
07f4b70Claude1690 <p class="admin-ap-foot">
8e9f1d9Claude1691 Opt out with env <code>AUTOPILOT_DISABLED=1</code>. Adjust cadence
1692 with <code>AUTOPILOT_INTERVAL_MS</code> (milliseconds).
1693 </p>
1694 </div>
07f4b70Claude1695 <style dangerouslySetInnerHTML={{ __html: adminStyles }} />
8e9f1d9Claude1696 </Layout>
1697 );
1698});
1699
988380aClaude1700admin.post("/admin/demo/reseed", async (c) => {
1701 const g = await gate(c);
1702 if (g instanceof Response) return g;
1703 const { user } = g;
1704 try {
1705 const result = await ensureDemoContent({ force: true });
1706 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}` : ""}`;
1707 await audit({
1708 userId: user.id,
1709 action: "admin.demo.reseed",
1710 targetType: "user",
1711 targetId: result.demoUser?.id ?? "demo",
1712 metadata: {
1713 createdUser: result.created.user,
1714 createdRepos: result.created.repos,
1715 createdIssues: result.created.issues,
1716 createdPrs: result.created.prs,
1717 errors: result.errors.slice(0, 5),
1718 },
1719 });
1720 return c.redirect(`/admin?result=${encodeURIComponent(summary)}`);
1721 } catch (err) {
1722 const message = err instanceof Error ? err.message : String(err);
1723 return c.redirect(
1724 `/admin?error=${encodeURIComponent("Demo reseed failed: " + message)}`
1725 );
1726 }
1727});
1728
1729// Public jump-to-demo — redirects to the first demo repo if present,
1730// otherwise to /explore. Useful as a landing-page-linkable "try it" URL.
1731admin.get("/demo", (c) => {
1732 return c.redirect(`/${DEMO_USERNAME}/hello-python`);
1733});
1734
8e9f1d9Claude1735admin.post("/admin/autopilot/run", async (c) => {
1736 const g = await gate(c);
1737 if (g instanceof Response) return g;
1738 const { user } = g;
1739 let summary = "";
1740 try {
1741 const result = await runAutopilotTick();
1742 const ok = result.tasks.filter((t) => t.ok).length;
1743 summary = `Tick complete: ${ok}/${result.tasks.length} tasks ok.`;
1744 await audit({
1745 userId: user.id,
1746 action: "admin.autopilot.run",
1747 targetType: "system",
1748 targetId: "autopilot",
1749 metadata: { ok, total: result.tasks.length },
1750 });
1751 return c.redirect(
1752 `/admin/autopilot?result=${encodeURIComponent(summary)}`
1753 );
1754 } catch (err) {
1755 const message = err instanceof Error ? err.message : String(err);
1756 return c.redirect(
1757 `/admin/autopilot?error=${encodeURIComponent("Tick failed: " + message)}`
1758 );
1759 }
1760});
1761
8f50ed0Claude1762export default admin;