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.tsxBlame581 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
08420cdClaude103 <div style="display:grid;grid-template-columns:repeat(4,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>
8f50ed0Claude116 </div>
117
118 <h3>Recent signups</h3>
119 <div class="panel" style="margin-bottom:20px">
120 {recent.map((u) => (
121 <div class="panel-item" style="justify-content:space-between">
122 <a href={`/${u.username}`}>{u.username}</a>
123 <span style="font-size:12px;color:var(--text-muted)">
124 {u.createdAt
125 ? new Date(u.createdAt as unknown as string).toLocaleString()
126 : ""}
127 </span>
128 </div>
129 ))}
130 </div>
131
132 <h3>Site admins</h3>
133 <div class="panel">
134 {admins.length === 0 ? (
135 <div class="panel-empty">
136 No admins (bootstrap mode — oldest user is admin).
137 </div>
138 ) : (
139 admins.map((a) => (
140 <div class="panel-item" style="justify-content:space-between">
141 <a href={`/${a.username}`}>{a.username}</a>
142 <span style="font-size:12px;color:var(--text-muted)">
143 Granted{" "}
144 {a.grantedAt
145 ? new Date(a.grantedAt as unknown as string).toLocaleDateString()
146 : ""}
147 </span>
148 </div>
149 ))
150 )}
151 </div>
152 </Layout>
153 );
154});
155
156// ----- Users -----
157
158admin.get("/admin/users", async (c) => {
159 const g = await gate(c);
160 if (g instanceof Response) return g;
161 const { user } = g;
162 const q = c.req.query("q") || "";
163 const rows = await db
164 .select({
165 id: users.id,
166 username: users.username,
167 email: users.email,
168 createdAt: users.createdAt,
169 })
170 .from(users)
171 .where(
172 q
173 ? or(ilike(users.username, `%${q}%`), ilike(users.email, `%${q}%`))!
174 : sql`1=1`
175 )
176 .orderBy(desc(users.createdAt))
177 .limit(200);
178
179 const adminIds = new Set((await listSiteAdmins()).map((a) => a.userId));
180
181 return c.html(
182 <Layout title="Admin — Users" user={user}>
183 <h2>Users</h2>
184 <form method="GET" action="/admin/users" style="margin-bottom:16px">
185 <input
186 type="text"
187 name="q"
188 value={q}
189 placeholder="Search username or email"
190 style="width:320px"
191 />{" "}
192 <button type="submit" class="btn">
193 Search
194 </button>
195 <a href="/admin" class="btn" style="margin-left:6px">
196 Back
197 </a>
198 </form>
199 <div class="panel">
200 {rows.length === 0 ? (
201 <div class="panel-empty">No users found.</div>
202 ) : (
203 rows.map((u) => {
204 const isAdmin = adminIds.has(u.id);
205 return (
206 <div class="panel-item" style="justify-content:space-between">
207 <div>
208 <a href={`/${u.username}`} style="font-weight:600">
209 {u.username}
210 </a>{" "}
211 <span style="color:var(--text-muted)">{u.email}</span>
212 {isAdmin && (
213 <span
214 style="margin-left:6px;font-size:11px;background:#8957e5;color:white;padding:2px 6px;border-radius:3px"
215 >
216 ADMIN
217 </span>
218 )}
219 </div>
220 <form
221 method="POST"
222 action={`/admin/users/${u.id}/admin`}
223 onsubmit={
224 isAdmin
225 ? "return confirm('Revoke site admin?')"
226 : "return confirm('Grant site admin?')"
227 }
228 >
229 <button type="submit" class="btn btn-sm">
230 {isAdmin ? "Revoke admin" : "Grant admin"}
231 </button>
232 </form>
233 </div>
234 );
235 })
236 )}
237 </div>
238 </Layout>
239 );
240});
241
242admin.post("/admin/users/:id/admin", async (c) => {
243 const g = await gate(c);
244 if (g instanceof Response) return g;
245 const { user } = g;
246 const id = c.req.param("id");
247 const admins = await listSiteAdmins();
248 const isAlready = admins.some((a) => a.userId === id);
249 if (isAlready) {
250 await revokeSiteAdmin(id);
251 await audit({
252 userId: user.id,
253 action: "site_admin.revoke",
254 targetType: "user",
255 targetId: id,
256 });
257 } else {
258 await grantSiteAdmin(id, user.id);
259 await audit({
260 userId: user.id,
261 action: "site_admin.grant",
262 targetType: "user",
263 targetId: id,
264 });
265 }
266 return c.redirect("/admin/users");
267});
268
269// ----- Repos -----
270
271admin.get("/admin/repos", async (c) => {
272 const g = await gate(c);
273 if (g instanceof Response) return g;
274 const { user } = g;
275 const rows = await db
276 .select({
277 id: repositories.id,
278 name: repositories.name,
279 ownerUsername: users.username,
280 visibility: repositories.visibility,
281 createdAt: repositories.createdAt,
282 starCount: repositories.starCount,
283 })
284 .from(repositories)
285 .innerJoin(users, eq(repositories.ownerId, users.id))
286 .orderBy(desc(repositories.createdAt))
287 .limit(200);
288
289 return c.html(
290 <Layout title="Admin — Repos" user={user}>
291 <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
292 <h2>Repositories</h2>
293 <a href="/admin" class="btn btn-sm">
294 Back
295 </a>
296 </div>
297 <div class="panel">
298 {rows.length === 0 ? (
299 <div class="panel-empty">No repositories.</div>
300 ) : (
301 rows.map((r) => (
302 <div class="panel-item" style="justify-content:space-between">
303 <div>
304 <a
305 href={`/${r.ownerUsername}/${r.name}`}
306 style="font-weight:600"
307 >
308 {r.ownerUsername}/{r.name}
309 </a>
310 <span
311 style="margin-left:6px;font-size:11px;color:var(--text-muted);text-transform:uppercase"
312 >
313 {r.visibility}
314 </span>
315 <div style="font-size:12px;color:var(--text-muted);margin-top:2px">
316 {r.starCount} stars ·{" "}
317 {r.createdAt
318 ? new Date(r.createdAt as unknown as string).toLocaleDateString()
319 : ""}
320 </div>
321 </div>
322 <form
323 method="POST"
324 action={`/admin/repos/${r.id}/delete`}
325 onsubmit="return confirm('Delete repository permanently? This cannot be undone.')"
326 >
327 <button type="submit" class="btn btn-sm btn-danger">
328 Delete
329 </button>
330 </form>
331 </div>
332 ))
333 )}
334 </div>
335 </Layout>
336 );
337});
338
339admin.post("/admin/repos/:id/delete", async (c) => {
340 const g = await gate(c);
341 if (g instanceof Response) return g;
342 const { user } = g;
343 const id = c.req.param("id");
344 try {
345 await db.delete(repositories).where(eq(repositories.id, id));
346 } catch (err) {
347 console.error("[admin] repo delete:", err);
348 }
349 await audit({
350 userId: user.id,
351 action: "admin.repo.delete",
352 targetType: "repository",
353 targetId: id,
354 });
355 return c.redirect("/admin/repos");
356});
357
358// ----- Flags -----
359
360admin.get("/admin/flags", async (c) => {
361 const g = await gate(c);
362 if (g instanceof Response) return g;
363 const { user } = g;
364
365 const existing = await listFlags();
366 const existingMap = new Map(existing.map((f) => [f.key, f.value]));
367 const keys = Object.keys(KNOWN_FLAGS) as Array<keyof typeof KNOWN_FLAGS>;
368
369 return c.html(
370 <Layout title="Admin — Flags" user={user}>
371 <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
372 <h2>Site flags</h2>
373 <a href="/admin" class="btn btn-sm">
374 Back
375 </a>
376 </div>
377 <form
378 method="POST"
379 action="/admin/flags"
380 class="panel"
381 style="padding:16px"
382 >
383 {keys.map((k) => {
384 const current = existingMap.get(k) ?? (KNOWN_FLAGS as any)[k];
385 return (
386 <div class="form-group">
387 <label>{k}</label>
388 <input
389 type="text"
390 name={k}
391 value={current}
392 style="font-family:var(--font-mono)"
393 />
394 <div
395 style="font-size:11px;color:var(--text-muted);margin-top:2px"
396 >
397 default: <code>{(KNOWN_FLAGS as any)[k] || "(empty)"}</code>
398 </div>
399 </div>
400 );
401 })}
402 <button type="submit" class="btn btn-primary">
403 Save
404 </button>
405 </form>
406 </Layout>
407 );
408});
409
410admin.post("/admin/flags", async (c) => {
411 const g = await gate(c);
412 if (g instanceof Response) return g;
413 const { user } = g;
414 const body = await c.req.parseBody();
415 const keys = Object.keys(KNOWN_FLAGS) as Array<keyof typeof KNOWN_FLAGS>;
416 for (const k of keys) {
417 const v = String(body[k] ?? "");
418 await setFlag(k, v, user.id);
419 }
420 await audit({ userId: user.id, action: "admin.flags.save" });
421 return c.redirect("/admin/flags");
422});
423
08420cdClaude424// ----- Email digests (Block I7) -----
425
426admin.get("/admin/digests", async (c) => {
427 const g = await gate(c);
428 if (g instanceof Response) return g;
429 const { user } = g;
430
431 const [optedRow] = await db
432 .select({ n: sql<number>`count(*)::int` })
433 .from(users)
434 .where(eq(users.notifyEmailDigestWeekly, true));
435 const opted = Number(optedRow?.n || 0);
436
437 const recentlySent = await db
438 .select({
439 id: users.id,
440 username: users.username,
441 lastDigestSentAt: users.lastDigestSentAt,
442 })
443 .from(users)
444 .where(sql`${users.lastDigestSentAt} is not null`)
445 .orderBy(desc(users.lastDigestSentAt))
446 .limit(20);
447
448 const result = c.req.query("result");
449 const error = c.req.query("error");
450
451 return c.html(
452 <Layout title="Admin — Digests" user={user}>
453 <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
454 <h2>Email digests</h2>
455 <a href="/admin" class="btn btn-sm">
456 Back
457 </a>
458 </div>
459
460 {result && (
461 <div class="auth-success">{decodeURIComponent(result)}</div>
462 )}
463 {error && (
464 <div class="auth-error">{decodeURIComponent(error)}</div>
465 )}
466
467 <div class="panel" style="padding:16px;margin-bottom:20px">
468 <div style="font-size:13px;color:var(--text-muted);margin-bottom:8px">
469 {opted} user{opted === 1 ? "" : "s"} opted into the weekly digest.
470 </div>
471 <form method="POST" action="/admin/digests/run" style="margin-bottom:8px">
472 <button
473 type="submit"
474 class="btn btn-primary"
475 onclick="return confirm('Send weekly digest to all opted-in users now?')"
476 >
477 Send digests now
478 </button>
479 </form>
480 <form method="POST" action="/admin/digests/preview" style="display:flex;gap:6px;align-items:center">
481 <input
482 type="text"
483 name="username"
484 placeholder="username"
485 required
486 style="width:240px"
487 />
488 <button type="submit" class="btn btn-sm">
489 Send to one user
490 </button>
491 </form>
492 </div>
493
494 <h3>Recently sent</h3>
495 <div class="panel">
496 {recentlySent.length === 0 ? (
497 <div class="panel-empty">No digests have been sent yet.</div>
498 ) : (
499 recentlySent.map((u) => (
500 <div class="panel-item" style="justify-content:space-between">
501 <a href={`/${u.username}`}>{u.username}</a>
502 <span style="font-size:12px;color:var(--text-muted)">
503 {u.lastDigestSentAt
504 ? new Date(
505 u.lastDigestSentAt as unknown as string
506 ).toLocaleString()
507 : ""}
508 </span>
509 </div>
510 ))
511 )}
512 </div>
513 </Layout>
514 );
515});
516
517admin.post("/admin/digests/run", async (c) => {
518 const g = await gate(c);
519 if (g instanceof Response) return g;
520 const { user } = g;
521 const results = await sendDigestsToAll();
522 const sent = results.filter((r) => r.ok).length;
523 const skipped = results.length - sent;
524 await audit({
525 userId: user.id,
526 action: "admin.digests.run",
527 metadata: { sent, skipped, total: results.length },
528 });
529 return c.redirect(
530 `/admin/digests?result=${encodeURIComponent(
531 `Processed ${results.length} opted-in users: ${sent} sent, ${skipped} skipped.`
532 )}`
533 );
534});
535
536admin.post("/admin/digests/preview", async (c) => {
537 const g = await gate(c);
538 if (g instanceof Response) return g;
539 const { user } = g;
540 const body = await c.req.parseBody();
541 const username = String(body.username || "").trim();
542 if (!username) {
543 return c.redirect("/admin/digests?error=Username+required");
544 }
545 const [target] = await db
546 .select({ id: users.id, username: users.username })
547 .from(users)
548 .where(eq(users.username, username))
549 .limit(1);
550 if (!target) {
551 return c.redirect("/admin/digests?error=User+not+found");
552 }
553 const result = await sendDigestForUser(target.id);
554 await audit({
555 userId: user.id,
556 action: "admin.digests.preview",
557 targetType: "user",
558 targetId: target.id,
559 metadata: {
560 ok: result.ok,
561 skipped: "skipped" in result ? result.skipped : null,
562 },
563 });
564 if (result.ok) {
565 return c.redirect(
566 `/admin/digests?result=${encodeURIComponent(
567 `Digest sent to ${target.username}.`
568 )}`
569 );
570 }
571 return c.redirect(
572 `/admin/digests?error=${encodeURIComponent(
573 `Not sent: ${"skipped" in result ? result.skipped : "unknown reason"}`
574 )}`
575 );
576});
577
578// Keep requireAuth import used even if some routes don't reference it here.
579void requireAuth;
580
8f50ed0Claude581export default admin;