From 378fcbe5f450d7dec9db1a07dbe8d2751729edce Mon Sep 17 00:00:00 2001 From: Oier Bravo Urtasun Date: Mon, 18 May 2026 02:30:09 +0200 Subject: [PATCH] feat(fase-10): auto language detection on first login (10.8) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New $lib/utils/accept-language.ts parses Accept-Language strings (server-style "es;q=0.9,en;q=0.8") and navigator.languages arrays, returns 'en' or 'es'. Rule (plan §10.8.1): the highest-q-scored 'es' entry wins if q >= 0.5; otherwise fall back to 'en'. Unknown languages (e.g. fr) → 'en'. Pure function, 12 unit tests covering bare tags, regional variants, q-score ordering, malformed input, navigator.languages arrays, and the borderline q=0.5 case. The root +layout.svelte calls maybeBootstrapLanguage() after each SIGNED_IN: it only fires for genuinely new users (public.users row created within the last 60s) whose language is still the default ('en'); it reads navigator.languages, runs the parser, and UPDATEs public.users.language if the result is 'es'. setLanguageTag() flips the live Paraglide runtime so the user lands on the next page already in Spanish — no reload needed. Established users keep their explicit choice (plan §10.8.5 / Riesgo 5). Server-side Accept-Language sniffing was rejected: the OIDC dance lives outside SvelteKit (Keycloak ↔ GoTrue), so a hooks.server.ts would never see those requests. navigator.languages is the client-side equivalent and survives Paraglide's compile-time tree-shaking. LANG-01/02 (vitest integration): UPDATE public.users RLS contract the bootstrap relies on — own row OK, other user's row rejected. Co-Authored-By: Claude Opus 4.7 --- .../web/src/lib/utils/accept-language.test.ts | 54 ++++++++++++ apps/web/src/lib/utils/accept-language.ts | 88 +++++++++++++++++++ apps/web/src/routes/+layout.svelte | 49 +++++++++++ .../tests/language-bootstrap.test.ts | 60 +++++++++++++ 4 files changed, 251 insertions(+) create mode 100644 apps/web/src/lib/utils/accept-language.test.ts create mode 100644 apps/web/src/lib/utils/accept-language.ts create mode 100644 packages/test-utils/tests/language-bootstrap.test.ts diff --git a/apps/web/src/lib/utils/accept-language.test.ts b/apps/web/src/lib/utils/accept-language.test.ts new file mode 100644 index 0000000..0d54588 --- /dev/null +++ b/apps/web/src/lib/utils/accept-language.test.ts @@ -0,0 +1,54 @@ +import { describe, it, expect } from 'vitest'; +import { detectLanguage } from './accept-language'; + +describe('detectLanguage (Accept-Language style strings)', () => { + it('AL-01: bare "es" maps to es', () => { + expect(detectLanguage('es')).toBe('es'); + }); + + it('AL-02: bare "en" maps to en', () => { + expect(detectLanguage('en')).toBe('en'); + }); + + it('AL-03: regional variant "es-ES" maps to es', () => { + expect(detectLanguage('es-ES')).toBe('es'); + }); + + it('AL-04: es;q=0.9,en;q=0.8 — es wins (higher q)', () => { + expect(detectLanguage('es;q=0.9,en;q=0.8')).toBe('es'); + }); + + it('AL-05: en-US,es;q=0.3 — en wins (no q means 1.0)', () => { + expect(detectLanguage('en-US,es;q=0.3')).toBe('en'); + }); + + it('AL-06: empty string falls back to en', () => { + expect(detectLanguage('')).toBe('en'); + }); + + it('AL-07: null falls back to en', () => { + expect(detectLanguage(null)).toBe('en'); + }); + + it('AL-08: undefined falls back to en', () => { + expect(detectLanguage(undefined)).toBe('en'); + }); + + it('AL-09: malformed string falls back to en', () => { + expect(detectLanguage('!!!@#$;q=??')).toBe('en'); + }); + + it('AL-10: es with q=0.5 — borderline accepted', () => { + // Per plan §10.8.1: q-score >= 0.5 still counts as Spanish preference. + expect(detectLanguage('en;q=0.4,es;q=0.5')).toBe('es'); + }); + + it('AL-11: array of language tags (navigator.languages) — first es wins', () => { + expect(detectLanguage(['en-GB', 'es-ES', 'fr'])).toBe('en'); + expect(detectLanguage(['es-ES', 'en-GB'])).toBe('es'); + }); + + it('AL-12: unknown language (fr) falls back to en', () => { + expect(detectLanguage('fr-FR')).toBe('en'); + }); +}); diff --git a/apps/web/src/lib/utils/accept-language.ts b/apps/web/src/lib/utils/accept-language.ts new file mode 100644 index 0000000..341e10c --- /dev/null +++ b/apps/web/src/lib/utils/accept-language.ts @@ -0,0 +1,88 @@ +/** + * Detect the user's preferred app language from an Accept-Language header + * (or navigator.languages array) — Fase 10.8. + * + * Supported app languages (in sync with messages/{en,es}.json + Paraglide + * languageTags): `en`, `es`. Anything else falls back to `en`. + * + * Rules (per plan §10.8.1): + * * Inputs we accept: + * - bare tag: `'es'`, `'en'`, `'es-ES'`, `'en-US'` + * - RFC 7231 list: `'es;q=0.9,en;q=0.8'` + * - `navigator.languages` array: `['es-ES', 'en-GB']` + * - null / undefined / empty → `'en'` + * * If the highest q-scored entry whose primary subtag is `es` has + * q ≥ 0.5, the result is `'es'`. Otherwise (including unknown + * languages, malformed input, or no clear preference) → `'en'`. + */ +export type AppLanguage = 'en' | 'es'; + +interface ParsedTag { + tag: string; + primary: string; + q: number; +} + +function parseTags(raw: string): ParsedTag[] { + return raw + .split(',') + .map((part) => { + const segments = part.trim().split(';').map((s) => s.trim()); + if (!segments[0]) return null; + const tag = segments[0].toLowerCase(); + let q = 1.0; + for (const seg of segments.slice(1)) { + const match = seg.match(/^q\s*=\s*([0-9.]+)$/); + if (match) { + const parsed = parseFloat(match[1]); + if (!Number.isNaN(parsed)) q = parsed; + } + } + const primary = tag.split('-')[0]; + // Filter junk: primary must be alpha-only and 1+ chars. + if (!/^[a-z]+$/.test(primary)) return null; + return { tag, primary, q }; + }) + .filter((p): p is ParsedTag => p !== null); +} + +export function detectLanguage( + input: string | string[] | null | undefined +): AppLanguage { + if (input == null) return 'en'; + + let parsed: ParsedTag[]; + + if (Array.isArray(input)) { + // navigator.languages: ordered by user preference, no q-scores. + parsed = input + .map((tag, index) => { + const t = tag.toLowerCase().trim(); + if (!t) return null; + const primary = t.split('-')[0]; + if (!/^[a-z]+$/.test(primary)) return null; + // Synthesize a decreasing q-score so first-listed wins. + return { tag: t, primary, q: 1 - index * 0.01 }; + }) + .filter((p): p is ParsedTag => p !== null); + } else { + const s = input.trim(); + if (!s) return 'en'; + parsed = parseTags(s); + } + + if (parsed.length === 0) return 'en'; + + // Sort by q-score descending. + parsed.sort((a, b) => b.q - a.q); + + // First app-known primary wins; if it's es with q ≥ 0.5 → es. + for (const p of parsed) { + if (p.primary === 'es') { + return p.q >= 0.5 ? 'es' : 'en'; + } + if (p.primary === 'en') return 'en'; + } + + return 'en'; +} diff --git a/apps/web/src/routes/+layout.svelte b/apps/web/src/routes/+layout.svelte index 19329f4..f284bb9 100644 --- a/apps/web/src/routes/+layout.svelte +++ b/apps/web/src/routes/+layout.svelte @@ -9,6 +9,8 @@ import { ParaglideJS } from '@inlang/paraglide-sveltekit'; import { i18n } from '$lib/i18n'; import { initTheme } from '$lib/theme'; + import { setLanguageTag } from '$lib/paraglide/runtime'; + import { detectLanguage } from '$lib/utils/accept-language'; onMount(() => { // Fase 9.1: hydrate the theme stores from localStorage and start @@ -39,6 +41,12 @@ setTimeout(async () => { await loadUserCollectives(session.user.id); + // Fase 10.8: auto-detect language for genuinely new users. + // Best-effort, runs after every auth event so newly-seeded + // (post-Keycloak-self-registration) rows pick up the user's + // browser preference instead of the seed default 'en'. + await maybeBootstrapLanguage(session.user.id); + if (event === 'SIGNED_IN') { // Post-login: send the user where they belong. // Only redirect when coming from the callback or the root — not on @@ -66,6 +74,47 @@ return () => subscription.unsubscribe(); }); + /** + * Fase 10.8 — Accept-Language detection for first-time users. + * + * The Paraglide stack is compile-time and the OIDC dance lives outside + * SvelteKit, so we can't sniff the real Accept-Language header server- + * side here without standing up a hooks.server.ts that the auth flow + * never traverses. Instead we use `navigator.languages` (the browser's + * Accept-Language proxy) on the client right after the first SIGNED_IN. + * + * Idempotency: we only UPDATE when the row was just created (within + * 60s of now) AND `language` still equals the seed default ('en'). This + * means a Spanish-browser user lands in Spanish on first run; an + * established user who later switched themselves to English stays + * English; existing users are not retroactively re-detected (plan + * §10.8.5 — respect their previous explicit choice). + */ + async function maybeBootstrapLanguage(userId: string): Promise { + const supabase = getSupabase(); + const { data } = await supabase + .from('users') + .select('language, created_at') + .eq('id', userId) + .single(); + if (!data) return; + + const ageMs = Date.now() - new Date(data.created_at).getTime(); + const isFreshAccount = ageMs < 60_000; + if (!isFreshAccount) return; + if (data.language !== 'en') return; + + const langs = + (typeof navigator !== 'undefined' && + (navigator.languages?.length ? Array.from(navigator.languages) : [navigator.language])) || + []; + const detected = detectLanguage(langs); + if (detected === 'en') return; + + await supabase.from('users').update({ language: detected }).eq('id', userId); + setLanguageTag(detected); + } + async function loadUserCollectives(userId: string): Promise { const supabase = getSupabase(); diff --git a/packages/test-utils/tests/language-bootstrap.test.ts b/packages/test-utils/tests/language-bootstrap.test.ts new file mode 100644 index 0000000..278cc41 --- /dev/null +++ b/packages/test-utils/tests/language-bootstrap.test.ts @@ -0,0 +1,60 @@ +/** + * LANG-series: language bootstrap (Fase 10.8). + * + * The bootstrap path is a two-step in the client: + * 1. detectLanguage(...) — pure parser (unit-tested in apps/web) + * 2. update public.users set language = ... where id = $1 + * + * This integration suite checks (2): the UPDATE wire-format the layout + * relies on works against the live RLS-enabled table, both as the user + * themselves (allowed) and as another seed user (rejected). The full + * end-to-end login dance can't be reproduced here without bringing up + * a real OIDC flow, so we focus on the contract the bootstrap depends + * on. + */ +import { describe, it, expect, afterAll } from 'vitest'; +import { createClientAs, createAdminClient } from '../src/supabase-clients.js'; +import { sql, closePool } from '../src/db-helpers.js'; +import { ANA_ID, BORJA_ID } from '../src/seed-constants.js'; + +const admin = createAdminClient(); + +afterAll(async () => { + // Restore Ana's seed language. + await sql(`UPDATE public.users SET language = 'es' WHERE id = $1`, [ANA_ID]); + await closePool(); +}); + +describe('Language bootstrap UPDATE', () => { + it('LANG-01: user can update their own language to es', async () => { + const ana = await createClientAs(ANA_ID); + // Reset to en first via admin. + await admin.from('users').update({ language: 'en' }).eq('id', ANA_ID); + + const { error } = await ana + .from('users') + .update({ language: 'es' }) + .eq('id', ANA_ID); + expect(error).toBeNull(); + + const { data } = await admin + .from('users') + .select('language') + .eq('id', ANA_ID) + .single(); + expect(data?.language).toBe('es'); + }); + + it('LANG-02: user cannot update another user\'s language', async () => { + const ana = await createClientAs(ANA_ID); + await ana.from('users').update({ language: 'en' }).eq('id', BORJA_ID); + + const { data } = await admin + .from('users') + .select('language') + .eq('id', BORJA_ID) + .single(); + // Borja's seed language is 'es'; RLS must have blocked Ana's update. + expect(data?.language).toBe('es'); + }); +});