✨ (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:
parent
4cd75af8d2
commit
04502ef81e
20 changed files with 162 additions and 92 deletions
|
|
@ -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<i32>,
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<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()
|
||||
}
|
||||
|
||||
// **< Implementación asíncrona >********************************************************************
|
||||
// **< Implementación asíncrona >*******************************************************************
|
||||
|
||||
async fn get_async<T: DeserializeOwned>(key: &str) -> Result<T, AdminError> {
|
||||
let model = Entity::find_by_id(key)
|
||||
|
|
@ -57,7 +57,7 @@ async fn set_async<T: Serialize>(
|
|||
user_id: Option<i32>,
|
||||
) -> 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<Vec<(String, String)>, 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)]
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ pub async fn login(
|
|||
plain_password: &str,
|
||||
remember: bool,
|
||||
) -> Result<String, AuthError> {
|
||||
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<Utc>,
|
||||
) -> 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()),
|
||||
|
|
|
|||
|
|
@ -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<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(®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
|
||||
}
|
||||
}
|
||||
|
||||
// `<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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@ pub(crate) async fn seed_demo_data() {
|
|||
}
|
||||
|
||||
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);
|
||||
for n in 1..=ROLE_COUNT {
|
||||
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> {
|
||||
let hash = password::hash_password(DEMO_PASSWORD)?;
|
||||
let now = Utc::now().naive_utc();
|
||||
let now = Utc::now();
|
||||
|
||||
for n in 1..=USER_COUNT {
|
||||
let new_user = user::ActiveModel {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
use pagetop::prelude::*;
|
||||
use pagetop_seaorm::db::*;
|
||||
|
||||
#[derive(Clone, Debug, DeriveEntityModel, PartialEq)]
|
||||
|
|
@ -12,8 +11,8 @@ pub struct Model {
|
|||
pub description: Option<String>,
|
||||
pub weight: i32,
|
||||
pub locked: bool,
|
||||
pub created_at: NaiveDateTime,
|
||||
pub updated_at: NaiveDateTime,
|
||||
pub created_at: DateTimeUtc,
|
||||
pub updated_at: DateTimeUtc,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, DeriveRelation, EnumIter)]
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
use pagetop::prelude::*;
|
||||
use pagetop_seaorm::db::*;
|
||||
|
||||
#[derive(Clone, Debug, DeriveEntityModel, PartialEq)]
|
||||
|
|
@ -8,7 +7,7 @@ pub struct Model {
|
|||
pub role_id: i32,
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub permission_key: String,
|
||||
pub granted_at: NaiveDateTime,
|
||||
pub granted_at: DateTimeUtc,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, DeriveRelation, EnumIter)]
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
use pagetop::prelude::*;
|
||||
use pagetop_seaorm::db::*;
|
||||
|
||||
#[derive(Clone, Debug, DeriveEntityModel, PartialEq)]
|
||||
|
|
@ -8,9 +7,9 @@ pub struct Model {
|
|||
pub sid: String,
|
||||
pub user_id: i32,
|
||||
pub data: String,
|
||||
pub last_activity_at: Option<NaiveDateTime>,
|
||||
pub expires_at: NaiveDateTime,
|
||||
pub created_at: NaiveDateTime,
|
||||
pub last_activity_at: Option<DateTimeUtc>,
|
||||
pub expires_at: DateTimeUtc,
|
||||
pub created_at: DateTimeUtc,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, DeriveRelation, EnumIter)]
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
use pagetop::prelude::*;
|
||||
use pagetop_seaorm::db::*;
|
||||
|
||||
#[derive(Clone, Debug, DeriveEntityModel, PartialEq)]
|
||||
|
|
@ -10,20 +9,20 @@ pub struct Model {
|
|||
pub username: String,
|
||||
#[sea_orm(unique)]
|
||||
pub email: String,
|
||||
pub email_verified_at: Option<NaiveDateTime>,
|
||||
pub email_verified_at: Option<DateTimeUtc>,
|
||||
pub password_hash: String,
|
||||
pub status: i16,
|
||||
pub language: Option<String>,
|
||||
pub timezone: Option<String>,
|
||||
pub display_name: Option<String>,
|
||||
pub last_login_at: Option<NaiveDateTime>,
|
||||
pub last_access_at: Option<NaiveDateTime>,
|
||||
pub last_login_at: Option<DateTimeUtc>,
|
||||
pub last_access_at: Option<DateTimeUtc>,
|
||||
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.
|
||||
pub is_admin: bool,
|
||||
pub created_at: NaiveDateTime,
|
||||
pub updated_at: NaiveDateTime,
|
||||
pub created_at: DateTimeUtc,
|
||||
pub updated_at: DateTimeUtc,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, DeriveRelation, EnumIter)]
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
use pagetop::prelude::*;
|
||||
use pagetop_seaorm::db::*;
|
||||
|
||||
#[derive(Clone, Debug, DeriveEntityModel, PartialEq)]
|
||||
|
|
@ -10,10 +9,10 @@ pub struct Model {
|
|||
pub kind: String,
|
||||
#[sea_orm(unique)]
|
||||
pub token_hash: String,
|
||||
pub expires_at: NaiveDateTime,
|
||||
pub consumed_at: Option<NaiveDateTime>,
|
||||
pub created_at: NaiveDateTime,
|
||||
pub updated_at: NaiveDateTime,
|
||||
pub expires_at: DateTimeUtc,
|
||||
pub consumed_at: Option<DateTimeUtc>,
|
||||
pub created_at: DateTimeUtc,
|
||||
pub updated_at: DateTimeUtc,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, DeriveRelation, EnumIter)]
|
||||
|
|
|
|||
|
|
@ -37,6 +37,9 @@ pub enum AuthError {
|
|||
#[error("password must be at least {0} characters")]
|
||||
PasswordTooShort(usize),
|
||||
|
||||
#[error("invalid IANA timezone identifier")]
|
||||
InvalidTimezone,
|
||||
|
||||
#[error("user not found")]
|
||||
UserNotFound,
|
||||
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
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::EmailTaken => Lc::t("error-email-taken", &LOCALES_USER),
|
||||
AuthError::UserNotFound => Lc::t("error-user-not-found", &LOCALES_USER),
|
||||
|
|
|
|||
|
|
@ -247,7 +247,7 @@ pub async fn password_reset_confirm_get(
|
|||
use crate::token::hash_token;
|
||||
use pagetop_seaorm::db::{ColumnTrait, EntityTrait, QueryFilter, dbconn};
|
||||
let hash = hash_token(&token);
|
||||
let now = Utc::now().naive_utc();
|
||||
let now = Utc::now();
|
||||
user_token::Entity::find()
|
||||
.filter(user_token::Column::TokenHash.eq(&hash))
|
||||
.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?;
|
||||
password::validate_strength(&form.password)?;
|
||||
let hash = password::hash_password(&form.password)?;
|
||||
let now = Utc::now().naive_utc();
|
||||
let now = Utc::now();
|
||||
user::ActiveModel {
|
||||
id: Set(user_id),
|
||||
password_hash: Set(hash),
|
||||
|
|
@ -333,7 +333,7 @@ pub async fn verify_email_get(
|
|||
use crate::entity::user;
|
||||
use pagetop_seaorm::db::{ActiveModelTrait, Set, dbconn};
|
||||
let user_id = consume_token(&token, TokenKind::EmailVerification).await?;
|
||||
let now = Utc::now().naive_utc();
|
||||
let now = Utc::now();
|
||||
user::ActiveModel {
|
||||
id: Set(user_id),
|
||||
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 {
|
||||
(
|
||||
|
|
|
|||
|
|
@ -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-password-mismatch = Passwords do not match.
|
||||
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-email-taken = This email address is already registered.
|
||||
error-token-invalid = This link is invalid or has expired.
|
||||
|
|
@ -104,17 +105,18 @@ col-users-count = Users
|
|||
|
||||
# **< Admin: field labels >**
|
||||
|
||||
field-username-admin = Username
|
||||
field-display-name = Display name
|
||||
field-language = Language
|
||||
field-timezone = Timezone
|
||||
field-machine-name = Machine name
|
||||
field-label = Label
|
||||
field-description = Description
|
||||
field-weight = Weight
|
||||
field-roles = Roles
|
||||
field-is-admin = Administrator (unrestricted access)
|
||||
field-search-users = Search by username, email or name...
|
||||
field-username-admin = Username
|
||||
field-display-name = Display name
|
||||
field-language = Language
|
||||
field-timezone = Timezone
|
||||
field-timezone-site-default = Use site timezone ({ $tz })
|
||||
field-machine-name = Machine name
|
||||
field-label = Label
|
||||
field-description = Description
|
||||
field-weight = Weight
|
||||
field-roles = Roles
|
||||
field-is-admin = Administrator (unrestricted access)
|
||||
field-search-users = Search by username, email or name...
|
||||
|
||||
help-machine-name-immutable =
|
||||
Lowercase letters, digits and underscores only. Cannot be changed after creation.
|
||||
|
|
|
|||
|
|
@ -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-password-mismatch = Las contraseñas no coinciden.
|
||||
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-email-taken = Esta dirección de email ya está registrada.
|
||||
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 >**
|
||||
|
||||
field-username-admin = Usuario
|
||||
field-display-name = Nombre visible
|
||||
field-language = Idioma
|
||||
field-timezone = Zona horaria
|
||||
field-machine-name = Nombre técnico
|
||||
field-label = Etiqueta
|
||||
field-description = Descripción
|
||||
field-weight = Peso
|
||||
field-roles = Roles
|
||||
field-is-admin = Administrador (acceso irrestricto)
|
||||
field-search-users = Buscar por usuario, email o nombre...
|
||||
field-username-admin = Usuario
|
||||
field-display-name = Nombre visible
|
||||
field-language = Idioma
|
||||
field-timezone = Zona horaria
|
||||
field-timezone-site-default = Usar la zona horaria del sitio ({ $tz })
|
||||
field-machine-name = Nombre técnico
|
||||
field-label = Etiqueta
|
||||
field-description = Descripción
|
||||
field-weight = Peso
|
||||
field-roles = Roles
|
||||
field-is-admin = Administrador (acceso irrestricto)
|
||||
field-search-users = Buscar por usuario, email o nombre...
|
||||
|
||||
help-machine-name-immutable =
|
||||
Sólo minúsculas, dígitos y guiones bajos. No se puede cambiar tras crearlo.
|
||||
|
|
|
|||
|
|
@ -178,7 +178,7 @@ pub(crate) async fn create_role(data: NewRoleData<'_>) -> Result<i32, AuthError>
|
|||
return Err(AuthError::RoleMachineNameTaken);
|
||||
}
|
||||
|
||||
let now = Utc::now().naive_utc();
|
||||
let now = Utc::now();
|
||||
let new_role = role::ActiveModel {
|
||||
id: ActiveValue::NotSet,
|
||||
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);
|
||||
}
|
||||
|
||||
let now = Utc::now().naive_utc();
|
||||
let now = Utc::now();
|
||||
role::ActiveModel {
|
||||
id: Set(role_id),
|
||||
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();
|
||||
dbconn()
|
||||
.transaction::<_, _, AuthError>(|txn| {
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ use crate::error::AuthError;
|
|||
use crate::password;
|
||||
use crate::session;
|
||||
|
||||
// **< listado >**************************************************************************************
|
||||
// **< listado >************************************************************************************
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub(crate) enum UserSortField {
|
||||
|
|
@ -126,7 +126,7 @@ async fn user_items(users: Vec<user::Model>) -> Result<Vec<UserListItem>, AuthEr
|
|||
.collect())
|
||||
}
|
||||
|
||||
// **< find_user / user_role_ids >**********************************************************************
|
||||
// **< find_user / user_role_ids >******************************************************************
|
||||
|
||||
pub(crate) async fn find_user(user_id: i32) -> Result<user::Model, AuthError> {
|
||||
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?)
|
||||
}
|
||||
|
||||
// **< create_user >*********************************************************************************
|
||||
// **< create_user >********************************************************************************
|
||||
|
||||
pub(crate) struct NewUserData<'a> {
|
||||
pub username: &'a str,
|
||||
|
|
@ -173,11 +173,12 @@ 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 timezone = validate_timezone(data.timezone)?;
|
||||
ensure_username_available(data.username, None).await?;
|
||||
ensure_email_available(data.email, None).await?;
|
||||
|
||||
let hash = password::hash_password(data.password)?;
|
||||
let now = Utc::now().naive_utc();
|
||||
let now = Utc::now();
|
||||
|
||||
let new_user = user::ActiveModel {
|
||||
id: ActiveValue::NotSet,
|
||||
|
|
@ -187,7 +188,7 @@ pub(crate) async fn create_user(data: NewUserData<'_>) -> Result<i32, AuthError>
|
|||
password_hash: Set(hash),
|
||||
status: Set(UserStatus::Active.as_i16()),
|
||||
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)),
|
||||
last_login_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)
|
||||
}
|
||||
|
||||
// **< update_user >*********************************************************************************
|
||||
// **< update_user >********************************************************************************
|
||||
|
||||
pub(crate) struct UserUpdateData<'a> {
|
||||
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> {
|
||||
let timezone = validate_timezone(data.timezone)?;
|
||||
ensure_username_available(data.username, 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 {
|
||||
id: Set(user_id),
|
||||
username: Set(data.username.to_owned()),
|
||||
email: Set(data.email.to_owned()),
|
||||
display_name: Set(data.display_name.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),
|
||||
..Default::default()
|
||||
}
|
||||
|
|
@ -237,7 +239,7 @@ pub(crate) async fn update_user(user_id: i32, data: UserUpdateData<'_>) -> Resul
|
|||
Ok(())
|
||||
}
|
||||
|
||||
// **< set_user_roles >******************************************************************************
|
||||
// **< set_user_roles >*****************************************************************************
|
||||
|
||||
/// 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)
|
||||
}
|
||||
|
||||
// **< 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
|
||||
/// 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 {
|
||||
id: Set(user_id),
|
||||
status: Set(new_status.as_i16()),
|
||||
|
|
@ -315,7 +317,7 @@ pub(crate) async fn set_user_status(
|
|||
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
|
||||
/// 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);
|
||||
}
|
||||
|
||||
let now = Utc::now().naive_utc();
|
||||
let now = Utc::now();
|
||||
user::ActiveModel {
|
||||
id: Set(user_id),
|
||||
is_admin: Set(is_admin),
|
||||
|
|
@ -348,7 +350,7 @@ pub(crate) async fn set_user_admin(
|
|||
Ok(())
|
||||
}
|
||||
|
||||
// **< admin_reset_password >************************************************************************
|
||||
// **< admin_reset_password >***********************************************************************
|
||||
|
||||
/// Restablece la contraseña de un usuario como acción administrativa e invalida sus sesiones
|
||||
/// activas.
|
||||
|
|
@ -360,7 +362,7 @@ pub(crate) async fn admin_reset_password(
|
|||
password::validate_strength(new_password)?;
|
||||
let hash = password::hash_password(new_password)?;
|
||||
|
||||
let now = Utc::now().naive_utc();
|
||||
let now = Utc::now();
|
||||
user::ActiveModel {
|
||||
id: Set(user_id),
|
||||
password_hash: Set(hash),
|
||||
|
|
@ -376,7 +378,17 @@ pub(crate) async fn admin_reset_password(
|
|||
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(
|
||||
username: &str,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
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.
|
||||
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,
|
||||
is_admin,
|
||||
};
|
||||
let timezone = user_model
|
||||
.timezone
|
||||
.as_deref()
|
||||
.and_then(|tz| tz.parse().ok());
|
||||
let current_user = CurrentUser::Authenticated {
|
||||
id: account.id,
|
||||
display_name: visible_name,
|
||||
timezone,
|
||||
};
|
||||
|
||||
(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.
|
||||
pub async fn create_session(user_id: i32, remember: bool) -> Result<String, DbErr> {
|
||||
let sid = generate_sid();
|
||||
let now = Utc::now().naive_utc();
|
||||
let now = Utc::now();
|
||||
let ttl = Duration::seconds(SETTINGS.session_ttl_secs);
|
||||
let idle = Duration::seconds(SETTINGS.session_idle_ttl_secs);
|
||||
let expires_at = if remember { now + ttl } else { now + idle };
|
||||
|
|
|
|||
|
|
@ -79,7 +79,7 @@ pub async fn create_token(user_id: i32, kind: TokenKind) -> Result<String, AuthE
|
|||
.await?;
|
||||
|
||||
let token = generate_token();
|
||||
let now = Utc::now().naive_utc();
|
||||
let now = Utc::now();
|
||||
let expires_at = now + Duration::seconds(kind.ttl_secs());
|
||||
|
||||
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.
|
||||
pub async fn consume_token(token: &str, kind: TokenKind) -> Result<i32, AuthError> {
|
||||
let hash = hash_token(token);
|
||||
let now = Utc::now().naive_utc();
|
||||
let now = Utc::now();
|
||||
|
||||
let row = user_token::Entity::find()
|
||||
.filter(user_token::Column::TokenHash.eq(&hash))
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ use crate::{CowStr, Weight};
|
|||
pub enum CurrentUser {
|
||||
/// Usuario no autenticado.
|
||||
Anonymous,
|
||||
/// Usuario autenticado con su identificador y nombre visible.
|
||||
/// Usuario autenticado con su identificador, nombre visible y zona horaria propia.
|
||||
Authenticated {
|
||||
/// Identificador único del usuario en el sistema.
|
||||
id: i32,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue