✨ (user): Añade zona horaria propia del usuario

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).
This commit is contained in:
Manuel Cillero 2026-10-02 22:31:58 +02:00
parent 4cd75af8d2
commit 04502ef81e
20 changed files with 162 additions and 92 deletions

View file

@ -1,4 +1,3 @@
use pagetop::prelude::*;
use pagetop_seaorm::db::*; use pagetop_seaorm::db::*;
/// Entidad SeaORM para la tabla `settings`. /// Entidad SeaORM para la tabla `settings`.
@ -9,7 +8,7 @@ pub struct Model {
pub key: String, pub key: String,
pub scope: String, pub scope: String,
pub value: String, pub value: String,
pub updated_at: NaiveDateTime, pub updated_at: DateTimeUtc,
pub updated_by: Option<i32>, pub updated_by: Option<i32>,
} }

View file

@ -11,7 +11,7 @@ use serde::{Serialize, de::DeserializeOwned};
use crate::entity::setting::{ActiveModel, Column, Entity}; use crate::entity::setting::{ActiveModel, Column, Entity};
use crate::error::AdminError; use crate::error::AdminError;
// **< API pública >********************************************************************************* // **< API pública >********************************************************************************
/// Lee un valor persistido, devolviendo el `Default` del tipo si no existe. /// Lee un valor persistido, devolviendo el `Default` del tipo si no existe.
pub async fn get<T: DeserializeOwned + Default>(key: &str) -> T { pub async fn get<T: DeserializeOwned + Default>(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() list_scope_async(scope).await.unwrap_or_default()
} }
// **< Implementación asíncrona >******************************************************************** // **< Implementación asíncrona >*******************************************************************
async fn get_async<T: DeserializeOwned>(key: &str) -> Result<T, AdminError> { async fn get_async<T: DeserializeOwned>(key: &str) -> Result<T, AdminError> {
let model = Entity::find_by_id(key) let model = Entity::find_by_id(key)
@ -57,7 +57,7 @@ async fn set_async<T: Serialize>(
user_id: Option<i32>, user_id: Option<i32>,
) -> Result<(), AdminError> { ) -> Result<(), AdminError> {
let value_json = serde_json::to_string(value)?; 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?; let existing = Entity::find_by_id(key).one(dbconn()).await?;
if existing.is_some() { if existing.is_some() {
@ -95,7 +95,7 @@ async fn list_scope_async(scope: &str) -> Result<Vec<(String, String)>, AdminErr
Ok(rows.into_iter().map(|m| (m.key, m.value)).collect()) 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`]. /// Tipo de campo de configuración para un [`SettingsSchema`].
#[derive(Clone, Debug)] #[derive(Clone, Debug)]

View file

@ -24,7 +24,7 @@ pub async fn login(
plain_password: &str, plain_password: &str,
remember: bool, remember: bool,
) -> Result<String, AuthError> { ) -> Result<String, AuthError> {
let now = Utc::now().naive_utc(); let now = Utc::now();
// Buscar usuario por username o email. // Buscar usuario por username o email.
let user_model = user::Entity::find() let user_model = user::Entity::find()
@ -119,7 +119,7 @@ pub async fn register(
} }
let hash = password::hash_password(plain_password)?; let hash = password::hash_password(plain_password)?;
let now = Utc::now().naive_utc(); let now = Utc::now();
let status = if SETTINGS.require_email_verification { let status = if SETTINGS.require_email_verification {
UserStatus::Pending UserStatus::Pending
} else { } else {
@ -175,7 +175,7 @@ pub async fn assign_role(user_id: i32, role_id: i32) -> Result<(), AuthError> {
async fn register_failed_login( async fn register_failed_login(
user_model: &user::Model, user_model: &user::Model,
now: NaiveDateTime, now: DateTime<Utc>,
) -> Result<(), AuthError> { ) -> Result<(), AuthError> {
let new_count = user_model.failed_login_count + 1; let new_count = user_model.failed_login_count + 1;
let lock_at = if new_count >= SETTINGS.max_failed_logins { 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 { let new_admin = user::ActiveModel {
id: ActiveValue::NotSet, id: ActiveValue::NotSet,
username: Set(cfg.admin_username.clone()), username: Set(cfg.admin_username.clone()),

View file

@ -1,5 +1,8 @@
//! Formulario de alta/edición de usuario. //! Formulario de alta/edición de usuario.
use std::collections::BTreeMap;
use std::sync::LazyLock;
use pagetop::prelude::*; use pagetop::prelude::*;
use crate::ADMIN_USERS_PATH; use crate::ADMIN_USERS_PATH;
@ -10,6 +13,32 @@ use crate::component::{PasswordConfirm, error_banner};
use super::{USER_ADMIN_FORM_ID, roles_fieldset}; 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<Vec<(&'static str, Vec<&'static str>)>> = 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(&region) || (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)] #[derive(AutoDefault, Clone, Copy, Debug, PartialEq)]
pub(crate) enum UserFormMode { pub(crate) enum UserFormMode {
#[default] #[default]
@ -81,17 +110,13 @@ impl Component for UserForm {
.with_value(self.language()) .with_value(self.language())
.with_label(Lc::t("field-language", &LOCALES_USER)), .with_label(Lc::t("field-language", &LOCALES_USER)),
) )
.with_child( .with_child(timezone_field(self.timezone()));
form::input::Field::text()
.with_name("timezone")
.with_value(self.timezone())
.with_label(Lc::t("field-timezone", &LOCALES_USER)),
);
if *self.mode() == UserFormMode::New { if *self.mode() == UserFormMode::New {
form = form form = form.with_child(PasswordConfirm::new());
.with_child(PasswordConfirm::new()) if !self.roles().is_empty() {
.with_child(roles_fieldset(self.roles())); form = form.with_child(roles_fieldset(self.roles()));
}
if *self.allow_admin_field() { if *self.allow_admin_field() {
form = form.with_child( form = form.with_child(
@ -181,3 +206,29 @@ impl UserForm {
self self
} }
} }
// `<select>` de zona horaria: primero la opción de usar la predeterminada de la aplicación y luego
// un `<optgroup>` por región. La etiqueta de cada opción es el nombre IANA completo.
fn timezone_field(selected: &str) -> form::select::Field {
let mut field = form::select::Field::new()
.with_name("timezone")
.with_label(Lc::t("field-timezone", &LOCALES_USER))
.with_item(
form::select::Item::new(
"",
Lc::t("field-timezone-site-default", &LOCALES_USER)
.with_arg("tz", Timezone::default_tz().name()),
)
.with_selected(selected.is_empty()),
);
for (region, names) in TZ_BY_REGION.iter() {
let mut group = form::select::Group::new(Lc::n(*region));
for name in names {
group = group.with_item(
form::select::Item::new(*name, Lc::n(*name)).with_selected(*name == selected),
);
}
field = field.with_group(group);
}
field
}

View file

@ -54,7 +54,7 @@ pub(crate) async fn seed_demo_data() {
} }
async fn create_demo_roles() -> Result<Vec<i32>, AuthError> { async fn create_demo_roles() -> Result<Vec<i32>, AuthError> {
let now = Utc::now().naive_utc(); let now = Utc::now();
let mut role_ids = Vec::with_capacity(ROLE_COUNT); let mut role_ids = Vec::with_capacity(ROLE_COUNT);
for n in 1..=ROLE_COUNT { for n in 1..=ROLE_COUNT {
let new_role = role::ActiveModel { let new_role = role::ActiveModel {
@ -78,7 +78,7 @@ async fn create_demo_roles() -> Result<Vec<i32>, AuthError> {
async fn create_demo_users(role_ids: &[i32]) -> Result<(), AuthError> { async fn create_demo_users(role_ids: &[i32]) -> Result<(), AuthError> {
let hash = password::hash_password(DEMO_PASSWORD)?; let hash = password::hash_password(DEMO_PASSWORD)?;
let now = Utc::now().naive_utc(); let now = Utc::now();
for n in 1..=USER_COUNT { for n in 1..=USER_COUNT {
let new_user = user::ActiveModel { let new_user = user::ActiveModel {

View file

@ -1,4 +1,3 @@
use pagetop::prelude::*;
use pagetop_seaorm::db::*; use pagetop_seaorm::db::*;
#[derive(Clone, Debug, DeriveEntityModel, PartialEq)] #[derive(Clone, Debug, DeriveEntityModel, PartialEq)]
@ -12,8 +11,8 @@ pub struct Model {
pub description: Option<String>, pub description: Option<String>,
pub weight: i32, pub weight: i32,
pub locked: bool, pub locked: bool,
pub created_at: NaiveDateTime, pub created_at: DateTimeUtc,
pub updated_at: NaiveDateTime, pub updated_at: DateTimeUtc,
} }
#[derive(Clone, Copy, Debug, DeriveRelation, EnumIter)] #[derive(Clone, Copy, Debug, DeriveRelation, EnumIter)]

View file

@ -1,4 +1,3 @@
use pagetop::prelude::*;
use pagetop_seaorm::db::*; use pagetop_seaorm::db::*;
#[derive(Clone, Debug, DeriveEntityModel, PartialEq)] #[derive(Clone, Debug, DeriveEntityModel, PartialEq)]
@ -8,7 +7,7 @@ pub struct Model {
pub role_id: i32, pub role_id: i32,
#[sea_orm(primary_key, auto_increment = false)] #[sea_orm(primary_key, auto_increment = false)]
pub permission_key: String, pub permission_key: String,
pub granted_at: NaiveDateTime, pub granted_at: DateTimeUtc,
} }
#[derive(Clone, Copy, Debug, DeriveRelation, EnumIter)] #[derive(Clone, Copy, Debug, DeriveRelation, EnumIter)]

View file

@ -1,4 +1,3 @@
use pagetop::prelude::*;
use pagetop_seaorm::db::*; use pagetop_seaorm::db::*;
#[derive(Clone, Debug, DeriveEntityModel, PartialEq)] #[derive(Clone, Debug, DeriveEntityModel, PartialEq)]
@ -8,9 +7,9 @@ pub struct Model {
pub sid: String, pub sid: String,
pub user_id: i32, pub user_id: i32,
pub data: String, pub data: String,
pub last_activity_at: Option<NaiveDateTime>, pub last_activity_at: Option<DateTimeUtc>,
pub expires_at: NaiveDateTime, pub expires_at: DateTimeUtc,
pub created_at: NaiveDateTime, pub created_at: DateTimeUtc,
} }
#[derive(Clone, Copy, Debug, DeriveRelation, EnumIter)] #[derive(Clone, Copy, Debug, DeriveRelation, EnumIter)]

View file

@ -1,4 +1,3 @@
use pagetop::prelude::*;
use pagetop_seaorm::db::*; use pagetop_seaorm::db::*;
#[derive(Clone, Debug, DeriveEntityModel, PartialEq)] #[derive(Clone, Debug, DeriveEntityModel, PartialEq)]
@ -10,20 +9,20 @@ pub struct Model {
pub username: String, pub username: String,
#[sea_orm(unique)] #[sea_orm(unique)]
pub email: String, pub email: String,
pub email_verified_at: Option<NaiveDateTime>, pub email_verified_at: Option<DateTimeUtc>,
pub password_hash: String, pub password_hash: String,
pub status: i16, pub status: i16,
pub language: Option<String>, pub language: Option<String>,
pub timezone: Option<String>, pub timezone: Option<String>,
pub display_name: Option<String>, pub display_name: Option<String>,
pub last_login_at: Option<NaiveDateTime>, pub last_login_at: Option<DateTimeUtc>,
pub last_access_at: Option<NaiveDateTime>, pub last_access_at: Option<DateTimeUtc>,
pub failed_login_count: i32, pub failed_login_count: i32,
pub locked_until: Option<NaiveDateTime>, pub locked_until: Option<DateTimeUtc>,
/// Acceso irrestricto al sistema, sin pasar por roles ni permisos. /// Acceso irrestricto al sistema, sin pasar por roles ni permisos.
pub is_admin: bool, pub is_admin: bool,
pub created_at: NaiveDateTime, pub created_at: DateTimeUtc,
pub updated_at: NaiveDateTime, pub updated_at: DateTimeUtc,
} }
#[derive(Clone, Copy, Debug, DeriveRelation, EnumIter)] #[derive(Clone, Copy, Debug, DeriveRelation, EnumIter)]

View file

@ -1,4 +1,3 @@
use pagetop::prelude::*;
use pagetop_seaorm::db::*; use pagetop_seaorm::db::*;
#[derive(Clone, Debug, DeriveEntityModel, PartialEq)] #[derive(Clone, Debug, DeriveEntityModel, PartialEq)]
@ -10,10 +9,10 @@ pub struct Model {
pub kind: String, pub kind: String,
#[sea_orm(unique)] #[sea_orm(unique)]
pub token_hash: String, pub token_hash: String,
pub expires_at: NaiveDateTime, pub expires_at: DateTimeUtc,
pub consumed_at: Option<NaiveDateTime>, pub consumed_at: Option<DateTimeUtc>,
pub created_at: NaiveDateTime, pub created_at: DateTimeUtc,
pub updated_at: NaiveDateTime, pub updated_at: DateTimeUtc,
} }
#[derive(Clone, Copy, Debug, DeriveRelation, EnumIter)] #[derive(Clone, Copy, Debug, DeriveRelation, EnumIter)]

View file

@ -37,6 +37,9 @@ pub enum AuthError {
#[error("password must be at least {0} characters")] #[error("password must be at least {0} characters")]
PasswordTooShort(usize), PasswordTooShort(usize),
#[error("invalid IANA timezone identifier")]
InvalidTimezone,
#[error("user not found")] #[error("user not found")]
UserNotFound, UserNotFound,

View file

@ -28,6 +28,7 @@ pub(crate) fn map_auth_error(err: &AuthError) -> Lc {
Lc::t("error-password-too-short", &LOCALES_USER).with_arg("n", n.to_string()) Lc::t("error-password-too-short", &LOCALES_USER).with_arg("n", n.to_string())
} }
AuthError::PasswordMismatch => Lc::t("error-password-mismatch", &LOCALES_USER), AuthError::PasswordMismatch => Lc::t("error-password-mismatch", &LOCALES_USER),
AuthError::InvalidTimezone => Lc::t("error-invalid-timezone", &LOCALES_USER),
AuthError::UsernameTaken => Lc::t("error-username-taken", &LOCALES_USER), AuthError::UsernameTaken => Lc::t("error-username-taken", &LOCALES_USER),
AuthError::EmailTaken => Lc::t("error-email-taken", &LOCALES_USER), AuthError::EmailTaken => Lc::t("error-email-taken", &LOCALES_USER),
AuthError::UserNotFound => Lc::t("error-user-not-found", &LOCALES_USER), AuthError::UserNotFound => Lc::t("error-user-not-found", &LOCALES_USER),

View file

@ -247,7 +247,7 @@ pub async fn password_reset_confirm_get(
use crate::token::hash_token; use crate::token::hash_token;
use pagetop_seaorm::db::{ColumnTrait, EntityTrait, QueryFilter, dbconn}; use pagetop_seaorm::db::{ColumnTrait, EntityTrait, QueryFilter, dbconn};
let hash = hash_token(&token); let hash = hash_token(&token);
let now = Utc::now().naive_utc(); let now = Utc::now();
user_token::Entity::find() user_token::Entity::find()
.filter(user_token::Column::TokenHash.eq(&hash)) .filter(user_token::Column::TokenHash.eq(&hash))
.filter(user_token::Column::UserId.eq(uid)) .filter(user_token::Column::UserId.eq(uid))
@ -291,7 +291,7 @@ pub async fn password_reset_confirm_post(
let user_id = consume_token(&token, TokenKind::PasswordReset).await?; let user_id = consume_token(&token, TokenKind::PasswordReset).await?;
password::validate_strength(&form.password)?; password::validate_strength(&form.password)?;
let hash = password::hash_password(&form.password)?; let hash = password::hash_password(&form.password)?;
let now = Utc::now().naive_utc(); let now = Utc::now();
user::ActiveModel { user::ActiveModel {
id: Set(user_id), id: Set(user_id),
password_hash: Set(hash), password_hash: Set(hash),
@ -333,7 +333,7 @@ pub async fn verify_email_get(
use crate::entity::user; use crate::entity::user;
use pagetop_seaorm::db::{ActiveModelTrait, Set, dbconn}; use pagetop_seaorm::db::{ActiveModelTrait, Set, dbconn};
let user_id = consume_token(&token, TokenKind::EmailVerification).await?; let user_id = consume_token(&token, TokenKind::EmailVerification).await?;
let now = Utc::now().naive_utc(); let now = Utc::now();
user::ActiveModel { user::ActiveModel {
id: Set(user_id), id: Set(user_id),
email_verified_at: Set(Some(now)), email_verified_at: Set(Some(now)),
@ -352,7 +352,7 @@ pub async fn verify_email_get(
} }
} }
// **< helpers privados >*************************************************************************** // **< HELPERS >************************************************************************************
fn redirect_with_cookie(to: impl Into<RoutePath>, cookie: &str) -> Response { fn redirect_with_cookie(to: impl Into<RoutePath>, cookie: &str) -> Response {
( (

View file

@ -55,6 +55,7 @@ error-account-pending = Please verify your email address before signing in.
error-account-locked = Too many failed attempts. Please try again later. error-account-locked = Too many failed attempts. Please try again later.
error-password-mismatch = Passwords do not match. error-password-mismatch = Passwords do not match.
error-password-too-short = Password must be at least { $n } characters. error-password-too-short = Password must be at least { $n } characters.
error-invalid-timezone = Invalid timezone.
error-username-taken = This username is already taken. error-username-taken = This username is already taken.
error-email-taken = This email address is already registered. error-email-taken = This email address is already registered.
error-token-invalid = This link is invalid or has expired. error-token-invalid = This link is invalid or has expired.
@ -104,17 +105,18 @@ col-users-count = Users
# **< Admin: field labels >** # **< Admin: field labels >**
field-username-admin = Username field-username-admin = Username
field-display-name = Display name field-display-name = Display name
field-language = Language field-language = Language
field-timezone = Timezone field-timezone = Timezone
field-machine-name = Machine name field-timezone-site-default = Use site timezone ({ $tz })
field-label = Label field-machine-name = Machine name
field-description = Description field-label = Label
field-weight = Weight field-description = Description
field-roles = Roles field-weight = Weight
field-is-admin = Administrator (unrestricted access) field-roles = Roles
field-search-users = Search by username, email or name... field-is-admin = Administrator (unrestricted access)
field-search-users = Search by username, email or name...
help-machine-name-immutable = help-machine-name-immutable =
Lowercase letters, digits and underscores only. Cannot be changed after creation. Lowercase letters, digits and underscores only. Cannot be changed after creation.

View file

@ -55,6 +55,7 @@ error-account-pending = Verifica tu dirección de email antes de iniciar ses
error-account-locked = Demasiados intentos fallidos. Inténtalo de nuevo más tarde. error-account-locked = Demasiados intentos fallidos. Inténtalo de nuevo más tarde.
error-password-mismatch = Las contraseñas no coinciden. error-password-mismatch = Las contraseñas no coinciden.
error-password-too-short = La contraseña debe tener al menos { $n } caracteres. error-password-too-short = La contraseña debe tener al menos { $n } caracteres.
error-invalid-timezone = Zona horaria no válida.
error-username-taken = Este nombre de usuario ya está en uso. error-username-taken = Este nombre de usuario ya está en uso.
error-email-taken = Esta dirección de email ya está registrada. error-email-taken = Esta dirección de email ya está registrada.
error-token-invalid = Este enlace no es válido o ha caducado. error-token-invalid = Este enlace no es válido o ha caducado.
@ -104,17 +105,18 @@ col-users-count = Usuarios
# **< Administración: etiquetas de campos >** # **< Administración: etiquetas de campos >**
field-username-admin = Usuario field-username-admin = Usuario
field-display-name = Nombre visible field-display-name = Nombre visible
field-language = Idioma field-language = Idioma
field-timezone = Zona horaria field-timezone = Zona horaria
field-machine-name = Nombre técnico field-timezone-site-default = Usar la zona horaria del sitio ({ $tz })
field-label = Etiqueta field-machine-name = Nombre técnico
field-description = Descripción field-label = Etiqueta
field-weight = Peso field-description = Descripción
field-roles = Roles field-weight = Peso
field-is-admin = Administrador (acceso irrestricto) field-roles = Roles
field-search-users = Buscar por usuario, email o nombre... field-is-admin = Administrador (acceso irrestricto)
field-search-users = Buscar por usuario, email o nombre...
help-machine-name-immutable = help-machine-name-immutable =
Sólo minúsculas, dígitos y guiones bajos. No se puede cambiar tras crearlo. Sólo minúsculas, dígitos y guiones bajos. No se puede cambiar tras crearlo.

View file

@ -178,7 +178,7 @@ pub(crate) async fn create_role(data: NewRoleData<'_>) -> Result<i32, AuthError>
return Err(AuthError::RoleMachineNameTaken); return Err(AuthError::RoleMachineNameTaken);
} }
let now = Utc::now().naive_utc(); let now = Utc::now();
let new_role = role::ActiveModel { let new_role = role::ActiveModel {
id: ActiveValue::NotSet, id: ActiveValue::NotSet,
machine_name: Set(data.machine_name.to_owned()), machine_name: Set(data.machine_name.to_owned()),
@ -207,7 +207,7 @@ pub(crate) async fn update_role(role_id: i32, data: RoleUpdateData<'_>) -> Resul
return Err(AuthError::RoleLocked); return Err(AuthError::RoleLocked);
} }
let now = Utc::now().naive_utc(); let now = Utc::now();
role::ActiveModel { role::ActiveModel {
id: Set(role_id), id: Set(role_id),
label: Set(data.label.to_owned()), label: Set(data.label.to_owned()),
@ -258,7 +258,7 @@ pub(crate) async fn set_role_permissions(
} }
} }
let now = Utc::now().naive_utc(); let now = Utc::now();
let keys = permission_keys.to_vec(); let keys = permission_keys.to_vec();
dbconn() dbconn()
.transaction::<_, _, AuthError>(|txn| { .transaction::<_, _, AuthError>(|txn| {

View file

@ -15,7 +15,7 @@ use crate::error::AuthError;
use crate::password; use crate::password;
use crate::session; use crate::session;
// **< listado >************************************************************************************** // **< listado >************************************************************************************
#[derive(Clone, Copy, Debug, Default, PartialEq)] #[derive(Clone, Copy, Debug, Default, PartialEq)]
pub(crate) enum UserSortField { pub(crate) enum UserSortField {
@ -126,7 +126,7 @@ async fn user_items(users: Vec<user::Model>) -> Result<Vec<UserListItem>, AuthEr
.collect()) .collect())
} }
// **< find_user / user_role_ids >********************************************************************** // **< find_user / user_role_ids >******************************************************************
pub(crate) async fn find_user(user_id: i32) -> Result<user::Model, AuthError> { pub(crate) async fn find_user(user_id: i32) -> Result<user::Model, AuthError> {
user::Entity::find_by_id(user_id) user::Entity::find_by_id(user_id)
@ -152,7 +152,7 @@ pub(crate) async fn user_roles(user_id: i32) -> Result<Vec<role::Model>, AuthErr
.await?) .await?)
} }
// **< create_user >********************************************************************************* // **< create_user >********************************************************************************
pub(crate) struct NewUserData<'a> { pub(crate) struct NewUserData<'a> {
pub username: &'a str, pub username: &'a str,
@ -173,11 +173,12 @@ pub(crate) struct NewUserData<'a> {
pub(crate) async fn create_user(data: NewUserData<'_>) -> Result<i32, AuthError> { pub(crate) async fn create_user(data: NewUserData<'_>) -> Result<i32, AuthError> {
password::validate_strength(data.password)?; password::validate_strength(data.password)?;
password::passwords_match(data.password, data.confirm_password)?; password::passwords_match(data.password, data.confirm_password)?;
let timezone = validate_timezone(data.timezone)?;
ensure_username_available(data.username, None).await?; ensure_username_available(data.username, None).await?;
ensure_email_available(data.email, None).await?; ensure_email_available(data.email, None).await?;
let hash = password::hash_password(data.password)?; let hash = password::hash_password(data.password)?;
let now = Utc::now().naive_utc(); let now = Utc::now();
let new_user = user::ActiveModel { let new_user = user::ActiveModel {
id: ActiveValue::NotSet, id: ActiveValue::NotSet,
@ -187,7 +188,7 @@ pub(crate) async fn create_user(data: NewUserData<'_>) -> Result<i32, AuthError>
password_hash: Set(hash), password_hash: Set(hash),
status: Set(UserStatus::Active.as_i16()), status: Set(UserStatus::Active.as_i16()),
language: Set(data.language.map(str::to_owned)), language: Set(data.language.map(str::to_owned)),
timezone: Set(data.timezone.map(str::to_owned)), timezone: Set(timezone.map(str::to_owned)),
display_name: Set(data.display_name.map(str::to_owned)), display_name: Set(data.display_name.map(str::to_owned)),
last_login_at: Set(None), last_login_at: Set(None),
last_access_at: Set(None), last_access_at: Set(None),
@ -207,7 +208,7 @@ pub(crate) async fn create_user(data: NewUserData<'_>) -> Result<i32, AuthError>
Ok(user_id) Ok(user_id)
} }
// **< update_user >********************************************************************************* // **< update_user >********************************************************************************
pub(crate) struct UserUpdateData<'a> { pub(crate) struct UserUpdateData<'a> {
pub username: &'a str, pub username: &'a str,
@ -218,17 +219,18 @@ pub(crate) struct UserUpdateData<'a> {
} }
pub(crate) async fn update_user(user_id: i32, data: UserUpdateData<'_>) -> Result<(), AuthError> { pub(crate) async fn update_user(user_id: i32, data: UserUpdateData<'_>) -> Result<(), AuthError> {
let timezone = validate_timezone(data.timezone)?;
ensure_username_available(data.username, Some(user_id)).await?; ensure_username_available(data.username, Some(user_id)).await?;
ensure_email_available(data.email, Some(user_id)).await?; ensure_email_available(data.email, Some(user_id)).await?;
let now = Utc::now().naive_utc(); let now = Utc::now();
user::ActiveModel { user::ActiveModel {
id: Set(user_id), id: Set(user_id),
username: Set(data.username.to_owned()), username: Set(data.username.to_owned()),
email: Set(data.email.to_owned()), email: Set(data.email.to_owned()),
display_name: Set(data.display_name.map(str::to_owned)), display_name: Set(data.display_name.map(str::to_owned)),
language: Set(data.language.map(str::to_owned)), language: Set(data.language.map(str::to_owned)),
timezone: Set(data.timezone.map(str::to_owned)), timezone: Set(timezone.map(str::to_owned)),
updated_at: Set(now), updated_at: Set(now),
..Default::default() ..Default::default()
} }
@ -237,7 +239,7 @@ pub(crate) async fn update_user(user_id: i32, data: UserUpdateData<'_>) -> Resul
Ok(()) Ok(())
} }
// **< set_user_roles >****************************************************************************** // **< set_user_roles >*****************************************************************************
/// Reemplaza por completo el conjunto de roles asignados a un usuario. /// Reemplaza por completo el conjunto de roles asignados a un usuario.
/// ///
@ -276,7 +278,7 @@ pub(crate) async fn set_user_roles(user_id: i32, role_ids: &[i32]) -> Result<(),
.map_err(flatten_txn_err) .map_err(flatten_txn_err)
} }
// **< set_user_status >***************************************************************************** // **< set_user_status >****************************************************************************
/// Cambia el estado de la cuenta. Rechaza que un usuario se bloquee a sí mismo o bloquee al último /// Cambia el estado de la cuenta. Rechaza que un usuario se bloquee a sí mismo o bloquee al último
/// administrador. Al bloquear, invalida todas las sesiones activas del usuario. /// administrador. Al bloquear, invalida todas las sesiones activas del usuario.
@ -296,7 +298,7 @@ pub(crate) async fn set_user_status(
} }
} }
let now = Utc::now().naive_utc(); let now = Utc::now();
user::ActiveModel { user::ActiveModel {
id: Set(user_id), id: Set(user_id),
status: Set(new_status.as_i16()), status: Set(new_status.as_i16()),
@ -315,7 +317,7 @@ pub(crate) async fn set_user_status(
Ok(()) Ok(())
} }
// **< set_user_admin >****************************************************************************** // **< set_user_admin >*****************************************************************************
/// Concede o revoca el acceso irrestricto (`is_admin`). No es un permiso del catálogo: sólo un /// Concede o revoca el acceso irrestricto (`is_admin`). No es un permiso del catálogo: sólo un
/// administrador puede concederlo o revocarlo (el handler comprueba `account.is_admin` /// administrador puede concederlo o revocarlo (el handler comprueba `account.is_admin`
@ -336,7 +338,7 @@ pub(crate) async fn set_user_admin(
return Err(AuthError::CannotModifyOwnAdminFlag); return Err(AuthError::CannotModifyOwnAdminFlag);
} }
let now = Utc::now().naive_utc(); let now = Utc::now();
user::ActiveModel { user::ActiveModel {
id: Set(user_id), id: Set(user_id),
is_admin: Set(is_admin), is_admin: Set(is_admin),
@ -348,7 +350,7 @@ pub(crate) async fn set_user_admin(
Ok(()) Ok(())
} }
// **< admin_reset_password >************************************************************************ // **< admin_reset_password >***********************************************************************
/// Restablece la contraseña de un usuario como acción administrativa e invalida sus sesiones /// Restablece la contraseña de un usuario como acción administrativa e invalida sus sesiones
/// activas. /// activas.
@ -360,7 +362,7 @@ pub(crate) async fn admin_reset_password(
password::validate_strength(new_password)?; password::validate_strength(new_password)?;
let hash = password::hash_password(new_password)?; let hash = password::hash_password(new_password)?;
let now = Utc::now().naive_utc(); let now = Utc::now();
user::ActiveModel { user::ActiveModel {
id: Set(user_id), id: Set(user_id),
password_hash: Set(hash), password_hash: Set(hash),
@ -376,7 +378,17 @@ pub(crate) async fn admin_reset_password(
Ok(()) Ok(())
} }
// **< helpers privados >**************************************************************************** // **< HELPERS >************************************************************************************
// Devuelve la zona sin espacios, tal como debe guardarse. Una zona ausente o en blanco es válida y
// devuelve `None`: equivale a usar la predeterminada de la aplicación.
fn validate_timezone(timezone: Option<&str>) -> Result<Option<&str>, AuthError> {
let timezone = timezone.and_then(util::non_blank);
if let Some(tz) = timezone {
tz.parse::<Tz>().map_err(|_| AuthError::InvalidTimezone)?;
}
Ok(timezone)
}
async fn ensure_username_available( async fn ensure_username_available(
username: &str, username: &str,

View file

@ -90,7 +90,7 @@ pub async fn resolve_session(headers: &web::http::HeaderMap) -> (CurrentUser, Op
/// ///
/// Devuelve `(CurrentUser::Anonymous, None)` si la sesión no existe o ha expirado. /// Devuelve `(CurrentUser::Anonymous, None)` si la sesión no existe o ha expirado.
pub async fn load_user_from_session(sid: &str) -> (CurrentUser, Option<Account>) { pub async fn load_user_from_session(sid: &str) -> (CurrentUser, Option<Account>) {
let now = Utc::now().naive_utc(); let now = Utc::now();
// Buscar sesión activa y no expirada. // Buscar sesión activa y no expirada.
let Ok(Some(sess)) = session::Entity::find_by_id(sid).one(dbconn()).await else { let Ok(Some(sess)) = session::Entity::find_by_id(sid).one(dbconn()).await else {
@ -178,9 +178,14 @@ pub async fn load_user_from_session(sid: &str) -> (CurrentUser, Option<Account>)
permissions, permissions,
is_admin, is_admin,
}; };
let timezone = user_model
.timezone
.as_deref()
.and_then(|tz| tz.parse().ok());
let current_user = CurrentUser::Authenticated { let current_user = CurrentUser::Authenticated {
id: account.id, id: account.id,
display_name: visible_name, display_name: visible_name,
timezone,
}; };
(current_user, Some(account)) (current_user, Some(account))
@ -191,7 +196,7 @@ pub async fn load_user_from_session(sid: &str) -> (CurrentUser, Option<Account>)
/// Crea una nueva sesión en base de datos y devuelve el session ID. /// Crea una nueva sesión en base de datos y devuelve el session ID.
pub async fn create_session(user_id: i32, remember: bool) -> Result<String, DbErr> { pub async fn create_session(user_id: i32, remember: bool) -> Result<String, DbErr> {
let sid = generate_sid(); let sid = generate_sid();
let now = Utc::now().naive_utc(); let now = Utc::now();
let ttl = Duration::seconds(SETTINGS.session_ttl_secs); let ttl = Duration::seconds(SETTINGS.session_ttl_secs);
let idle = Duration::seconds(SETTINGS.session_idle_ttl_secs); let idle = Duration::seconds(SETTINGS.session_idle_ttl_secs);
let expires_at = if remember { now + ttl } else { now + idle }; let expires_at = if remember { now + ttl } else { now + idle };

View file

@ -79,7 +79,7 @@ pub async fn create_token(user_id: i32, kind: TokenKind) -> Result<String, AuthE
.await?; .await?;
let token = generate_token(); let token = generate_token();
let now = Utc::now().naive_utc(); let now = Utc::now();
let expires_at = now + Duration::seconds(kind.ttl_secs()); let expires_at = now + Duration::seconds(kind.ttl_secs());
let new_token = user_token::ActiveModel { let new_token = user_token::ActiveModel {
@ -102,7 +102,7 @@ pub async fn create_token(user_id: i32, kind: TokenKind) -> Result<String, AuthE
/// y lo marca como consumido. Devuelve el `user_id` asociado. /// y lo marca como consumido. Devuelve el `user_id` asociado.
pub async fn consume_token(token: &str, kind: TokenKind) -> Result<i32, AuthError> { pub async fn consume_token(token: &str, kind: TokenKind) -> Result<i32, AuthError> {
let hash = hash_token(token); let hash = hash_token(token);
let now = Utc::now().naive_utc(); let now = Utc::now();
let row = user_token::Entity::find() let row = user_token::Entity::find()
.filter(user_token::Column::TokenHash.eq(&hash)) .filter(user_token::Column::TokenHash.eq(&hash))

View file

@ -44,7 +44,7 @@ use crate::{CowStr, Weight};
pub enum CurrentUser { pub enum CurrentUser {
/// Usuario no autenticado. /// Usuario no autenticado.
Anonymous, Anonymous,
/// Usuario autenticado con su identificador y nombre visible. /// Usuario autenticado con su identificador, nombre visible y zona horaria propia.
Authenticated { Authenticated {
/// Identificador único del usuario en el sistema. /// Identificador único del usuario en el sistema.
id: i32, id: i32,