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
This commit is contained in:
Edouard Vanbelle
2026-06-03 14:26:45 +02:00
parent 854f1d3a07
commit 7db27af7a6
14 changed files with 342 additions and 26 deletions
@@ -1164,6 +1164,7 @@ impl AuthApplicationService {
&self,
caller_id: Uuid,
dto: crate::application::dtos::user_dto::UpdateProfileDto,
locale_registry: &crate::common::locale::LocaleRegistry,
) -> Result<UserDto, DomainError> {
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<OidcCallbackResult, DomainError> {
// 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
@@ -96,6 +96,11 @@ pub struct MagicLinkInviteService {
email_sender: Arc<dyn EmailSender>,
user_lifecycle: Arc<UserLifecycleService>,
i18n: Arc<I18nApplicationService>,
/// 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<crate::common::locale::LocaleRegistry>,
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<dyn EmailSender>,
user_lifecycle: Arc<UserLifecycleService>,
i18n: Arc<I18nApplicationService>,
locale_registry: Arc<crate::common::locale::LocaleRegistry>,
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<User, DomainError> {
pub async fn resolve_or_create_recipient(
&self,
raw_email: &str,
inviter_id: Option<uuid::Uuid>,
) -> Result<User, DomainError> {
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<User, DomainError> {
///
/// `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<String>,
) -> Result<User, DomainError> {
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)];