✨ (pagetop): Mejora la elección de zona horaria

- Nuevas opciones `app.timezone_regions`, `app.timezone_order` y
  `app.timezone_per_user` para limitar y ordenar las regiones ofrecidas
  y decidir si cada usuario puede tener su propia zona horaria.
- Añade `TzRegion` y `TimezoneOrder`; `Timezone::supported_by_region()`
  devuelve ahora `TzRegion` y excluye los alias de la base IANA que
  repiten otra zona (tzdata 2025b).
- Añade `Timezone::is_supported_in()` para validar con otras regiones.
- `SelectTimezone` admite `with_regions()`, `with_order()` y
  `with_utc_offset()`, muestra nombres legibles y conserva la zona
  actual aunque ya no se ofrezca.
- `app.timezone` se resuelve sin distinguir mayúsculas.
- pagetop-user no ofrece idioma ni zona horaria si no se aplican y
  conserva lo guardado; acepta la zona actual aunque ya no se ofrezca.
This commit is contained in:
Manuel Cillero 2026-10-07 01:19:01 +02:00
parent f819181872
commit 346714ab29
19 changed files with 1257 additions and 131 deletions

View file

@ -3,6 +3,7 @@
use pagetop::prelude::*;
use crate::LOCALES_USER;
use crate::config::{user_language_applies, user_timezone_applies};
use crate::user_path;
use crate::{ADMIN_USERS_PATH, PROFILE_EDIT_PATH};
@ -86,25 +87,33 @@ impl Component for UserForm {
.with_name("display_name")
.with_value(self.display_name())
.with_label(Lc::t("field-display-name", &LOCALES_USER)),
)
.with_child(
);
// Sólo se ofrecen si se aplican (ver `config::user_language_applies()` y
// `config::user_timezone_applies()`).
if user_language_applies() {
form = form.with_child(
form::SelectLanguage::new()
.with_name("language")
.with_label(Lc::t("field-language", &LOCALES_USER))
.with_selected(self.language()),
)
.with_child(
);
}
if user_timezone_applies() {
form = form.with_child(
form::SelectTimezone::new()
.with_name("timezone")
.with_label(Lc::t("field-timezone", &LOCALES_USER))
.with_utc_offset(true)
.with_selected(self.timezone()),
)
.with_child(
form::SelectTheme::new()
.with_name("theme")
.with_label(Lc::t("field-theme", &LOCALES_USER))
.with_selected(self.theme()),
);
}
form = form.with_child(
form::SelectTheme::new()
.with_name("theme")
.with_label(Lc::t("field-theme", &LOCALES_USER))
.with_selected(self.theme()),
);
if *self.mode() == UserFormMode::New {
form = form.with_child(PasswordConfirm::new());

View file

@ -134,3 +134,17 @@ impl Default for AdminConfig {
AdminConfig { list_page_size: 20 }
}
}
// **< Preferencias del usuario >*******************************************************************
// Si se aplica el idioma propio del usuario: no con `lang_negotiation = "ConfigOnly"`, que usa
// siempre el de la configuración. Si no se aplica, no se ofrece elegirlo ni se borra el guardado.
pub(crate) fn user_language_applies() -> bool {
global::SETTINGS.app.lang_negotiation != global::LangNegotiation::ConfigOnly
}
// Si se aplica la zona horaria propia del usuario (`app.timezone_per_user`). Si no se aplica, no se
// ofrece elegirla ni se borra la guardada.
pub(crate) fn user_timezone_applies() -> bool {
global::SETTINGS.app.timezone_per_user
}

View file

