diff --git a/apps/api/src/auth/user.ts b/apps/api/src/auth/user.ts index 32c5507..3e3a383 100644 --- a/apps/api/src/auth/user.ts +++ b/apps/api/src/auth/user.ts @@ -8,7 +8,7 @@ * Source: RESEARCH.md § "User upsert with color assignment" */ -import { and, eq } from 'drizzle-orm'; +import { and, eq, sql } from 'drizzle-orm'; import { db } from '../db/client.js'; import { users } from '../db/schema.js'; @@ -109,7 +109,20 @@ export async function upsertUser(oidcIss: string, oidcSub: string, displayName?: COLOR_PALETTE.find((c) => !usedColors.has(c)) ?? COLOR_PALETTE[usedColors.size % COLOR_PALETTE.length]; - // 3. Insert new user row + // 3. First-login-wins is_admin bootstrap (D-01). + // When zero admins currently exist, the first new user becomes admin. + // Phase 12 will tighten this to: first user after app_config.setup_complete. + // Until then, "first user when zero admins exist" is the bootstrap condition. + // This hook reads cleanly: Phase 12 adds a setup_complete check before the + // COUNT, so only first login AFTER setup is flagged — no restructuring needed. + const [{ count }] = await db + .select({ count: sql`COUNT(*)` }) + .from(users) + .where(eq(users.isAdmin, true)) + .limit(1); + const shouldBeAdmin = Number(count) === 0; + + // 4. Insert new user row // mysql2 has no RETURNING clause — use $returningId() then re-select const [inserted] = await db .insert(users) @@ -118,10 +131,11 @@ export async function upsertUser(oidcIss: string, oidcSub: string, displayName?: oidcSub, displayName: displayName ?? null, color, + isAdmin: shouldBeAdmin, }) .$returningId(); - // 4. Re-select to return the full typed row + // 5. Re-select to return the full typed row const [newUser] = await db.select().from(users).where(eq(users.id, inserted.id)).limit(1); return newUser;