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