diff --git a/extensions/pagetop-user/src/account.rs b/extensions/pagetop-user/src/account.rs index 6cf50562..ffe5eccc 100644 --- a/extensions/pagetop-user/src/account.rs +++ b/extensions/pagetop-user/src/account.rs @@ -4,6 +4,8 @@ use std::collections::HashSet; use pagetop::prelude::*; +use crate::entity::user; + // **< UserStatus >********************************************************************************* /// Estado de la cuenta de usuario. @@ -59,39 +61,65 @@ impl PermissionSet { /// Se almacena en las extensiones de la petición HTTP durante la fase de middleware y se accede /// desde los handlers o desde handlers de [`CheckPermission`] mediante /// [`HttpRequest::extension::()`](pagetop::web::HttpRequest::extension). -#[derive(Clone, Debug)] +#[derive(Clone, Debug, Getters)] pub struct Account { - pub id: i32, - pub username: String, - pub email: String, - pub display_name: String, - pub status: UserStatus, + /// Identificador del usuario. + id: i32, + /// Nombre de usuario. + username: String, + /// Correo electrónico. + email: String, + /// Nombre para mostrar; cadena vacía si no está definido (ver [`Account::display`]). + display_name: String, + /// Estado de la cuenta. + #[getters(copy)] + status: UserStatus, /// Nombres de máquina de los roles asignados explícitamente. No incluye "authenticated", que /// es implícito (ver [`Account::has_role`]). - pub roles: Vec, - /// Unión de permisos de todos sus roles. - pub permissions: PermissionSet, - /// `true` si alguno de sus roles tiene `is_admin = true`. - pub is_admin: bool, + roles: Vec, + // Unión de permisos de sus roles y del rol implícito "authenticated". Vacío si es + // administrador: no se cargan porque `has_permission()` ya concede todos. Sin getter para que + // la única consulta posible sea `has_permission()`. + #[getters(skip)] + permissions: PermissionSet, + /// `true` si el usuario tiene acceso sin restricciones (`user.is_admin`), con independencia de + /// sus roles. + #[getters(copy)] + is_admin: bool, } impl Account { + // Sólo se construye desde la carga de sesión, para que ningún otro crate pueda fabricar una + // cuenta e inyectarla en la petición. + pub(crate) fn new(user: user::Model, roles: Vec, permissions: PermissionSet) -> Self { + Account { + id: user.id, + username: user.username, + email: user.email, + display_name: user.display_name.unwrap_or_default(), + status: UserStatus::from_i16(user.status), + roles, + permissions, + is_admin: user.is_admin, + } + } + /// Comprueba si la cuenta tiene el permiso indicado, teniendo en cuenta el flag `is_admin`. pub fn has_permission(&self, perm: PermissionRef) -> bool { - self.is_admin || self.permissions.contains(perm.key().as_ref()) + self.is_admin() || self.permissions.contains(perm.key().as_ref()) } /// Devuelve el nombre visible: `display_name` si está definido, o `username`. pub fn display(&self) -> &str { - if self.display_name.is_empty() { - &self.username + if self.display_name().is_empty() { + self.username() } else { - &self.display_name + self.display_name() } } /// Comprueba si la cuenta tiene el rol indicado ("authenticated" siempre se cumple). pub fn has_role(&self, machine_name: &str) -> bool { - machine_name == "authenticated" || self.roles.iter().any(|r| r == machine_name) + machine_name == "authenticated" || self.roles().iter().any(|r| r == machine_name) } } diff --git a/extensions/pagetop-user/src/auth.rs b/extensions/pagetop-user/src/auth.rs index 6d737bda..a2832d70 100644 --- a/extensions/pagetop-user/src/auth.rs +++ b/extensions/pagetop-user/src/auth.rs @@ -1,4 +1,4 @@ -//! Lógica de autenticación: login, logout, registro, semilla inicial. +//! Lógica de autenticación: login, logout, registro, cambio de contraseña, semilla inicial. use pagetop::prelude::*; use pagetop_seaorm::db::{ @@ -8,9 +8,10 @@ use pagetop_seaorm::db::{ use crate::account::UserStatus; use crate::config::SETTINGS; -use crate::entity::{user, user_role}; +use crate::entity::{role_permission, user, user_role}; use crate::error::AuthError; use crate::password; +use crate::permission::UserPermission; use crate::session; // **< login >************************************************************************************** @@ -135,7 +136,9 @@ pub async fn register( status: Set(status.as_i16()), language: Set(None), timezone: Set(None), + theme: Set(None), display_name: Set(None), + about: Set(None), last_login_at: Set(None), last_access_at: Set(None), failed_login_count: Set(0), @@ -171,6 +174,44 @@ pub async fn assign_role(user_id: i32, role_id: i32) -> Result<(), AuthError> { Ok(()) } +// **< change_own_password >************************************************************************ + +/// Cambia la contraseña del propio usuario tras comprobar la actual, y cierra el resto de sus +/// sesiones abiertas conservando la indicada en `current_sid` (la de quien hace el cambio). +/// +/// Devuelve [`AuthError::InvalidCredentials`] si la contraseña actual no es correcta. +pub(crate) async fn change_own_password( + user_id: i32, + current_password: &str, + new_password: &str, + current_sid: Option<&str>, +) -> Result<(), AuthError> { + let user_model = user::Entity::find_by_id(user_id) + .one(dbconn()) + .await? + .ok_or(AuthError::UserNotFound)?; + if !password::verify_password(current_password, &user_model.password_hash) { + return Err(AuthError::InvalidCredentials); + } + password::validate_strength(new_password)?; + let hash = password::hash_password(new_password)?; + + user::ActiveModel { + id: Set(user_id), + password_hash: Set(hash), + updated_at: Set(Utc::now()), + ..Default::default() + } + .update(dbconn()) + .await?; + + match current_sid { + Some(sid) => session::destroy_other_sessions(user_id, sid).await?, + None => session::destroy_user_sessions(user_id).await?, + } + Ok(()) +} + // **< register_failed_login >********************************************************************** async fn register_failed_login( @@ -197,11 +238,15 @@ async fn register_failed_login( // **< seed_initial_data >************************************************************************** -/// Crea el usuario administrador inicial si no existe ningún usuario en la base de datos. +/// Prepara una instalación nueva si no existe ningún usuario en la base de datos. /// /// Se llama desde `Extension::initialize()`. Si la tabla está vacía, crea el administrador /// con las credenciales configuradas en `[user.seed]`. La contraseña se genera aleatoriamente /// si no está configurada, y se imprime por stdout una sola vez para que el operador la recoja. +/// +/// Concede además al rol "authenticated" los permisos que todo usuario espera tener sobre su +/// propia cuenta: editar su perfil y cambiar su contraseña. Sólo en la instalación nueva; después +/// los gestiona el administrador desde la interfaz. pub(crate) async fn seed_initial_data() { do_seed().await; } @@ -246,7 +291,9 @@ async fn do_seed() { status: Set(UserStatus::Active.as_i16()), language: Set(None), timezone: Set(None), + theme: Set(None), display_name: Set(Some("Administrator".into())), + about: Set(None), last_login_at: Set(None), last_access_at: Set(None), failed_login_count: Set(0), @@ -265,6 +312,28 @@ async fn do_seed() { ); } } - Err(e) => eprintln!("pagetop-user seed error: {}", e), + Err(e) => { + eprintln!("pagetop-user seed error: {}", e); + return; + } + } + + let defaults = [ + UserPermission::EditOwnProfile, + UserPermission::ChangeOwnPassword, + ]; + let rows = defaults.map(|perm| role_permission::ActiveModel { + role_id: Set(crate::AUTHENTICATED_ROLE_ID), + permission_key: Set(perm.key().into_owned()), + granted_at: Set(now), + }); + if let Err(e) = role_permission::Entity::insert_many(rows) + .exec(dbconn()) + .await + { + eprintln!( + "pagetop-user seed error: failed to grant default permissions: {}", + e + ); } } diff --git a/extensions/pagetop-user/src/component.rs b/extensions/pagetop-user/src/component.rs index 6746e469..7f94f830 100644 --- a/extensions/pagetop-user/src/component.rs +++ b/extensions/pagetop-user/src/component.rs @@ -3,21 +3,75 @@ pub(crate) mod admin; mod account_menu; +mod change_password_form; mod login_form; mod password_confirm; mod password_reset_confirm_form; mod password_reset_form; mod register_form; +mod user_name; pub use account_menu::{AccountMenu, account_menu}; +pub(crate) use change_password_form::ChangePasswordForm; pub use login_form::LoginForm; pub(crate) use password_confirm::PasswordConfirm; pub use password_reset_confirm_form::PasswordResetConfirmForm; pub use password_reset_form::PasswordResetForm; pub use register_form::RegisterForm; +pub use user_name::UserName; use pagetop::prelude::*; +use crate::LOCALES_USER; + +// Texto libre de varias líneas como contenido seguro: cada salto de línea se convierte en `
`, +// sin depender del CSS del tema. Se usa para mostrar el "Sobre mí" del usuario. +pub(crate) fn multiline_text(text: String) -> Html { + Html::with(move |_| { + html! { + @for (i, line) in text.lines().enumerate() { + @if i > 0 { br; } + (line) + } + } + }) +} + +// Nombre traducido del idioma guardado en el perfil de un usuario, o "-" si no tiene ninguno. Un +// identificador que ya no esté entre los soportados se muestra tal cual. +pub(crate) fn language_name(language: Option<&str>) -> Lc { + let Some(code) = language else { + return Lc::n("-"); + }; + Locale::supported_languages() + .into_iter() + .find(|(langid, _)| langid.to_string() == code) + .map_or_else(|| Lc::n(code.to_owned()), |(_, name)| name) +} + +// Nombre traducido del tema guardado en el perfil de un usuario o, si no tiene ninguno, el del tema +// predeterminado del sitio, que es el que se le aplica. Un tema que ya no esté habilitado se +// muestra con el nombre guardado. +pub(crate) fn theme_name(theme: Option<&str>) -> table::Cell { + let Some(name) = theme else { + // Los argumentos de `Lc` son texto fijo: el nombre del tema se traduce al renderizar. + return Html::with(|cx| { + let theme = default_theme(); + let name = theme + .name() + .lookup(cx) + .unwrap_or_else(|| theme.short_name().to_owned()); + Lc::t("value-theme-site-default", &LOCALES_USER) + .with_arg("theme", name) + .using(cx) + }) + .into(); + }; + theme_by_short_name(name) + .map_or_else(|| Lc::n(name.to_owned()), |theme| theme.name()) + .into() +} + // Banner de error de formulario; se renderiza vacío si `error` es `None`. Compartido por los // formularios de autenticación y por los de administración. pub(crate) fn error_banner(error: Option) -> Html { diff --git a/extensions/pagetop-user/src/component/admin.rs b/extensions/pagetop-user/src/component/admin.rs index 01835906..b01025c7 100644 --- a/extensions/pagetop-user/src/component/admin.rs +++ b/extensions/pagetop-user/src/component/admin.rs @@ -7,7 +7,6 @@ mod role_form; mod role_permissions_form; mod role_table; mod user_form; -mod user_roles_form; mod user_table; pub(crate) use admin_password_form::AdminPasswordForm; @@ -15,7 +14,6 @@ pub(crate) use role_form::{RoleForm, RoleFormMode}; pub(crate) use role_permissions_form::RolePermissionsForm; pub(crate) use role_table::RoleTable; pub(crate) use user_form::{UserForm, UserFormMode}; -pub(crate) use user_roles_form::UserRolesForm; pub(crate) use user_table::{UserTable, status_key}; use pagetop::prelude::*; @@ -41,10 +39,10 @@ pub(crate) type PermissionGroups = Vec<(Lc, Vec)>; // **< HELPERS >************************************************************************************ -// `Fieldset` con las casillas para asignar roles (usado en el alta de usuario y en la pantalla de -// asignación de roles). El rol "authenticated" no se lista como casilla ni se envía: todo usuario -// autenticado lo tiene concedido por definición (ver `session::load_user_from_session`), sin -// necesidad de una fila en `user_role`. +// `Fieldset` con las casillas para asignar roles (usado en el alta y en la edición de usuario). El +// rol "authenticated" no se lista como casilla ni se envía: todo usuario autenticado lo tiene +// concedido por definición (ver `session::load_user_from_session`), sin necesidad de una fila en +// `user_role`. pub(crate) fn roles_fieldset(roles: &[(i32, String, bool)]) -> form::Fieldset { let mut field = form::check::Field::new().with_name("role_ids"); for (role_id, label, checked) in roles { diff --git a/extensions/pagetop-user/src/component/admin/user_form.rs b/extensions/pagetop-user/src/component/admin/user_form.rs index 42ccd0e2..0f50aee5 100644 --- a/extensions/pagetop-user/src/component/admin/user_form.rs +++ b/extensions/pagetop-user/src/component/admin/user_form.rs @@ -1,49 +1,23 @@ -//! Formulario de alta/edición de usuario. - -use std::collections::BTreeMap; -use std::sync::LazyLock; +//! Formulario de alta/edición de usuario, también usado para que cada usuario edite su perfil. use pagetop::prelude::*; -use crate::ADMIN_USERS_PATH; use crate::LOCALES_USER; use crate::user_path; +use crate::{ADMIN_USERS_PATH, PROFILE_EDIT_PATH}; use crate::component::{PasswordConfirm, error_banner}; +use crate::service::user_admin::ABOUT_MAX_CHARS; 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] New, Edit, + /// Edición del perfil propio: sin roles ni casilla de administrador. + Profile, } #[derive(AutoDefault, Clone, Debug, Getters)] @@ -56,12 +30,17 @@ pub(crate) struct UserForm { username: String, email: String, display_name: String, + /// Texto "Sobre mí"; sólo se edita en los modos `Edit` y `Profile`, no en el alta. + about: String, language: String, timezone: String, - /// Roles asignables (excluye "anonymous" y "authenticated"); sólo se renderiza en modo `New`. + theme: String, + /// Roles asignables (excluye "anonymous" y "authenticated"); si no hay ninguno, no se muestra. roles: Vec<(i32, String, bool)>, /// Si se ofrece la casilla "administrador"; sólo cuando quien da de alta ya es administrador. allow_admin_field: bool, + /// En modo `Profile`, si el nombre de usuario es editable; si no, se muestra de sólo lectura. + allow_username_field: bool, is_admin: bool, } @@ -75,7 +54,10 @@ impl Component for UserForm { let action = match self.mode() { UserFormMode::New => util::join!(ADMIN_USERS_PATH, "/new"), UserFormMode::Edit => user_path(self.user_id().copied().unwrap_or_default(), "edit"), + UserFormMode::Profile => PROFILE_EDIT_PATH.into(), }; + let username_readonly = + *self.mode() == UserFormMode::Profile && !*self.allow_username_field(); let action = self.waypoint().append_to(cx.route(action)); let mut form = Form::new() @@ -89,6 +71,7 @@ impl Component for UserForm { .with_value(self.username()) .with_label(Lc::t("field-username-admin", &LOCALES_USER)) .with_required(true) + .with_readonly(username_readonly) .with_maxlength(Some(64)), ) .with_child( @@ -105,33 +88,54 @@ impl Component for UserForm { .with_label(Lc::t("field-display-name", &LOCALES_USER)), ) .with_child( - form::input::Field::text() + form::SelectLanguage::new() .with_name("language") - .with_value(self.language()) - .with_label(Lc::t("field-language", &LOCALES_USER)), + .with_label(Lc::t("field-language", &LOCALES_USER)) + .with_selected(self.language()), ) - .with_child(timezone_field(self.timezone())); + .with_child( + form::SelectTimezone::new() + .with_name("timezone") + .with_label(Lc::t("field-timezone", &LOCALES_USER)) + .with_selected(self.timezone()), + ) + .with_child( + form::SelectTheme::new() + .with_name("theme") + .with_label(Lc::t("field-theme", &LOCALES_USER)) + .with_selected(self.theme()), + ); if *self.mode() == UserFormMode::New { form = form.with_child(PasswordConfirm::new()); - if !self.roles().is_empty() { - form = form.with_child(roles_fieldset(self.roles())); - } + } else { + form = form.with_child( + form::Textarea::new() + .with_name("about") + .with_value(self.about()) + .with_label(Lc::t("field-about", &LOCALES_USER)) + .with_rows(Some(5)) + .with_maxlength(Some(ABOUT_MAX_CHARS)), + ); + } - if *self.allow_admin_field() { - form = form.with_child( - form::Checkbox::check() - .with_name("is_admin") - .with_label(Lc::t("field-is-admin", &LOCALES_USER)) - .with_checked(*self.is_admin()), - ); - } + if *self.mode() != UserFormMode::Profile && !self.roles().is_empty() { + form = form.with_child(roles_fieldset(self.roles())); + } + + if *self.mode() == UserFormMode::New && *self.allow_admin_field() { + form = form.with_child( + form::Checkbox::check() + .with_name("is_admin") + .with_label(Lc::t("field-is-admin", &LOCALES_USER)) + .with_checked(*self.is_admin()), + ); } // En modo `Edit`, "Guardar" se renderiza fuera del formulario, junto al resto de acciones - // de la pantalla (ver `USER_ADMIN_FORM_ID`); en modo `New` no hay ninguna botonera con la - // que agruparlo, así que se queda aquí, dentro del propio `
`. - if *self.mode() == UserFormMode::New { + // de la pantalla (ver `USER_ADMIN_FORM_ID`); en los demás modos no hay ninguna botonera con + // la que agruparlo, así que se queda aquí, dentro del propio ``. + if *self.mode() != UserFormMode::Edit { form = form.with_child( Button::submit(Lc::t("btn-save", &LOCALES_USER)) .with_style(button::Style::Solid(Intent::Primary)), @@ -181,6 +185,11 @@ impl UserForm { self } + pub(crate) fn with_about(mut self, about: impl Into) -> Self { + self.about = about.into(); + self + } + pub(crate) fn with_language(mut self, language: impl Into) -> Self { self.language = language.into(); self @@ -191,6 +200,11 @@ impl UserForm { self } + pub(crate) fn with_theme(mut self, theme: impl Into) -> Self { + self.theme = theme.into(); + self + } + pub(crate) fn with_roles(mut self, roles: Vec<(i32, String, bool)>) -> Self { self.roles = roles; self @@ -201,34 +215,13 @@ impl UserForm { self } + pub(crate) fn with_allow_username_field(mut self, allow_username_field: bool) -> Self { + self.allow_username_field = allow_username_field; + self + } + pub(crate) fn with_is_admin(mut self, is_admin: bool) -> Self { self.is_admin = is_admin; self } } - -// `