From 04502ef81e2c9be2b3a36c09e0a78e3960a2069e Mon Sep 17 00:00:00 2001 From: Manuel Cillero Date: Fri, 2 Oct 2026 22:31:58 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20(user):=20A=C3=B1ade=20zona=20horar?= =?UTF-8?q?ia=20propia=20del=20usuario?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sustituye el campo de texto libre por un selector de zonas horarias IANA canónicas agrupadas por región, con la opción de usar la del sitio (que muestra la zona efectiva). --- .../pagetop-admin/src/entity/setting.rs | 3 +- extensions/pagetop-admin/src/settings.rs | 8 +-- extensions/pagetop-user/src/auth.rs | 8 +-- .../src/component/admin/user_form.rs | 69 ++++++++++++++++--- extensions/pagetop-user/src/demo.rs | 4 +- extensions/pagetop-user/src/entity/role.rs | 5 +- .../src/entity/role_permission.rs | 3 +- extensions/pagetop-user/src/entity/session.rs | 7 +- extensions/pagetop-user/src/entity/user.rs | 13 ++-- .../pagetop-user/src/entity/user_token.rs | 9 ++- extensions/pagetop-user/src/error.rs | 3 + extensions/pagetop-user/src/handlers/admin.rs | 1 + extensions/pagetop-user/src/handlers/auth.rs | 8 +-- .../pagetop-user/src/locale/en-US/common.ftl | 24 ++++--- .../pagetop-user/src/locale/es-ES/common.ftl | 24 ++++--- .../pagetop-user/src/service/role_admin.rs | 6 +- .../pagetop-user/src/service/user_admin.rs | 44 +++++++----- extensions/pagetop-user/src/session.rs | 9 ++- extensions/pagetop-user/src/token.rs | 4 +- src/auth.rs | 2 +- 20 files changed, 162 insertions(+), 92 deletions(-) diff --git a/extensions/pagetop-admin/src/entity/setting.rs b/extensions/pagetop-admin/src/entity/setting.rs index 95b6c4c6..8907f0fd 100644 --- a/extensions/pagetop-admin/src/entity/setting.rs +++ b/extensions/pagetop-admin/src/entity/setting.rs @@ -1,4 +1,3 @@ -use pagetop::prelude::*; use pagetop_seaorm::db::*; /// Entidad SeaORM para la tabla `settings`. @@ -9,7 +8,7 @@ pub struct Model { pub key: String, pub scope: String, pub value: String, - pub updated_at: NaiveDateTime, + pub updated_at: DateTimeUtc, pub updated_by: Option, } diff --git a/extensions/pagetop-admin/src/settings.rs b/extensions/pagetop-admin/src/settings.rs index 60f73637..d145709d 100644 --- a/extensions/pagetop-admin/src/settings.rs +++ b/extensions/pagetop-admin/src/settings.rs @@ -11,7 +11,7 @@ use serde::{Serialize, de::DeserializeOwned}; use crate::entity::setting::{ActiveModel, Column, Entity}; use crate::error::AdminError; -// **< API pública >********************************************************************************* +// **< API pública >******************************************************************************** /// Lee un valor persistido, devolviendo el `Default` del tipo si no existe. pub async fn get(key: &str) -> T { @@ -40,7 +40,7 @@ pub async fn list_scope(scope: &str) -> Vec<(String, String)> { list_scope_async(scope).await.unwrap_or_default() } -// **< Implementación asíncrona >******************************************************************** +// **< Implementación asíncrona >******************************************************************* async fn get_async(key: &str) -> Result { let model = Entity::find_by_id(key) @@ -57,7 +57,7 @@ async fn set_async( user_id: Option, ) -> Result<(), AdminError> { let value_json = serde_json::to_string(value)?; - let now = Utc::now().naive_utc(); + let now = Utc::now(); let existing = Entity::find_by_id(key).one(dbconn()).await?; if existing.is_some() { @@ -95,7 +95,7 @@ async fn list_scope_async(scope: &str) -> Result, AdminErr Ok(rows.into_iter().map(|m| (m.key, m.value)).collect()) } -// **< Tipos de esquema >**************************************************************************** +// **< Tipos de esquema >*************************************************************************** /// Tipo de campo de configuración para un [`SettingsSchema`]. #[derive(Clone, Debug)] diff --git a/extensions/pagetop-user/src/auth.rs b/extensions/pagetop-user/src/auth.rs index 67d21aa6..6d737bda 100644 --- a/extensions/pagetop-user/src/auth.rs +++ b/extensions/pagetop-user/src/auth.rs @@ -24,7 +24,7 @@ pub async fn login( plain_password: &str, remember: bool, ) -> Result { - let now = Utc::now().naive_utc(); + let now = Utc::now(); // Buscar usuario por username o email. let user_model = user::Entity::find() @@ -119,7 +119,7 @@ pub async fn register( } let hash = password::hash_password(plain_password)?; - let now = Utc::now().naive_utc(); + let now = Utc::now(); let status = if SETTINGS.require_email_verification { UserStatus::Pending } else { @@ -175,7 +175,7 @@ pub async fn assign_role(user_id: i32, role_id: i32) -> Result<(), AuthError> { async fn register_failed_login( user_model: &user::Model, - now: NaiveDateTime, + now: DateTime, ) -> Result<(), AuthError> { let new_count = user_model.failed_login_count + 1; let lock_at = if new_count >= SETTINGS.max_failed_logins { @@ -236,7 +236,7 @@ async fn do_seed() { } }; - let now = Utc::now().naive_utc(); + let now = Utc::now(); let new_admin = user::ActiveModel { id: ActiveValue::NotSet, username: Set(cfg.admin_username.clone()), diff --git a/extensions/pagetop-user/src/component/admin/user_form.rs b/extensions/pagetop-user/src/component/admin/user_form.rs index 621f5d9e..42ccd0e2 100644 --- a/extensions/pagetop-user/src/component/admin/user_form.rs +++ b/extensions/pagetop-user/src/component/admin/user_form.rs @@ -1,5 +1,8 @@ //! Formulario de alta/edición de usuario. +use std::collections::BTreeMap; +use std::sync::LazyLock; + use pagetop::prelude::*; use crate::ADMIN_USERS_PATH; @@ -10,6 +13,32 @@ use crate::component::{PasswordConfirm, error_banner}; use super::{USER_ADMIN_FORM_ID, roles_fieldset}; +// Regiones de la base IANA que sólo contienen alias heredados (fichero `backward`), todos con una +// zona canónica equivalente en otra región (p. ej. `US/Eastern` es `America/New_York`). +const LEGACY_REGIONS: [&str; 5] = ["Brazil", "Canada", "Chile", "Mexico", "US"]; + +// Zonas horarias IANA canónicas agrupadas por región (lo anterior a la primera `/`), ordenadas por +// región y nombre. Se descartan los alias heredados: los nombres sin región (`GB`, `Japan`, +// `EST5EDT`...), los de `LEGACY_REGIONS` y los de `Etc` salvo `Etc/UTC`, cuyo grupo va al final. +static TZ_BY_REGION: LazyLock)>> = LazyLock::new(|| { + let mut regions: BTreeMap<&'static str, Vec<&'static str>> = BTreeMap::new(); + for tz in TZ_VARIANTS.iter() { + let name = tz.name(); + let Some((region, _)) = name.split_once('/') else { + continue; + }; + if LEGACY_REGIONS.contains(®ion) || (region == "Etc" && name != "Etc/UTC") { + continue; + } + regions.entry(region).or_default().push(name); + } + for names in regions.values_mut() { + names.sort_unstable(); + } + let etc = regions.remove_entry("Etc"); + regions.into_iter().chain(etc).collect() +}); + #[derive(AutoDefault, Clone, Copy, Debug, PartialEq)] pub(crate) enum UserFormMode { #[default] @@ -81,17 +110,13 @@ impl Component for UserForm { .with_value(self.language()) .with_label(Lc::t("field-language", &LOCALES_USER)), ) - .with_child( - form::input::Field::text() - .with_name("timezone") - .with_value(self.timezone()) - .with_label(Lc::t("field-timezone", &LOCALES_USER)), - ); + .with_child(timezone_field(self.timezone())); if *self.mode() == UserFormMode::New { - form = form - .with_child(PasswordConfirm::new()) - .with_child(roles_fieldset(self.roles())); + form = form.with_child(PasswordConfirm::new()); + if !self.roles().is_empty() { + form = form.with_child(roles_fieldset(self.roles())); + } if *self.allow_admin_field() { form = form.with_child( @@ -181,3 +206,29 @@ impl UserForm { self } } + +// `