feat(fase-10): auto language detection on first login (10.8)
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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<void> {
|
||||
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<void> {
|
||||
const supabase = getSupabase();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user