From 7db27af7a69bca4d96433346c5064acc0ef2bb67 Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Wed, 3 Jun 2026 14:26:45 +0200 Subject: [PATCH] feat(user.prefered_locale): save user's locale + invited have same locale as inviters - OIDC JIT define the locale only at user creation, user can so change his preference later - invited users will inherit inviter's locale - email will use prefered_locale - login to a new browser will use prefered_locale --- .../20260623000000_users_preferred_locale.sql | 47 ++++++++++ src/application/dtos/user_dto.rs | 14 +++ src/application/ports/auth_ports.rs | 6 ++ .../services/auth_application_service.rs | 54 ++++++++++++ .../services/magic_link_invite_service.rs | 85 ++++++++++++++++--- src/common/di.rs | 1 + src/domain/entities/user.rs | 40 +++++++++ .../repositories/pg/user_pg_repository.rs | 31 ++++--- src/infrastructure/services/oidc_service.rs | 4 + src/interfaces/api/handlers/auth_handler.rs | 4 +- src/interfaces/api/handlers/grant_handler.rs | 9 +- static/js/app/authSession.js | 37 ++++++++ static/js/core/i18n.js | 35 ++++++++ static/js/core/types.js | 1 + 14 files changed, 342 insertions(+), 26 deletions(-) create mode 100644 migrations/20260623000000_users_preferred_locale.sql diff --git a/migrations/20260623000000_users_preferred_locale.sql b/migrations/20260623000000_users_preferred_locale.sql new file mode 100644 index 00000000..3010d69c --- /dev/null +++ b/migrations/20260623000000_users_preferred_locale.sql @@ -0,0 +1,47 @@ +-- ════════════════════════════════════════════════════════════════════════════ +-- Per-user preferred locale (PR C of the i18n / magic-link templating work) +-- ════════════════════════════════════════════════════════════════════════════ +-- Carries the user's preference for server-rendered surfaces — chiefly +-- transactional emails (invitation, login-via-email magic-link) and +-- future server-rendered HTML for authenticated users. The frontend +-- language switcher writes here via PATCH /api/auth/me/profile so the +-- choice survives across sessions and devices; the OIDC callback writes +-- here once at JIT provisioning if the IdP's `locale` claim resolves +-- against the LocaleRegistry; the magic-link invitation flow copies the +-- inviter's value into the new external user's row. +-- +-- Value semantics: +-- NULL — no explicit preference. The application resolves to +-- OXICLOUD_DEFAULT_LOCALE (default "en"). NULL is also the +-- post-rollback shape; nothing reads this column in a way +-- that requires it to be set. +-- "xx" — IETF BCP-47 primary tag, e.g. "en", "fr", "ja". +-- "xx-YY" — primary + region subtag, e.g. "zh-TW". +-- +-- The CHECK below enforces a permissive but bounded shape that matches +-- what the LocaleRegistry's case-insensitive comparison will canonicalise +-- successfully. We do NOT enforce membership in the registry's +-- discovered codes at the DB level — that list is build-time runtime +-- state, not schema. The application layer is the gatekeeper: +-- `update_profile_with_perms` rejects unknown codes with 400, and the +-- email-render path silently falls back to the server default when a +-- stored value no longer resolves (e.g. after dropping a locale file). +-- +-- No backfill: every existing row stays NULL → inherits the server +-- default, which is the same behaviour every row had before this +-- migration. Pre-PR-C users see no change. + +ALTER TABLE auth.users + ADD COLUMN preferred_locale TEXT NULL + CHECK (preferred_locale IS NULL + OR preferred_locale ~ '^[a-zA-Z]{2,3}(-[a-zA-Z0-9]{2,8})*$'); + +COMMENT ON COLUMN auth.users.preferred_locale IS + 'User-preferred locale for server-rendered surfaces (emails, future + auth pages). IETF BCP-47 shape: primary tag + optional subtags. + NULL = no preference; resolves to OXICLOUD_DEFAULT_LOCALE. + Set by: UI language switcher (PATCH /api/auth/me/profile), + OIDC JIT provisioning (one-shot, never re-applied on subsequent + logins — UI choice is canonical), inheritance from inviter at + external-user creation. Application enforces registry membership; + schema only constrains the textual shape.'; diff --git a/src/application/dtos/user_dto.rs b/src/application/dtos/user_dto.rs index ef7e1f75..abe91b79 100644 --- a/src/application/dtos/user_dto.rs +++ b/src/application/dtos/user_dto.rs @@ -47,6 +47,12 @@ pub struct UserDto { /// subsequent verifications. #[serde(skip_serializing_if = "Option::is_none")] pub email_verified_at: Option>, + /// User-chosen locale for server-rendered surfaces (emails, + /// future authenticated HTML). `None` = no preference (the server + /// resolves to `OXICLOUD_DEFAULT_LOCALE` when rendering). Round-trips + /// through `/api/auth/me` and `PATCH /api/auth/me/profile`. + #[serde(skip_serializing_if = "Option::is_none")] + pub preferred_locale: Option, } impl From for UserDto { @@ -69,6 +75,7 @@ impl From for UserDto { given_name: user.given_name().map(str::to_string), family_name: user.family_name().map(str::to_string), email_verified_at: user.email_verified_at(), + preferred_locale: user.preferred_locale().map(str::to_string), } } } @@ -155,6 +162,13 @@ pub struct UpdateProfileDto { /// New last/family name. Same semantics as `given_name`. #[serde(default)] pub family_name: Option, + /// New preferred locale (BCP-47 shape, e.g. `"fr"`, `"zh-TW"`). + /// Must resolve against the server's `LocaleRegistry` — unknown + /// codes are rejected with 400. Pass an empty string to clear the + /// preference back to the server default (the application layer + /// normalises `""` → `None`). + #[serde(default)] + pub preferred_locale: Option, } #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] diff --git a/src/application/ports/auth_ports.rs b/src/application/ports/auth_ports.rs index 4f5bf126..0672c8a6 100644 --- a/src/application/ports/auth_ports.rs +++ b/src/application/ports/auth_ports.rs @@ -173,6 +173,12 @@ pub struct OidcIdClaims { pub family_name: Option, pub groups: Vec, pub picture: Option, + /// Standard OpenID claim `locale` (BCP-47 language tag, e.g. + /// `"fr"`, `"zh-TW"`). Populated on the new `User` row at OIDC JIT + /// provisioning if the claim resolves against the server's + /// `LocaleRegistry`; ignored on subsequent logins so a later + /// UI-driven choice isn't overwritten by the IdP. + pub locale: Option, } /// Port for OIDC operations — implemented in infrastructure layer diff --git a/src/application/services/auth_application_service.rs b/src/application/services/auth_application_service.rs index 750dae86..8bae16a0 100644 --- a/src/application/services/auth_application_service.rs +++ b/src/application/services/auth_application_service.rs @@ -1164,6 +1164,7 @@ impl AuthApplicationService { &self, caller_id: Uuid, dto: crate::application::dtos::user_dto::UpdateProfileDto, + locale_registry: &crate::common::locale::LocaleRegistry, ) -> Result { let mut user = self.user_storage.get_user_by_id(caller_id).await?; @@ -1261,6 +1262,42 @@ impl AuthApplicationService { changed.push("family_name"); } + // ── Preferred locale ───────────────────────────────────── + // Treat `""` as an explicit clear (frontend may send the empty + // string when the user picks "Use server default"). Any other + // non-empty value must resolve against the LocaleRegistry — an + // unknown code is a 400 so the client can show the user a + // useful error rather than silently dropping the change. + if let Some(ref code) = dto.preferred_locale { + let trimmed = code.trim(); + if trimmed.is_empty() { + user.set_preferred_locale(None); + changed.push("preferred_locale"); + } else if let Some(canonical) = locale_registry.parse(trimmed) { + user.set_preferred_locale(Some(canonical.as_str().to_string())); + changed.push("preferred_locale"); + } else { + tracing::info!( + target: "audit", + event = "auth.profile_update_rejected", + reason = "unknown_locale", + caller_id = %caller_id, + attempted_locale = %trimmed, + "👤 profile update rejected: locale '{}' not in registry", + trimmed, + ); + return Err(DomainError::new( + ErrorKind::InvalidInput, + "User", + format!( + "Unknown locale '{}'. Use one of the codes returned \ + by /api/i18n/locales.", + trimmed, + ), + )); + } + } + if changed.is_empty() { // No-op — return the current user without a DB write. return Ok(UserDto::from(user)); @@ -1917,6 +1954,7 @@ impl AuthApplicationService { &self, code: &str, state: &str, + locale_registry: &crate::common::locale::LocaleRegistry, ) -> Result { // 0. Validate CSRF state and retrieve PKCE verifier + nonce + optional NC token // (entry is auto-expired by moka TTL — remove returns None if expired) @@ -1968,6 +2006,7 @@ impl AuthApplicationService { given_name: user_info.given_name.or(claims.given_name), family_name: user_info.family_name.or(claims.family_name), email_verified: user_info.email_verified.or(claims.email_verified), + locale: user_info.locale.or(claims.locale), groups: if user_info.groups.is_empty() { claims.groups } else { @@ -2133,6 +2172,21 @@ impl AuthApplicationService { new_user.set_image(claims.picture.clone()); new_user.set_given_name(claims.given_name.clone()); new_user.set_family_name(claims.family_name.clone()); + // PR C: provision the user's preferred_locale from the + // OIDC `locale` claim AT JIT ONLY. Subsequent logins + // never re-apply this — a UI-driven choice ("I prefer + // English even though my IdP says fr-CA") must not be + // silently overwritten on the next sign-in. We validate + // the claim against the registry so an obscure or + // malformed code (e.g. `klingon`, `fr-FR-x-private`) + // doesn't end up stored only to fail at render time; + // unresolvable claims fall through to NULL → server + // default. + if let Some(claim) = claims.locale.as_deref() + && let Some(canonical) = locale_registry.parse(claim) + { + new_user.set_preferred_locale(Some(canonical.as_str().to_string())); + } // PR 23: the OIDC callback rejected any caller upstream // whose `email_verified` claim wasn't true, so users // reaching this branch have an IdP-vetted email. Stamp diff --git a/src/application/services/magic_link_invite_service.rs b/src/application/services/magic_link_invite_service.rs index 4d5a851c..bb7bd816 100644 --- a/src/application/services/magic_link_invite_service.rs +++ b/src/application/services/magic_link_invite_service.rs @@ -96,6 +96,11 @@ pub struct MagicLinkInviteService { email_sender: Arc, user_lifecycle: Arc, i18n: Arc, + /// Used to validate a stored `preferred_locale` at render time — + /// a code that's no longer in the registry (e.g. operator removed + /// `pl.json`) falls back to the server default instead of raising + /// a translation error. + locale_registry: Arc, magic_link_cfg: MagicLinkConfig, /// Public base URL of this OxiCloud instance — used to build the /// `/magic/v1/{token}` invitation link. Sourced from @@ -111,6 +116,7 @@ impl MagicLinkInviteService { email_sender: Arc, user_lifecycle: Arc, i18n: Arc, + locale_registry: Arc, magic_link_cfg: MagicLinkConfig, public_base_url: String, ) -> Self { @@ -120,11 +126,25 @@ impl MagicLinkInviteService { email_sender, user_lifecycle, i18n, + locale_registry, magic_link_cfg, public_base_url, } } + /// Resolve the recipient's preferred locale into a usable `Locale`. + /// Returns the server default when: + /// - `preferred_locale` is `None` (the common case for pre-PR-C + /// users and recipients who never picked a language), + /// - the stored code no longer resolves against the registry + /// (e.g. operator removed a locale file after the row was + /// written, or a future schema migration relaxed the CHECK). + fn locale_for(&self, user: &User) -> Locale { + user.preferred_locale() + .and_then(|code| self.locale_registry.parse(code)) + .unwrap_or_else(|| self.locale_registry.default_locale().clone()) + } + /// Resolve the email to an existing user, or lazily provision a new /// external user. Returns the resolved [`User`]. /// @@ -134,24 +154,54 @@ impl MagicLinkInviteService { /// (`OXICLOUD_ALLOW_EXTERNAL_USERS=false`) and no matching user /// exists, OR the email's domain isn't in the allowlist. /// - any propagated repo error. - pub async fn resolve_or_create_recipient(&self, raw_email: &str) -> Result { + pub async fn resolve_or_create_recipient( + &self, + raw_email: &str, + inviter_id: Option, + ) -> Result { let normalised = normalize_email(raw_email).map_err(|e| { DomainError::new(ErrorKind::InvalidInput, "MagicLinkInvite", format!("{}", e)) })?; // Fast path: existing user with this email — works for both // internal (was previously created via normal registration) and - // external (previous invitation re-sharing) cases. + // external (previous invitation re-sharing) cases. We do NOT + // touch `preferred_locale` on an existing row; the recipient's + // own choice (or a previously-inherited value) wins. match UserRepository::get_user_by_email(&*self.user_storage, &normalised).await { Ok(user) => Ok(user), - Err(UserRepositoryError::NotFound(_)) => self.create_external_user(&normalised).await, + Err(UserRepositoryError::NotFound(_)) => { + // Best-effort inviter locale lookup. A failure here + // (deleted inviter row, transient DB blip) is non-fatal + // — the recipient is created with NULL locale and + // resolves to the server default like any pre-PR-C row. + let inviter_locale = if let Some(uid) = inviter_id { + match UserRepository::get_user_by_id(&*self.user_storage, uid).await { + Ok(u) => u.preferred_locale().map(str::to_string), + Err(_) => None, + } + } else { + None + }; + self.create_external_user(&normalised, inviter_locale).await + } Err(e) => Err(DomainError::from(e)), } } /// Lazy provisioning path. Runs the two policy guards (kill switch /// and per-domain allowlist) before touching the DB. - async fn create_external_user(&self, normalised_email: &str) -> Result { + /// + /// `inviter_locale` is the inviter's `preferred_locale` if any — + /// PR C inherits it into the new external user's row so the + /// invitation mail (and any subsequent emails to the recipient) + /// arrive in a language the inviter likely shares with them. The + /// recipient can override later via the language switcher. + async fn create_external_user( + &self, + normalised_email: &str, + inviter_locale: Option, + ) -> Result { if !self.magic_link_cfg.allow_external_users { return Err(DomainError::new( ErrorKind::AccessDenied, @@ -175,7 +225,7 @@ impl MagicLinkInviteService { // External users are created without a username or password. // `password_hash IS NULL` is the canonical no-password marker. - let user = User::new( + let mut user = User::new( normalised_email.to_string(), None, None, @@ -192,6 +242,14 @@ impl MagicLinkInviteService { format!("invalid external user data: {}", e), ) })?; + // PR C: inherit the inviter's preferred locale at row creation + // (decision 6 in the plan). Treated as advisory — frequently + // wrong, but the recipient can override via the language + // switcher, and the bilingual email partial ships English + // alongside any non-English copy as a safety net. + if let Some(locale) = inviter_locale { + user.set_preferred_locale(Some(locale)); + } let saved = UserRepository::create_user(&*self.user_storage, user.clone()) .await @@ -269,11 +327,12 @@ impl MagicLinkInviteService { Resource::Folder(_) => "server.magic_link.email.kind_folder", Resource::File(_) => "server.magic_link.email.kind_file", }; - // PR C will resolve the recipient's preferred_locale. For now - // (PR B) every magic-link email defaults to the server default - // locale; the bilingual partial below means non-English - // recipients still see English as a safety net. - let locale = Locale::default(); + // PR C: render in the recipient's preferred locale (set by UI + // switcher, OIDC JIT claim, or inviter inheritance at row + // creation). The bilingual partial appends English below when + // the resolved locale isn't English, so a wrong guess still + // produces a readable mail. + let locale = self.locale_for(recipient); let kind_label = self.i18n_or(kind_key, &locale, &[]).await; let ttl_hours = self.magic_link_cfg.invite_ttl_hours.to_string(); let invite_args: Vec<(&str, &str)> = vec![ @@ -455,9 +514,9 @@ impl MagicLinkInviteService { self.public_base_url.trim_end_matches('/'), token.token(), ); - // PR C will switch to `user.preferred_locale` once the column - // lands. Today the login-via-email path uses the server default. - let locale = Locale::default(); + // PR C: render in the user's preferred locale. Same bilingual + // safety net as the invitation path — see `issue_invitation`. + let locale = self.locale_for(&user); let ttl_minutes = self.magic_link_cfg.login_ttl_minutes.to_string(); let login_args: Vec<(&str, &str)> = vec![("link", &link), ("ttl_minutes", &ttl_minutes)]; diff --git a/src/common/di.rs b/src/common/di.rs index d5f71326..bac3b96e 100644 --- a/src/common/di.rs +++ b/src/common/di.rs @@ -1018,6 +1018,7 @@ impl AppServiceFactory { email_sender, lifecycle, app_state.applications.i18n_service.clone(), + app_state.locale_registry.clone(), self.config.magic_link.clone(), self.config.base_url(), ), diff --git a/src/domain/entities/user.rs b/src/domain/entities/user.rs index f6288826..b4c8806d 100644 --- a/src/domain/entities/user.rs +++ b/src/domain/entities/user.rs @@ -72,6 +72,19 @@ pub struct User { /// flow. PR 23 ships the signal only — future policy PRs gate /// features (uploads, shares, etc.) on this column. email_verified_at: Option>, + /// User-chosen locale for server-rendered surfaces (transactional + /// emails, future authenticated HTML pages). `None` = no preference, + /// resolves to `OXICLOUD_DEFAULT_LOCALE` at use time. Set by: + /// - the frontend language switcher (PATCH /api/auth/me/profile), + /// - the OIDC JIT path at provisioning **only**, never re-applied + /// on subsequent logins (a UI choice always wins over the IdP), + /// - the magic-link invitation flow, which copies the inviter's + /// value into the new external user's row. + /// + /// Schema-level CHECK enforces a textual BCP-47 shape; the + /// application layer is the authoritative gatekeeper against the + /// `LocaleRegistry`. + preferred_locale: Option, } impl User { @@ -161,6 +174,10 @@ impl User { // magic-link redemption or OIDC JIT (where the IdP has // already confirmed the email). email_verified_at: None, + // PR C: no locale preference at creation. OIDC JIT, the + // language switcher, or invitation-time inheritance fill + // this in later. NULL resolves to OXICLOUD_DEFAULT_LOCALE. + preferred_locale: None, }) } @@ -203,6 +220,7 @@ impl User { given_name: None, family_name: None, email_verified_at: None, + preferred_locale: None, } } @@ -226,6 +244,7 @@ impl User { given_name: Option, family_name: Option, email_verified_at: Option>, + preferred_locale: Option, ) -> Self { Self { id, @@ -246,6 +265,7 @@ impl User { given_name, family_name, email_verified_at, + preferred_locale, } } @@ -390,6 +410,26 @@ impl User { self.updated_at = Utc::now(); } + /// Borrow the user's stored locale code (e.g. `"fr"`, `"zh-TW"`), + /// if any. The application layer is expected to feed this through + /// `LocaleRegistry::parse_or_default` before rendering, so an + /// orphaned code from a since-removed locale falls back gracefully + /// instead of triggering a translation error. + pub fn preferred_locale(&self) -> Option<&str> { + self.preferred_locale.as_deref() + } + + /// Set or clear the user's preferred locale. The caller is + /// responsible for having already validated the code against the + /// `LocaleRegistry` — at the entity layer we treat the field as + /// opaque text, the way we do for `given_name` / `family_name`. + /// Passing `None` clears the preference (subsequent renders fall + /// back to the server default). + pub fn set_preferred_locale(&mut self, locale: Option) { + self.preferred_locale = locale; + self.updated_at = Utc::now(); + } + /// Claim or change the username. Runs the same validation as the /// constructor — callers must still ensure uniqueness at the repo /// level. Bumps `updated_at`. Used by the post-create profile-edit diff --git a/src/infrastructure/repositories/pg/user_pg_repository.rs b/src/infrastructure/repositories/pg/user_pg_repository.rs index 94c536f3..9dcb1df3 100644 --- a/src/infrastructure/repositories/pg/user_pg_repository.rs +++ b/src/infrastructure/repositories/pg/user_pg_repository.rs @@ -96,10 +96,11 @@ impl UserRepository for UserPgRepository { storage_quota_bytes, storage_used_bytes, created_at, updated_at, last_login_at, active, oidc_provider, oidc_subject, is_external, - given_name, family_name, email_verified_at + given_name, family_name, email_verified_at, + preferred_locale ) VALUES ( $1, $2, $3, $4, $5::auth.userrole, $6, $7, $8, $9, $10, $11, - $12, $13, $14, $15, $16, $17 + $12, $13, $14, $15, $16, $17, $18 ) RETURNING * "#, @@ -121,6 +122,7 @@ impl UserRepository for UserPgRepository { .bind(user_clone.given_name()) .bind(user_clone.family_name()) .bind(user_clone.email_verified_at()) + .bind(user_clone.preferred_locale()) .execute(&mut **tx) .await .map_err(Self::map_sqlx_error)?; @@ -145,7 +147,7 @@ impl UserRepository for UserPgRepository { storage_quota_bytes, storage_used_bytes, created_at, updated_at, last_login_at, active, oidc_provider, oidc_subject, image, is_external, - given_name, family_name, email_verified_at + given_name, family_name, email_verified_at, preferred_locale FROM auth.users WHERE id = $1 "#, @@ -181,6 +183,7 @@ impl UserRepository for UserPgRepository { row.get("given_name"), row.get("family_name"), row.get("email_verified_at"), + row.get("preferred_locale"), )) } @@ -193,7 +196,7 @@ impl UserRepository for UserPgRepository { storage_quota_bytes, storage_used_bytes, created_at, updated_at, last_login_at, active, oidc_provider, oidc_subject, image, is_external, - given_name, family_name, email_verified_at + given_name, family_name, email_verified_at, preferred_locale FROM auth.users WHERE username = $1 "#, @@ -229,6 +232,7 @@ impl UserRepository for UserPgRepository { row.get("given_name"), row.get("family_name"), row.get("email_verified_at"), + row.get("preferred_locale"), )) } @@ -241,7 +245,7 @@ impl UserRepository for UserPgRepository { storage_quota_bytes, storage_used_bytes, created_at, updated_at, last_login_at, active, oidc_provider, oidc_subject, image, is_external, - given_name, family_name, email_verified_at + given_name, family_name, email_verified_at, preferred_locale FROM auth.users WHERE email = $1 "#, @@ -277,6 +281,7 @@ impl UserRepository for UserPgRepository { row.get("given_name"), row.get("family_name"), row.get("email_verified_at"), + row.get("preferred_locale"), )) } @@ -304,7 +309,8 @@ impl UserRepository for UserPgRepository { image = $11, given_name = $12, family_name = $13, - email_verified_at = $14 + email_verified_at = $14, + preferred_locale = $15 WHERE id = $1 "#, ) @@ -322,6 +328,7 @@ impl UserRepository for UserPgRepository { .bind(user_clone.given_name()) .bind(user_clone.family_name()) .bind(user_clone.email_verified_at()) + .bind(user_clone.preferred_locale()) .execute(&mut **tx) .await .map_err(Self::map_sqlx_error)?; @@ -394,7 +401,7 @@ impl UserRepository for UserPgRepository { storage_quota_bytes, storage_used_bytes, created_at, updated_at, last_login_at, active, oidc_provider, oidc_subject, image, is_external, - given_name, family_name, email_verified_at + given_name, family_name, email_verified_at, preferred_locale FROM auth.users WHERE ($3 OR is_external = FALSE) ORDER BY created_at DESC @@ -437,6 +444,7 @@ impl UserRepository for UserPgRepository { row.get("given_name"), row.get("family_name"), row.get("email_verified_at"), + row.get("preferred_locale"), ) }) .collect(); @@ -458,7 +466,7 @@ impl UserRepository for UserPgRepository { storage_quota_bytes, storage_used_bytes, created_at, updated_at, last_login_at, active, oidc_provider, oidc_subject, image, is_external, - given_name, family_name, email_verified_at + given_name, family_name, email_verified_at, preferred_locale FROM auth.users WHERE (username ILIKE $1 OR email ILIKE $1) AND ($3 OR is_external = FALSE) @@ -501,6 +509,7 @@ impl UserRepository for UserPgRepository { row.get("given_name"), row.get("family_name"), row.get("email_verified_at"), + row.get("preferred_locale"), ) }) .collect(); @@ -588,7 +597,7 @@ impl UserRepository for UserPgRepository { storage_quota_bytes, storage_used_bytes, created_at, updated_at, last_login_at, active, oidc_provider, oidc_subject, image, is_external, - given_name, family_name, email_verified_at + given_name, family_name, email_verified_at, preferred_locale FROM auth.users WHERE role::text = $1 ORDER BY created_at DESC @@ -628,6 +637,7 @@ impl UserRepository for UserPgRepository { row.get("given_name"), row.get("family_name"), row.get("email_verified_at"), + row.get("preferred_locale"), ) }) .collect(); @@ -664,7 +674,7 @@ impl UserRepository for UserPgRepository { storage_quota_bytes, storage_used_bytes, created_at, updated_at, last_login_at, active, oidc_provider, oidc_subject, image, is_external, - given_name, family_name, email_verified_at + given_name, family_name, email_verified_at, preferred_locale FROM auth.users WHERE oidc_provider = $1 AND oidc_subject = $2 "#, @@ -700,6 +710,7 @@ impl UserRepository for UserPgRepository { row.get("given_name"), row.get("family_name"), row.get("email_verified_at"), + row.get("preferred_locale"), )) } diff --git a/src/infrastructure/services/oidc_service.rs b/src/infrastructure/services/oidc_service.rs index 0d8b8f6e..6a845e74 100644 --- a/src/infrastructure/services/oidc_service.rs +++ b/src/infrastructure/services/oidc_service.rs @@ -70,6 +70,7 @@ struct IdTokenClaims { groups: Option>, nonce: Option, picture: Option, + locale: Option, // Standard JWT fields #[allow(dead_code)] iss: Option, @@ -96,6 +97,7 @@ struct UserInfoResponse { family_name: Option, groups: Option>, picture: Option, + locale: Option, } // ============================================================================ @@ -469,6 +471,7 @@ impl OidcServicePort for OidcService { family_name: claims.family_name, groups: claims.groups.unwrap_or_default(), picture: claims.picture, + locale: claims.locale, }) } @@ -523,6 +526,7 @@ impl OidcServicePort for OidcService { family_name: info.family_name, groups: info.groups.unwrap_or_default(), picture: info.picture, + locale: info.locale, }) } diff --git a/src/interfaces/api/handlers/auth_handler.rs b/src/interfaces/api/handlers/auth_handler.rs index d10de7f3..0388eebc 100644 --- a/src/interfaces/api/handlers/auth_handler.rs +++ b/src/interfaces/api/handlers/auth_handler.rs @@ -559,7 +559,7 @@ pub async fn update_profile( let updated = auth_service .auth_application_service - .update_profile_with_perms(user_id, dto) + .update_profile_with_perms(user_id, dto, &state.locale_registry) .await?; Ok((StatusCode::OK, Json(updated))) @@ -926,7 +926,7 @@ pub async fn oidc_callback( // Exchange code, validate state/nonce/PKCE, authenticate user let result = auth_app - .oidc_callback(&query.code, &query.state) + .oidc_callback(&query.code, &query.state, &state.locale_registry) .await .map_err(|e| { tracing::error!("OIDC callback failed: {}", e); diff --git a/src/interfaces/api/handlers/grant_handler.rs b/src/interfaces/api/handlers/grant_handler.rs index 47af0069..cbb065b2 100644 --- a/src/interfaces/api/handlers/grant_handler.rs +++ b/src/interfaces/api/handlers/grant_handler.rs @@ -137,7 +137,14 @@ pub async fn create_grant( state.email_invite_rate_limiter.retry_after(), ); } - match invite_svc.resolve_or_create_recipient(&email).await { + // PR C: pass the inviter id so resolve_or_create_recipient + // can inherit their preferred_locale onto a freshly- + // provisioned external user (best-effort; lookup failure + // just leaves the new row's locale NULL, no hard error). + match invite_svc + .resolve_or_create_recipient(&email, Some(caller_id)) + .await + { Ok(user) => (Subject::User(user.id()), Some(user)), Err(e) => return AppError::from(e).into_response(), } diff --git a/static/js/app/authSession.js b/static/js/app/authSession.js index 41b992bd..728b5980 100644 --- a/static/js/app/authSession.js +++ b/static/js/app/authSession.js @@ -3,6 +3,7 @@ */ import { getCsrfHeaders } from '../core/csrf.js'; +import { i18n } from '../core/i18n.js'; import { updateStorageUsageDisplay } from './main.js'; import { app } from './state.js'; import { ui } from './ui.js'; @@ -12,6 +13,37 @@ import { updateUserMenuData } from './userMenu.js'; * @import {User} from '../core/types.js' */ +/** + * Apply the server's `preferred_locale` to this browser if it differs + * from the currently-active one. + * + * The page initially renders in whichever locale `i18n.initI18n()` + * picked from localStorage / Accept-Language. After `/api/auth/me` + * returns we know the user's persisted choice; if this is a fresh + * browser (no `oxicloud-locale` in localStorage) or the local copy + * drifted (user changed their preference elsewhere), switching here + * is what makes "sign in on phone, see UI in the language I picked on + * my laptop" work. + * + * Safeguards: + * - `null` / `undefined` server value means "no preference stored" → + * leave the browser-picked locale alone. + * - When the server value matches the active locale we skip + * `setLocale` entirely to avoid a no-op `translatePage()` flash. + * - `setLocale` itself writes the new value back via PATCH; that's + * benign here (server already agrees) and avoids special-casing + * the call site. + * + * @param {string|undefined|null} serverLocale + */ +function _syncPreferredLocale(serverLocale) { + if (!serverLocale) return; + if (i18n.getCurrentLocale && i18n.getCurrentLocale() === serverLocale) return; + i18n.setLocale(serverLocale).catch((err) => { + console.debug('locale: sync from server failed:', err?.message ?? err); + }); +} + /** * * @returns {Promise} @@ -40,6 +72,11 @@ async function refreshUserData() { localStorage.setItem(USER_DATA_KEY, JSON.stringify(userData)); app.isExternalUser = !!userData.is_external; + // PR C: sync the server-stored preferred_locale to this device. + // Triggered on every `/api/auth/me` fetch, but `_syncPreferredLocale` + // short-circuits when the active locale already matches so we + // don't trigger an unnecessary translatePage() pass. + _syncPreferredLocale(userData.preferred_locale); updateStorageUsageDisplay(userData); return userData; } catch (error) { diff --git a/static/js/core/i18n.js b/static/js/core/i18n.js index 3822a4c4..5721aed7 100644 --- a/static/js/core/i18n.js +++ b/static/js/core/i18n.js @@ -5,6 +5,8 @@ * It loads translations from the server and provides functions to translate keys. */ +import { getCsrfHeaders } from './csrf.js'; + // Supported locales (languages that have locale files on the server) // Keep in sync with AVAILABLE_LOCALES in core/languageSelector.js const supportedLocales = ['en', 'es', 'zh', 'zh-TW', 'fa', 'fr', 'de', 'pt', 'nl', 'it', 'hi', 'ar', 'ru', 'ja', 'ko', 'pl']; @@ -141,6 +143,14 @@ async function setLocale(locale) { // Save locale preference localStorage.setItem('oxicloud-locale', locale); + // PR C: also persist server-side via PATCH /api/auth/me/profile + // so the same choice is honoured by transactional emails and + // survives across devices. Fire-and-forget — anonymous callers + // (login page, magic-link landing) will 401 and that's fine; a + // network blip just leaves the row at its previous value, which + // localStorage already reflects on this device. + _persistLocaleToServer(locale); + // Trigger an event for components to update window.dispatchEvent(new CustomEvent('localeChanged', { detail: { locale } })); @@ -150,6 +160,31 @@ async function setLocale(locale) { return true; } +/** + * Fire-and-forget POST of the new locale to the server. Called from + * `setLocale`; failures are logged but never block the UI flip. + * + * The server side rejects requests from anonymous callers (no session + * cookie) with 401 — that's expected on the login / magic-link pages + * where i18n.js runs before the user is authenticated, so we treat any + * non-2xx as "skip, the next save will reconcile". + * + * @param {string} locale + */ +function _persistLocaleToServer(locale) { + fetch('/api/auth/me/profile', { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + ...getCsrfHeaders() + }, + credentials: 'same-origin', + body: JSON.stringify({ preferred_locale: locale }) + }).catch((err) => { + console.debug('locale: server persistence skipped:', err?.message ?? err); + }); +} + /** * Initialize the i18n system * @returns {Promise} diff --git a/static/js/core/types.js b/static/js/core/types.js index 5092ff06..b74c5078 100644 --- a/static/js/core/types.js +++ b/static/js/core/types.js @@ -165,6 +165,7 @@ * @property {string} [given_name] First/given name; set at OIDC JIT or via PATCH /api/auth/me/profile (PR 24) * @property {string} [family_name] Last/family name; set at OIDC JIT or via PATCH /api/auth/me/profile (PR 24) * @property {string} [email_verified_at] ISO 8601 timestamp of the first proof-of-email-control (PR 23). Omitted when unverified. + * @property {string} [preferred_locale] User-chosen locale code (e.g. `"fr"`, `"zh-TW"`); omitted when unset. Round-trips via PATCH /api/auth/me/profile. */ /**