feat(12-03): upsertUser first-login-claims branch (D-08)
- Add isNull import from drizzle-orm; add appConfig to schema imports - After identity lookup, read app_config.setup_complete per call (D-10 freshness) - When setup_complete='true' and unclaimed user exists (isNull(oidcIss) AND claimed=false): claim it via db.update() — binds oidcIss/oidcSub, sets claimed=true, preserves is_admin - shouldBeAdmin gated: flagRow?.value !== 'true' AND admin COUNT === 0 (T-12-11) - No email keying in claim branch — isNull(oidcIss) AND claimed=false only (D-10/T-12-12) - 399 tests pass; typecheck clean Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
7a26b4aa06
commit
c8894adc3f
+45
-12
@@ -8,9 +8,9 @@
|
||||
* Source: RESEARCH.md § "User upsert with color assignment"
|
||||
*/
|
||||
|
||||
import { and, eq, sql } from 'drizzle-orm';
|
||||
import { and, eq, isNull, sql } from 'drizzle-orm';
|
||||
import { db } from '../db/client.js';
|
||||
import { users } from '../db/schema.js';
|
||||
import { users, appConfig } from '../db/schema.js';
|
||||
|
||||
/**
|
||||
* Accessible, visually-distinct palette for per-member member-color assignment.
|
||||
@@ -96,7 +96,42 @@ export async function upsertUser(oidcIss: string, oidcSub: string, displayName?:
|
||||
return existing[0];
|
||||
}
|
||||
|
||||
// 2. Assign the first palette color NOT already in use by another member.
|
||||
// 2. Check app_config.setup_complete and attempt first-login-claims (D-08).
|
||||
// When setup is complete, the first OIDC login from an unknown identity claims
|
||||
// the single unclaimed local user (oidcIss IS NULL AND claimed=false), binding
|
||||
// the OIDC identity to the wizard-provisioned row. This preserves is_admin and
|
||||
// the CalDAV credential stored by the wizard.
|
||||
// MUST NOT match by email — query is strictly isNull(oidcIss) AND claimed=false (D-10).
|
||||
const [flagRow] = await db
|
||||
.select({ value: appConfig.value })
|
||||
.from(appConfig)
|
||||
.where(eq(appConfig.key, 'setup_complete'))
|
||||
.limit(1);
|
||||
|
||||
if (flagRow?.value === 'true') {
|
||||
const [unclaimed] = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(and(isNull(users.oidcIss), eq(users.claimed, false)))
|
||||
.limit(1);
|
||||
|
||||
if (unclaimed) {
|
||||
// Claim: bind the OIDC identity, mark claimed=true, update displayName if provided.
|
||||
// is_admin is NOT overwritten — it was pre-set by the wizard (operator's intent).
|
||||
await db
|
||||
.update(users)
|
||||
.set({
|
||||
oidcIss,
|
||||
oidcSub,
|
||||
claimed: true,
|
||||
displayName: displayName ?? unclaimed.displayName,
|
||||
})
|
||||
.where(eq(users.id, unclaimed.id));
|
||||
return { ...unclaimed, oidcIss, oidcSub, claimed: true };
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Assign the first palette color NOT already in use by another member.
|
||||
// A plain COUNT(*) % palette collides under deletions: a deleted user
|
||||
// shifts the count so the next insert reuses an in-use slot (observed in
|
||||
// Gate 2 — two members both got #E8734A). Selecting the first unused color
|
||||
@@ -109,20 +144,18 @@ export async function upsertUser(oidcIss: string, oidcSub: string, displayName?:
|
||||
COLOR_PALETTE.find((c) => !usedColors.has(c)) ??
|
||||
COLOR_PALETTE[usedColors.size % COLOR_PALETTE.length];
|
||||
|
||||
// 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.
|
||||
// 4. First-login-wins is_admin bootstrap (D-01), tightened by Phase 12 (D-08).
|
||||
// When setup_complete is true, a claimed admin already exists — new users must
|
||||
// NOT auto-promote. Only grant admin when setup is not yet complete AND no admins
|
||||
// exist (the original first-login-wins bootstrap for fresh pre-wizard instances).
|
||||
const [{ count }] = await db
|
||||
.select({ count: sql<number>`COUNT(*)` })
|
||||
.from(users)
|
||||
.where(eq(users.isAdmin, true))
|
||||
.limit(1);
|
||||
const shouldBeAdmin = Number(count) === 0;
|
||||
const shouldBeAdmin = flagRow?.value !== 'true' && Number(count) === 0;
|
||||
|
||||
// 4. Insert new user row
|
||||
// 5. Insert new user row
|
||||
// mysql2 has no RETURNING clause — use $returningId() then re-select
|
||||
const [inserted] = await db
|
||||
.insert(users)
|
||||
@@ -135,7 +168,7 @@ export async function upsertUser(oidcIss: string, oidcSub: string, displayName?:
|
||||
})
|
||||
.$returningId();
|
||||
|
||||
// 5. Re-select to return the full typed row
|
||||
// 6. 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;
|
||||
|
||||
Reference in New Issue
Block a user