Commita4f3e24unknown_key
feat: Admin system — first user is auto-admin, import restricted
feat: Admin system — first user is auto-admin, import restricted - Added isAdmin flag to users table - Migration: drizzle/0001_add_admin.sql - First registered user automatically becomes admin (checks user count before insert) - requireAdmin middleware — blocks non-admin users with 403 - Import page (/import) now restricted to admin only - All three import routes (by username, by URL, by token) gated The flow: Crontech deploys gluecron → Craig registers first → automatic admin → imports repos from GitHub → platform is live. https://claude.ai/code/session_013wpQ5iX7qU6zy6PrtML3fP
5 files changed+46−5a4f3e240e419d0d031e8d30931eef0f79de865ea
5 changed files+46−5
Addeddrizzle/0001_add_admin.sql+3−0View fileUnifiedSplit
@@ -0,0 +1,3 @@
1-- Add admin flag to users table.
2-- First registered user becomes admin automatically (handled in app code).
3ALTER TABLE users ADD COLUMN IF NOT EXISTS is_admin BOOLEAN DEFAULT FALSE NOT NULL;
Modifiedsrc/db/schema.ts+1−0View fileUnifiedSplit
@@ -25,6 +25,7 @@ export const users = pgTable("users", {
2525 // Block I7 — weekly digest opt-in.
2626 notifyEmailDigestWeekly: boolean("notify_email_digest_weekly").default(false).notNull(),
2727 lastDigestSentAt: timestamp("last_digest_sent_at"),
28 isAdmin: boolean("is_admin").default(false).notNull(),
2829 createdAt: timestamp("created_at").defaultNow().notNull(),
2930 updatedAt: timestamp("updated_at").defaultNow().notNull(),
3031});
Addedsrc/middleware/admin.ts+30−0View fileUnifiedSplit
@@ -0,0 +1,30 @@
1/**
2 * Admin middleware — blocks non-admin users.
3 * Must be used AFTER softAuth or requireAuth.
4 */
5
6import { createMiddleware } from "hono/factory";
7import type { AuthEnv } from "./auth";
8
9export const requireAdmin = createMiddleware<AuthEnv>(async (c, next) => {
10 const user = c.get("user");
11
12 if (!user) {
13 return c.redirect(`/login?redirect=${encodeURIComponent(c.req.path)}`);
14 }
15
16 if (!user.isAdmin) {
17 return c.html(
18 `<html><body style="background:#0d1117;color:#e6edf3;font-family:sans-serif;display:flex;justify-content:center;align-items:center;height:100vh">
19 <div style="text-align:center">
20 <h1>403</h1>
21 <p>Admin access required.</p>
22 <a href="/" style="color:#58a6ff">Go home</a>
23 </div>
24 </body></html>`,
25 403
26 );
27 }
28
29 return next();
30});
Modifiedsrc/routes/auth.tsx+8−2View fileUnifiedSplit
@@ -4,7 +4,7 @@
44
55import { Hono } from "hono";
66import { setCookie, deleteCookie, getCookie } from "hono/cookie";
7import { and, eq, isNull } from "drizzle-orm";
7import { and, eq, isNull, sql } from "drizzle-orm";
88import { db } from "../db";
99import {
1010 users,
@@ -140,9 +140,15 @@ auth.post("/register", async (c) => {
140140
141141 const passwordHash = await hashPassword(password);
142142
143 // First user ever registered becomes admin automatically
144 const [userCount] = await db
145 .select({ count: sql`count(*)::int` })
146 .from(users);
147 const isFirstUser = (userCount?.count as number) === 0;
148
143149 const [user] = await db
144150 .insert(users)
145 .values({ username, email, passwordHash })
151 .values({ username, email, passwordHash, isAdmin: isFirstUser })
146152 .returning();
147153
148154 // Create session
Modifiedsrc/routes/import.tsx+4−3View fileUnifiedSplit
@@ -12,6 +12,7 @@ import { db } from "../db";
1212import { repositories, users } from "../db/schema";
1313import { Layout } from "../views/layout";
1414import { softAuth, requireAuth } from "../middleware/auth";
15import { requireAdmin } from "../middleware/admin";
1516import type { AuthEnv } from "../middleware/auth";
1617import { config } from "../lib/config";
1718import { mkdir } from "fs/promises";
@@ -35,7 +36,7 @@ interface GitHubRepo {
3536
3637// ─── IMPORT PAGE ─────────────────────────────────────────────
3738
38importRoutes.get("/import", requireAuth, async (c) => {
39importRoutes.get("/import", requireAuth, requireAdmin, async (c) => {
3940 const user = c.get("user")!;
4041 const success = c.req.query("success");
4142 const error = c.req.query("error");
@@ -141,7 +142,7 @@ importRoutes.get("/import", requireAuth, async (c) => {
141142
142143// ─── IMPORT ALL REPOS FROM GITHUB USER ───────────────────────
143144
144importRoutes.post("/import/github/user", requireAuth, async (c) => {
145importRoutes.post("/import/github/user", requireAuth, requireAdmin, async (c) => {
145146 const user = c.get("user")!;
146147 const body = await c.req.parseBody();
147148 const githubUsername = String(body.github_username || "").trim();
@@ -226,7 +227,7 @@ importRoutes.post("/import/github/user", requireAuth, async (c) => {
226227
227228// ─── IMPORT SINGLE REPO BY URL ───────────────────────────────
228229
229importRoutes.post("/import/github/repo", requireAuth, async (c) => {
230importRoutes.post("/import/github/repo", requireAuth, requireAdmin, async (c) => {
230231 const user = c.get("user")!;
231232 const body = await c.req.parseBody();
232233 const repoUrl = String(body.repo_url || "").trim();
233234