diff --git a/apps/api/src/auth/user.ts b/apps/api/src/auth/user.ts index a3073fb..5e423af 100644 --- a/apps/api/src/auth/user.ts +++ b/apps/api/src/auth/user.ts @@ -49,6 +49,15 @@ export async function upsertUser( .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] } diff --git a/apps/api/src/routes/events.ts b/apps/api/src/routes/events.ts index 8087463..1b7307a 100644 --- a/apps/api/src/routes/events.ts +++ b/apps/api/src/routes/events.ts @@ -65,9 +65,19 @@ async function resolveUserId(c: any): Promise { const iss = (auth.iss as string | undefined) ?? '' const sub = auth.sub ?? '' - const email = typeof auth.email === 'string' ? auth.email : undefined - const user = await upsertUser(iss, sub, email) + // Derive displayName with same preference order as me.ts (name → preferred_username + // → email → sub fallback). Both call sites must agree so a write-path upsert does not + // overwrite a correctly-derived name with a worse one. + const claimStr = (v: unknown): string | undefined => + typeof v === 'string' && v.trim() !== '' ? v.trim() : undefined + const displayName = + claimStr(auth.name) ?? + claimStr(auth.preferred_username) ?? + claimStr(auth.email) ?? + `Member ${String(sub).slice(0, 8)}` + + const user = await upsertUser(iss, sub, displayName) return user?.id ?? null } diff --git a/apps/api/src/routes/me.ts b/apps/api/src/routes/me.ts index 5c6c199..ec86a0c 100644 --- a/apps/api/src/routes/me.ts +++ b/apps/api/src/routes/me.ts @@ -4,8 +4,9 @@ * Flow (normal — OIDC active): * 1. getAuth(c) reads iss + sub from the OIDC session JWT cookie * (validated and refreshed by oidcAuthMiddleware — never reaches here unauthenticated) - * 2. upsertUser(iss, sub, email) writes the user row on first visit, returns - * the existing row on subsequent visits (idempotent, keyed on iss+sub, D-10) + * 2. Derives displayName from OIDC claims (name → preferred_username → email → sub fallback) + * then calls upsertUser(iss, sub, displayName) — writes on first visit, corrects a + * previously blank displayName on subsequent visits (idempotent, keyed on iss+sub, D-10) * 3. Returns { user: { id, displayName, color } } * * Flow (dev bypass — DEV_AUTH_BYPASS=true, non-production): @@ -48,12 +49,28 @@ meRouter.get('/', async (c) => { return c.json({ error: 'Unauthorized' }, 401) } - // iss and sub are the stable identity fields; email is a display hint only (D-10) + // iss and sub are the stable identity fields — identity is always keyed on iss+sub (D-10). const iss = (auth.iss as string | undefined) ?? '' const sub = auth.sub ?? '' - const email = typeof auth.email === 'string' ? auth.email : undefined - const user = await upsertUser(iss, sub, email) + // 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. sub — always present; not human-friendly but never blank + // + // Each candidate is tested defensively — Authelia may omit or blank-out any claim. + // Whether Authelia emits name/preferred_username is an operator configuration concern + // (e.g. userinfo scope, claim mappings in authelia config) — out of scope here. + const claimStr = (v: unknown): string | undefined => + typeof v === 'string' && v.trim() !== '' ? v.trim() : undefined + const displayName = + claimStr(auth.name) ?? + claimStr(auth.preferred_username) ?? + claimStr(auth.email) ?? + `Member ${String(sub).slice(0, 8)}` + + const user = await upsertUser(iss, sub, displayName) if (!user) { return c.json({ error: 'Could not resolve user' }, 500)