@ -9,6 +9,7 @@ use crate::account::{Account, UserStatus};
use crate::auth;
use crate::component::admin::{UserForm, UserFormMode, status_key};
use crate::component::{ChangePasswordForm, language_name, multiline_text, theme_name};
use crate::config::{user_language_applies, user_timezone_applies};
use crate::entity::{role, user};
use crate::error::AuthError;
use crate::handlers::admin::map_auth_error;
@ -119,17 +120,23 @@ async fn profile_details(user: &user::Model, status: UserStatus, cx: &mut Contex
.with_cell(multiline_text(
user.about.clone().unwrap_or_else(|| "-".into()),
)),
)
.with_row(
);
// Sólo se muestran si se aplican, igual que en el formulario de edición.
if user_language_applies() {
table = table.with_row(
table::Row::new()
.with_cell(Lc::t("field-language", &LOCALES_USER))
.with_cell(language_name(user.language.as_deref())),
)
.with_row(
);
}
if user_timezone_applies() {
table = table.with_row(
table::Row::new()
.with_cell(Lc::t("field-timezone", &LOCALES_USER))
.with_cell(user.timezone.as_deref().unwrap_or("-")),
)
);
}
table = table
.with_row(
table::Row::new()
.with_cell(Lc::t("field-theme", &LOCALES_USER))

View file

@ -14,7 +14,7 @@ use crate::component::admin::{
AdminPasswordForm, USER_ADMIN_FORM_ID, UserForm, UserFormMode, UserTable, status_key,
};
use crate::component::{language_name, multiline_text, theme_name};
use crate::config::SETTINGS;
use crate::config::{SETTINGS, user_language_applies, user_timezone_applies};
use crate::entity::{role, user};
use crate::error::AuthError;
use crate::handlers::admin::{back_link, frame, map_auth_error};
@ -589,17 +589,23 @@ async fn user_view_details(user: &user::Model, status: UserStatus, cx: &mut Cont
.with_cell(multiline_text(
user.about.clone().unwrap_or_else(|| "-".into()),
)),
)
.with_row(
);
// Sólo se muestran si se aplican, igual que en el formulario de edición.
if user_language_applies() {
table = table.with_row(
table::Row::new()
.with_cell(Lc::t("field-language", &LOCALES_USER))
.with_cell(language_name(user.language.as_deref())),
)
.with_row(
);
}
if user_timezone_applies() {
table = table.with_row(
table::Row::new()
.with_cell(Lc::t("field-timezone", &LOCALES_USER))
.with_cell(user.timezone.as_deref().unwrap_or("-")),
)
);
}
table = table
.with_row(
table::Row::new()
.with_cell(Lc::t("field-theme", &LOCALES_USER))

View file

@ -10,6 +10,7 @@ use pagetop_seaorm::db::{
};
use crate::account::UserStatus;
use crate::config::{user_language_applies, user_timezone_applies};
use crate::entity::{role, user, user_role};
use crate::error::AuthError;
use crate::password;
@ -174,8 +175,18 @@ pub(crate) struct NewUserData<'a> {
pub(crate) async fn create_user(data: NewUserData<'_>) -> Result<i32, AuthError> {
password::validate_strength(data.password)?;
password::passwords_match(data.password, data.confirm_password)?;
let language = validate_language(data.language)?;
let timezone = validate_timezone(data.timezone)?;
// El idioma y la zona horaria que no se aplican no se ofrecen en el formulario: se ignora lo
// que pudiera llegar y el usuario se crea sin ellos.
let language = if user_language_applies() {
validate_language(data.language)?
} else {
None
};
let timezone = if user_timezone_applies() {
validate_timezone(data.timezone)?
} else {
None
};
let theme = validate_theme(data.theme)?;
ensure_username_available(data.username, None).await?;
ensure_email_available(data.email, None).await?;
@ -230,8 +241,24 @@ pub(crate) struct UserUpdateData<'a> {
}
pub(crate) async fn update_user(user_id: i32, data: UserUpdateData<'_>) -> Result<(), AuthError> {
let language = validate_language(data.language)?;
let timezone = validate_timezone(data.timezone)?;
// El idioma y la zona horaria que no se aplican tampoco se ofrecen en el formulario, así que no
// llegan: se conserva lo guardado por si se vuelven a aplicar.
let language = if user_language_applies() {
Set(validate_language(data.language)?.map(str::to_owned))
} else {
ActiveValue::NotSet
};
let timezone = if user_timezone_applies() {
let timezone = match validate_timezone(data.timezone) {
Err(AuthError::InvalidTimezone) => {
keep_current_timezone(user_id, data.timezone).await?
}
result => result?,
};
Set(timezone.map(str::to_owned))
} else {
ActiveValue::NotSet
};
let theme = validate_theme(data.theme)?;
// El navegador envía los saltos de línea de un `<textarea>` como `\r\n`, pero `maxlength` puede
// contarlos como un único carácter: se normalizan antes de medir para no rechazar un texto que
@ -254,8 +281,8 @@ pub(crate) async fn update_user(user_id: i32, data: UserUpdateData<'_>) -> Resul
email: Set(data.email.to_owned()),
display_name: Set(data.display_name.map(str::to_owned)),
about: Set(about),
language: Set(language.map(str::to_owned)),
timezone: Set(timezone.map(str::to_owned)),
language,
timezone,
theme: Set(theme.map(str::to_owned)),
updated_at: Set(now),
..Default::default()
@ -448,6 +475,22 @@ fn validate_timezone(timezone: Option<&str>) -> Result<Option<&str>, AuthError>
Ok(timezone)
}
// Acepta una zona que ya no se ofrece si es la que el usuario tenía guardada: el selector la sigue
// mostrando para que volver a guardar el formulario sin tocarla no la descarte. Sólo se consulta la
// base de datos cuando la zona recibida no se ofrece.
async fn keep_current_timezone(
user_id: i32,
timezone: Option<&str>,
) -> Result<Option<&str>, AuthError> {
let timezone = timezone.and_then(util::non_blank);
let current = find_user(user_id).await?.timezone;
if timezone.is_some() && timezone == current.as_deref() {
Ok(timezone)
} else {
Err(AuthError::InvalidTimezone)
}
}
async fn ensure_username_available(
username: &str,
exclude_id: Option<i32>,