The displayName claim-preference logic (name → preferred_username → email → sub fallback) was duplicated verbatim in me.ts and events.ts resolveUserId. Extract it to auth/user.ts as deriveDisplayName and use it in both call sites, so the rule has one definition. Update the events.test.ts user.js mock to keep the real helper (spread importActual) while stubbing only upsertUser.
121 lines
3.9 KiB
TypeScript
121 lines
3.9 KiB
TypeScript
/**
|
|
* User identity upsert + stable per-member color assignment.
|
|
*
|
|
* Identity is keyed on oidc_iss + oidc_sub — never email (D-10).
|
|
* Color is auto-assigned from a curated palette on first login, round-robin
|
|
* by join order (D-06). Stable across sessions: re-upsert returns same row.
|
|
*
|
|
* Source: RESEARCH.md § "User upsert with color assignment"
|
|
*/
|
|
|
|
import { and, eq, sql } from 'drizzle-orm'
|
|
import { db } from '../db/client.js'
|
|
import { users } from '../db/schema.js'
|
|
|
|
/**
|
|
* Accessible, visually-distinct palette for per-member color assignment.
|
|
* Assigned round-robin by join order (COUNT of existing users at insert time).
|
|
* Values are Claude's choice per D-06.
|
|
*/
|
|
export const COLOR_PALETTE: string[] = [
|
|
'#4A90D9', // calm blue
|
|
'#E8734A', // warm coral
|
|
'#5BA85A', // forest green
|
|
'#9B6DC5', // soft purple
|
|
'#E8A840', // warm amber
|
|
'#3AAFA9', // teal
|
|
]
|
|
|
|
/** Coerce an OIDC claim to a trimmed non-empty string, else undefined. */
|
|
const claimStr = (v: unknown): string | undefined =>
|
|
typeof v === 'string' && v.trim() !== '' ? v.trim() : undefined
|
|
|
|
/**
|
|
* Derive the best available display name from OIDC claims, in preference order:
|
|
* 1. name — full name set by the IdP (most human-friendly)
|
|
* 2. preferred_username — often the login handle; still readable
|
|
* 3. email — readable but reveals contact info; acceptable fallback
|
|
* 4. `Member <sub>` — sub is always present; never blank
|
|
*
|
|
* Shared by every call site that upserts a user (me.ts, events.ts resolveUserId)
|
|
* so a write-path upsert never overwrites a correctly-derived name with a worse
|
|
* one. Whether Authelia emits name/preferred_username is an operator config
|
|
* concern (userinfo scope + claim mappings) — out of scope here.
|
|
*/
|
|
export function deriveDisplayName(
|
|
claims: { name?: unknown; preferred_username?: unknown; email?: unknown },
|
|
sub: string,
|
|
): string {
|
|
return (
|
|
claimStr(claims.name) ??
|
|
claimStr(claims.preferred_username) ??
|
|
claimStr(claims.email) ??
|
|
`Member ${String(sub).slice(0, 8)}`
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Upsert a user by their OIDC identity (iss + sub).
|
|
*
|
|
* - If a row with matching (oidc_iss, oidc_sub) exists, return it unchanged.
|
|
* - Otherwise, count current users to pick the next palette color, insert a
|
|
* new row, then re-select and return it.
|
|
*
|
|
* Never keys on email or displayName for identity. displayName is stored as a
|
|
* display hint only and may change without affecting identity.
|
|
*/
|
|
export async function upsertUser(
|
|
oidcIss: string,
|
|
oidcSub: string,
|
|
displayName?: string,
|
|
) {
|
|
// 1. Look up by composite identity key (iss + sub) — never email
|
|
const existing = await db
|
|
.select()
|
|
.from(users)
|
|
.where(and(eq(users.oidcIss, oidcIss), eq(users.oidcSub, oidcSub)))
|
|
.limit(1)
|
|
|
|
if (existing[0]) {
|
|
// If the existing row has no displayName but the caller supplies one, update it now.
|
|
// This corrects rows created before robust claim derivation was in place (BUG 2 fix).
|
|
if (!existing[0].displayName && displayName) {
|
|
await db
|
|
.update(users)
|
|
.set({ displayName })
|
|
.where(eq(users.id, existing[0].id))
|
|
return { ...existing[0], displayName }
|
|
}
|
|
return existing[0]
|
|
}
|
|
|
|
// 2. Count existing users to determine round-robin color slot
|
|
const countResult = await db
|
|
.select({ count: sql<number>`COUNT(*)` })
|
|
.from(users)
|
|
|
|
const count = Number(countResult[0]?.count ?? 0)
|
|
const color = COLOR_PALETTE[count % COLOR_PALETTE.length]
|
|
|
|
// 3. Insert new user row
|
|
// mysql2 has no RETURNING clause — use $returningId() then re-select
|
|
const [inserted] = await db
|
|
.insert(users)
|
|
.values({
|
|
oidcIss,
|
|
oidcSub,
|
|
displayName: displayName ?? null,
|
|
color,
|
|
})
|
|
.$returningId()
|
|
|
|
// 4. 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
|
|
}
|