Files
familysync/apps/api/src/auth/user.ts
T
Lucas Berger d4d5327fc4 fix(calendar): all-day off-by-one (exclusive DTEND) + member color too close to shared rose
All-day: a single-day all-day event displayed across two days. iCal all-day
DTEND is EXCLUSIVE (1-day event = DTSTART:24/DTEND:25) and the server occurrence
carries that exclusive end, but Schedule-X treats all-day end as INCLUSIVE.
hydrateEvents now subtracts one day (clamped to >= start) so a 1-day event shows
on one day and an N-day event spans N days. Write path was already correct
(verified against stored VEVENTs). +regression test.

Color: a member's coral (#E8734A) was mistaken for the shared-family rose
(#F25C7A). Reorder COLOR_PALETTE so warm near-rose hues (amber, coral) are
assigned LAST; early members get cool, clearly-distinct colors (blue/green/teal).
2026-06-07 18:37:56 -04:00

143 lines
5.5 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 } from 'drizzle-orm'
import { db } from '../db/client.js'
import { users } from '../db/schema.js'
/**
* Accessible, visually-distinct palette for per-member member-color assignment.
* A new member is given the first entry not already in use (see upsertUser).
*
* Ordering matters: the SHARED-family calendar is reserved rose (#F25C7A, D-06),
* so the warm near-rose hues (coral, amber) are placed LAST. Early members get
* cool colors (blue, green, teal) that read clearly distinct from the shared
* lane — otherwise a member's coral was mistaken for the shared rose.
* Values are Claude's choice per D-06.
*/
export const COLOR_PALETTE: string[] = [
'#4A90D9', // calm blue
'#5BA85A', // forest green
'#3AAFA9', // teal
'#9B6DC5', // soft purple
'#E8A840', // warm amber (near shared rose — assigned only after cool colors)
'#E8734A', // warm coral (closest to shared rose — assigned last)
]
/** 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. null — no usable claim; the UI degrades to a generic "Member"
*
* Returns null (NOT a synthetic `Member <sub>`) when no real claim is present so
* we never persist an ugly sub-derived string. Whether Authelia emits
* name/preferred_username/email in the ID TOKEN (not just at the userinfo
* endpoint) is an operator config concern: Authelia 4.39+ requires a
* `claims_policies` entry adding those claims to `id_token` for this client,
* because @hono/oidc-auth reads ID-token claims only (no userinfo fetch). Until
* that is set, every claim here is absent and the legend shows "Member".
*
* Shared by every call site that upserts a user (me.ts, events.ts resolveUserId)
* so the write-path upsert agrees with /api/me.
*/
export function deriveDisplayName(claims: {
name?: unknown
preferred_username?: unknown
email?: unknown
}): string | null {
return (
claimStr(claims.name) ??
claimStr(claims.preferred_username) ??
claimStr(claims.email) ??
null
)
}
/**
* 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 | null,
) {
// 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]) {
// Track the IdP display name authoritatively: when the caller supplies a
// real (non-null) name that differs from what's stored, update it. This both
// corrects rows created before robust claim derivation (blank → name) and
// self-heals once an operator adds the name/email claims to Authelia's ID
// token (e.g. a stale "Member …"/email → the real name) — no DB surgery.
// A null displayName (no usable claim this request) never overwrites a good
// stored value.
if (displayName != null && displayName !== existing[0].displayName) {
await db
.update(users)
.set({ displayName })
.where(eq(users.id, existing[0].id))
return { ...existing[0], displayName }
}
return existing[0]
}
// 2. 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
// guarantees distinct, stable colors for up to COLOR_PALETTE.length members
// (AUTH-03). Falls back to round-robin by count only once the palette is
// exhausted (more members than colors).
const usedRows = await db.select({ color: users.color }).from(users)
const usedColors = new Set(usedRows.map((r) => r.color))
const color =
COLOR_PALETTE.find((c) => !usedColors.has(c)) ??
COLOR_PALETTE[usedColors.size % 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
}