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.tsxBlame584 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`.
15 */
16
17import { Hono } from "hono";
18import { and, desc, eq, ilike, or, sql } from "drizzle-orm";
19import { db } from "../db";
20import { repositories, users } from "../db/schema";
21import { Layout } from "../views/layout";
22import { softAuth, requireAuth } from "../middleware/auth";
23import type { AuthEnv } from "../middleware/auth";
24import {
25 grantSiteAdmin,
26 isSiteAdmin,
27 KNOWN_FLAGS,
28 listFlags,
29 listSiteAdmins,
30 revokeSiteAdmin,
31 setFlag,
32} from "../lib/admin";
33import { audit } from "../lib/notify";
08420cdClaude34import { sendDigestsToAll, sendDigestForUser } from "../lib/email-digest";
8f50ed0Claude35
36const admin = new Hono<AuthEnv>();
37admin.use("*", softAuth);
38
39async function gate(c: any): Promise<{ user: any } | Response> {
40 const user = c.get("user");
41 if (!user) return c.redirect("/login?next=/admin");
42 if (!(await isSiteAdmin(user.id))) {
43 return c.html(
44 <Layout title="Forbidden" user={user}>
45 <div class="empty-state">
46 <h2>403 — Not a site admin</h2>
47 <p>You don't have permission to view this page.</p>
48 </div>
49 </Layout>,
50 403
51 );
52 }
53 return { user };
54}
55
56admin.get("/admin", async (c) => {
57 const g = await gate(c);
58 if (g instanceof Response) return g;
59 const { user } = g;
60
61 const [uc] = await db.select({ n: sql<number>`count(*)::int` }).from(users);
62 const [rc] = await db
63 .select({ n: sql<number>`count(*)::int` })
64 .from(repositories);
65
66 const recent = await db
67 .select({
68 id: users.id,
69 username: users.username,
70 createdAt: users.createdAt,
71 })
72 .from(users)
73 .orderBy(desc(users.createdAt))
74 .limit(10);
75
76 const admins = await listSiteAdmins();
77
78 return c.html(
79 <Layout title="Admin — Gluecron" user={user}>
80 <h2>Site admin</h2>
81
82 <div style="display:grid;grid-template-columns:repeat(3,1fr);gap:12px;margin-bottom:20px">
83 <div class="panel" style="padding:12px;text-align:center">
84 <div style="font-size:22px;font-weight:700">{Number(uc?.n || 0)}</div>
85 <div style="font-size:11px;color:var(--text-muted);text-transform:uppercase">
86 Users
87 </div>
88 </div>
89 <div class="panel" style="padding:12px;text-align:center">
90 <div style="font-size:22px;font-weight:700">{Number(rc?.n || 0)}</div>
91 <div style="font-size:11px;color:var(--text-muted);text-transform:uppercase">
92 Repos
93 </div>
94 </div>
95 <div class="panel" style="padding:12px;text-align:center">
96 <div style="font-size:22px;font-weight:700">{admins.length}</div>
97 <div style="font-size:11px;color:var(--text-muted);text-transform:uppercase">
98 Site admins
99 </div>
100 </div>
101 </div>
102
edf7c36Claude103 <div style="display:grid;grid-template-columns:repeat(5,1fr);gap:8px;margin-bottom:20px">
8f50ed0Claude104 <a href="/admin/users" class="btn">
105 Manage users
106 </a>
107 <a href="/admin/repos" class="btn">
108 Manage repos
109 </a>
110 <a href="/admin/flags" class="btn">
111 Site flags
112 </a>
08420cdClaude113 <a href="/admin/digests" class="btn">
114 Email digests
115 </a>
edf7c36Claude116 <a href="/admin/sso" class="btn">
117 Enterprise SSO
118 </a>
8f50ed0Claude119 </div>
120
121 <h3>Recent signups</h3>
122 <div class="panel" style="margin-bottom:20px">
123 {recent.map((u) => (
124 <div class="panel-item" style="justify-content:space-between">
125 <a href={`/${u.username}`}>{u.username}</a>
126 <span style="font-size:12px;color:var(--text-muted)">
127 {u.createdAt
128 ? new Date(u.createdAt as unknown as string).toLocaleString()
129 : ""}
130 </span>
131 </div>
132 ))}
133 </div>
134
135 <h3>Site admins</h3>
136 <div class="panel">
137 {admins.length === 0 ? (
138 <div class="panel-empty">
139 No admins (bootstrap mode — oldest user is admin).
140 </div>
141 ) : (
142 admins.map((a) => (
143 <div class="panel-item" style="justify-content:space-between">
144 <a href={`/${a.username}`}>{a.username}</a>
145 <span style="font-size:12px;color:var(--text-muted)">
146 Granted{" "}
147 {a.grantedAt
148 ? new Date(a.grantedAt as unknown as string).toLocaleDateString()
149 : ""}
150 </span>
151 </div>
152 ))
153 )}
154 </div>
155 </Layout>
156 );
157});
158
159// ----- Users -----
160
161admin.get("/admin/users", async (c) => {
162 const g = await gate(c);
163 if (g instanceof Response) return g;
164 const { user } = g;
165 const q = c.req.query("q") || "";
166 const rows = await db
167 .select({
168 id: users.id,
169 username: users.username,
170 email: users.email,
171 createdAt: users.createdAt,
172 })
173 .from(users)
174 .where(
175 q
176 ? or(ilike(users.username, `%${q}%`), ilike(users.email, `%${q}%`))!
177 : sql`1=1`
178 )
179 .orderBy(desc(users.createdAt))
180 .limit(200);
181
182 const adminIds = new Set((await listSiteAdmins()).map((a) => a.userId));
183
184 return c.html(
185 <Layout title="Admin — Users" user={user}>
186 <h2>Users</h2>
b9968e3Claude187 <form method="get" action="/admin/users" style="margin-bottom:16px">
8f50ed0Claude188 <input
189 type="text"
190 name="q"
191 value={q}
192 placeholder="Search username or email"
193 style="width:320px"
194 />{" "}
195 <button type="submit" class="btn">
196 Search
197 </button>
198 <a href="/admin" class="btn" style="margin-left:6px">
199 Back
200 </a>
201 </form>
202 <div class="panel">
203 {rows.length === 0 ? (
204 <div class="panel-empty">No users found.</div>
205 ) : (
206 rows.map((u) => {
207 const isAdmin = adminIds.has(u.id);
208 return (
209 <div class="panel-item" style="justify-content:space-between">
210 <div>
211 <a href={`/${u.username}`} style="font-weight:600">
212 {u.username}
213 </a>{" "}
214 <span style="color:var(--text-muted)">{u.email}</span>
215 {isAdmin && (
216 <span
217 style="margin-left:6px;font-size:11px;background:#8957e5;color:white;padding:2px 6px;border-radius:3px"
218 >
219 ADMIN
220 </span>
221 )}
222 </div>
223 <form
b9968e3Claude224 method="post"
8f50ed0Claude225 action={`/admin/users/${u.id}/admin`}
226 onsubmit={
227 isAdmin
228 ? "return confirm('Revoke site admin?')"
229 : "return confirm('Grant site admin?')"
230 }
231 >
232 <button type="submit" class="btn btn-sm">
233 {isAdmin ? "Revoke admin" : "Grant admin"}
234 </button>
235 </form>
236 </div>
237 );
238 })
239 )}
240 </div>
241 </Layout>
242 );
243});
244
245admin.post("/admin/users/:id/admin", async (c) => {
246 const g = await gate(c);
247 if (g instanceof Response) return g;
248 const { user } = g;
249 const id = c.req.param("id");
250 const admins = await listSiteAdmins();
251 const isAlready = admins.some((a) => a.userId === id);
252 if (isAlready) {
253 await revokeSiteAdmin(id);
254 await audit({
255 userId: user.id,
256 action: "site_admin.revoke",
257 targetType: "user",
258 targetId: id,
259 });
260 } else {
261 await grantSiteAdmin(id, user.id);
262 await audit({
263 userId: user.id,
264 action: "site_admin.grant",
265 targetType: "user",
266 targetId: id,
267 });
268 }
269 return c.redirect("/admin/users");
270});
271
272// ----- Repos -----
273
274admin.get("/admin/repos", async (c) => {
275 const g = await gate(c);
276 if (g instanceof Response) return g;
277 const { user } = g;
278 const rows = await db
279 .select({
280 id: repositories.id,
281 name: repositories.name,
282 ownerUsername: users.username,
0316dbbClaude283 isPrivate: repositories.isPrivate,
8f50ed0Claude284 createdAt: repositories.createdAt,
285 starCount: repositories.starCount,
286 })
287 .from(repositories)
288 .innerJoin(users, eq(repositories.ownerId, users.id))
289 .orderBy(desc(repositories.createdAt))
290 .limit(200);
291
292 return c.html(
293 <Layout title="Admin — Repos" user={user}>
294 <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
295 <h2>Repositories</h2>
296 <a href="/admin" class="btn btn-sm">
297 Back
298 </a>
299 </div>
300 <div class="panel">
301 {rows.length === 0 ? (
302 <div class="panel-empty">No repositories.</div>
303 ) : (
304 rows.map((r) => (
305 <div class="panel-item" style="justify-content:space-between">
306 <div>
307 <a
308 href={`/${r.ownerUsername}/${r.name}`}
309 style="font-weight:600"
310 >
311 {r.ownerUsername}/{r.name}
312 </a>
313 <span
314 style="margin-left:6px;font-size:11px;color:var(--text-muted);text-transform:uppercase"
315 >
0316dbbClaude316 {r.isPrivate ? "private" : "public"}
8f50ed0Claude317 </span>
318 <div style="font-size:12px;color:var(--text-muted);margin-top:2px">
319 {r.starCount} stars ·{" "}
320 {r.createdAt
321 ? new Date(r.createdAt as unknown as string).toLocaleDateString()
322 : ""}
323 </div>
324 </div>
325 <form
b9968e3Claude326 method="post"
8f50ed0Claude327 action={`/admin/repos/${r.id}/delete`}
328 onsubmit="return confirm('Delete repository permanently? This cannot be undone.')"
329 >
330 <button type="submit" class="btn btn-sm btn-danger">
331 Delete
332 </button>
333 </form>
334 </div>
335 ))
336 )}
337 </div>
338 </Layout>
339 );
340});
341
342admin.post("/admin/repos/:id/delete", async (c) => {
343 const g = await gate(c);
344 if (g instanceof Response) return g;
345 const { user } = g;
346 const id = c.req.param("id");
347 try {
348 await db.delete(repositories).where(eq(repositories.id, id));
349 } catch (err) {
350 console.error("[admin] repo delete:", err);
351 }
352 await audit({
353 userId: user.id,
354 action: "admin.repo.delete",
355 targetType: "repository",
356 targetId: id,
357 });
358 return c.redirect("/admin/repos");
359});
360
361// ----- Flags -----
362
363admin.get("/admin/flags", async (c) => {
364 const g = await gate(c);
365 if (g instanceof Response) return g;
366 const { user } = g;
367
368 const existing = await listFlags();
369 const existingMap = new Map(existing.map((f) => [f.key, f.value]));
370 const keys = Object.keys(KNOWN_FLAGS) as Array<keyof typeof KNOWN_FLAGS>;
371
372 return c.html(
373 <Layout title="Admin — Flags" user={user}>
374 <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
375 <h2>Site flags</h2>
376 <a href="/admin" class="btn btn-sm">
377 Back
378 </a>
379 </div>
380 <form
b9968e3Claude381 method="post"
8f50ed0Claude382 action="/admin/flags"
383 class="panel"
384 style="padding:16px"
385 >
386 {keys.map((k) => {
387 const current = existingMap.get(k) ?? (KNOWN_FLAGS as any)[k];
388 return (
389 <div class="form-group">
390 <label>{k}</label>
391 <input
392 type="text"
393 name={k}
394 value={current}
395 style="font-family:var(--font-mono)"
396 />
397 <div
398 style="font-size:11px;color:var(--text-muted);margin-top:2px"
399 >
400 default: <code>{(KNOWN_FLAGS as any)[k] || "(empty)"}</code>
401 </div>
402 </div>
403 );
404 })}
405 <button type="submit" class="btn btn-primary">
406 Save
407 </button>
408 </form>
409 </Layout>
410 );
411});
412
413admin.post("/admin/flags", async (c) => {
414 const g = await gate(c);
415 if (g instanceof Response) return g;
416 const { user } = g;
417 const body = await c.req.parseBody();
418 const keys = Object.keys(KNOWN_FLAGS) as Array<keyof typeof KNOWN_FLAGS>;
419 for (const k of keys) {
420 const v = String(body[k] ?? "");
421 await setFlag(k, v, user.id);
422 }
423 await audit({ userId: user.id, action: "admin.flags.save" });
424 return c.redirect("/admin/flags");
425});
426
08420cdClaude427// ----- Email digests (Block I7) -----
428
429admin.get("/admin/digests", async (c) => {
430 const g = await gate(c);
431 if (g instanceof Response) return g;
432 const { user } = g;
433
434 const [optedRow] = await db
435 .select({ n: sql<number>`count(*)::int` })
436 .from(users)
437 .where(eq(users.notifyEmailDigestWeekly, true));
438 const opted = Number(optedRow?.n || 0);
439
440 const recentlySent = await db
441 .select({
442 id: users.id,
443 username: users.username,
444 lastDigestSentAt: users.lastDigestSentAt,
445 })
446 .from(users)
447 .where(sql`${users.lastDigestSentAt} is not null`)
448 .orderBy(desc(users.lastDigestSentAt))
449 .limit(20);
450
451 const result = c.req.query("result");
452 const error = c.req.query("error");
453
454 return c.html(
455 <Layout title="Admin — Digests" user={user}>
456 <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
457 <h2>Email digests</h2>
458 <a href="/admin" class="btn btn-sm">
459 Back
460 </a>
461 </div>
462
463 {result && (
464 <div class="auth-success">{decodeURIComponent(result)}</div>
465 )}
466 {error && (
467 <div class="auth-error">{decodeURIComponent(error)}</div>
468 )}
469
470 <div class="panel" style="padding:16px;margin-bottom:20px">
471 <div style="font-size:13px;color:var(--text-muted);margin-bottom:8px">
472 {opted} user{opted === 1 ? "" : "s"} opted into the weekly digest.
473 </div>
b9968e3Claude474 <form method="post" action="/admin/digests/run" style="margin-bottom:8px">
08420cdClaude475 <button
476 type="submit"
477 class="btn btn-primary"
478 onclick="return confirm('Send weekly digest to all opted-in users now?')"
479 >
480 Send digests now
481 </button>
482 </form>
b9968e3Claude483 <form method="post" action="/admin/digests/preview" style="display:flex;gap:6px;align-items:center">
08420cdClaude484 <input
485 type="text"
486 name="username"
487 placeholder="username"
488 required
489 style="width:240px"
490 />
491 <button type="submit" class="btn btn-sm">
492 Send to one user
493 </button>
494 </form>
495 </div>
496
497 <h3>Recently sent</h3>
498 <div class="panel">
499 {recentlySent.length === 0 ? (
500 <div class="panel-empty">No digests have been sent yet.</div>
501 ) : (
502 recentlySent.map((u) => (
503 <div class="panel-item" style="justify-content:space-between">
504 <a href={`/${u.username}`}>{u.username}</a>
505 <span style="font-size:12px;color:var(--text-muted)">
506 {u.lastDigestSentAt
507 ? new Date(
508 u.lastDigestSentAt as unknown as string
509 ).toLocaleString()
510 : ""}
511 </span>
512 </div>
513 ))
514 )}
515 </div>
516 </Layout>
517 );
518});
519
520admin.post("/admin/digests/run", async (c) => {
521 const g = await gate(c);
522 if (g instanceof Response) return g;
523 const { user } = g;
524 const results = await sendDigestsToAll();
525 const sent = results.filter((r) => r.ok).length;
526 const skipped = results.length - sent;
527 await audit({
528 userId: user.id,
529 action: "admin.digests.run",
530 metadata: { sent, skipped, total: results.length },
531 });
532 return c.redirect(
533 `/admin/digests?result=${encodeURIComponent(
534 `Processed ${results.length} opted-in users: ${sent} sent, ${skipped} skipped.`
535 )}`
536 );
537});
538
539admin.post("/admin/digests/preview", async (c) => {
540 const g = await gate(c);
541 if (g instanceof Response) return g;
542 const { user } = g;
543 const body = await c.req.parseBody();
544 const username = String(body.username || "").trim();
545 if (!username) {
546 return c.redirect("/admin/digests?error=Username+required");
547 }
548 const [target] = await db
549 .select({ id: users.id, username: users.username })
550 .from(users)
551 .where(eq(users.username, username))
552 .limit(1);
553 if (!target) {
554 return c.redirect("/admin/digests?error=User+not+found");
555 }
556 const result = await sendDigestForUser(target.id);
557 await audit({
558 userId: user.id,
559 action: "admin.digests.preview",
560 targetType: "user",
561 targetId: target.id,
562 metadata: {
563 ok: result.ok,
564 skipped: "skipped" in result ? result.skipped : null,
565 },
566 });
567 if (result.ok) {
568 return c.redirect(
569 `/admin/digests?result=${encodeURIComponent(
570 `Digest sent to ${target.username}.`
571 )}`
572 );
573 }
574 return c.redirect(
575 `/admin/digests?error=${encodeURIComponent(
576 `Not sent: ${"skipped" in result ? result.skipped : "unknown reason"}`
577 )}`
578 );
579});
580
581// Keep requireAuth import used even if some routes don't reference it here.
582void requireAuth;
583
8f50ed0Claude584export default admin;