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:
@@ -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.';
|
||||
@@ -47,6 +47,12 @@ pub struct UserDto {
|
||||
/// subsequent verifications.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub email_verified_at: Option<DateTime<Utc>>,
|
||||
/// 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<String>,
|
||||
}
|
||||
|
||||
impl From<User> for UserDto {
|
||||
@@ -69,6 +75,7 @@ impl From<User> 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<String>,
|
||||
/// 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<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
|
||||
@@ -173,6 +173,12 @@ pub struct OidcIdClaims {
|
||||
pub family_name: Option<String>,
|
||||
pub groups: Vec<String>,
|
||||
pub picture: Option<String>,
|
||||
/// 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<String>,
|
||||
}
|
||||
|
||||
/// Port for OIDC operations — implemented in infrastructure layer
|
||||
|
||||
@@ -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)];
|
||||
|
||||
|
||||
@@ -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(),
|
||||
),
|
||||
|
||||
@@ -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<DateTime<Utc>>,
|
||||
/// 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<String>,
|
||||
}
|
||||
|
||||
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<String>,
|
||||
family_name: Option<String>,
|
||||
email_verified_at: Option<DateTime<Utc>>,
|
||||
preferred_locale: Option<String>,
|
||||
) -> 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<String>) {
|
||||
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
|
||||
|
||||
@@ -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"),
|
||||
))
|
||||
}
|
||||
|
||||
|
||||
@@ -70,6 +70,7 @@ struct IdTokenClaims {
|
||||
groups: Option<Vec<String>>,
|
||||
nonce: Option<String>,
|
||||
picture: Option<String>,
|
||||
locale: Option<String>,
|
||||
// Standard JWT fields
|
||||
#[allow(dead_code)]
|
||||
iss: Option<String>,
|
||||
@@ -96,6 +97,7 @@ struct UserInfoResponse {
|
||||
family_name: Option<String>,
|
||||
groups: Option<Vec<String>>,
|
||||
picture: Option<String>,
|
||||
locale: Option<String>,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
|
||||
@@ -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<User | null>}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<void>}
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user