✨ (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:
parent
f819181872
commit
346714ab29
19 changed files with 1257 additions and 131 deletions
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue