feat: server-side language preference — survives clear site data
Problem: language preference stored only in localStorage, lost on 'clear site data'. French users with browser lang=fr always got French UI even when profile was English. Solution: - Backend: add 'language' field to user profile (default 'fr') - Backend: PATCH /api/auth/me to update language preference - Frontend: initI18n() priority: server > localStorage > navigator > 'fr' - Frontend: initAuth() runs before initI18n() so server check works - Frontend: profile Save persists language to server via PATCH Verified: tabs show English after server profile set to 'en'
This commit is contained in:
@@ -242,10 +242,41 @@ async def get_me(current_user=Depends(require_auth)):
|
|||||||
"display_name": current_user["display_name"],
|
"display_name": current_user["display_name"],
|
||||||
"role": current_user["role"],
|
"role": current_user["role"],
|
||||||
"vaults": current_user["vaults"],
|
"vaults": current_user["vaults"],
|
||||||
|
"language": current_user.get("language", "fr"),
|
||||||
"last_login": current_user.get("last_login"),
|
"last_login": current_user.get("last_login"),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateMeRequest(BaseModel):
|
||||||
|
"""Fields the user can update on their own profile."""
|
||||||
|
display_name: Optional[str] = None
|
||||||
|
language: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/me")
|
||||||
|
async def patch_me(req: UpdateMeRequest, current_user=Depends(require_auth)):
|
||||||
|
"""Update current user's profile fields (display_name, language)."""
|
||||||
|
from .user_store import update_user
|
||||||
|
updates = {}
|
||||||
|
if req.display_name is not None:
|
||||||
|
updates["display_name"] = req.display_name
|
||||||
|
if req.language is not None:
|
||||||
|
if req.language not in ("fr", "en"):
|
||||||
|
raise HTTPException(400, "language must be 'fr' or 'en'")
|
||||||
|
updates["language"] = req.language
|
||||||
|
if not updates:
|
||||||
|
raise HTTPException(400, "No fields to update")
|
||||||
|
updated = update_user(current_user["username"], updates)
|
||||||
|
return {
|
||||||
|
"username": updated["username"],
|
||||||
|
"display_name": updated["display_name"],
|
||||||
|
"role": updated["role"],
|
||||||
|
"vaults": updated["vaults"],
|
||||||
|
"language": updated.get("language", "fr"),
|
||||||
|
"last_login": updated.get("last_login"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/change-password")
|
@router.post("/change-password")
|
||||||
async def change_password(
|
async def change_password(
|
||||||
req: ChangePasswordRequest,
|
req: ChangePasswordRequest,
|
||||||
|
|||||||
@@ -88,6 +88,7 @@ def create_user(
|
|||||||
"role": role,
|
"role": role,
|
||||||
"vaults": vaults or [],
|
"vaults": vaults or [],
|
||||||
"active": True,
|
"active": True,
|
||||||
|
"language": "fr", # default UI language
|
||||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||||
"last_login": None,
|
"last_login": None,
|
||||||
"failed_attempts": 0,
|
"failed_attempts": 0,
|
||||||
|
|||||||
+6
-3
@@ -23,7 +23,12 @@ import { initMermaid, setupFocusMode } from './mermaid-viewer.js';
|
|||||||
// Initialization — mirrors the original app.js init() ordering
|
// Initialization — mirrors the original app.js init() ordering
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
async function init() {
|
async function init() {
|
||||||
await initI18n(); // Must run before any rendering that uses t()
|
// 1. Init auth first — needed for server-side language preference
|
||||||
|
const authOk = await Auth.AuthManager.initAuth();
|
||||||
|
|
||||||
|
// 2. Init i18n after auth so server language preference is available
|
||||||
|
await initI18n();
|
||||||
|
|
||||||
UI.initTheme();
|
UI.initTheme();
|
||||||
UI.initHeaderMenu();
|
UI.initHeaderMenu();
|
||||||
UI.initCustomDropdowns();
|
UI.initCustomDropdowns();
|
||||||
@@ -54,8 +59,6 @@ async function init() {
|
|||||||
initCommandPalette();
|
initCommandPalette();
|
||||||
initMobileToolbar();
|
initMobileToolbar();
|
||||||
|
|
||||||
const authOk = await Auth.AuthManager.initAuth();
|
|
||||||
|
|
||||||
if (authOk) {
|
if (authOk) {
|
||||||
Legacy.initSyncStatus();
|
Legacy.initSyncStatus();
|
||||||
|
|
||||||
|
|||||||
@@ -1561,6 +1561,17 @@ function initProfile() {
|
|||||||
var newLang = langSel.value;
|
var newLang = langSel.value;
|
||||||
await setLocale(newLang); // Live-reload all translations
|
await setLocale(newLang); // Live-reload all translations
|
||||||
if (nameInp) localStorage.setItem('obsigate-name', nameInp.value.trim());
|
if (nameInp) localStorage.setItem('obsigate-name', nameInp.value.trim());
|
||||||
|
|
||||||
|
// Persist language to server (survives clear site data)
|
||||||
|
try {
|
||||||
|
await fetch('/api/auth/me', {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify({ language: newLang }),
|
||||||
|
});
|
||||||
|
} catch (e) { /* non-bloquant */ }
|
||||||
|
|
||||||
if (savedEl) { savedEl.style.display = 'inline'; setTimeout(function() { savedEl.style.display = 'none'; }, 2000); }
|
if (savedEl) { savedEl.style.display = 'inline'; setTimeout(function() { savedEl.style.display = 'none'; }, 2000); }
|
||||||
} catch(e) {}
|
} catch(e) {}
|
||||||
});
|
});
|
||||||
|
|||||||
+20
-4
@@ -91,12 +91,28 @@ export async function setLocale(locale) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Initialise the i18n system. Call once, early, before any rendering.
|
/** Initialise the i18n system. Call once, early, before any rendering.
|
||||||
* Reads saved preference from localStorage, falls back to navigator.language,
|
* Priority: server profile > localStorage > navigator.language > 'fr'. */
|
||||||
* then to 'fr'. */
|
|
||||||
export async function initI18n() {
|
export async function initI18n() {
|
||||||
// 1. Determine locale: saved > navigator.language > 'fr'
|
// 1. Determine locale: server profile > localStorage > navigator.language > 'fr'
|
||||||
let locale = null;
|
let locale = null;
|
||||||
try { locale = localStorage.getItem('obsigate-lang'); } catch (e) { /* */ }
|
|
||||||
|
// Try server-side language preference (survives clear site data)
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/auth/me', { credentials: 'include' });
|
||||||
|
if (resp.ok) {
|
||||||
|
const user = await resp.json();
|
||||||
|
if (user.language === 'en' || user.language === 'fr') {
|
||||||
|
locale = user.language;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) { /* not logged in or network error — fall through */ }
|
||||||
|
|
||||||
|
// Fall back to localStorage
|
||||||
|
if (!locale) {
|
||||||
|
try { locale = localStorage.getItem('obsigate-lang'); } catch (e) { /* */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to navigator.language
|
||||||
if (!locale) {
|
if (!locale) {
|
||||||
const nav = (navigator.language || '').toLowerCase();
|
const nav = (navigator.language || '').toLowerCase();
|
||||||
locale = nav.startsWith('fr') ? 'fr'
|
locale = nav.startsWith('fr') ? 'fr'
|
||||||
|
|||||||
Reference in New Issue
Block a user