✨ (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::*;
/// 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>,
}

View file

@ -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)]

View file

@ -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()),

View file

@ -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(&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)]
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
}

View file

@ -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 {

View file

@ -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)]

View file

@ -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)]

View file

@ -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)]

View file

@ -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)]

View file

@ -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)]

View file

@ -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,

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())
}
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),

View file

@ -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 {
(

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-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.

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-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.

View file

@ -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| {

View file

@ -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,

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.
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 };

View file

@ -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))

View file

@ -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,