diff --git a/extensions/pagetop-user/Cargo.toml b/extensions/pagetop-user/Cargo.toml new file mode 100644 index 00000000..76870d99 --- /dev/null +++ b/extensions/pagetop-user/Cargo.toml @@ -0,0 +1,34 @@ +[package] +name = "pagetop-user" +version = "0.1.0" + +description = """ + Gestión de usuarios, autenticación, roles y permisos para aplicaciones PageTop. +""" +categories = ["authentication", "web-programming::http-server"] +keywords = ["pagetop", "auth", "users", "session", "rbac"] + +repository.workspace = true +homepage.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true + +[features] +demo-data = [] + +[dependencies] +pagetop.workspace = true +pagetop-admin.workspace = true +pagetop-seaorm.workspace = true +pagetop-htmx.workspace = true +serde.workspace = true +serde_json.workspace = true +serde_qs.workspace = true +thiserror.workspace = true + +# Criptografía +argon2.workspace = true +base64ct.workspace = true +sha2.workspace = true +rand_core.workspace = true diff --git a/extensions/pagetop-user/LICENSE-APACHE b/extensions/pagetop-user/LICENSE-APACHE new file mode 100644 index 00000000..263ddac1 --- /dev/null +++ b/extensions/pagetop-user/LICENSE-APACHE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2022 Manuel Cillero + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. \ No newline at end of file diff --git a/extensions/pagetop-user/LICENSE-MIT b/extensions/pagetop-user/LICENSE-MIT new file mode 100644 index 00000000..cd8af3d6 --- /dev/null +++ b/extensions/pagetop-user/LICENSE-MIT @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022 Manuel Cillero + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/extensions/pagetop-user/src/account.rs b/extensions/pagetop-user/src/account.rs new file mode 100644 index 00000000..106c7977 --- /dev/null +++ b/extensions/pagetop-user/src/account.rs @@ -0,0 +1,97 @@ +//! Tipos en memoria que representan los datos ricos del usuario durante una petición. + +use std::collections::HashSet; + +use pagetop::auth::PermissionRef; + +// **< UserStatus >********************************************************************************* + +/// Estado de la cuenta de usuario. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum UserStatus { + Active, + Blocked, + Pending, +} + +impl UserStatus { + pub fn from_i16(v: i16) -> Self { + match v { + 1 => UserStatus::Active, + 2 => UserStatus::Pending, + _ => UserStatus::Blocked, + } + } + + pub fn as_i16(self) -> i16 { + match self { + UserStatus::Blocked => 0, + UserStatus::Active => 1, + UserStatus::Pending => 2, + } + } +} + +// **< PermissionSet >****************************************************************************** + +/// Conjunto de permisos resuelto para un usuario concreto. +#[derive(Clone, Debug, Default)] +pub struct PermissionSet(HashSet); + +impl PermissionSet { + pub fn new(keys: impl IntoIterator) -> Self { + PermissionSet(keys.into_iter().collect()) + } + + pub fn contains(&self, key: &str) -> bool { + self.0.contains(key) + } + + pub fn extend(&mut self, keys: impl IntoIterator) { + self.0.extend(keys); + } +} + +// **< Account >************************************************************************************ + +/// Datos ricos del usuario autenticado inyectados por el middleware de sesión. +/// +/// 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`](pagetop::auth::CheckPermission) +/// mediante [`HttpRequest::extension::()`](pagetop::web::HttpRequest::extension). +#[derive(Clone, Debug)] +pub struct Account { + pub id: i32, + pub username: String, + pub email: String, + pub display_name: String, + pub status: UserStatus, + /// Nombres de máquina de los roles asignados, incluido "authenticated" (se asigna + /// automáticamente a toda cuenta en el alta, ver `auth::assign_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, +} + +impl Account { + /// 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()) + } + + /// Devuelve el nombre visible: `display_name` si está definido, o `username`. + pub fn display(&self) -> &str { + if self.display_name.is_empty() { + &self.username + } else { + &self.display_name + } + } + + /// Comprueba si la cuenta tiene el rol indicado. + pub fn has_role(&self, machine_name: &str) -> bool { + self.roles.iter().any(|r| r == machine_name) + } +} diff --git a/extensions/pagetop-user/src/auth.rs b/extensions/pagetop-user/src/auth.rs new file mode 100644 index 00000000..91801ab9 --- /dev/null +++ b/extensions/pagetop-user/src/auth.rs @@ -0,0 +1,276 @@ +//! Lógica de autenticación: login, logout, registro, semilla inicial. + +use pagetop::datetime::{Duration, NaiveDateTime, Utc}; + +use pagetop_seaorm::db::{ + ActiveModelTrait, ActiveValue, ColumnTrait, Condition, EntityTrait, PaginatorTrait, + QueryFilter, Set, dbconn, +}; + +use crate::account::UserStatus; +use crate::config::SETTINGS; +use crate::entity::{user, user_role}; +use crate::error::AuthError; +use crate::password; +use crate::session; + +// **< login >************************************************************************************** + +/// Valida las credenciales y crea una sesión. Devuelve el session ID. +/// +/// Acepta nombre de usuario o email como primer parámetro. Aplica la política de bloqueo +/// por intentos fallidos y rehashea la contraseña si los parámetros de coste han cambiado. +pub async fn login( + username_or_email: &str, + plain_password: &str, + remember: bool, +) -> Result { + let now = Utc::now().naive_utc(); + + // Buscar usuario por username o email. + let user_model = user::Entity::find() + .filter( + Condition::any() + .add(user::Column::Username.eq(username_or_email)) + .add(user::Column::Email.eq(username_or_email)), + ) + .one(dbconn()) + .await? + .ok_or(AuthError::InvalidCredentials)?; + + // Comprobar bloqueo temporal por intentos fallidos. + if let Some(locked_until) = user_model.locked_until + && locked_until > now + { + return Err(AuthError::AccountLocked); + } + + // Comprobar estado de la cuenta. + match UserStatus::from_i16(user_model.status) { + UserStatus::Blocked => return Err(AuthError::AccountBlocked), + UserStatus::Pending => return Err(AuthError::AccountPending), + UserStatus::Active => {} + } + + // Verificar contraseña. + if !password::verify_password(plain_password, &user_model.password_hash) { + register_failed_login(&user_model, now).await?; + return Err(AuthError::InvalidCredentials); + } + + // Actualizar last_login_at y resetear contador de fallos. + let user_id = user_model.id; + let needs_rehash = password::needs_rehash(&user_model.password_hash); + let mut active: user::ActiveModel = user_model.into(); + active.last_login_at = Set(Some(now)); + active.last_access_at = Set(Some(now)); + active.failed_login_count = Set(0); + active.locked_until = Set(None); + if needs_rehash { + let new_hash = password::hash_password(plain_password)?; + active.password_hash = Set(new_hash); + } + active.update(dbconn()).await?; + + // Crear sesión y devolver el SID. + session::create_session(user_id, remember) + .await + .map_err(AuthError::Database) +} + +// **< logout >************************************************************************************* + +/// Destruye la sesión indicada. +pub async fn logout(sid: &str) -> Result<(), AuthError> { + session::destroy_session(sid) + .await + .map_err(AuthError::Database) +} + +// **< register >*********************************************************************************** + +/// Registra un nuevo usuario. Devuelve el `user_id` asignado. +/// +/// Valida: longitud de contraseña, coincidencia de confirmación, y unicidad de username y email. +pub async fn register( + username: &str, + email: &str, + plain_password: &str, + confirm_password: &str, +) -> Result { + password::validate_strength(plain_password)?; + password::passwords_match(plain_password, confirm_password)?; + + // Comprobar unicidad de username y email. + if user::Entity::find() + .filter(user::Column::Username.eq(username)) + .one(dbconn()) + .await? + .is_some() + { + return Err(AuthError::UsernameTaken); + } + if user::Entity::find() + .filter(user::Column::Email.eq(email)) + .one(dbconn()) + .await? + .is_some() + { + return Err(AuthError::EmailTaken); + } + + let hash = password::hash_password(plain_password)?; + let now = Utc::now().naive_utc(); + let status = if SETTINGS.require_email_verification { + UserStatus::Pending + } else { + UserStatus::Active + }; + + let new_user = user::ActiveModel { + id: ActiveValue::NotSet, + username: Set(username.to_owned()), + email: Set(email.to_owned()), + email_verified_at: Set(None), + password_hash: Set(hash), + status: Set(status.as_i16()), + language: Set(None), + timezone: Set(None), + display_name: Set(None), + last_login_at: Set(None), + last_access_at: Set(None), + failed_login_count: Set(0), + locked_until: Set(None), + is_admin: Set(false), + created_at: Set(now), + updated_at: Set(now), + }; + let result = user::Entity::insert(new_user).exec(dbconn()).await?; + let user_id = result.last_insert_id; + + assign_role(user_id, crate::AUTHENTICATED_ROLE_ID).await?; + + Ok(user_id) +} + +// **< assign_role >******************************************************************************** + +/// Asigna un rol a un usuario (sin error si ya está asignado). +pub async fn assign_role(user_id: i32, role_id: i32) -> Result<(), AuthError> { + let already = user_role::Entity::find() + .filter(user_role::Column::UserId.eq(user_id)) + .filter(user_role::Column::RoleId.eq(role_id)) + .one(dbconn()) + .await?; + + if already.is_none() { + user_role::Entity::insert(user_role::ActiveModel { + user_id: Set(user_id), + role_id: Set(role_id), + }) + .exec(dbconn()) + .await?; + } + Ok(()) +} + +// **< register_failed_login >********************************************************************** + +async fn register_failed_login( + user_model: &user::Model, + now: NaiveDateTime, +) -> Result<(), AuthError> { + let new_count = user_model.failed_login_count + 1; + let lock_at = if new_count >= SETTINGS.max_failed_logins { + Some(now + Duration::seconds(SETTINGS.locked_for_secs)) + } else { + None + }; + user::ActiveModel { + id: Set(user_model.id), + failed_login_count: Set(new_count), + locked_until: Set(lock_at), + updated_at: Set(now), + ..Default::default() + } + .update(dbconn()) + .await?; + Ok(()) +} + +// **< seed_initial_data >************************************************************************** + +/// Crea el usuario administrador inicial 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. +pub(crate) async fn seed_initial_data() { + do_seed().await; +} + +async fn do_seed() { + let count = user::Entity::find().count(dbconn()).await.unwrap_or(1); + if count > 0 { + return; + } + + let cfg = &SETTINGS.seed; + let (admin_password, generated) = match &cfg.admin_password { + Some(p) if !p.is_empty() => (p.clone(), false), + _ => { + // Generar contraseña aleatoria de 20 caracteres. + use base64ct::{Base64UrlUnpadded, Encoding}; + use rand_core::{OsRng, RngCore}; + let mut bytes = [0u8; 15]; + OsRng.fill_bytes(&mut bytes); + (Base64UrlUnpadded::encode_string(&bytes), true) + } + }; + + let hash = match password::hash_password(&admin_password) { + Ok(h) => h, + Err(e) => { + eprintln!( + "pagetop-user seed error: failed to hash admin password: {}", + e + ); + return; + } + }; + + let now = Utc::now().naive_utc(); + let new_admin = user::ActiveModel { + id: ActiveValue::NotSet, + username: Set(cfg.admin_username.clone()), + email: Set(cfg.admin_email.clone()), + email_verified_at: Set(Some(now)), + password_hash: Set(hash), + status: Set(UserStatus::Active.as_i16()), + language: Set(None), + timezone: Set(None), + display_name: Set(Some("Administrator".into())), + last_login_at: Set(None), + last_access_at: Set(None), + failed_login_count: Set(0), + locked_until: Set(None), + is_admin: Set(true), + created_at: Set(now), + updated_at: Set(now), + }; + + match user::Entity::insert(new_admin).exec(dbconn()).await { + Ok(result) => { + if let Err(e) = assign_role(result.last_insert_id, crate::AUTHENTICATED_ROLE_ID).await { + eprintln!("pagetop-user seed error: {}", e); + } + if generated { + println!( + "\npagetop-user: admin account created.\n username: {}\n password: {}\n", + cfg.admin_username, admin_password + ); + } + } + Err(e) => eprintln!("pagetop-user seed error: {}", e), + } +} diff --git a/extensions/pagetop-user/src/component.rs b/extensions/pagetop-user/src/component.rs new file mode 100644 index 00000000..e52e00fa --- /dev/null +++ b/extensions/pagetop-user/src/component.rs @@ -0,0 +1,28 @@ +//! Componentes de UI de `pagetop-user`. + +pub(crate) mod admin; + +mod login_form; +mod password_confirm; +mod password_reset_confirm_form; +mod password_reset_form; +mod register_form; +mod user_block; + +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_block::UserBlock; + +use pagetop::prelude::*; + +// 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 { + Html::with(move |cx| match &error { + Some(e) => html! { div.user-form-error role="alert" { (e.clone().using(cx)) } }, + None => html! {}, + }) +} diff --git a/extensions/pagetop-user/src/component/admin.rs b/extensions/pagetop-user/src/component/admin.rs new file mode 100644 index 00000000..296b3370 --- /dev/null +++ b/extensions/pagetop-user/src/component/admin.rs @@ -0,0 +1,58 @@ +//! Componentes de administración: listados y mantenimiento de usuarios, roles y permisos. +//! +//! Todos son `pub(crate)`: son UI interna de esta extensión, no forman parte del *prelude* público. + +mod admin_password_form; +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; +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::*; + +use crate::LOCALES_USER; + +// **< constantes compartidas >********************************************************************** + +/// Identificador del `
` de [`UserForm`] en modo [`UserFormMode::Edit`]. En ese modo, el botón +/// "Guardar" no se renderiza dentro del formulario (ver `UserForm::prepare()`): lo añade la pantalla +/// de edición (`handlers::admin::users::edit_actions()`) junto al resto de acciones, referenciando +/// este id mediante el atributo `form` para seguir enviando el formulario aunque esté fuera de él. +pub(crate) const USER_ADMIN_FORM_ID: &str = "user-admin-form"; + +// **< tipos compartidos >*************************************************************************** + +/// Un permiso dentro de un grupo del catálogo: `(clave, etiqueta, concedido)`. +pub(crate) type PermissionItem = (CowStr, Lc, bool); + +/// Catálogo de permisos agrupado: `(título del grupo, permisos del grupo)`. +pub(crate) type PermissionGroups = Vec<(Lc, Vec)>; + +// **< helpers compartidos >************************************************************************* + +// `Fieldset` con las casillas para asignar roles (usado en el alta de usuario y en la pantalla +// dedicada 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`. +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 { + field = field.with_item( + form::check::Item::new(role_id.to_string(), Lc::n(label.clone())) + .with_checked(*checked), + ); + } + form::Fieldset::new() + .with_legend(Lc::t("field-roles", &LOCALES_USER)) + .with_child(field) +} diff --git a/extensions/pagetop-user/src/component/admin/admin_password_form.rs b/extensions/pagetop-user/src/component/admin/admin_password_form.rs new file mode 100644 index 00000000..85befebf --- /dev/null +++ b/extensions/pagetop-user/src/component/admin/admin_password_form.rs @@ -0,0 +1,66 @@ +//! Formulario de restablecimiento de contraseña por un administrador (sin contraseña actual). + +use pagetop::prelude::*; + +use crate::ADMIN_USERS_PATH; +use crate::LOCALES_USER; + +use crate::component::{PasswordConfirm, error_banner}; + +#[derive(AutoDefault, Clone, Debug, Getters)] +pub(crate) struct AdminPasswordForm { + user_id: i32, + error: Option, + /// Listado de origen al que volver tras guardar (orden, búsqueda, página). + waypoint: Waypoint, +} + +#[async_trait] +impl Component for AdminPasswordForm { + fn new() -> Self { + Self::default() + } + + async fn prepare(&self, cx: &mut Context) -> Result { + let action = format!("{ADMIN_USERS_PATH}/{}/password", self.user_id()); + let action = self.waypoint().append_to(cx.route(action)); + + let mut form = Form::new() + .with_id("user-admin-password-form") + .with_action(action) + .with_method(form::Method::Post) + .with_child(error_banner(self.error().cloned())) + .with_child( + PasswordConfirm::new() + .with_password_label(Lc::t("field-new-password", &LOCALES_USER)), + ) + .with_child( + Button::submit(Lc::t("btn-save", &LOCALES_USER)) + .with_style(button::Style::Solid(Intent::Primary)), + ); + + Ok(form.render(cx).await) + } +} + +impl AdminPasswordForm { + // **< AdminPasswordForm BUILDER >************************************************************** + + #[builder_fn] + pub(crate) fn with_user_id(mut self, user_id: i32) -> Self { + self.user_id = user_id; + self + } + + #[builder_fn] + pub(crate) fn with_error(mut self, error: impl Into>) -> Self { + self.error = error.into(); + self + } + + #[builder_fn] + pub(crate) fn with_waypoint(mut self, waypoint: impl Into) -> Self { + self.waypoint = waypoint.into(); + self + } +} diff --git a/extensions/pagetop-user/src/component/admin/role_form.rs b/extensions/pagetop-user/src/component/admin/role_form.rs new file mode 100644 index 00000000..d8efba69 --- /dev/null +++ b/extensions/pagetop-user/src/component/admin/role_form.rs @@ -0,0 +1,151 @@ +//! Formulario de alta/edición de rol. + +use pagetop::prelude::*; + +use crate::ADMIN_ROLES_PATH; +use crate::LOCALES_USER; + +use crate::component::error_banner; + +#[derive(AutoDefault, Clone, Copy, Debug, PartialEq)] +pub(crate) enum RoleFormMode { + #[default] + New, + Edit, +} + +#[derive(AutoDefault, Clone, Debug, Getters)] +pub(crate) struct RoleForm { + mode: RoleFormMode, + error: Option, + role_id: Option, + /// Listado de origen al que volver tras guardar (orden). + waypoint: Waypoint, + machine_name: String, + label: String, + description: String, + weight: i32, +} + +#[async_trait] +impl Component for RoleForm { + fn new() -> Self { + Self::default() + } + + async fn prepare(&self, cx: &mut Context) -> Result { + let action = match self.mode() { + RoleFormMode::New => format!("{ADMIN_ROLES_PATH}/new"), + RoleFormMode::Edit => { + format!( + "{ADMIN_ROLES_PATH}/{}/edit", + self.role_id().copied().unwrap_or_default() + ) + } + }; + let action = self.waypoint().append_to(cx.route(action)); + + let machine_name_field = match self.mode() { + RoleFormMode::New => form::input::Field::text() + .with_name("machine_name") + .with_value(self.machine_name()) + .with_label(Lc::t("field-machine-name", &LOCALES_USER)) + .with_help_text(Lc::t("help-machine-name-immutable", &LOCALES_USER)) + .with_required(true) + .with_maxlength(Some(64)), + RoleFormMode::Edit => form::input::Field::text() + .with_name("machine_name") + .with_value(self.machine_name()) + .with_label(Lc::t("field-machine-name", &LOCALES_USER)) + .with_plaintext(true), + }; + + let mut form = Form::new() + .with_id("role-admin-form") + .with_action(action) + .with_method(form::Method::Post) + .with_child(error_banner(self.error().cloned())) + .with_child(machine_name_field) + .with_child( + form::input::Field::text() + .with_name("label") + .with_value(self.label()) + .with_label(Lc::t("field-label", &LOCALES_USER)) + .with_required(true) + .with_maxlength(Some(128)), + ) + .with_child( + form::Textarea::new() + .with_name("description") + .with_value(self.description()) + .with_label(Lc::t("field-description", &LOCALES_USER)) + .with_rows(Some(3)), + ) + .with_child( + form::input::Field::text() + .with_name("weight") + .with_value(self.weight().to_string()) + .with_label(Lc::t("field-weight", &LOCALES_USER)) + .with_inputmode(Some(form::input::Mode::Numeric)), + ); + + form = form.with_child( + Button::submit(Lc::t("btn-save", &LOCALES_USER)) + .with_style(button::Style::Solid(Intent::Primary)), + ); + + Ok(form.render(cx).await) + } +} + +impl RoleForm { + // **< RoleForm BUILDER >*********************************************************************** + + #[builder_fn] + pub(crate) fn with_mode(mut self, mode: RoleFormMode) -> Self { + self.mode = mode; + self + } + + #[builder_fn] + pub(crate) fn with_error(mut self, error: impl Into>) -> Self { + self.error = error.into(); + self + } + + #[builder_fn] + pub(crate) fn with_role_id(mut self, role_id: impl Into>) -> Self { + self.role_id = role_id.into(); + self + } + + #[builder_fn] + pub(crate) fn with_waypoint(mut self, waypoint: impl Into) -> Self { + self.waypoint = waypoint.into(); + self + } + + #[builder_fn] + pub(crate) fn with_machine_name(mut self, machine_name: impl Into) -> Self { + self.machine_name = machine_name.into(); + self + } + + #[builder_fn] + pub(crate) fn with_label(mut self, label: impl Into) -> Self { + self.label = label.into(); + self + } + + #[builder_fn] + pub(crate) fn with_description(mut self, description: impl Into) -> Self { + self.description = description.into(); + self + } + + #[builder_fn] + pub(crate) fn with_weight(mut self, weight: i32) -> Self { + self.weight = weight; + self + } +} diff --git a/extensions/pagetop-user/src/component/admin/role_permissions_form.rs b/extensions/pagetop-user/src/component/admin/role_permissions_form.rs new file mode 100644 index 00000000..9263588e --- /dev/null +++ b/extensions/pagetop-user/src/component/admin/role_permissions_form.rs @@ -0,0 +1,90 @@ +//! Formulario de asignación de permisos a un rol, agrupados por categoría. Reemplaza siempre el +//! conjunto completo. Permitido incluso en roles bloqueados (`locked`): los roles de sistema +//! también necesitan permisos gestionables. + +use pagetop::prelude::*; + +use crate::ADMIN_ROLES_PATH; +use crate::LOCALES_USER; + +use crate::component::admin::PermissionGroups; +use crate::component::error_banner; + +#[derive(AutoDefault, Clone, Debug, Getters)] +pub(crate) struct RolePermissionsForm { + role_id: i32, + error: Option, + groups: PermissionGroups, + /// Listado de origen al que volver tras guardar (orden). + waypoint: Waypoint, +} + +#[async_trait] +impl Component for RolePermissionsForm { + fn new() -> Self { + Self::default() + } + + async fn prepare(&self, cx: &mut Context) -> Result { + let action = format!("{ADMIN_ROLES_PATH}/{}/permissions", self.role_id()); + let action = self.waypoint().append_to(cx.route(action)); + + let mut form = Form::new() + .with_id("role-permissions-form") + .with_action(action) + .with_method(form::Method::Post) + .with_child(error_banner(self.error().cloned())); + + for (idx, (group_label, perms)) in self.groups().iter().enumerate() { + let mut field = form::check::Field::new() + .with_id(format!("permission-group-{idx}")) + .with_name("permission_keys"); + for (key, label, checked) in perms { + let text = label.lookup(cx).unwrap_or_default(); + field = field.with_item( + form::check::Item::new(key, Lc::n(format!("{text} ({key})"))) + .with_checked(*checked), + ); + } + let fieldset = form::Fieldset::new() + .with_legend(group_label.clone()) + .with_child(field); + form = form.with_child(fieldset); + } + + form = form.with_child( + Button::submit(Lc::t("btn-save", &LOCALES_USER)) + .with_style(button::Style::Solid(Intent::Primary)), + ); + + Ok(form.render(cx).await) + } +} + +impl RolePermissionsForm { + // **< RolePermissionsForm BUILDER >************************************************************ + + #[builder_fn] + pub(crate) fn with_role_id(mut self, role_id: i32) -> Self { + self.role_id = role_id; + self + } + + #[builder_fn] + pub(crate) fn with_error(mut self, error: impl Into>) -> Self { + self.error = error.into(); + self + } + + #[builder_fn] + pub(crate) fn with_groups(mut self, groups: PermissionGroups) -> Self { + self.groups = groups; + self + } + + #[builder_fn] + pub(crate) fn with_waypoint(mut self, waypoint: impl Into) -> Self { + self.waypoint = waypoint.into(); + self + } +} diff --git a/extensions/pagetop-user/src/component/admin/role_table.rs b/extensions/pagetop-user/src/component/admin/role_table.rs new file mode 100644 index 00000000..9c28ead8 --- /dev/null +++ b/extensions/pagetop-user/src/component/admin/role_table.rs @@ -0,0 +1,305 @@ +//! Tabla de roles: cabeceras ordenables y filas con acciones. + +use pagetop::prelude::*; + +use pagetop::base::component::table::{Column, Row}; +use pagetop_htmx::hx; +use pagetop_htmx::hx_table::sort_link; + +use crate::ADMIN_ROLES_PATH; +use crate::LOCALES_USER; +use crate::service::role_admin::{RoleListItem, RoleSortField}; + +#[derive(AutoDefault, Clone, Debug, Getters)] +pub(crate) struct RoleTable { + props: Props, + items: Vec, + sort: RoleSortField, + dir: SortDir, + page: u64, + per_page: u64, + total: u64, + /// Mensaje de error a mostrar (p. ej. al fallar un borrado). No persiste entre peticiones. + message: Option, +} + +#[async_trait] +impl Component for RoleTable { + fn new() -> Self { + Self::default() + } + + fn id(&self) -> Option { + self.props.get_id() + } + + fn setup(&mut self, _cx: &Context) { + self.alter_prop(PropsOp::set_id("role-table-wrapper")); + self.alter_prop(PropsOp::prepend_classes("user-admin-table-wrapper")); + } + + async fn prepare(&self, cx: &mut Context) -> Result { + let pager = Pager::new() + .with_base_path(ADMIN_ROLES_PATH) + .with_extra_query("sort", self.sort().as_str()) + .with_extra_query("dir", *self.dir()) + .with_current_page(self.page()) + .with_items_per_page(self.per_page()) + .with_total_items(self.total()) + .with_prop(PropsOp::set(hx::BOOST, "true")) + .with_prop(PropsOp::set(hx::TARGET, "#role-table-wrapper")) + .with_prop(PropsOp::set(hx::SWAP, hx::swap::OUTER_HTML_SCROLL_TOP)) + .with_prop(PropsOp::set(hx::PUSH_URL, "true")) + .render(cx) + .await; + + let mut table = Table::new() + .with_prop(PropsOp::add_classes("user-admin-table")) + .with_column(self.sort_column(cx, RoleSortField::MachineName, "col-machine-name")) + .with_column(self.sort_column(cx, RoleSortField::Label, "col-label")) + .with_column(Lc::t("col-type", &LOCALES_USER)) + .with_column(Lc::t("col-users-count", &LOCALES_USER)) + .with_column(Lc::t("col-actions", &LOCALES_USER)) + .with_empty(Lc::t("empty-roles-list", &LOCALES_USER)); + + let confirm_dialog = Dialog::new() + .with_id("confirm-delete-role") + .with_child(Html::with(|cx| { + html! { p { (Lc::t("confirm-delete-role", &LOCALES_USER).using(cx)) } } + })) + .with_footer( + Button::plain(Lc::t("btn-cancel", &LOCALES_USER)) + .with_prop(PropsOp::set("data-dialog-dismiss", "modal")), + ) + .with_footer(Html::with( + |_cx| html! { span id="role-delete-confirm-action" {} }, + )) + .render(cx) + .await; + + let waypoint = Waypoint::from(self.list_href(cx)); + + for role in self.items() { + let system_badge = if role.locked { + Some( + Badge::labeled(Lc::t("badge-system-role", &LOCALES_USER)) + .with_prop(PropsOp::add_classes("user-admin-badge-system")) + .render(cx) + .await, + ) + } else { + None + }; + + table.alter_row( + Row::new() + .with_cell(role.machine_name.as_str()) + .with_cell(label_cell(role, &waypoint)) + .with_cell(Html::with(move |_cx| { + html! { + @if let Some(badge) = &system_badge { (badge) } + } + })) + .with_cell(role.user_count.to_string()) + .with_cell( + actions_cell(role, &waypoint, *self.sort(), *self.dir(), self.page(), cx) + .await, + ), + ); + } + + let new_href = waypoint.append_to(cx.route(format!("{ADMIN_ROLES_PATH}/new"))); + + Ok(html! { + div (self.props()) { + div.user-admin-actions { + a href=(new_href) { + (Lc::t("btn-create-role", &LOCALES_USER).using(cx)) + } + } + @if let Some(message) = self.message() { + div.user-form-error role="alert" { (message.clone().using(cx)) } + } + (table.render(cx).await) + (pager) + (confirm_dialog) + } + }) + } +} + +impl RoleTable { + // **< RoleTable BUILDER >********************************************************************** + + #[builder_fn] + pub(crate) fn with_prop(mut self, op: PropsOp) -> Self { + self.props.alter_prop(op); + self + } + + #[builder_fn] + pub(crate) fn with_items(mut self, items: Vec) -> Self { + self.items = items; + self + } + + #[builder_fn] + pub(crate) fn with_message(mut self, message: impl Into>) -> Self { + self.message = message.into(); + self + } + + #[builder_fn] + pub(crate) fn with_sort(mut self, sort: RoleSortField) -> Self { + self.sort = sort; + self + } + + #[builder_fn] + pub(crate) fn with_dir(mut self, dir: SortDir) -> Self { + self.dir = dir; + self + } + + #[builder_fn] + pub(crate) fn with_page(mut self, page: u64) -> Self { + self.page = page; + self + } + + #[builder_fn] + pub(crate) fn with_per_page(mut self, per_page: u64) -> Self { + self.per_page = per_page; + self + } + + #[builder_fn] + pub(crate) fn with_total(mut self, total: u64) -> Self { + self.total = total; + self + } + + // URL del listado con el estado actual (orden, página): es el valor que viaja como + // `waypoint` en los enlaces de ver/editar/permisos, para poder volver exactamente a este + // mismo estado. Se construye con `cx.route()` para que preserve el parámetro `lang` cuando + // corresponda. + fn list_href(&self, cx: &Context) -> String { + cx.route(ADMIN_ROLES_PATH) + .alter_param("sort", self.sort().as_str()) + .alter_param("dir", *self.dir()) + .alter_param("page", self.page().to_string()) + .to_string() + } + + // Construye la cabecera ordenable de una columna: el enlace ya funciona sin HTMX (navega con + // una petición normal); `pagetop_htmx::hx_table::sort_link()` añade aparte los atributos `hx-*` + // para que la tabla se actualice sin recargar la página cuando la extensión esté disponible. + fn sort_column(&self, cx: &Context, field: RoleSortField, label_key: &'static str) -> Column { + let is_active = *self.sort() == field; + let active = is_active.then_some(*self.dir()); + let next_dir = SortDir::next_for(active); + let mut route = cx.route(ADMIN_ROLES_PATH); + route + .alter_param("sort", field.as_str()) + .alter_param("dir", next_dir); + + Column::new(Lc::t(label_key, &LOCALES_USER)).with_sort(sort_link( + route, + "#role-table-wrapper", + active, + )) + } +} + +// Construye la celda de etiqueta: enlaza a la pantalla de sólo lectura con todos los permisos del +// rol. Devuelve un componente `Html` para que el marcado se genere cuando `Table` renderice la +// celda, no al construir la fila. +fn label_cell(role: &RoleListItem, waypoint: &Waypoint) -> Html { + let label = role.label.clone(); + let id = role.id; + let waypoint = waypoint.clone(); + Html::with(move |cx| { + let view_href = waypoint.append_to(cx.route(format!("{ADMIN_ROLES_PATH}/{id}/view"))); + html! { + a href=(view_href) { (label.as_str()) } + } + }) +} + +// Construye la celda de acciones: gestionar permisos siempre, y editar/borrar sólo si el rol no +// está bloqueado por el sistema. Devuelve un componente `Html` para que el marcado se genere +// cuando `Table` renderice la celda, no al construir la fila. +async fn actions_cell( + role: &RoleListItem, + waypoint: &Waypoint, + sort: RoleSortField, + dir: SortDir, + page: u64, + cx: &mut Context, +) -> Html { + let id = role.id; + let locked = role.locked; + let waypoint = waypoint.clone(); + + let permissions_href = + waypoint.append_to(cx.route(format!("{ADMIN_ROLES_PATH}/{id}/permissions"))); + + // Los botones se renderizan aquí, no dentro del `Html::with()` de abajo: necesitan pasar por + // su propio ciclo de renderizado (`.render().await`) para que el tema activo los estilice + // igual (ver `pagetop-bootsier::theme::bs::button`), incluida la traducción de `data-dialog-*` + // que usa el botón de borrado. + let permissions_button = Button::anchor( + Lc::t("btn-manage-permissions", &LOCALES_USER), + permissions_href, + ) + .with_style(button::Style::Solid(Intent::Neutral)) + .with_size(button::Size::Small) + .render(cx) + .await; + + let (edit_button, delete_button) = if locked { + (None, None) + } else { + let edit_href = waypoint.append_to(cx.route(format!("{ADMIN_ROLES_PATH}/{id}/edit"))); + let edit_button = Button::anchor(Lc::t("btn-edit", &LOCALES_USER), edit_href) + .with_style(button::Style::Solid(Intent::Primary)) + .with_size(button::Size::Small) + .render(cx) + .await; + + // Viaja como query string para que, tanto si el borrado falla como si tiene éxito, la + // tabla vuelva a mostrarse en la misma página/orden en que estaba, en vez de reiniciarse. + let confirm_href = cx + .route(format!("{ADMIN_ROLES_PATH}/{id}/delete/confirm")) + .alter_param("sort", sort.as_str()) + .alter_param("dir", dir) + .alter_param("page", page.to_string()) + .to_string(); + + let delete_button = Button::plain(Lc::t("btn-delete", &LOCALES_USER)) + .with_style(button::Style::Solid(Intent::Severe)) + .with_size(button::Size::Small) + .with_prop(PropsOp::set(hx::GET, confirm_href)) + .with_prop(PropsOp::set(hx::TARGET, "#role-delete-confirm-action")) + .with_prop(PropsOp::set("data-dialog-toggle", "modal")) + .with_prop(PropsOp::set("data-dialog-target", "#confirm-delete-role")) + .render(cx) + .await; + + (Some(edit_button), Some(delete_button)) + }; + + Html::with(move |_cx| { + html! { + (permissions_button) + @if let Some(edit_button) = &edit_button { + " " + (edit_button) + } + @if let Some(delete_button) = &delete_button { + " " + (delete_button) + } + } + }) +} diff --git a/extensions/pagetop-user/src/component/admin/user_form.rs b/extensions/pagetop-user/src/component/admin/user_form.rs new file mode 100644 index 00000000..4ee68fcd --- /dev/null +++ b/extensions/pagetop-user/src/component/admin/user_form.rs @@ -0,0 +1,198 @@ +//! Formulario de alta/edición de usuario. + +use pagetop::prelude::*; + +use crate::ADMIN_USERS_PATH; +use crate::LOCALES_USER; + +use crate::component::{PasswordConfirm, error_banner}; + +use super::{USER_ADMIN_FORM_ID, roles_fieldset}; + +#[derive(AutoDefault, Clone, Copy, Debug, PartialEq)] +pub(crate) enum UserFormMode { + #[default] + New, + Edit, +} + +#[derive(AutoDefault, Clone, Debug, Getters)] +pub(crate) struct UserForm { + mode: UserFormMode, + error: Option, + user_id: Option, + /// Listado de origen al que volver tras guardar (orden, búsqueda, página). + waypoint: Waypoint, + username: String, + email: String, + display_name: String, + language: String, + timezone: String, + /// Roles asignables (excluye "anonymous" y "authenticated"); sólo se renderiza en modo `New`. + 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, + is_admin: bool, +} + +#[async_trait] +impl Component for UserForm { + fn new() -> Self { + Self::default() + } + + async fn prepare(&self, cx: &mut Context) -> Result { + let action = match self.mode() { + UserFormMode::New => format!("{ADMIN_USERS_PATH}/new"), + UserFormMode::Edit => { + format!( + "{ADMIN_USERS_PATH}/{}/edit", + self.user_id().copied().unwrap_or_default() + ) + } + }; + let action = self.waypoint().append_to(cx.route(action)); + + let mut form = Form::new() + .with_id(USER_ADMIN_FORM_ID) + .with_action(action) + .with_method(form::Method::Post) + .with_child(error_banner(self.error().cloned())) + .with_child( + form::input::Field::text() + .with_name("username") + .with_value(self.username()) + .with_label(Lc::t("field-username-admin", &LOCALES_USER)) + .with_required(true) + .with_maxlength(Some(64)), + ) + .with_child( + form::input::Field::email() + .with_name("email") + .with_value(self.email()) + .with_label(Lc::t("field-email", &LOCALES_USER)) + .with_required(true), + ) + .with_child( + form::input::Field::text() + .with_name("display_name") + .with_value(self.display_name()) + .with_label(Lc::t("field-display-name", &LOCALES_USER)), + ) + .with_child( + form::input::Field::text() + .with_name("language") + .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)), + ); + + if *self.mode() == UserFormMode::New { + form = form + .with_child(PasswordConfirm::new()) + .with_child(roles_fieldset(self.roles())); + + 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()), + ); + } + } + + // 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 { + form = form.with_child( + Button::submit(Lc::t("btn-save", &LOCALES_USER)) + .with_style(button::Style::Solid(Intent::Primary)), + ); + } + + Ok(form.render(cx).await) + } +} + +impl UserForm { + // **< UserForm BUILDER >*********************************************************************** + + #[builder_fn] + pub(crate) fn with_mode(mut self, mode: UserFormMode) -> Self { + self.mode = mode; + self + } + + #[builder_fn] + pub(crate) fn with_error(mut self, error: impl Into>) -> Self { + self.error = error.into(); + self + } + + #[builder_fn] + pub(crate) fn with_user_id(mut self, user_id: impl Into>) -> Self { + self.user_id = user_id.into(); + self + } + + #[builder_fn] + pub(crate) fn with_waypoint(mut self, waypoint: impl Into) -> Self { + self.waypoint = waypoint.into(); + self + } + + #[builder_fn] + pub(crate) fn with_username(mut self, username: impl Into) -> Self { + self.username = username.into(); + self + } + + #[builder_fn] + pub(crate) fn with_email(mut self, email: impl Into) -> Self { + self.email = email.into(); + self + } + + #[builder_fn] + pub(crate) fn with_display_name(mut self, display_name: impl Into) -> Self { + self.display_name = display_name.into(); + self + } + + #[builder_fn] + pub(crate) fn with_language(mut self, language: impl Into) -> Self { + self.language = language.into(); + self + } + + #[builder_fn] + pub(crate) fn with_timezone(mut self, timezone: impl Into) -> Self { + self.timezone = timezone.into(); + self + } + + #[builder_fn] + pub(crate) fn with_roles(mut self, roles: Vec<(i32, String, bool)>) -> Self { + self.roles = roles; + self + } + + #[builder_fn] + pub(crate) fn with_allow_admin_field(mut self, allow_admin_field: bool) -> Self { + self.allow_admin_field = allow_admin_field; + self + } + + #[builder_fn] + pub(crate) fn with_is_admin(mut self, is_admin: bool) -> Self { + self.is_admin = is_admin; + self + } +} diff --git a/extensions/pagetop-user/src/component/admin/user_roles_form.rs b/extensions/pagetop-user/src/component/admin/user_roles_form.rs new file mode 100644 index 00000000..cbf9e779 --- /dev/null +++ b/extensions/pagetop-user/src/component/admin/user_roles_form.rs @@ -0,0 +1,72 @@ +//! Formulario de asignación de roles a un usuario. Reemplaza siempre el conjunto completo. + +use pagetop::prelude::*; + +use crate::ADMIN_USERS_PATH; +use crate::LOCALES_USER; + +use crate::component::error_banner; + +use super::roles_fieldset; + +#[derive(AutoDefault, Clone, Debug, Getters)] +pub(crate) struct UserRolesForm { + user_id: i32, + error: Option, + roles: Vec<(i32, String, bool)>, + /// Listado de origen al que volver tras guardar (orden, búsqueda, página). + waypoint: Waypoint, +} + +#[async_trait] +impl Component for UserRolesForm { + fn new() -> Self { + Self::default() + } + + async fn prepare(&self, cx: &mut Context) -> Result { + let action = format!("{ADMIN_USERS_PATH}/{}/roles", self.user_id()); + let action = self.waypoint().append_to(cx.route(action)); + + let mut form = Form::new() + .with_id("user-roles-form") + .with_action(action) + .with_method(form::Method::Post) + .with_child(error_banner(self.error().cloned())) + .with_child(roles_fieldset(self.roles())) + .with_child( + Button::submit(Lc::t("btn-save", &LOCALES_USER)) + .with_style(button::Style::Solid(Intent::Primary)), + ); + + Ok(form.render(cx).await) + } +} + +impl UserRolesForm { + // **< UserRolesForm BUILDER >****************************************************************** + + #[builder_fn] + pub(crate) fn with_user_id(mut self, user_id: i32) -> Self { + self.user_id = user_id; + self + } + + #[builder_fn] + pub(crate) fn with_error(mut self, error: impl Into>) -> Self { + self.error = error.into(); + self + } + + #[builder_fn] + pub(crate) fn with_roles(mut self, roles: Vec<(i32, String, bool)>) -> Self { + self.roles = roles; + self + } + + #[builder_fn] + pub(crate) fn with_waypoint(mut self, waypoint: impl Into) -> Self { + self.waypoint = waypoint.into(); + self + } +} diff --git a/extensions/pagetop-user/src/component/admin/user_table.rs b/extensions/pagetop-user/src/component/admin/user_table.rs new file mode 100644 index 00000000..d8b67b17 --- /dev/null +++ b/extensions/pagetop-user/src/component/admin/user_table.rs @@ -0,0 +1,304 @@ +//! Tabla de usuarios: cabeceras ordenables, filas y paginación embebida. + +use pagetop::prelude::*; + +use pagetop::base::component::table::{Column, Row}; +use pagetop_htmx::hx; +use pagetop_htmx::hx_table::sort_link; + +use crate::ADMIN_USERS_PATH; +use crate::LOCALES_USER; +use crate::account::UserStatus; +use crate::permission::UserPermission; +use crate::service::user_admin::{UserListItem, UserSortField}; + +#[derive(AutoDefault, Clone, Debug, Getters)] +pub(crate) struct UserTable { + props: Props, + items: Vec, + sort: UserSortField, + dir: SortDir, + query: Option, + page: u64, + per_page: u64, + total: u64, +} + +#[async_trait] +impl Component for UserTable { + fn new() -> Self { + Self::default() + } + + fn id(&self) -> Option { + self.props.get_id() + } + + fn setup(&mut self, _cx: &Context) { + self.alter_prop(PropsOp::set_id("user-table-wrapper")); + self.alter_prop(PropsOp::prepend_classes("user-admin-table-wrapper")); + } + + async fn prepare(&self, cx: &mut Context) -> Result { + let pager = Pager::new() + .with_base_path(ADMIN_USERS_PATH) + .with_extra_query("q", self.query().cloned().unwrap_or_default()) + .with_extra_query("sort", self.sort().as_str()) + .with_extra_query("dir", *self.dir()) + .with_current_page(self.page()) + .with_items_per_page(self.per_page()) + .with_total_items(self.total()) + .with_prop(PropsOp::set(hx::BOOST, "true")) + .with_prop(PropsOp::set(hx::TARGET, "#user-table-wrapper")) + .with_prop(PropsOp::set(hx::SWAP, hx::swap::OUTER_HTML_SCROLL_TOP)) + .with_prop(PropsOp::set(hx::PUSH_URL, "true")) + .render(cx) + .await; + + let mut table = Table::new() + .with_prop(PropsOp::add_classes("user-admin-table")) + .with_column(self.sort_column(cx, UserSortField::Username, "col-username")) + .with_column(self.sort_column(cx, UserSortField::Email, "col-email")) + .with_column(Lc::t("col-display-name", &LOCALES_USER)) + .with_column(Lc::t("col-roles", &LOCALES_USER)) + .with_column(Lc::t("col-status", &LOCALES_USER)) + .with_column(Lc::t("col-actions", &LOCALES_USER)) + .with_empty(Lc::t("empty-users-list", &LOCALES_USER)); + + let waypoint = Waypoint::from(self.list_href(cx)); + let can_assign_roles = cx + .request() + .is_some_and(|r| has_permission(r, &UserPermission::AssignRoles)); + + for user in self.items() { + let status = user.status; + + table.alter_row( + Row::new() + .with_cell(username_cell(user, &waypoint)) + .with_cell(user.email.as_str()) + .with_cell(user.display_name.as_deref().unwrap_or("-")) + .with_cell(roles_cell(user, cx).await) + .with_cell(Lc::t(status_key(status), &LOCALES_USER)) + .with_cell(actions_cell(user, &waypoint, can_assign_roles, cx).await), + ); + } + + let new_href = waypoint.append_to(cx.route(format!("{ADMIN_USERS_PATH}/new"))); + + Ok(html! { + div (self.props()) { + div.user-admin-actions { + a href=(new_href) { + (Lc::t("btn-create-user", &LOCALES_USER).using(cx)) + } + } + (table.render(cx).await) + (pager) + } + }) + } +} + +impl UserTable { + // **< UserTable BUILDER >********************************************************************** + + #[builder_fn] + pub(crate) fn with_prop(mut self, op: PropsOp) -> Self { + self.props.alter_prop(op); + self + } + + #[builder_fn] + pub(crate) fn with_items(mut self, items: Vec) -> Self { + self.items = items; + self + } + + #[builder_fn] + pub(crate) fn with_sort(mut self, sort: UserSortField) -> Self { + self.sort = sort; + self + } + + #[builder_fn] + pub(crate) fn with_dir(mut self, dir: SortDir) -> Self { + self.dir = dir; + self + } + + #[builder_fn] + pub(crate) fn with_query(mut self, query: impl Into>) -> Self { + self.query = query.into(); + self + } + + #[builder_fn] + pub(crate) fn with_page(mut self, page: u64) -> Self { + self.page = page; + self + } + + #[builder_fn] + pub(crate) fn with_per_page(mut self, per_page: u64) -> Self { + self.per_page = per_page; + self + } + + #[builder_fn] + pub(crate) fn with_total(mut self, total: u64) -> Self { + self.total = total; + self + } + + // URL del listado con el estado actual (búsqueda, orden, página): es el valor que viaja como + // `waypoint` en los enlaces de ver/editar, para poder volver exactamente a este mismo estado. + // Se construye con `cx.route()` para que preserve el parámetro `lang` cuando corresponda. + fn list_href(&self, cx: &Context) -> String { + let mut route = cx.route(ADMIN_USERS_PATH); + if let Some(q) = self.query().filter(|q| !q.is_empty()) { + route.alter_param("q", q); + } + route + .alter_param("sort", self.sort().as_str()) + .alter_param("dir", *self.dir()) + .alter_param("page", self.page().to_string()) + .to_string() + } + + // Construye la cabecera ordenable de una columna: el enlace ya funciona sin HTMX (navega con + // una petición normal); `pagetop_htmx::hx_table::sort_link()` añade aparte los atributos `hx-*` + // para que la tabla se actualice sin recargar la página cuando la extensión esté disponible. + fn sort_column(&self, cx: &Context, field: UserSortField, label_key: &'static str) -> Column { + let is_active = *self.sort() == field; + let active = is_active.then_some(*self.dir()); + let next_dir = SortDir::next_for(active); + + let mut route = cx.route(ADMIN_USERS_PATH); + if let Some(q) = self.query().filter(|q| !q.is_empty()) { + route.alter_param("q", q); + } + route + .alter_param("sort", field.as_str()) + .alter_param("dir", next_dir); + + Column::new(Lc::t(label_key, &LOCALES_USER)).with_sort(sort_link( + route, + "#user-table-wrapper", + active, + )) + } +} + +// Construye la celda de usuario: el nombre enlaza a la pantalla de sólo lectura con todos sus +// datos y roles. Devuelve un componente `Html` para que el marcado se genere cuando `Table` +// renderice la celda, no al construir la fila. +fn username_cell(user: &UserListItem, waypoint: &Waypoint) -> Html { + let username = user.username.clone(); + let id = user.id; + let waypoint = waypoint.clone(); + Html::with(move |cx| { + let view_href = waypoint.append_to(cx.route(format!("{ADMIN_USERS_PATH}/{id}/view"))); + html! { + a href=(view_href) { (username.as_str()) } + } + }) +} + +// Construye la celda de acciones: editar siempre, y gestionar roles sólo si el usuario autenticado +// tiene permiso para asignarlos. Devuelve un componente `Html` para que el marcado se genere +// cuando `Table` renderice la celda, no al construir la fila. +async fn actions_cell( + user: &UserListItem, + waypoint: &Waypoint, + can_assign_roles: bool, + cx: &mut Context, +) -> Html { + let id = user.id; + let edit_href = waypoint.append_to(cx.route(format!("{ADMIN_USERS_PATH}/{id}/edit"))); + + // El botón se renderiza aquí, no dentro del `Html::with()` de abajo: necesita pasar por su + // propio ciclo de renderizado (`.render().await`) para que el tema activo lo estilice igual + // que el resto de acciones (ver `pagetop-bootsier::theme::bs::button`). + let edit_button = Button::anchor(Lc::t("btn-edit", &LOCALES_USER), edit_href) + .with_style(button::Style::Solid(Intent::Primary)) + .with_size(button::Size::Small) + .render(cx) + .await; + + let roles_button = if can_assign_roles { + let roles_href = waypoint.append_to(cx.route(format!("{ADMIN_USERS_PATH}/{id}/roles"))); + Some( + Button::anchor(Lc::t("btn-manage-roles", &LOCALES_USER), roles_href) + .with_style(button::Style::Solid(Intent::Neutral)) + .with_size(button::Size::Small) + .render(cx) + .await, + ) + } else { + None + }; + + Html::with(move |_cx| { + html! { + (edit_button) + @if let Some(roles_button) = &roles_button { + " " + (roles_button) + } + } + }) +} + +// Construye la celda de roles: la insignia de administrador y las insignias de cada rol asignado, +// o "-" si el usuario no tiene ningún rol ni es administrador. Los badges se renderizan aquí mismo +// (con el `cx` del ciclo de renderizado de `Table`); el resto del marcado se difiere al `Html` que +// se devuelve, igual que el resto de celdas. +async fn roles_cell(user: &UserListItem, cx: &mut Context) -> Html { + let admin_badge = if user.is_admin { + Some( + Badge::labeled(Lc::t("badge-admin", &LOCALES_USER)) + .with_prop(PropsOp::add_classes("user-admin-badge-admin")) + .render(cx) + .await, + ) + } else { + None + }; + + let mut role_badges = Vec::with_capacity(user.roles.len()); + for role in &user.roles { + role_badges.push( + Badge::labeled(Lc::n(role.clone())) + .with_prop(PropsOp::add_classes("user-admin-badge")) + .render(cx) + .await, + ); + } + + let is_admin = user.is_admin; + Html::with(move |_cx| { + html! { + @if let Some(badge) = &admin_badge { + (badge) + " " + } + @if role_badges.is_empty() { + @if !is_admin { "-" } + } @else { + @for badge in &role_badges { + (badge) + " " + } + } + } + }) +} + +pub(crate) fn status_key(status: UserStatus) -> &'static str { + match status { + UserStatus::Active => "status-active", + UserStatus::Blocked => "status-blocked", + UserStatus::Pending => "status-pending", + } +} diff --git a/extensions/pagetop-user/src/component/login_form.rs b/extensions/pagetop-user/src/component/login_form.rs new file mode 100644 index 00000000..7012331a --- /dev/null +++ b/extensions/pagetop-user/src/component/login_form.rs @@ -0,0 +1,112 @@ +//! Componente de formulario de inicio de sesión. + +use pagetop::prelude::*; + +use crate::component::error_banner; +use crate::config::SETTINGS; +use crate::{LOCALES_USER, LOGIN_PATH, PASSWORD_RESET_PATH, REGISTER_PATH}; + +#[derive(AutoDefault, Clone, Debug, Getters)] +pub struct LoginForm { + error: Option, + /// Página a la que volver tras iniciar sesión. + waypoint: Waypoint, +} + +#[async_trait] +impl Component for LoginForm { + fn new() -> Self { + Self::default() + } + + async fn prepare(&self, cx: &mut Context) -> Result { + let action = self.waypoint().append_to(cx.route(LOGIN_PATH)); + let strict = SETTINGS.login_strict; + + // En modo estricto se dificulta que el navegador recuerde o autorrellene las + // credenciales: nombres de campo neutros y campos `Field::strict_text()`/ + // `Field::strict_password()`, que ya incluyen el resto de medidas (ver su documentación). + let username = if strict { + form::input::Field::strict_text() + } else { + form::input::Field::text().with_autocomplete(Some(form::Autocomplete::username())) + } + .with_name(if strict { "ident" } else { "username" }) + .with_label(Lc::t("field-username", &LOCALES_USER)) + .with_autofocus(true) + .with_required(true); + + let password = if strict { + form::input::Field::strict_password() + } else { + form::input::Field::password() + .with_autocomplete(Some(form::Autocomplete::current_password())) + } + .with_name(if strict { "token" } else { "password" }) + .with_label(Lc::t("field-password", &LOCALES_USER)) + .with_required(true); + + let mut form = Form::new() + .with_id("user-login-form") + .with_action(action) + .with_method(form::Method::Post) + .with_child(error_banner(self.error().cloned())) + .with_child(username) + .with_child(password) + .with_child( + form::Checkbox::check() + .with_name("remember") + .with_label(Lc::t("field-remember-me", &LOCALES_USER)), + ) + .with_child(Button::submit(Lc::t("btn-login", &LOCALES_USER))) + .with_child(links(SETTINGS.allow_registration)); + + if strict { + form = form + .with_prop(PropsOp::set("autocomplete", "off")) + .with_prop(PropsOp::set("novalidate", "novalidate")); + } + + let form = form.render(cx).await; + Ok(html! { + div.user-login-page { + div.user-login-card { + (form) + } + } + }) + } +} + +fn links(allow_registration: bool) -> Html { + Html::with(move |cx| { + html! { + @if allow_registration { + p.user-register-link { + a href=(cx.route(REGISTER_PATH)) { + (Lc::t("link-register", &LOCALES_USER).using(cx)) + } + } + } + p.user-reset-link { + a href=(cx.route(PASSWORD_RESET_PATH)) { + (Lc::t("link-forgot-password", &LOCALES_USER).using(cx)) + } + } + } + }) +} + +impl LoginForm { + #[builder_fn] + pub fn with_error(mut self, error: impl Into>) -> Self { + self.error = error.into(); + self + } + + #[builder_fn] + pub fn with_waypoint(mut self, waypoint: impl Into) -> Self { + self.waypoint = waypoint.into(); + self + } +} diff --git a/extensions/pagetop-user/src/component/password_confirm.rs b/extensions/pagetop-user/src/component/password_confirm.rs new file mode 100644 index 00000000..93c445b1 --- /dev/null +++ b/extensions/pagetop-user/src/component/password_confirm.rs @@ -0,0 +1,67 @@ +//! Par de campos "contraseña" + "confirmar contraseña", reutilizado en los formularios de +//! registro, alta y restablecimiento de contraseña. + +use pagetop::prelude::*; + +use crate::LOCALES_USER; + +/// Campos de contraseña y confirmación, compuestos a partir de +/// [`form::input::Field::password()`] del core. No valida en ningún momento que ambos valores +/// coincidan -- eso ocurre en el servidor, tras el envío del formulario -- sólo renderiza los dos +/// campos, uno junto al otro, sin ningún contenedor propio. +#[derive(AutoDefault, Clone, Debug, Getters)] +pub(crate) struct PasswordConfirm { + /// Devuelve la etiqueta del campo de contraseña. + password_label: Lc, + /// Devuelve la etiqueta del campo de confirmación. + confirm_label: Lc, +} + +#[async_trait] +impl Component for PasswordConfirm { + // Las etiquetas por defecto cubren los dos casos más habituales (alta de cuenta, alta de + // usuario desde administración); `with_password_label()` cubre el caso distinto + // (restablecimiento de contraseña por un administrador). + fn new() -> Self { + Self { + password_label: Lc::t("field-password", &LOCALES_USER), + confirm_label: Lc::t("field-confirm-password", &LOCALES_USER), + } + } + + async fn prepare(&self, cx: &mut Context) -> Result { + let mut password = form::input::Field::password() + .with_name("password") + .with_label(self.password_label().clone()) + .with_autocomplete(Some(form::Autocomplete::new_password())) + .with_required(true); + let mut confirm = form::input::Field::password() + .with_name("confirm_password") + .with_label(self.confirm_label().clone()) + .with_autocomplete(Some(form::Autocomplete::new_password())) + .with_required(true); + + Ok(html! { + (password.render(cx).await) + (confirm.render(cx).await) + }) + } +} + +impl PasswordConfirm { + // **< PasswordConfirm BUILDER >******************************************************************** + + /// Establece la etiqueta del campo de contraseña (por defecto, "field-password"). + #[builder_fn] + pub(crate) fn with_password_label(mut self, label: Lc) -> Self { + self.password_label = label; + self + } + + /// Establece la etiqueta del campo de confirmación (por defecto, "field-confirm-password"). + #[builder_fn] + pub(crate) fn with_confirm_label(mut self, label: Lc) -> Self { + self.confirm_label = label; + self + } +} diff --git a/extensions/pagetop-user/src/component/password_reset_confirm_form.rs b/extensions/pagetop-user/src/component/password_reset_confirm_form.rs new file mode 100644 index 00000000..dc185c07 --- /dev/null +++ b/extensions/pagetop-user/src/component/password_reset_confirm_form.rs @@ -0,0 +1,53 @@ +//! Componente de formulario para introducir la nueva contraseña. + +use pagetop::prelude::*; + +use crate::LOCALES_USER; +use crate::component::error_banner; + +#[derive(AutoDefault, Clone, Debug)] +pub struct PasswordResetConfirmForm { + error: Option, +} + +#[async_trait] +impl Component for PasswordResetConfirmForm { + fn new() -> Self { + Self::default() + } + + async fn prepare(&self, cx: &mut Context) -> Result { + // Sin `with_action()`: se envía a la propia URL, que incluye el `{uid}/{token}` de la + // petición actual. + let mut form = Form::new() + .with_id("user-new-password-form") + .with_method(form::Method::Post) + .with_child(error_banner(self.error.clone())) + .with_child( + form::input::Field::password() + .with_name("password") + .with_label(Lc::t("field-new-password", &LOCALES_USER)) + .with_autocomplete(Some(form::Autocomplete::new_password())) + .with_autofocus(true) + .with_required(true), + ) + .with_child( + form::input::Field::password() + .with_name("confirm_password") + .with_label(Lc::t("field-confirm-password", &LOCALES_USER)) + .with_autocomplete(Some(form::Autocomplete::new_password())) + .with_required(true), + ) + .with_child(Button::submit(Lc::t("btn-set-password", &LOCALES_USER))); + + Ok(form.render(cx).await) + } +} + +impl PasswordResetConfirmForm { + #[builder_fn] + pub fn with_error(mut self, error: impl Into>) -> Self { + self.error = error.into(); + self + } +} diff --git a/extensions/pagetop-user/src/component/password_reset_form.rs b/extensions/pagetop-user/src/component/password_reset_form.rs new file mode 100644 index 00000000..5e0bdb29 --- /dev/null +++ b/extensions/pagetop-user/src/component/password_reset_form.rs @@ -0,0 +1,58 @@ +//! Componente de formulario de solicitud de restablecimiento de contraseña. + +use pagetop::prelude::*; + +use crate::component::error_banner; +use crate::{LOCALES_USER, LOGIN_PATH, PASSWORD_RESET_PATH}; + +#[derive(AutoDefault, Clone, Debug)] +pub struct PasswordResetForm { + error: Option, +} + +#[async_trait] +impl Component for PasswordResetForm { + fn new() -> Self { + Self::default() + } + + async fn prepare(&self, cx: &mut Context) -> Result { + let mut form = Form::new() + .with_id("user-password-reset-form") + .with_action(PASSWORD_RESET_PATH) + .with_method(form::Method::Post) + .with_child(error_banner(self.error.clone())) + .with_child( + form::input::Field::email() + .with_name("email") + .with_label(Lc::t("field-email", &LOCALES_USER)) + .with_autocomplete(Some(form::Autocomplete::email())) + .with_autofocus(true) + .with_required(true), + ) + .with_child(Button::submit(Lc::t("btn-send-reset-link", &LOCALES_USER))) + .with_child(back_to_login()); + + Ok(form.render(cx).await) + } +} + +fn back_to_login() -> Html { + Html::with(|cx| { + html! { + p { + a href=(cx.route(LOGIN_PATH)) { + (Lc::t("link-back-to-login", &LOCALES_USER).using(cx)) + } + } + } + }) +} + +impl PasswordResetForm { + #[builder_fn] + pub fn with_error(mut self, error: impl Into>) -> Self { + self.error = error.into(); + self + } +} diff --git a/extensions/pagetop-user/src/component/register_form.rs b/extensions/pagetop-user/src/component/register_form.rs new file mode 100644 index 00000000..66ac153b --- /dev/null +++ b/extensions/pagetop-user/src/component/register_form.rs @@ -0,0 +1,54 @@ +//! Componente de formulario de registro. + +use pagetop::prelude::*; + +use crate::LOCALES_USER; +use crate::REGISTER_PATH; +use crate::component::{PasswordConfirm, error_banner}; + +#[derive(AutoDefault, Clone, Debug)] +pub struct RegisterForm { + error: Option, +} + +#[async_trait] +impl Component for RegisterForm { + fn new() -> Self { + Self::default() + } + + async fn prepare(&self, cx: &mut Context) -> Result { + let mut form = Form::new() + .with_id("user-register-form") + .with_action(REGISTER_PATH) + .with_method(form::Method::Post) + .with_child(error_banner(self.error.clone())) + .with_child( + form::input::Field::text() + .with_name("username") + .with_label(Lc::t("field-username", &LOCALES_USER)) + .with_autocomplete(Some(form::Autocomplete::username())) + .with_autofocus(true) + .with_required(true), + ) + .with_child( + form::input::Field::email() + .with_name("email") + .with_label(Lc::t("field-email", &LOCALES_USER)) + .with_autocomplete(Some(form::Autocomplete::email())) + .with_required(true), + ) + .with_child(PasswordConfirm::new()) + .with_child(Button::submit(Lc::t("btn-register", &LOCALES_USER))); + + Ok(form.render(cx).await) + } +} + +impl RegisterForm { + #[builder_fn] + pub fn with_error(mut self, error: impl Into>) -> Self { + self.error = error.into(); + self + } +} diff --git a/extensions/pagetop-user/src/component/user_block.rs b/extensions/pagetop-user/src/component/user_block.rs new file mode 100644 index 00000000..d31ccac2 --- /dev/null +++ b/extensions/pagetop-user/src/component/user_block.rs @@ -0,0 +1,48 @@ +//! Componente de bloque de usuario (login/logout en la cabecera). + +use pagetop::prelude::*; + +use crate::config::SETTINGS; +use crate::{LOCALES_USER, LOGIN_PATH, LOGOUT_PATH, REGISTER_PATH}; + +#[derive(AutoDefault, Clone, Debug)] +pub struct UserBlock; + +#[async_trait] +impl Component for UserBlock { + fn new() -> Self { + Self + } + + async fn prepare(&self, cx: &mut Context) -> Result { + let user = cx.current_user(); + Ok(if user.is_authenticated() { + let display = user.display_name().unwrap_or("?"); + html! { + nav.user-block { + span.user-name { (display) } + " · " + form.user-logout-inline method="post" action=(cx.route(LOGOUT_PATH)) { + button type="submit" { + (Lc::t("btn-logout", &LOCALES_USER).using(cx)) + } + } + } + } + } else { + html! { + nav.user-block { + a href=(cx.route(LOGIN_PATH)) { + (Lc::t("btn-login", &LOCALES_USER).using(cx)) + } + @if SETTINGS.allow_registration { + " · " + a href=(cx.route(REGISTER_PATH)) { + (Lc::t("link-register", &LOCALES_USER).using(cx)) + } + } + } + } + }) + } +} diff --git a/extensions/pagetop-user/src/config.rs b/extensions/pagetop-user/src/config.rs new file mode 100644 index 00000000..f1922d86 --- /dev/null +++ b/extensions/pagetop-user/src/config.rs @@ -0,0 +1,136 @@ +//! Configuración de `pagetop-user`. +//! +//! Todos los valores pueden sobreescribirse en los ficheros TOML de la aplicación: +//! +//! ```toml +//! [user.password] +//! min_length = 10 +//! ``` + +use pagetop::prelude::*; +use serde::Deserialize; + +use std::sync::LazyLock; + +// **< CONFIG_USER >******************************************************************************** + +include_config!(CONFIG_USER: UserTopConfig => [ + // Política de registro y verificación. + "user.allow_registration" => true, + "user.require_email_verification" => false, + + // Sesiones. + "user.session_cookie_name" => "pgt_session", + "user.session_ttl_secs" => 1_209_600_i64, // 14 días + "user.session_idle_ttl_secs" => 7_200_i64, // 2 horas + "user.secure_cookie" => false, + + // Anti-fuerza-bruta. + "user.max_failed_logins" => 5_i32, + "user.failed_login_window_secs" => 900_i64, // 15 min + "user.locked_for_secs" => 600_i64, // 10 min + + // Contraseñas (Argon2id). + "user.password.argon2_m_cost" => 19456_u32, + "user.password.argon2_t_cost" => 2_u32, + "user.password.argon2_p_cost" => 1_u32, + "user.password.min_length" => 8_u64, + + // Semilla del primer administrador. + "user.seed.admin_username" => "admin", + "user.seed.admin_email" => "admin@example.com", + + // Listados de administración (usuarios, roles...). + "user.admin.list_page_size" => 20_u64, + + // Modo de login. + "user.login_strict" => false, +]); + +// **< UserTopConfig >****************************************************************************** + +/// Estructura raíz para la sección `[user]` del fichero de configuración. +#[derive(Clone, Debug, Deserialize)] +pub struct UserTopConfig { + pub user: Settings, +} + +// **< SETTINGS >*********************************************************************************** + +/// Acceso directo a los ajustes de `pagetop-user` (alias de `CONFIG_USER.user`). +pub static SETTINGS: LazyLock = LazyLock::new(|| CONFIG_USER.user.clone()); + +// **< Settings >*********************************************************************************** + +/// Ajustes de la extensión `pagetop-user`, accesibles en la sección `[user]` del TOML. +#[derive(Clone, Debug, Default, Deserialize)] +pub struct Settings { + // Política de registro y verificación. + pub allow_registration: bool, + pub require_email_verification: bool, + + // Sesiones. + pub session_cookie_name: String, + pub session_ttl_secs: i64, + pub session_idle_ttl_secs: i64, + pub secure_cookie: bool, + + // Anti-fuerza-bruta. + pub max_failed_logins: i32, + pub failed_login_window_secs: i64, + pub locked_for_secs: i64, + + // Contraseñas. + pub password: PasswordConfig, + + // Semilla del primer administrador. + pub seed: SeedConfig, + + // Listados de administración. + pub admin: AdminConfig, + + /// Activa las medidas para dificultar que el navegador recuerde o autorrellene las + /// credenciales en la pantalla de login (gestor de contraseñas, autocompletado agresivo...). + pub login_strict: bool, +} + +/// Ajustes de la sección `[user.password]`. +#[derive(Clone, Debug, Deserialize)] +pub struct PasswordConfig { + pub argon2_m_cost: u32, + pub argon2_t_cost: u32, + pub argon2_p_cost: u32, + pub min_length: usize, +} + +impl Default for PasswordConfig { + fn default() -> Self { + PasswordConfig { + argon2_m_cost: 19456, + argon2_t_cost: 2, + argon2_p_cost: 1, + min_length: 8, + } + } +} + +/// Ajustes de la sección `[user.seed]`. +#[derive(Clone, Debug, Default, Deserialize)] +pub struct SeedConfig { + pub admin_username: String, + pub admin_email: String, + pub admin_password: Option, +} + +/// Ajustes de la sección `[user.admin]`. +#[derive(Clone, Debug, Deserialize)] +pub struct AdminConfig { + /// Número de filas por página en los listados de administración (usuarios, roles...). + pub list_page_size: u64, +} + +impl Default for AdminConfig { + fn default() -> Self { + AdminConfig { list_page_size: 20 } + } +} diff --git a/extensions/pagetop-user/src/demo.rs b/extensions/pagetop-user/src/demo.rs new file mode 100644 index 00000000..d8e65d0e --- /dev/null +++ b/extensions/pagetop-user/src/demo.rs @@ -0,0 +1,113 @@ +//! Datos de demostración: 25 roles y 48 usuarios ficticios para explorar la administración. +//! +//! Sólo se compila con la feature `demo-data`. Se ejecuta una vez desde +//! `Extension::initialize()`, después de `auth::seed_initial_data()`. Si el rol +//! `demo_role_01` ya existe, la siembra se omite para no duplicar datos en reinicios. + +use pagetop::datetime::Utc; + +use pagetop_seaorm::db::{ActiveValue, ColumnTrait, EntityTrait, QueryFilter, Set, dbconn}; + +use crate::account::UserStatus; +use crate::auth; +use crate::entity::{role, user}; +use crate::error::AuthError; +use crate::password; + +const ROLE_COUNT: usize = 25; +const USER_COUNT: usize = 48; +const DEMO_PASSWORD: &str = "Demo12345!"; + +/// Crea los roles y usuarios de demostración si todavía no existen. +pub(crate) async fn seed_demo_data() { + let already_seeded = matches!( + role::Entity::find() + .filter(role::Column::MachineName.eq("demo_role_01")) + .one(dbconn()) + .await, + Ok(Some(_)) + ); + if already_seeded { + return; + } + + let role_ids = match create_demo_roles().await { + Ok(ids) => ids, + Err(e) => { + eprintln!( + "pagetop-user demo-data error: failed to create roles: {}", + e + ); + return; + } + }; + + match create_demo_users(&role_ids).await { + Ok(()) => println!( + "\npagetop-user: demo data created ({ROLE_COUNT} roles, {USER_COUNT} users).\n \ + password: {DEMO_PASSWORD}\n" + ), + Err(e) => eprintln!( + "pagetop-user demo-data error: failed to create users: {}", + e + ), + } +} + +async fn create_demo_roles() -> Result, AuthError> { + let now = Utc::now().naive_utc(); + let mut role_ids = Vec::with_capacity(ROLE_COUNT); + for n in 1..=ROLE_COUNT { + let new_role = role::ActiveModel { + id: ActiveValue::NotSet, + machine_name: Set(format!("demo_role_{n:02}")), + label: Set(format!("Demo Role {n:02}")), + description: Set(Some( + "Rol de demostración generado por la feature demo-data.".into(), + )), + // Los pesos 0 y 1 los ocupan los roles de sistema (anonymous, authenticated). + weight: Set(n as i32 + 1), + locked: Set(false), + created_at: Set(now), + updated_at: Set(now), + }; + let result = role::Entity::insert(new_role).exec(dbconn()).await?; + role_ids.push(result.last_insert_id); + } + Ok(role_ids) +} + +async fn create_demo_users(role_ids: &[i32]) -> Result<(), AuthError> { + let hash = password::hash_password(DEMO_PASSWORD)?; + let now = Utc::now().naive_utc(); + + for n in 1..=USER_COUNT { + let new_user = user::ActiveModel { + id: ActiveValue::NotSet, + username: Set(format!("demo_user_{n:02}")), + email: Set(format!("demo_user_{n:02}@example.com")), + email_verified_at: Set(Some(now)), + password_hash: Set(hash.clone()), + status: Set(UserStatus::Active.as_i16()), + language: Set(None), + timezone: Set(None), + display_name: Set(Some(format!("Demo User {n:02}"))), + last_login_at: Set(None), + last_access_at: Set(None), + failed_login_count: Set(0), + locked_until: Set(None), + is_admin: Set(false), + created_at: Set(now), + updated_at: Set(now), + }; + let result = user::Entity::insert(new_user).exec(dbconn()).await?; + let user_id = result.last_insert_id; + + auth::assign_role(user_id, crate::AUTHENTICATED_ROLE_ID).await?; + + // Reparte los usuarios de forma cíclica entre los roles de demostración. + let role_id = role_ids[(n - 1) % role_ids.len()]; + auth::assign_role(user_id, role_id).await?; + } + Ok(()) +} diff --git a/extensions/pagetop-user/src/entity.rs b/extensions/pagetop-user/src/entity.rs new file mode 100644 index 00000000..c2f95bc7 --- /dev/null +++ b/extensions/pagetop-user/src/entity.rs @@ -0,0 +1,8 @@ +//! Entidades SeaORM de `pagetop-user`. + +pub mod role; +pub mod role_permission; +pub mod session; +pub mod user; +pub mod user_role; +pub mod user_token; diff --git a/extensions/pagetop-user/src/entity/role.rs b/extensions/pagetop-user/src/entity/role.rs new file mode 100644 index 00000000..6432a108 --- /dev/null +++ b/extensions/pagetop-user/src/entity/role.rs @@ -0,0 +1,40 @@ +use pagetop_seaorm::db::*; + +use pagetop::datetime::NaiveDateTime; + +#[derive(Clone, Debug, DeriveEntityModel, PartialEq)] +#[sea_orm(table_name = "roles")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: i32, + #[sea_orm(unique)] + pub machine_name: String, + pub label: String, + pub description: Option, + pub weight: i32, + pub locked: bool, + pub created_at: NaiveDateTime, + pub updated_at: NaiveDateTime, +} + +#[derive(Clone, Copy, Debug, DeriveRelation, EnumIter)] +pub enum Relation { + #[sea_orm(has_many = "super::role_permission::Entity")] + RolePermissions, + #[sea_orm(has_many = "super::user_role::Entity")] + UserRoles, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::RolePermissions.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::UserRoles.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/extensions/pagetop-user/src/entity/role_permission.rs b/extensions/pagetop-user/src/entity/role_permission.rs new file mode 100644 index 00000000..67427dd9 --- /dev/null +++ b/extensions/pagetop-user/src/entity/role_permission.rs @@ -0,0 +1,32 @@ +use pagetop_seaorm::db::*; + +use pagetop::datetime::NaiveDateTime; + +#[derive(Clone, Debug, DeriveEntityModel, PartialEq)] +#[sea_orm(table_name = "role_permissions")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub role_id: i32, + #[sea_orm(primary_key, auto_increment = false)] + pub permission_key: String, + pub granted_at: NaiveDateTime, +} + +#[derive(Clone, Copy, Debug, DeriveRelation, EnumIter)] +pub enum Relation { + #[sea_orm( + belongs_to = "super::role::Entity", + from = "Column::RoleId", + to = "super::role::Column::Id", + on_delete = "Cascade" + )] + Role, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Role.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/extensions/pagetop-user/src/entity/session.rs b/extensions/pagetop-user/src/entity/session.rs new file mode 100644 index 00000000..32ad7f08 --- /dev/null +++ b/extensions/pagetop-user/src/entity/session.rs @@ -0,0 +1,34 @@ +use pagetop_seaorm::db::*; + +use pagetop::datetime::NaiveDateTime; + +#[derive(Clone, Debug, DeriveEntityModel, PartialEq)] +#[sea_orm(table_name = "sessions")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub sid: String, + pub user_id: i32, + pub data: String, + pub last_activity_at: Option, + pub expires_at: NaiveDateTime, + pub created_at: NaiveDateTime, +} + +#[derive(Clone, Copy, Debug, DeriveRelation, EnumIter)] +pub enum Relation { + #[sea_orm( + belongs_to = "super::user::Entity", + from = "Column::UserId", + to = "super::user::Column::Id", + on_delete = "Cascade" + )] + User, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::User.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/extensions/pagetop-user/src/entity/user.rs b/extensions/pagetop-user/src/entity/user.rs new file mode 100644 index 00000000..5656a490 --- /dev/null +++ b/extensions/pagetop-user/src/entity/user.rs @@ -0,0 +1,58 @@ +use pagetop_seaorm::db::*; + +use pagetop::datetime::NaiveDateTime; + +#[derive(Clone, Debug, DeriveEntityModel, PartialEq)] +#[sea_orm(table_name = "users")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: i32, + #[sea_orm(unique)] + pub username: String, + #[sea_orm(unique)] + pub email: String, + pub email_verified_at: Option, + pub password_hash: String, + pub status: i16, + pub language: Option, + pub timezone: Option, + pub display_name: Option, + pub last_login_at: Option, + pub last_access_at: Option, + pub failed_login_count: i32, + pub locked_until: Option, + /// Acceso irrestricto al sistema, sin pasar por roles ni permisos. + pub is_admin: bool, + pub created_at: NaiveDateTime, + pub updated_at: NaiveDateTime, +} + +#[derive(Clone, Copy, Debug, DeriveRelation, EnumIter)] +pub enum Relation { + #[sea_orm(has_many = "super::user_role::Entity")] + UserRoles, + #[sea_orm(has_many = "super::session::Entity")] + Sessions, + #[sea_orm(has_many = "super::user_token::Entity")] + UserTokens, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::UserRoles.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Sessions.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::UserTokens.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/extensions/pagetop-user/src/entity/user_role.rs b/extensions/pagetop-user/src/entity/user_role.rs new file mode 100644 index 00000000..1d33121d --- /dev/null +++ b/extensions/pagetop-user/src/entity/user_role.rs @@ -0,0 +1,42 @@ +use pagetop_seaorm::db::*; + +#[derive(Clone, Debug, DeriveEntityModel, PartialEq)] +#[sea_orm(table_name = "user_roles")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub user_id: i32, + #[sea_orm(primary_key, auto_increment = false)] + pub role_id: i32, +} + +#[derive(Clone, Copy, Debug, DeriveRelation, EnumIter)] +pub enum Relation { + #[sea_orm( + belongs_to = "super::user::Entity", + from = "Column::UserId", + to = "super::user::Column::Id", + on_delete = "Cascade" + )] + User, + #[sea_orm( + belongs_to = "super::role::Entity", + from = "Column::RoleId", + to = "super::role::Column::Id", + on_delete = "Cascade" + )] + Role, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::User.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Role.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/extensions/pagetop-user/src/entity/user_token.rs b/extensions/pagetop-user/src/entity/user_token.rs new file mode 100644 index 00000000..d497d979 --- /dev/null +++ b/extensions/pagetop-user/src/entity/user_token.rs @@ -0,0 +1,37 @@ +use pagetop_seaorm::db::*; + +use pagetop::datetime::NaiveDateTime; + +#[derive(Clone, Debug, DeriveEntityModel, PartialEq)] +#[sea_orm(table_name = "user_tokens")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: i32, + pub user_id: i32, + pub kind: String, + #[sea_orm(unique)] + pub token_hash: String, + pub expires_at: NaiveDateTime, + pub consumed_at: Option, + pub created_at: NaiveDateTime, + pub updated_at: NaiveDateTime, +} + +#[derive(Clone, Copy, Debug, DeriveRelation, EnumIter)] +pub enum Relation { + #[sea_orm( + belongs_to = "super::user::Entity", + from = "Column::UserId", + to = "super::user::Column::Id", + on_delete = "Cascade" + )] + User, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::User.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/extensions/pagetop-user/src/error.rs b/extensions/pagetop-user/src/error.rs new file mode 100644 index 00000000..8e96e7a7 --- /dev/null +++ b/extensions/pagetop-user/src/error.rs @@ -0,0 +1,69 @@ +//! Errores de `pagetop-user`. + +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum AuthError { + #[error("invalid credentials")] + InvalidCredentials, + + #[error("account is blocked")] + AccountBlocked, + + #[error("account is pending email verification")] + AccountPending, + + #[error("account is temporarily locked due to too many failed login attempts")] + AccountLocked, + + #[error("password hashing failed: {0}")] + PasswordHash(String), + + #[error("database error: {0}")] + Database(#[from] pagetop_seaorm::db::DbErr), + + #[error("token is invalid or has expired")] + InvalidToken, + + #[error("username is already taken")] + UsernameTaken, + + #[error("email is already registered")] + EmailTaken, + + #[error("passwords do not match")] + PasswordMismatch, + + #[error("password must be at least {0} characters")] + PasswordTooShort(usize), + + #[error("user not found")] + UserNotFound, + + #[error("role not found")] + RoleNotFound, + + #[error("role machine name is already taken")] + RoleMachineNameTaken, + + #[error("role machine name must contain only lowercase letters, digits and underscores")] + InvalidMachineName, + + #[error("role is locked and cannot be modified or deleted")] + RoleLocked, + + #[error("role has users assigned and cannot be deleted")] + RoleInUse, + + #[error("cannot remove the last administrator")] + LastAdministrator, + + #[error("cannot block your own account")] + CannotBlockSelf, + + #[error("cannot modify your own administrator flag")] + CannotModifyOwnAdminFlag, + + #[error("unknown permission key: {0}")] + UnknownPermission(String), +} diff --git a/extensions/pagetop-user/src/handlers.rs b/extensions/pagetop-user/src/handlers.rs new file mode 100644 index 00000000..52fb3546 --- /dev/null +++ b/extensions/pagetop-user/src/handlers.rs @@ -0,0 +1,5 @@ +//! Handlers HTTP de `pagetop-user`. + +pub(crate) mod account; +pub(crate) mod admin; +pub(crate) mod auth; diff --git a/extensions/pagetop-user/src/handlers/account.rs b/extensions/pagetop-user/src/handlers/account.rs new file mode 100644 index 00000000..f38964e9 --- /dev/null +++ b/extensions/pagetop-user/src/handlers/account.rs @@ -0,0 +1,137 @@ +//! Handler HTTP para el perfil del propio usuario autenticado. + +use pagetop::base::component::table::Row; +use pagetop::prelude::*; + +use crate::account::UserStatus; +use crate::component::admin::status_key; +use crate::entity::{role, user}; +use crate::service::user_admin; +use crate::{LOCALES_USER, LOGIN_PATH, PROFILE_PATH}; + +// **< profile_get >******************************************************************************** + +/// GET /user - Perfil del usuario autenticado. Redirige al formulario de inicio de sesión si no hay +/// sesión activa, conservando la URL de retorno. +pub(crate) async fn profile_get(request: HttpRequest) -> Response { + let cx = Context::new(request.clone()); + let Some(id) = cx.current_user().id() else { + let target = cx.route(LOGIN_PATH).with_param("next", PROFILE_PATH); + return Redirect::see_other(target).into_response(); + }; + + let user = match user_admin::find_user(id).await { + Ok(user) => user, + Err(_) => return ErrorPage::NotFound(Some(request)).into_response(), + }; + let roles = match user_admin::user_roles(id).await { + Ok(roles) => roles, + Err(_) => return ErrorPage::InternalError(Some(request)).into_response(), + }; + let status = UserStatus::from_i16(user.status); + + let mut page = Page::new(request); + let details_block = profile_details(&user, status, page.context()).await; + let roles_block = profile_roles(&roles, page.context()).await; + + page.with_title(Lc::t("title-profile", &LOCALES_USER)) + .with_child(details_block) + .with_child(roles_block) + .render() + .await + .into_response() +} + +// Bloque de sólo lectura con los datos de perfil del usuario autenticado. +async fn profile_details(user: &user::Model, status: UserStatus, cx: &mut Context) -> Block { + let mut table = Table::new() + .with_prop(PropsOp::add_classes("user-admin-table")) + .with_row( + Row::new() + .with_cell(Lc::t("field-username-admin", &LOCALES_USER)) + .with_cell(user.username.as_str()), + ) + .with_row( + Row::new() + .with_cell(Lc::t("field-email", &LOCALES_USER)) + .with_cell(user.email.as_str()), + ) + .with_row( + Row::new() + .with_cell(Lc::t("field-display-name", &LOCALES_USER)) + .with_cell(user.display_name.as_deref().unwrap_or("-")), + ) + .with_row( + Row::new() + .with_cell(Lc::t("field-language", &LOCALES_USER)) + .with_cell(user.language.as_deref().unwrap_or("-")), + ) + .with_row( + Row::new() + .with_cell(Lc::t("field-timezone", &LOCALES_USER)) + .with_cell(user.timezone.as_deref().unwrap_or("-")), + ) + .with_row( + Row::new() + .with_cell(Lc::t("col-status", &LOCALES_USER)) + .with_cell(Lc::t(status_key(status), &LOCALES_USER)), + ); + + if user.is_admin { + let badge = Badge::labeled(Lc::t("badge-admin", &LOCALES_USER)) + .with_prop(PropsOp::add_classes("user-admin-badge-admin")) + .render(cx) + .await; + table = table.with_row( + Row::new() + .with_cell("") + .with_cell(Html::with(move |_| badge.clone())), + ); + } + + Block::new() + .with_title(Lc::t("title-user-details", &LOCALES_USER)) + .with_child(table) +} + +// Bloque de sólo lectura con los roles del usuario autenticado. A diferencia de la vista de +// administración, no enlaza cada rol a su pantalla de detalle: un usuario sin permisos de +// administración no puede acceder a ella. +async fn profile_roles(roles: &[role::Model], cx: &mut Context) -> Block { + let mut items: Vec<(String, Option)> = Vec::with_capacity(roles.len()); + for r in roles { + let system_badge = if r.locked { + Some( + Badge::labeled(Lc::t("badge-system-role", &LOCALES_USER)) + .with_prop(PropsOp::add_classes("user-admin-badge-system")) + .render(cx) + .await, + ) + } else { + None + }; + items.push((r.label.clone(), system_badge)); + } + + Block::new() + .with_title(Lc::t("field-roles", &LOCALES_USER)) + .with_child(Html::with(move |_cx| { + html! { + @if items.is_empty() { + "-" + } @else { + ul.user-profile-roles { + @for (label, system_badge) in &items { + li { + (label.as_str()) + @if let Some(badge) = system_badge { + " " + (badge) + } + } + } + } + } + } + })) +} diff --git a/extensions/pagetop-user/src/handlers/admin.rs b/extensions/pagetop-user/src/handlers/admin.rs new file mode 100644 index 00000000..e4aaba45 --- /dev/null +++ b/extensions/pagetop-user/src/handlers/admin.rs @@ -0,0 +1,59 @@ +//! Handlers HTTP para el mantenimiento de usuarios, roles y permisos. +//! +//! Accesibles sólo por URL directa (sin entradas de menú). Cada handler comprueba el permiso +//! correspondiente con [`require_permission()`](pagetop::auth::require_permission) antes de +//! construir la página. + +pub(crate) mod permissions; +pub(crate) mod roles; +pub(crate) mod users; + +use pagetop::prelude::*; +use pagetop_admin::component::AdminFrame; + +use crate::LOCALES_USER; +use crate::error::AuthError; + +// Envuelve el contenido de una página de administración en el frame de `pagetop-admin`: +// breadcrumb, tareas y acciones locales registradas para la ruta actual en su `AdminRegistry`. +pub(crate) fn frame(title: Lc) -> AdminFrame { + AdminFrame::new().with_title(title) +} + +// Traduce un `AuthError` a su clave Lc, para mostrarlo en el formulario que falló. Cubre tanto +// los errores de autenticación existentes como los nuevos de administración. +pub(crate) fn map_auth_error(err: &AuthError) -> Lc { + match err { + AuthError::PasswordTooShort(n) => { + Lc::t("error-password-too-short", &LOCALES_USER).with_arg("n", n.to_string()) + } + AuthError::PasswordMismatch => Lc::t("error-password-mismatch", &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), + AuthError::RoleNotFound => Lc::t("error-role-not-found", &LOCALES_USER), + AuthError::RoleMachineNameTaken => Lc::t("error-role-machine-name-taken", &LOCALES_USER), + AuthError::InvalidMachineName => Lc::t("error-invalid-machine-name", &LOCALES_USER), + AuthError::RoleLocked => Lc::t("error-role-locked", &LOCALES_USER), + AuthError::RoleInUse => Lc::t("error-role-in-use", &LOCALES_USER), + AuthError::LastAdministrator => Lc::t("error-last-administrator", &LOCALES_USER), + AuthError::CannotBlockSelf => Lc::t("error-cannot-block-self", &LOCALES_USER), + AuthError::CannotModifyOwnAdminFlag => { + Lc::t("error-cannot-modify-own-admin-flag", &LOCALES_USER) + } + AuthError::UnknownPermission(_) => Lc::t("error-unknown-permission", &LOCALES_USER), + _ => Lc::t("error-internal", &LOCALES_USER), + } +} + +// Enlace de vuelta al listado, usado en las pantallas de alta/edición/asignación. +pub(crate) fn back_link(href: impl Into) -> Html { + let href = href.into(); + Html::with(move |cx| { + html! { + p.user-admin-back-link { + a href=(href.clone()) { (Lc::t("link-back-to-list", &LOCALES_USER).using(cx)) } + } + } + }) +} diff --git a/extensions/pagetop-user/src/handlers/admin/permissions.rs b/extensions/pagetop-user/src/handlers/admin/permissions.rs new file mode 100644 index 00000000..62d6e1df --- /dev/null +++ b/extensions/pagetop-user/src/handlers/admin/permissions.rs @@ -0,0 +1,46 @@ +//! Handler de listado de permisos. +//! +//! Sólo lectura: los permisos se declaran en código mediante la acción `DeclarePermissions`, no se +//! crean, editan ni eliminan desde la UI. La única forma de "gestionarlos" es asignarlos a un rol +//! (ver `handlers::admin::roles::permissions_get/post`). + +use pagetop::prelude::*; + +use crate::LOCALES_USER; +use crate::handlers::admin::frame; +use crate::permission::{self, UserPermission}; + +/// GET /admin/user/permissions - Catálogo de permisos agrupado. +pub(crate) async fn list_get(request: HttpRequest) -> Result { + require_permission(&request, &UserPermission::AdminPermissions)?; + + let registry = permission::registry(); + let title = Lc::t("title-admin-permissions", &LOCALES_USER); + let mut content = frame(title.clone()); + + for (group, group_label) in registry.groups() { + let items: Vec<(CowStr, Lc)> = registry + .by_group(group) + .map(|permission| (permission.key(), permission.label())) + .collect(); + content = content.with_child(Block::new().with_title(group_label.clone()).with_child( + Html::with(move |cx| { + html! { + table.user-admin-table { + tbody { + @for (key, label) in &items { + tr { + td { (label.using(cx)) } + td.user-admin-permission-key { (key) } + } + } + } + } + } + }), + )); + } + + let mut page = Page::admin(request).with_title(title).with_child(content); + Ok(page.render().await.into_response()) +} diff --git a/extensions/pagetop-user/src/handlers/admin/roles.rs b/extensions/pagetop-user/src/handlers/admin/roles.rs new file mode 100644 index 00000000..a2211ce9 --- /dev/null +++ b/extensions/pagetop-user/src/handlers/admin/roles.rs @@ -0,0 +1,584 @@ +//! Handlers de administración de roles. + +use serde::Deserialize; + +use pagetop::base::component::table::Row; +use pagetop::prelude::*; +use pagetop_htmx::prelude::*; + +use crate::ADMIN_ROLES_PATH; +use crate::LOCALES_USER; +use crate::component::admin::{ + PermissionGroups, RoleForm, RoleFormMode, RolePermissionsForm, RoleTable, +}; +use crate::config::SETTINGS; +use crate::entity::role; +use crate::handlers::admin::{back_link, frame, map_auth_error}; +use crate::permission::{self, UserPermission}; +use crate::service::role_admin::{self, RolePageParams, RoleSortField}; + +#[derive(Deserialize)] +pub(crate) struct RolesQuery { + #[serde(default)] + sort: Option, + #[serde(default)] + dir: Option, + #[serde(default)] + page: Option, +} + +// **< list_get >*********************************************************************************** + +/// GET /admin/user/roles - Listado de roles (orden y paginación vía HTMX). +pub(crate) async fn list_get( + request: HttpRequest, + web::Query(query): web::Query, +) -> Result { + require_permission(&request, &UserPermission::AdminRoles)?; + + let params = RolePageParams { + sort: RoleSortField::from_query(query.sort.as_deref()), + dir: SortDir::from_query(query.dir.as_deref()), + page: query.page.unwrap_or(1).max(1), + per_page: SETTINGS.admin.list_page_size, + }; + + let result = match role_admin::list_roles_page(¶ms).await { + Ok(result) => result, + Err(_) => return Err(ErrorPage::InternalError(Some(request))), + }; + + let mut table = RoleTable::new() + .with_items(result.items) + .with_sort(params.sort) + .with_dir(params.dir) + .with_page(result.page) + .with_per_page(result.per_page) + .with_total(result.total); + + if request.is_htmx() { + let mut cx = Context::admin(request); + Ok(HtmxResponse::new(table.render(&mut cx).await).into_response()) + } else { + let title = Lc::t("title-admin-roles", &LOCALES_USER); + Ok(Page::admin(request) + .with_title(title.clone()) + .with_child(frame(title).with_child(table)) + .render() + .await + .into_response()) + } +} + +// **< new_get / new_post >************************************************************************* + +/// GET /admin/user/roles/new - Formulario de alta de rol. +pub(crate) async fn new_get( + request: HttpRequest, + web::Query(waypoint): web::Query, +) -> Result { + require_permission(&request, &UserPermission::AdminRoles)?; + let mut page = Page::admin(request); + let back_href = waypoint.or(page.context().route(ADMIN_ROLES_PATH)); + let title = Lc::t("title-admin-role-new", &LOCALES_USER); + Ok(page + .with_title(title.clone()) + .with_child( + frame(title) + .with_child( + RoleForm::new() + .with_mode(RoleFormMode::New) + .with_waypoint(waypoint), + ) + .with_child(back_link(back_href)), + ) + .render() + .await + .into_response()) +} + +#[derive(Deserialize)] +pub(crate) struct NewRoleFormData { + machine_name: String, + label: String, + #[serde(default)] + description: String, + #[serde(default)] + weight: String, +} + +/// POST /admin/user/roles/new - Crea un rol. +pub(crate) async fn new_post( + request: HttpRequest, + web::Query(waypoint): web::Query, + web::Form(form): web::Form, +) -> Result { + require_permission(&request, &UserPermission::AdminRoles)?; + + let weight: i32 = form.weight.trim().parse().unwrap_or(0); + + let result = role_admin::create_role(role_admin::NewRoleData { + machine_name: form.machine_name.trim(), + label: &form.label, + description: util::non_blank(&form.description), + weight, + }) + .await; + + match result { + Ok(_) => { + let cx = Context::admin(request); + let target = waypoint.or(cx.route(ADMIN_ROLES_PATH)); + Ok(Redirect::see_other(target).into_response()) + } + Err(err) => { + let mut page = Page::admin(request); + let back_href = waypoint.or(page.context().route(ADMIN_ROLES_PATH)); + let form_component = RoleForm::new() + .with_mode(RoleFormMode::New) + .with_machine_name(form.machine_name) + .with_label(form.label) + .with_description(form.description) + .with_weight(weight) + .with_error(map_auth_error(&err)) + .with_waypoint(waypoint); + let title = Lc::t("title-admin-role-new", &LOCALES_USER); + Ok(page + .with_title(title.clone()) + .with_child( + frame(title) + .with_child(form_component) + .with_child(back_link(back_href)), + ) + .render() + .await + .into_response()) + } + } +} + +// **< edit_get / edit_post >************************************************************************ + +/// GET /admin/user/roles/{id}/edit - Formulario de edición de rol. +pub(crate) async fn edit_get( + request: HttpRequest, + web::Path(id): web::Path, + web::Query(waypoint): web::Query, +) -> Result { + require_permission(&request, &UserPermission::AdminRoles)?; + + let role = match role_admin::find_role(id).await { + Ok(role) => role, + Err(_) => return Err(ErrorPage::NotFound(Some(request))), + }; + if role.locked { + return Err(ErrorPage::AccessDenied(Some(request))); + } + + let mut page = Page::admin(request); + let back_href = waypoint.or(page.context().route(ADMIN_ROLES_PATH)); + + let title = Lc::t("title-admin-role-edit", &LOCALES_USER); + Ok(page + .with_title(title.clone()) + .with_child( + frame(title) + .with_child( + RoleForm::new() + .with_mode(RoleFormMode::Edit) + .with_role_id(Some(id)) + .with_machine_name(role.machine_name) + .with_label(role.label) + .with_description(role.description.unwrap_or_default()) + .with_weight(role.weight) + .with_waypoint(waypoint), + ) + .with_child(back_link(back_href)), + ) + .render() + .await + .into_response()) +} + +#[derive(Deserialize)] +pub(crate) struct EditRoleFormData { + label: String, + #[serde(default)] + description: String, + #[serde(default)] + weight: String, +} + +/// POST /admin/user/roles/{id}/edit - Actualiza un rol (rechazado si está bloqueado). +pub(crate) async fn edit_post( + request: HttpRequest, + web::Path(id): web::Path, + web::Query(waypoint): web::Query, + web::Form(form): web::Form, +) -> Result { + require_permission(&request, &UserPermission::AdminRoles)?; + + let weight: i32 = form.weight.trim().parse().unwrap_or(0); + + let result = role_admin::update_role( + id, + role_admin::RoleUpdateData { + label: &form.label, + description: util::non_blank(&form.description), + weight, + }, + ) + .await; + + match result { + Ok(()) => { + let cx = Context::admin(request); + let target = waypoint.or(cx.route(ADMIN_ROLES_PATH)); + Ok(Redirect::see_other(target).into_response()) + } + Err(err) => { + let machine_name = role_admin::find_role(id) + .await + .map(|role| role.machine_name) + .unwrap_or_default(); + let mut page = Page::admin(request); + let back_href = waypoint.or(page.context().route(ADMIN_ROLES_PATH)); + let form_component = RoleForm::new() + .with_mode(RoleFormMode::Edit) + .with_role_id(Some(id)) + .with_machine_name(machine_name) + .with_label(form.label) + .with_description(form.description) + .with_weight(weight) + .with_error(map_auth_error(&err)) + .with_waypoint(waypoint); + let title = Lc::t("title-admin-role-edit", &LOCALES_USER); + Ok(page + .with_title(title.clone()) + .with_child( + frame(title) + .with_child(form_component) + .with_child(back_link(back_href)), + ) + .render() + .await + .into_response()) + } + } +} + +// **< view_get >*********************************************************************************** + +/// GET /admin/user/roles/{id}/view - Pantalla de sólo lectura con los datos del rol y todos los +/// permisos del catálogo, marcando los que tiene concedidos. +pub(crate) async fn view_get( + request: HttpRequest, + web::Path(id): web::Path, + web::Query(waypoint): web::Query, +) -> Result { + require_permission(&request, &UserPermission::AdminRoles)?; + + let role = match role_admin::find_role(id).await { + Ok(role) => role, + Err(_) => return Err(ErrorPage::NotFound(Some(request))), + }; + let selected = match role_admin::role_permission_keys(id).await { + Ok(keys) => keys, + Err(_) => return Err(ErrorPage::InternalError(Some(request))), + }; + let groups = build_permission_groups(&selected); + + let mut page = Page::admin(request); + let back_href = waypoint.or(page.context().route(ADMIN_ROLES_PATH)); + let details_block = role_view_details(&role, page.context()).await; + + let title = Lc::t("title-admin-role-view", &LOCALES_USER); + let mut content = frame(title.clone()).with_child(details_block); + for group_block in role_view_permissions(&groups) { + content = content.with_child(group_block); + } + content = content.with_child(back_link(back_href)); + + Ok(page + .with_title(title) + .with_child(content) + .render() + .await + .into_response()) +} + +// Bloque de sólo lectura con los datos del rol. +async fn role_view_details(role: &role::Model, cx: &mut Context) -> Block { + let mut table = Table::new() + .with_prop(PropsOp::add_classes("user-admin-table")) + .with_row( + Row::new() + .with_cell(Lc::t("field-machine-name", &LOCALES_USER)) + .with_cell(role.machine_name.as_str()), + ) + .with_row( + Row::new() + .with_cell(Lc::t("field-label", &LOCALES_USER)) + .with_cell(role.label.as_str()), + ) + .with_row( + Row::new() + .with_cell(Lc::t("field-description", &LOCALES_USER)) + .with_cell(role.description.as_deref().unwrap_or("-")), + ) + .with_row( + Row::new() + .with_cell(Lc::t("field-weight", &LOCALES_USER)) + .with_cell(role.weight.to_string()), + ); + + if role.locked { + let badge = Badge::labeled(Lc::t("badge-system-role", &LOCALES_USER)) + .with_prop(PropsOp::add_classes("user-admin-badge-system")) + .render(cx) + .await; + table = table.with_row( + Row::new() + .with_cell("") + .with_cell(Html::with(move |_| badge.clone())), + ); + } + + Block::new() + .with_title(Lc::t("title-role-details", &LOCALES_USER)) + .with_child(table) +} + +// Un bloque por grupo del catálogo de permisos: cada permiso concedido se marca con la clase +// `user-admin-permission-granted` (negrita, vía CSS del tema); el resto con +// `user-admin-permission-missing` (gris claro, vía CSS del tema). +fn role_view_permissions(groups: &PermissionGroups) -> Vec { + groups + .iter() + .map(|(group_label, perms)| { + let perms = perms.clone(); + Block::new() + .with_title(group_label.clone()) + .with_child(Html::with(move |cx| { + html! { + ul.user-admin-permission-list { + @for (_key, label, granted) in &perms { + @if *granted { + li.user-admin-permission-granted { (label.using(cx)) } + } @else { + li.user-admin-permission-missing { (label.using(cx)) } + } + } + } + } + })) + }) + .collect() +} + +// **< delete_post >******************************************************************************** + +/// POST /admin/user/roles/{id}/delete - Elimina un rol (rechazado si está bloqueado o en uso). +/// +/// Vuelve a mostrar la página/orden indicados en `sort`/`dir`/`page` (propagados desde la fila que +/// inició el borrado, ver `delete_confirm_get`), tanto si el borrado tiene éxito como si falla, en +/// vez de reiniciar siempre a la primera página. +pub(crate) async fn delete_post( + request: HttpRequest, + web::Path(id): web::Path, + web::Query(query): web::Query, +) -> Result { + require_permission(&request, &UserPermission::AdminRoles)?; + + let result = role_admin::delete_role(id).await; + + if request.is_htmx() { + let params = RolePageParams { + sort: RoleSortField::from_query(query.sort.as_deref()), + dir: SortDir::from_query(query.dir.as_deref()), + page: query.page.unwrap_or(1).max(1), + per_page: SETTINGS.admin.list_page_size, + }; + let page_result = role_admin::list_roles_page(¶ms) + .await + .unwrap_or_default(); + let message = result.err().as_ref().map(map_auth_error); + let mut table = RoleTable::new() + .with_items(page_result.items) + .with_sort(params.sort) + .with_dir(params.dir) + .with_page(page_result.page) + .with_per_page(page_result.per_page) + .with_total(page_result.total) + .with_message(message); + let mut cx = Context::new(request); + Ok(HtmxResponse::new(table.render(&mut cx).await).into_response()) + } else { + let cx = Context::new(request); + match result { + Ok(()) => { + let target = cx + .route(ADMIN_ROLES_PATH) + .alter_param( + "sort", + RoleSortField::from_query(query.sort.as_deref()).as_str(), + ) + .alter_param("dir", SortDir::from_query(query.dir.as_deref())) + .alter_param("page", query.page.unwrap_or(1).max(1).to_string()) + .to_string(); + Ok(Redirect::see_other(target).into_response()) + } + Err(_) => Err(ErrorPage::BadRequest(cx.request().cloned())), + } + } +} + +// **< delete_confirm_get >************************************************************************* + +/// GET /admin/user/roles/{id}/delete/confirm - Botón de confirmación de borrado para esta fila, +/// insertado vía htmx en el diálogo de confirmación compartido por toda la tabla (ver +/// `RoleTable`), con la URL de borrado de este rol ya incrustada. +/// +/// Propaga `sort`/`dir`/`page` (recibidos como query string desde la propia fila) hacia la URL de +/// borrado, para que `delete_post` pueda volver a mostrar la misma página tras el borrado, en vez +/// de reiniciar siempre al listado por defecto. +pub(crate) async fn delete_confirm_get( + request: HttpRequest, + web::Path(id): web::Path, + web::Query(query): web::Query, +) -> Result { + require_permission(&request, &UserPermission::AdminRoles)?; + + let mut cx = Context::admin(request); + let delete_href = cx + .route(format!("{ADMIN_ROLES_PATH}/{id}/delete")) + .alter_param( + "sort", + RoleSortField::from_query(query.sort.as_deref()).as_str(), + ) + .alter_param("dir", SortDir::from_query(query.dir.as_deref())) + .alter_param("page", query.page.unwrap_or(1).max(1).to_string()) + .to_string(); + + let mut button = Button::plain(Lc::t("btn-delete", &LOCALES_USER)) + .with_prop(PropsOp::set(hx::POST, delete_href)) + .with_prop(PropsOp::set(hx::TARGET, "#role-table-wrapper")) + .with_prop(PropsOp::set(hx::SWAP, hx::swap::OUTER_HTML_SCROLL_TOP)) + .with_prop(PropsOp::set("data-dialog-dismiss", "modal")); + + Ok(HtmxResponse::new(button.render(&mut cx).await).into_response()) +} + +// **< permissions_get / permissions_post >********************************************************** + +fn build_permission_groups(selected: &[String]) -> PermissionGroups { + let registry = permission::registry(); + registry + .groups() + .iter() + .map(|(group, group_label)| { + let perms = registry + .by_group(group) + .map(|permission| { + let key = permission.key(); + let checked = selected.iter().any(|k| k.as_str() == key); + (key, permission.label(), checked) + }) + .collect(); + (group_label.clone(), perms) + }) + .collect() +} + +/// GET /admin/user/roles/{id}/permissions - Formulario de asignación de permisos de un rol. +/// Permitido aunque el rol esté bloqueado: los roles de sistema también necesitan permisos. +pub(crate) async fn permissions_get( + request: HttpRequest, + web::Path(id): web::Path, + web::Query(waypoint): web::Query, +) -> Result { + require_permission(&request, &UserPermission::AdminRoles)?; + require_permission(&request, &UserPermission::AdminPermissions)?; + + if role_admin::find_role(id).await.is_err() { + return Err(ErrorPage::NotFound(Some(request))); + } + let selected = match role_admin::role_permission_keys(id).await { + Ok(keys) => keys, + Err(_) => return Err(ErrorPage::InternalError(Some(request))), + }; + let groups = build_permission_groups(&selected); + + let mut page = Page::admin(request); + let back_href = waypoint.or(page.context().route(ADMIN_ROLES_PATH)); + + let title = Lc::t("title-admin-role-permissions", &LOCALES_USER); + Ok(page + .with_title(title.clone()) + .with_child( + frame(title) + .with_child( + RolePermissionsForm::new() + .with_role_id(id) + .with_groups(groups) + .with_waypoint(waypoint), + ) + .with_child(back_link(back_href)), + ) + .render() + .await + .into_response()) +} + +#[derive(Deserialize)] +pub(crate) struct RolePermissionsFormData { + #[serde(default)] + permission_keys: Vec, +} + +/// POST /admin/user/roles/{id}/permissions - Reemplaza el conjunto de permisos de un rol. +/// +/// Usa `RawForm` + `serde_qs` en lugar de `axum::extract::Form` (basado en `serde_urlencoded`, +/// que no deserializa claves repetidas como `permission_keys=a&permission_keys=b` en un `Vec`). +pub(crate) async fn permissions_post( + request: HttpRequest, + web::Path(id): web::Path, + web::Query(waypoint): web::Query, + raw: web::RawForm, +) -> Result { + require_permission(&request, &UserPermission::AdminRoles)?; + require_permission(&request, &UserPermission::AdminPermissions)?; + + let Ok(form) = serde_qs::from_bytes::(&raw.0) else { + return Err(ErrorPage::BadRequest(Some(request))); + }; + + match role_admin::set_role_permissions(id, &form.permission_keys).await { + Ok(()) => { + let cx = Context::admin(request); + let target = waypoint.or(cx.route(ADMIN_ROLES_PATH)); + Ok(Redirect::see_other(target).into_response()) + } + Err(err) => { + let groups = build_permission_groups(&form.permission_keys); + let mut page = Page::admin(request); + let back_href = waypoint.or(page.context().route(ADMIN_ROLES_PATH)); + let form_component = RolePermissionsForm::new() + .with_role_id(id) + .with_groups(groups) + .with_error(map_auth_error(&err)) + .with_waypoint(waypoint); + let title = Lc::t("title-admin-role-permissions", &LOCALES_USER); + Ok(page + .with_title(title.clone()) + .with_child( + frame(title) + .with_child(form_component) + .with_child(back_link(back_href)), + ) + .render() + .await + .into_response()) + } + } +} diff --git a/extensions/pagetop-user/src/handlers/admin/users.rs b/extensions/pagetop-user/src/handlers/admin/users.rs new file mode 100644 index 00000000..b4704e57 --- /dev/null +++ b/extensions/pagetop-user/src/handlers/admin/users.rs @@ -0,0 +1,891 @@ +//! Handlers de administración de usuarios. + +use serde::Deserialize; + +use pagetop::base::component::table::Row; +use pagetop::prelude::*; +use pagetop_htmx::prelude::*; + +use crate::ADMIN_ROLES_PATH; +use crate::ADMIN_USERS_PATH; +use crate::ANONYMOUS_ROLE_ID; +use crate::AUTHENTICATED_ROLE_ID; +use crate::LOCALES_USER; +use crate::account::{Account, UserStatus}; +use crate::component::admin::{ + AdminPasswordForm, USER_ADMIN_FORM_ID, UserForm, UserFormMode, UserRolesForm, UserTable, + status_key, +}; +use crate::config::SETTINGS; +use crate::entity::{role, user}; +use crate::error::AuthError; +use crate::handlers::admin::{back_link, frame, map_auth_error}; +use crate::password; +use crate::permission::UserPermission; +use crate::service::role_admin; +use crate::service::user_admin::{self, UserListParams, UserSortField}; + +#[derive(Deserialize)] +pub(crate) struct UsersQuery { + #[serde(default)] + q: Option, + #[serde(default)] + sort: Option, + #[serde(default)] + dir: Option, + #[serde(default)] + page: Option, +} + +// **< list_get >*********************************************************************************** + +/// GET /admin/user/users - Listado de usuarios (búsqueda, orden y paginación vía HTMX). +pub(crate) async fn list_get( + request: HttpRequest, + web::Query(query): web::Query, +) -> Result { + require_permission(&request, &UserPermission::AdminUsers)?; + + let params = UserListParams { + query: query.q.clone(), + sort: UserSortField::from_query(query.sort.as_deref()), + dir: SortDir::from_query(query.dir.as_deref()), + page: query.page.unwrap_or(1).max(1), + per_page: SETTINGS.admin.list_page_size, + }; + + let result = match user_admin::list_users(¶ms).await { + Ok(result) => result, + Err(_) => return Err(ErrorPage::InternalError(Some(request))), + }; + + let mut table = UserTable::new() + .with_items(result.items) + .with_sort(params.sort) + .with_dir(params.dir) + .with_query(query.q.clone()) + .with_page(result.page) + .with_per_page(result.per_page) + .with_total(result.total); + + if request.is_htmx() { + let mut cx = Context::admin(request); + Ok(HtmxResponse::new(table.render(&mut cx).await).into_response()) + } else { + let title = Lc::t("title-admin-users", &LOCALES_USER); + Ok(Page::admin(request) + .with_title(title.clone()) + .with_child( + frame(title) + .with_child(search_bar(query.q)) + .with_child(table), + ) + .render() + .await + .into_response()) + } +} + +// **< search_bar >********************************************************************************* + +fn search_bar(current_query: Option) -> Html { + let value = current_query.unwrap_or_default(); + Html::with(move |cx| { + html! { + div.user-admin-search { + input + type="search" + id="user-admin-search-input" + name="q" + value=(value.as_str()) + placeholder=[Lc::t("field-search-users", &LOCALES_USER).lookup(cx)] + hx-get=(cx.route(ADMIN_USERS_PATH).to_string()) + hx-trigger="keyup changed delay:400ms, search" + hx-target="#user-table-wrapper" + hx-swap=(hx::swap::OUTER_HTML_SCROLL_TOP) + hx-push-url="true"; + } + } + }) +} + +// **< available_roles >**************************************************************************** + +// Roles asignables desde la UI de usuarios: excluye "anonymous" (nunca se asigna explícitamente) +// y "authenticated" (se envía siempre fijo vía campo oculto). +async fn available_roles(selected: &[i32]) -> Result, AuthError> { + let items = role_admin::list_roles(&role_admin::RoleListParams { + sort: role_admin::RoleSortField::Weight, + dir: SortDir::Asc, + }) + .await?; + Ok(items + .into_iter() + .filter(|r| r.id != ANONYMOUS_ROLE_ID && r.id != AUTHENTICATED_ROLE_ID) + .map(|r| { + let checked = selected.contains(&r.id); + (r.id, r.label, checked) + }) + .collect()) +} + +// **< new_get / new_post >************************************************************************* + +/// GET /admin/user/users/new - Formulario de alta de usuario. +pub(crate) async fn new_get( + request: HttpRequest, + web::Query(waypoint): web::Query, +) -> Result { + require_permission(&request, &UserPermission::AdminUsers)?; + let roles = match available_roles(&[]).await { + Ok(roles) => roles, + Err(_) => return Err(ErrorPage::InternalError(Some(request))), + }; + // El campo "administrador" sólo se ofrece si quien da de alta ya es administrador: no es un + // permiso del catálogo (igual que conceder/revocar en la edición, ver `set_user_admin`). + let allow_admin_field = request.extension::().is_some_and(|a| a.is_admin); + let mut page = Page::admin(request); + let back_href = waypoint.or(page.context().route(ADMIN_USERS_PATH)); + let title = Lc::t("title-admin-user-new", &LOCALES_USER); + Ok(page + .with_title(title.clone()) + .with_child( + frame(title) + .with_child( + UserForm::new() + .with_mode(UserFormMode::New) + .with_roles(roles) + .with_allow_admin_field(allow_admin_field) + .with_waypoint(waypoint), + ) + .with_child(back_link(back_href)), + ) + .render() + .await + .into_response()) +} + +#[derive(Deserialize)] +pub(crate) struct NewUserFormData { + username: String, + email: String, + password: String, + confirm_password: String, + #[serde(default)] + display_name: String, + #[serde(default)] + language: String, + #[serde(default)] + timezone: String, + #[serde(default)] + role_ids: Vec, + #[serde(default)] + is_admin: bool, +} + +/// POST /admin/user/users/new - Crea un usuario. +/// +/// Usa `RawForm` + `serde_qs` en lugar de `axum::extract::Form` (basado en `serde_urlencoded`, +/// que no deserializa claves repetidas como `role_ids=2&role_ids=3` en un `Vec`). +pub(crate) async fn new_post( + request: HttpRequest, + web::Query(waypoint): web::Query, + raw: web::RawForm, +) -> Result { + require_permission(&request, &UserPermission::AdminUsers)?; + let Ok(form) = serde_qs::from_bytes::(&raw.0) else { + return Err(ErrorPage::BadRequest(Some(request))); + }; + let allow_admin_field = request.extension::().is_some_and(|a| a.is_admin); + // Nunca fiarse sólo de que el campo esté presente en el formulario: sólo se concede si quien + // envía la petición ya es administrador, aunque alguien manipulase la petición a mano. + let is_admin = form.is_admin && allow_admin_field; + + let role_ids: Vec = form + .role_ids + .iter() + .filter_map(|s| s.parse().ok()) + .collect(); + + let result = user_admin::create_user(user_admin::NewUserData { + username: &form.username, + email: &form.email, + password: &form.password, + confirm_password: &form.confirm_password, + display_name: util::non_blank(&form.display_name), + language: util::non_blank(&form.language), + timezone: util::non_blank(&form.timezone), + initial_role_ids: &role_ids, + is_admin, + }) + .await; + + match result { + Ok(_) => { + let cx = Context::admin(request); + let target = waypoint.or(cx.route(ADMIN_USERS_PATH)); + Ok(Redirect::see_other(target).into_response()) + } + Err(err) => { + let roles = available_roles(&role_ids).await.unwrap_or_default(); + let mut page = Page::admin(request); + let back_href = waypoint.or(page.context().route(ADMIN_USERS_PATH)); + let form_component = UserForm::new() + .with_mode(UserFormMode::New) + .with_username(form.username) + .with_email(form.email) + .with_display_name(form.display_name) + .with_language(form.language) + .with_timezone(form.timezone) + .with_roles(roles) + .with_allow_admin_field(allow_admin_field) + .with_is_admin(is_admin) + .with_error(map_auth_error(&err)) + .with_waypoint(waypoint); + let title = Lc::t("title-admin-user-new", &LOCALES_USER); + Ok(page + .with_title(title.clone()) + .with_child( + frame(title) + .with_child(form_component) + .with_child(back_link(back_href)), + ) + .render() + .await + .into_response()) + } + } +} + +// **< edit_get / edit_post >************************************************************************ + +async fn render_user_edit( + request: HttpRequest, + id: i32, + error: Option, + waypoint: Waypoint, +) -> Response { + let user = match user_admin::find_user(id).await { + Ok(user) => user, + Err(_) => return ErrorPage::NotFound(Some(request)).into_response(), + }; + let status = UserStatus::from_i16(user.status); + // El botón de conceder/revocar sólo se muestra si quien lo ve ya es administrador y no está + // viendo su propio perfil: no es un permiso del catálogo, y nadie puede automodificarse el + // flag (ver `set_user_admin`). + let can_toggle_admin = request + .extension::() + .is_some_and(|a| a.is_admin && a.id != id); + let mut page = Page::admin(request); + let back_href = waypoint.or(page.context().route(ADMIN_USERS_PATH)); + let title = Lc::t("title-admin-user-edit", &LOCALES_USER); + let actions = edit_actions( + id, + status, + user.is_admin, + can_toggle_admin, + &waypoint, + page.context(), + ); + + page.with_title(title.clone()) + .with_child( + frame(title) + .with_child( + UserForm::new() + .with_mode(UserFormMode::Edit) + .with_user_id(Some(id)) + .with_username(user.username) + .with_email(user.email) + .with_display_name(user.display_name.unwrap_or_default()) + .with_language(user.language.unwrap_or_default()) + .with_timezone(user.timezone.unwrap_or_default()) + .with_error(error) + .with_waypoint(waypoint.clone()), + ) + .with_child(actions) + .with_child(back_link(back_href)), + ) + .render() + .await + .into_response() +} + +// Enlaces a las pantallas dedicadas (roles, restablecer contraseña) y botones de bloqueo/activación +// y de concesión/revocación de acceso irrestricto (este último sólo si `can_toggle_admin`). El +// listado de origen (`waypoint`) se arrastra a todas ellas para que, al volver aquí, esta misma +// pantalla siga sabiendo devolver al listado en el estado en que se dejó. +// +// "Guardar" (envía el `` de `UserForm` vía el atributo `form`, ver `USER_ADMIN_FORM_ID`), +// "Gestionar roles" y "Restablecer contraseña" se agrupan en un `button::ButtonSet` para que el +// tema los alinee con espaciado uniforme. Los botones de bloqueo/activación y de +// concesión/revocación de admin (este último sólo si `can_toggle_admin`) van cada uno en su propio +// `Form`, con un campo `Hidden` para el nuevo valor: `ButtonSet` sólo admite componentes `Button`, +// así que no pueden ir dentro; conservan así el envío nativo sin JavaScript, mejorado con +// `hx-post`/`hx-confirm`. Todo se construye con componentes -- `Container`, `Form`, `Hidden`, +// `ButtonSet`, `Button` --, sin `html!` en bruto (ver PAGETOP.md, "Preferir componentes a `html!` +// en bruto"); la alineación en línea de los tres queda pendiente de una pasada posterior. +fn edit_actions( + user_id: i32, + status: UserStatus, + target_is_admin: bool, + can_toggle_admin: bool, + waypoint: &Waypoint, + cx: &mut Context, +) -> Container { + let (next_status, label_key) = match status { + UserStatus::Blocked => ("active", "btn-activate"), + _ => ("blocked", "btn-block"), + }; + let (next_is_admin, admin_label_key, admin_confirm_key) = if target_is_admin { + ("false", "btn-revoke-admin", "confirm-revoke-admin") + } else { + ("true", "btn-grant-admin", "confirm-grant-admin") + }; + + let roles_href = waypoint.append_to(cx.route(format!("{ADMIN_USERS_PATH}/{user_id}/roles"))); + let password_href = + waypoint.append_to(cx.route(format!("{ADMIN_USERS_PATH}/{user_id}/password"))); + let status_action = + waypoint.append_to(cx.route(format!("{ADMIN_USERS_PATH}/{user_id}/status"))); + let admin_action = waypoint.append_to(cx.route(format!("{ADMIN_USERS_PATH}/{user_id}/admin"))); + + let buttons = button::ButtonSet::new() + .with_button( + Button::submit(Lc::t("btn-save", &LOCALES_USER)) + .with_style(button::Style::Solid(Intent::Primary)) + .with_prop(PropsOp::set("form", USER_ADMIN_FORM_ID)), + ) + .with_button( + Button::anchor(Lc::t("btn-manage-roles", &LOCALES_USER), roles_href) + .with_style(button::Style::Solid(Intent::Neutral)), + ) + .with_button( + Button::anchor(Lc::t("btn-reset-password", &LOCALES_USER), password_href) + .with_style(button::Style::Solid(Intent::Neutral)), + ); + + let mut status_form = Form::new() + .with_action(status_action.clone()) + .with_method(form::Method::Post) + .with_prop(PropsOp::set(hx::POST, status_action.to_string())) + .with_child(form::Hidden::field("status", next_status)) + .with_child( + Button::submit(Lc::t(label_key, &LOCALES_USER)) + .with_style(button::Style::Solid(Intent::Warning)), + ); + if let Some(confirm) = Lc::t("confirm-change-status", &LOCALES_USER).lookup(cx) { + status_form = status_form.with_prop(PropsOp::set(hx::CONFIRM, confirm)); + } + + let mut container = Container::new().with_child(buttons).with_child(status_form); + + if can_toggle_admin { + let mut admin_form = Form::new() + .with_action(admin_action.clone()) + .with_method(form::Method::Post) + .with_prop(PropsOp::set(hx::POST, admin_action.to_string())) + .with_child(form::Hidden::field("is_admin", next_is_admin)) + .with_child(Button::submit(Lc::t(admin_label_key, &LOCALES_USER))); + if let Some(confirm) = Lc::t(admin_confirm_key, &LOCALES_USER).lookup(cx) { + admin_form = admin_form.with_prop(PropsOp::set(hx::CONFIRM, confirm)); + } + container = container.with_child(admin_form); + } + + container +} + +/// GET /admin/user/users/{id}/edit - Formulario de edición de usuario. +pub(crate) async fn edit_get( + request: HttpRequest, + web::Path(id): web::Path, + web::Query(waypoint): web::Query, +) -> Result { + require_permission(&request, &UserPermission::AdminUsers)?; + Ok(render_user_edit(request, id, None, waypoint).await) +} + +#[derive(Deserialize)] +pub(crate) struct EditUserFormData { + username: String, + email: String, + #[serde(default)] + display_name: String, + #[serde(default)] + language: String, + #[serde(default)] + timezone: String, +} + +/// POST /admin/user/users/{id}/edit - Actualiza los datos de perfil de un usuario. +pub(crate) async fn edit_post( + request: HttpRequest, + web::Path(id): web::Path, + web::Query(waypoint): web::Query, + web::Form(form): web::Form, +) -> Result { + require_permission(&request, &UserPermission::AdminUsers)?; + + let result = user_admin::update_user( + id, + user_admin::UserUpdateData { + username: &form.username, + email: &form.email, + display_name: util::non_blank(&form.display_name), + language: util::non_blank(&form.language), + timezone: util::non_blank(&form.timezone), + }, + ) + .await; + + match result { + Ok(()) => { + let cx = Context::admin(request); + let target = waypoint.or(cx.route(ADMIN_USERS_PATH)); + Ok(Redirect::see_other(target).into_response()) + } + Err(err) => { + let mut page = Page::admin(request); + let back_href = waypoint.or(page.context().route(ADMIN_USERS_PATH)); + let form_component = UserForm::new() + .with_mode(UserFormMode::Edit) + .with_user_id(Some(id)) + .with_username(form.username) + .with_email(form.email) + .with_display_name(form.display_name) + .with_language(form.language) + .with_timezone(form.timezone) + .with_error(map_auth_error(&err)) + .with_waypoint(waypoint); + let title = Lc::t("title-admin-user-edit", &LOCALES_USER); + Ok(page + .with_title(title.clone()) + .with_child( + frame(title) + .with_child(form_component) + .with_child(back_link(back_href)), + ) + .render() + .await + .into_response()) + } + } +} + +// **< view_get >*********************************************************************************** + +/// GET /admin/user/users/{id}/view - Pantalla de sólo lectura con todos los datos del usuario, +/// incluidos los roles asignados. +pub(crate) async fn view_get( + request: HttpRequest, + web::Path(id): web::Path, + web::Query(waypoint): web::Query, +) -> Result { + require_permission(&request, &UserPermission::AdminUsers)?; + + let user = match user_admin::find_user(id).await { + Ok(user) => user, + Err(_) => return Err(ErrorPage::NotFound(Some(request))), + }; + let roles = match user_admin::user_roles(id).await { + Ok(roles) => roles, + Err(_) => return Err(ErrorPage::InternalError(Some(request))), + }; + let status = UserStatus::from_i16(user.status); + + let mut page = Page::admin(request); + let back_href = waypoint.or(page.context().route(ADMIN_USERS_PATH)); + let details_block = user_view_details(&user, status, page.context()).await; + let roles_block = user_view_roles(&roles, page.context()).await; + + let title = Lc::t("title-admin-user-view", &LOCALES_USER); + Ok(page + .with_title(title.clone()) + .with_child( + frame(title) + .with_child(details_block) + .with_child(roles_block) + .with_child(back_link(back_href)), + ) + .render() + .await + .into_response()) +} + +// Bloque de sólo lectura con los datos de perfil del usuario. +async fn user_view_details(user: &user::Model, status: UserStatus, cx: &mut Context) -> Block { + let mut table = Table::new() + .with_prop(PropsOp::add_classes("user-admin-table")) + .with_row( + Row::new() + .with_cell(Lc::t("field-username-admin", &LOCALES_USER)) + .with_cell(user.username.as_str()), + ) + .with_row( + Row::new() + .with_cell(Lc::t("field-email", &LOCALES_USER)) + .with_cell(user.email.as_str()), + ) + .with_row( + Row::new() + .with_cell(Lc::t("field-display-name", &LOCALES_USER)) + .with_cell(user.display_name.as_deref().unwrap_or("-")), + ) + .with_row( + Row::new() + .with_cell(Lc::t("field-language", &LOCALES_USER)) + .with_cell(user.language.as_deref().unwrap_or("-")), + ) + .with_row( + Row::new() + .with_cell(Lc::t("field-timezone", &LOCALES_USER)) + .with_cell(user.timezone.as_deref().unwrap_or("-")), + ) + .with_row( + Row::new() + .with_cell(Lc::t("col-status", &LOCALES_USER)) + .with_cell(Lc::t(status_key(status), &LOCALES_USER)), + ); + + if user.is_admin { + let badge = Badge::labeled(Lc::t("badge-admin", &LOCALES_USER)) + .with_prop(PropsOp::add_classes("user-admin-badge-admin")) + .render(cx) + .await; + table = table.with_row( + Row::new() + .with_cell("") + .with_cell(Html::with(move |_| badge.clone())), + ); + } + + Block::new() + .with_title(Lc::t("title-user-details", &LOCALES_USER)) + .with_child(table) +} + +// Bloque de sólo lectura con los roles asignados al usuario, cada uno enlazado a su propia +// pantalla de vista. +async fn user_view_roles(roles: &[role::Model], cx: &mut Context) -> Block { + let mut items: Vec<(i32, String, String, Option)> = Vec::with_capacity(roles.len()); + for r in roles { + let system_badge = if r.locked { + Some( + Badge::labeled(Lc::t("badge-system-role", &LOCALES_USER)) + .with_prop(PropsOp::add_classes("user-admin-badge-system")) + .render(cx) + .await, + ) + } else { + None + }; + items.push((r.id, r.machine_name.clone(), r.label.clone(), system_badge)); + } + + Block::new() + .with_title(Lc::t("field-roles", &LOCALES_USER)) + .with_child(Html::with(move |cx| { + html! { + @if items.is_empty() { + "-" + } @else { + table.user-admin-table { + tbody { + @for (id, machine_name, label, system_badge) in &items { + tr { + td { + a href=(cx.route(format!("{ADMIN_ROLES_PATH}/{id}/view")).to_string()) { + (label.as_str()) + } + } + td { (machine_name.as_str()) } + td { + @if let Some(badge) = system_badge { (badge) } + } + } + } + } + } + } + } + })) +} + +// **< roles_get / roles_post >********************************************************************** + +/// GET /admin/user/users/{id}/roles - Formulario de asignación de roles de un usuario. +pub(crate) async fn roles_get( + request: HttpRequest, + web::Path(id): web::Path, + web::Query(waypoint): web::Query, +) -> Result { + require_permission(&request, &UserPermission::AdminUsers)?; + require_permission(&request, &UserPermission::AssignRoles)?; + + if user_admin::find_user(id).await.is_err() { + return Err(ErrorPage::NotFound(Some(request))); + } + let current = match user_admin::user_role_ids(id).await { + Ok(ids) => ids, + Err(_) => return Err(ErrorPage::InternalError(Some(request))), + }; + let roles = match available_roles(¤t).await { + Ok(roles) => roles, + Err(_) => return Err(ErrorPage::InternalError(Some(request))), + }; + + let mut page = Page::admin(request); + let back_href = waypoint.or(page.context().route(ADMIN_USERS_PATH)); + + let title = Lc::t("title-admin-user-roles", &LOCALES_USER); + Ok(page + .with_title(title.clone()) + .with_child( + frame(title) + .with_child( + UserRolesForm::new() + .with_user_id(id) + .with_roles(roles) + .with_waypoint(waypoint), + ) + .with_child(back_link(back_href)), + ) + .render() + .await + .into_response()) +} + +#[derive(Deserialize)] +pub(crate) struct UserRolesFormData { + #[serde(default)] + role_ids: Vec, +} + +/// POST /admin/user/users/{id}/roles - Reemplaza el conjunto de roles asignados a un usuario. +pub(crate) async fn roles_post( + request: HttpRequest, + web::Path(id): web::Path, + web::Query(waypoint): web::Query, + raw: web::RawForm, +) -> Result { + require_permission(&request, &UserPermission::AdminUsers)?; + require_permission(&request, &UserPermission::AssignRoles)?; + + let Ok(form) = serde_qs::from_bytes::(&raw.0) else { + return Err(ErrorPage::BadRequest(Some(request))); + }; + + let role_ids: Vec = form + .role_ids + .iter() + .filter_map(|s| s.parse().ok()) + .collect(); + + match user_admin::set_user_roles(id, &role_ids).await { + Ok(()) => { + let cx = Context::admin(request); + let target = waypoint.or(cx.route(ADMIN_USERS_PATH)); + Ok(Redirect::see_other(target).into_response()) + } + Err(err) => { + let roles = available_roles(&role_ids).await.unwrap_or_default(); + let mut page = Page::admin(request); + let back_href = waypoint.or(page.context().route(ADMIN_USERS_PATH)); + let form_component = UserRolesForm::new() + .with_user_id(id) + .with_roles(roles) + .with_error(map_auth_error(&err)) + .with_waypoint(waypoint); + let title = Lc::t("title-admin-user-roles", &LOCALES_USER); + Ok(page + .with_title(title.clone()) + .with_child( + frame(title) + .with_child(form_component) + .with_child(back_link(back_href)), + ) + .render() + .await + .into_response()) + } + } +} + +// **< status_post >******************************************************************************** + +#[derive(Deserialize)] +pub(crate) struct StatusFormData { + status: String, +} + +/// POST /admin/user/users/{id}/status - Bloquea o activa una cuenta. +pub(crate) async fn status_post( + request: HttpRequest, + web::Path(id): web::Path, + web::Query(waypoint): web::Query, + web::Form(form): web::Form, +) -> Result { + require_permission(&request, &UserPermission::AdminUsers)?; + require_permission(&request, &UserPermission::BlockAccounts)?; + let Some(account) = request.extension::().cloned() else { + return Err(ErrorPage::AccessDenied(Some(request))); + }; + + let new_status = match form.status.as_str() { + "blocked" => UserStatus::Blocked, + _ => UserStatus::Active, + }; + // Ver comentario equivalente en `admin_post`: el botón envía la petición vía `hx-post` (para el + // diálogo `hx-confirm`) sin `hx-target`, así que un `Redirect` normal terminaría anidado dentro + // del propio ``. `HtmxResponse::redirect()` fuerza una navegación real en el cliente. + let is_htmx = request.is_htmx(); + + match user_admin::set_user_status(id, new_status, account.id).await { + Ok(()) => { + let cx = Context::admin(request); + let edit_href = waypoint.append_to(cx.route(format!("{ADMIN_USERS_PATH}/{id}/edit"))); + if is_htmx { + Ok(HtmxResponse::empty().redirect(edit_href).into_response()) + } else { + Ok(Redirect::see_other(edit_href).into_response()) + } + } + Err(err) => Ok(render_user_edit(request, id, Some(map_auth_error(&err)), waypoint).await), + } +} + +// **< admin_post >********************************************************************************* + +#[derive(Deserialize)] +pub(crate) struct AdminFormData { + is_admin: String, +} + +/// POST /admin/user/users/{id}/admin - Concede o revoca el acceso irrestricto (`is_admin`). +/// +/// No pasa por `require_permission`: conceder o revocar este flag no es un permiso del catálogo, +/// se comprueba directamente contra `account.is_admin` para que sólo un administrador pueda +/// tocarlo (un permiso concedido vía rol nunca basta). +pub(crate) async fn admin_post( + request: HttpRequest, + web::Path(id): web::Path, + web::Query(waypoint): web::Query, + web::Form(form): web::Form, +) -> Result { + let Some(account) = request.extension::().cloned() else { + return Err(ErrorPage::AccessDenied(Some(request))); + }; + if !account.is_admin { + return Err(ErrorPage::AccessDenied(Some(request))); + } + + let new_is_admin = form.is_admin == "true"; + // El botón envía la petición vía `hx-post` (para el diálogo `hx-confirm`), sin `hx-target`: si + // se responde con un `Redirect` normal, HTMX sigue la redirección con `fetch` y sustituye el + // propio `` (destino por defecto sin `hx-target`) por la página completa que devuelve, + // anidándola dentro de sí misma. `HtmxResponse::redirect()` evita eso: instruye al cliente + // (cabecera `HX-Redirect`) para que navegue de verdad a la URL, en vez de intentar un `swap`. + let is_htmx = request.is_htmx(); + + match user_admin::set_user_admin(id, new_is_admin, account.id).await { + Ok(()) => { + let cx = Context::admin(request); + let edit_href = waypoint.append_to(cx.route(format!("{ADMIN_USERS_PATH}/{id}/edit"))); + if is_htmx { + Ok(HtmxResponse::empty().redirect(edit_href).into_response()) + } else { + Ok(Redirect::see_other(edit_href).into_response()) + } + } + Err(err) => Ok(render_user_edit(request, id, Some(map_auth_error(&err)), waypoint).await), + } +} + +// **< password_get / password_post >**************************************************************** + +/// GET /admin/user/users/{id}/password - Formulario de restablecimiento de contraseña por un +/// administrador. +pub(crate) async fn password_get( + request: HttpRequest, + web::Path(id): web::Path, + web::Query(waypoint): web::Query, +) -> Result { + require_permission(&request, &UserPermission::AdminUsers)?; + if user_admin::find_user(id).await.is_err() { + return Err(ErrorPage::NotFound(Some(request))); + } + let mut page = Page::admin(request); + let edit_href = waypoint.append_to( + page.context() + .route(format!("{ADMIN_USERS_PATH}/{id}/edit")), + ); + let title = Lc::t("title-admin-user-password", &LOCALES_USER); + Ok(page + .with_title(title.clone()) + .with_child( + frame(title) + .with_child( + AdminPasswordForm::new() + .with_user_id(id) + .with_waypoint(waypoint), + ) + .with_child(back_link(edit_href)), + ) + .render() + .await + .into_response()) +} + +#[derive(Deserialize)] +pub(crate) struct AdminPasswordFormData { + password: String, + confirm_password: String, +} + +/// POST /admin/user/users/{id}/password - Aplica la nueva contraseña e invalida las sesiones +/// activas del usuario. +pub(crate) async fn password_post( + request: HttpRequest, + web::Path(id): web::Path, + web::Query(waypoint): web::Query, + web::Form(form): web::Form, +) -> Result { + require_permission(&request, &UserPermission::AdminUsers)?; + + let result = match password::passwords_match(&form.password, &form.confirm_password) { + Ok(()) => user_admin::admin_reset_password(id, &form.password).await, + Err(err) => Err(err), + }; + + match result { + Ok(()) => { + let cx = Context::admin(request); + let edit_href = waypoint.append_to(cx.route(format!("{ADMIN_USERS_PATH}/{id}/edit"))); + Ok(Redirect::see_other(edit_href).into_response()) + } + Err(err) => { + let mut page = Page::admin(request); + let edit_href = waypoint.append_to( + page.context() + .route(format!("{ADMIN_USERS_PATH}/{id}/edit")), + ); + let title = Lc::t("title-admin-user-password", &LOCALES_USER); + Ok(page + .with_title(title.clone()) + .with_child( + frame(title) + .with_child( + AdminPasswordForm::new() + .with_user_id(id) + .with_error(map_auth_error(&err)) + .with_waypoint(waypoint), + ) + .with_child(back_link(edit_href)), + ) + .render() + .await + .into_response()) + } + } +} diff --git a/extensions/pagetop-user/src/handlers/auth.rs b/extensions/pagetop-user/src/handlers/auth.rs new file mode 100644 index 00000000..7186eab4 --- /dev/null +++ b/extensions/pagetop-user/src/handlers/auth.rs @@ -0,0 +1,350 @@ +//! Handlers HTTP para autenticación y gestión de cuenta. + +use serde::Deserialize; + +use pagetop::auth::CurrentUser; +use pagetop::prelude::*; + +use crate::auth; +use crate::component::{LoginForm, PasswordResetConfirmForm, PasswordResetForm, RegisterForm}; +use crate::config::SETTINGS; +use crate::error::AuthError; +use crate::handlers::admin::map_auth_error; +use crate::password; +use crate::session; +use crate::token::{TokenKind, consume_token, create_token}; +use crate::{LOCALES_USER, LOGIN_PATH, PROFILE_PATH}; + +// **< Tipos de formularios >*********************************************************************** + +#[derive(Deserialize)] +pub struct LoginFormData { + // Acepta también `ident`, el nombre de campo usado en el modo de login estricto + // (ver `[user.login]`). + #[serde(alias = "ident")] + username: String, + // Acepta también `token`, el nombre de campo usado en el modo de login estricto. + #[serde(alias = "token")] + password: String, + #[serde(default)] + remember: bool, +} + +#[derive(Deserialize)] +pub struct RegisterFormData { + username: String, + email: String, + password: String, + confirm_password: String, +} + +#[derive(Deserialize)] +pub struct PasswordResetFormData { + email: String, +} + +#[derive(Deserialize)] +pub struct PasswordResetConfirmFormData { + password: String, + confirm_password: String, +} + +// **< login_get >********************************************************************************** + +/// GET /user/login - Formulario de inicio de sesión. +pub async fn login_get( + request: HttpRequest, + web::Query(waypoint): web::Query, +) -> Response { + // Un usuario ya autenticado no debe volver a ver el formulario de login: se le redirige al + // waypoint transportado si lo hay (p. ej. llegó aquí desde un enlace obsoleto o una pestaña + // duplicada), o a su perfil en caso contrario. + if request + .extension::() + .is_some_and(CurrentUser::is_authenticated) + { + let cx = Context::new(request.clone()); + return Redirect::see_other(waypoint.or(cx.route(PROFILE_PATH))); + } + Page::new(request) + .with_title(Lc::t("title-login", &LOCALES_USER)) + .with_child(LoginForm::new().with_waypoint(waypoint)) + .render() + .await + .into_response() +} + +// **< login_post >********************************************************************************* + +/// POST /user/login - Procesa las credenciales y abre la sesión. +pub async fn login_post( + request: HttpRequest, + web::Query(waypoint): web::Query, + web::Form(form): web::Form, +) -> Response { + let cx = Context::new(request.clone()); + let next = waypoint.or(cx.route("/")); + let result = auth::login(&form.username, &form.password, form.remember).await; + match result { + Ok(sid) => { + let cookie = session::build_cookie(&sid, form.remember); + redirect_with_cookie(next, &cookie) + } + Err(err) => { + let error_key = match &err { + AuthError::AccountBlocked => "error-account-blocked", + AuthError::AccountPending => "error-account-pending", + AuthError::AccountLocked => "error-account-locked", + _ => "error-invalid-credentials", + }; + Page::new(request) + .with_title(Lc::t("title-login", &LOCALES_USER)) + .with_child( + LoginForm::new() + .with_error(Lc::t(error_key, &LOCALES_USER)) + .with_waypoint(waypoint), + ) + .render() + .await + .into_response() + } + } +} + +// **< logout_post >******************************************************************************** + +/// POST /user/logout - Cierra la sesión y redirige al login. +pub async fn logout_post(request: HttpRequest) -> Response { + let cx = Context::new(request.clone()); + if let Some(sid) = session::extract_sid(Some(request.headers())) { + auth::logout(&sid).await.ok(); + } + let expiry = session::expiry_cookie(); + redirect_with_cookie(cx.route(LOGIN_PATH), &expiry) +} + +// **< register_get >******************************************************************************* + +/// GET /user/register - Formulario de registro de cuenta. +pub async fn register_get(request: HttpRequest) -> Result { + if !SETTINGS.allow_registration { + return Err(ErrorPage::NotFound(Some(request))); + } + Page::new(request) + .with_title(Lc::t("title-register", &LOCALES_USER)) + .with_child(RegisterForm::new()) + .render() + .await +} + +// **< register_post >****************************************************************************** + +/// POST /user/register - Registra un nuevo usuario. +pub async fn register_post( + request: HttpRequest, + web::Form(form): web::Form, +) -> Response { + if !SETTINGS.allow_registration { + return ErrorPage::NotFound(Some(request)).into_response(); + } + let cx = Context::new(request.clone()); + let result = auth::register( + &form.username, + &form.email, + &form.password, + &form.confirm_password, + ) + .await; + match result { + Ok(_user_id) => Redirect::see_other(cx.route(LOGIN_PATH)), + Err(err) => { + let error_lc = match &err { + AuthError::PasswordTooShort(n) => { + Lc::t("error-password-too-short", &LOCALES_USER).with_arg("n", n.to_string()) + } + AuthError::PasswordMismatch => Lc::t("error-password-mismatch", &LOCALES_USER), + AuthError::UsernameTaken => Lc::t("error-username-taken", &LOCALES_USER), + AuthError::EmailTaken => Lc::t("error-email-taken", &LOCALES_USER), + _ => Lc::t("error-internal", &LOCALES_USER), + }; + Page::new(request) + .with_title(Lc::t("title-register", &LOCALES_USER)) + .with_child(RegisterForm::new().with_error(error_lc)) + .render() + .await + .into_response() + } + } +} + +// **< password_reset_get >************************************************************************* + +/// GET /user/password/reset - Formulario de solicitud de restablecimiento. +pub async fn password_reset_get(request: HttpRequest) -> Result { + Page::new(request) + .with_title(Lc::t("title-password-reset", &LOCALES_USER)) + .with_child(PasswordResetForm::new()) + .render() + .await +} + +// **< password_reset_post >************************************************************************ + +/// POST /user/password/reset - Inicia el flujo de restablecimiento de contraseña. +pub async fn password_reset_post( + request: HttpRequest, + web::Form(form): web::Form, +) -> Result { + // Respondemos igual exista o no el email para no revelar qué emails están registrados. + { + use crate::entity::user; + use pagetop_seaorm::db::{ColumnTrait, EntityTrait, QueryFilter, dbconn}; + if let Ok(Some(user_model)) = user::Entity::find() + .filter(user::Column::Email.eq(&form.email)) + .one(dbconn()) + .await + { + create_token(user_model.id, TokenKind::PasswordReset) + .await + .ok(); + // TODO: enviar email con el token al usuario. + } + } + Page::new(request) + .with_title(Lc::t("title-password-reset", &LOCALES_USER)) + .with_child(Html::with(|cx| { + html! { p { (Lc::t("msg-password-reset-sent", &LOCALES_USER).using(cx)) } } + })) + .render() + .await +} + +// **< password_reset_confirm_get >***************************************************************** + +/// GET /user/password/reset/{uid}/{token} - Formulario para introducir la nueva contraseña. +pub async fn password_reset_confirm_get( + request: HttpRequest, + web::Path((uid, token)): web::Path<(i32, String)>, +) -> Result { + let valid = { + use crate::entity::user_token; + use crate::token::hash_token; + use pagetop_seaorm::db::{ColumnTrait, EntityTrait, QueryFilter, dbconn}; + let hash = hash_token(&token); + let now = Utc::now().naive_utc(); + user_token::Entity::find() + .filter(user_token::Column::TokenHash.eq(&hash)) + .filter(user_token::Column::UserId.eq(uid)) + .filter(user_token::Column::ConsumedAt.is_null()) + .one(dbconn()) + .await + .ok() + .flatten() + .is_some_and(|r| r.expires_at > now) + }; + if !valid { + return Err(ErrorPage::NotFound(Some(request))); + } + Page::new(request) + .with_title(Lc::t("title-new-password", &LOCALES_USER)) + .with_child(PasswordResetConfirmForm::new()) + .render() + .await +} + +// **< password_reset_confirm_post >**************************************************************** + +/// POST /user/password/reset/{uid}/{token} - Aplica la nueva contraseña. +pub async fn password_reset_confirm_post( + request: HttpRequest, + web::Path((_uid, token)): web::Path<(i32, String)>, + web::Form(form): web::Form, +) -> Response { + let cx = Context::new(request.clone()); + if let Err(err) = password::passwords_match(&form.password, &form.confirm_password) { + return Page::new(request) + .with_title(Lc::t("title-new-password", &LOCALES_USER)) + .with_child(PasswordResetConfirmForm::new().with_error(map_auth_error(&err))) + .render() + .await + .into_response(); + } + let result = async { + use crate::entity::user; + use pagetop_seaorm::db::{ActiveModelTrait, Set, dbconn}; + 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(); + user::ActiveModel { + id: Set(user_id), + password_hash: Set(hash), + updated_at: Set(now), + ..Default::default() + } + .update(dbconn()) + .await?; + session::destroy_user_sessions(user_id) + .await + .map_err(AuthError::Database)?; + Ok::<(), AuthError>(()) + } + .await; + match result { + Ok(()) => Redirect::see_other(cx.route(LOGIN_PATH)), + Err(_) => Page::new(request) + .with_title(Lc::t("title-new-password", &LOCALES_USER)) + .with_child( + PasswordResetConfirmForm::new() + .with_error(Lc::t("error-token-invalid", &LOCALES_USER)), + ) + .render() + .await + .into_response(), + } +} + +// **< verify_email_get >*************************************************************************** + +/// GET /user/verify/{uid}/{token} - Confirma la dirección de email. +pub async fn verify_email_get( + request: HttpRequest, + web::Path((_uid, token)): web::Path<(i32, String)>, +) -> Response { + let cx = Context::new(request.clone()); + let result = async { + use crate::account::UserStatus; + 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(); + user::ActiveModel { + id: Set(user_id), + email_verified_at: Set(Some(now)), + status: Set(UserStatus::Active.as_i16()), + updated_at: Set(now), + ..Default::default() + } + .update(dbconn()) + .await?; + Ok::<(), AuthError>(()) + } + .await; + match result { + Ok(()) => Redirect::see_other(cx.route(LOGIN_PATH)), + Err(_) => ErrorPage::NotFound(Some(request)).into_response(), + } +} + +// **< helpers privados >*************************************************************************** + +fn redirect_with_cookie(to: impl Into, cookie: &str) -> Response { + ( + web::http::StatusCode::SEE_OTHER, + [ + (web::http::header::LOCATION, to.into().to_string()), + (web::http::header::SET_COOKIE, cookie.to_owned()), + ], + ) + .into_response() +} diff --git a/extensions/pagetop-user/src/lib.rs b/extensions/pagetop-user/src/lib.rs new file mode 100644 index 00000000..3cffd393 --- /dev/null +++ b/extensions/pagetop-user/src/lib.rs @@ -0,0 +1,319 @@ +/*! +
+ +

PageTop User

+ +

Gestión de usuarios, autenticación, roles y permisos para PageTop.

+ +
+ +## Guía rápida + +Declara la dependencia en tu `Cargo.toml`, activando en `pagetop-seaorm` el motor de base de +datos que vayas a usar: + +```toml +[dependencies] +pagetop-user = "..." +pagetop-seaorm = { version = "...", features = ["postgres"] } +``` + +Añade `&pagetop_user::User` a las dependencias de tu extensión. El usuario actual se inyecta +automáticamente en el contexto de cada [`Page`] al crearla con [`Page::new()`], sin necesidad de +llamadas adicionales. Usa las helpers del core para acceder a él: + +```rust,no_run +use pagetop::prelude::*; +use pagetop_user::prelude::*; + +#[derive(Clone, Copy, Debug)] +enum MyPermission { + SeeDashboard, +} + +impl Permission for MyPermission { + fn key(&self) -> CowStr { + match self { + Self::SeeDashboard => "myapp.see_dashboard".into(), + } + } +} + +struct MyApp; + +#[async_trait] +impl Extension for MyApp { + fn dependencies(&self) -> Vec { + vec![&pagetop_user::User] + } + + fn configure_router(&self, router: Router) -> Router { + router.route("/dashboard", web::get(dashboard)) + } +} + +async fn dashboard(request: HttpRequest) -> Result { + if !has_permission(&request, &MyPermission::SeeDashboard) { + return Err(ErrorPage::NotFound(Some(request))); + } + Page::new(request) + .with_child(Html::with(|_| html! { h1 { "Panel" } })) + .render().await +} +``` +*/ + +use pagetop::prelude::*; +use pagetop_admin::prelude::*; +use pagetop_seaorm::install_migrations; + +pub mod account; +pub mod config; +pub mod error; +pub mod password; +pub mod permission; + +pub(crate) mod auth; +#[cfg(feature = "demo-data")] +pub(crate) mod demo; +pub(crate) mod entity; +pub(crate) mod handlers; +pub(crate) mod middleware; +pub(crate) mod migration; +pub(crate) mod service; +pub(crate) mod session; +pub(crate) mod token; + +pub mod component; + +include_locales!(LOCALES_USER); + +pub use account::{Account, UserStatus}; +pub use permission::{DeclarePermissions, PermissionRegistry}; + +/// Prelude de `pagetop-user`. +pub mod prelude { + pub use crate::component::{LoginForm, UserBlock}; + pub use crate::error::AuthError; + pub use crate::{Account, DeclarePermissions, UserStatus}; +} + +// **< Rutas HTTP (fijas) >************************************************************************* + +// Declaradas en la raíz del crate: un ítem privado aquí ya es visible desde cualquier módulo del +// crate (todos son descendientes de la raíz), así que `pub(crate)` sería redundante. + +// GET - muestra el formulario de inicio de sesión. +// POST - valida las credenciales y abre la sesión. +const LOGIN_PATH: &str = "/user/login"; +// POST - cierra la sesión activa y redirige al formulario de login. +const LOGOUT_PATH: &str = "/user/logout"; +// GET - muestra el formulario de registro. +// POST - crea la cuenta de usuario. +const REGISTER_PATH: &str = "/user/register"; +// GET - perfil del usuario autenticado; redirige a LOGIN_PATH si no hay sesión activa. +const PROFILE_PATH: &str = "/user"; +// GET - muestra el formulario de solicitud de restablecimiento. +// POST - inicia el flujo (envío del token). +// Con el sufijo `/{uid}/{token}`: GET - muestra el formulario de nueva contraseña. +// POST - la aplica. +const PASSWORD_RESET_PATH: &str = "/user/password/reset"; +// Con el sufijo `/{uid}/{token}`: confirma la dirección de email del usuario. +const VERIFY_PATH: &str = "/user/verify"; + +// **< Rutas de administración (fijas) >************************************************************ + +// Listado, alta, edición, asignación de roles, bloqueo/activación y restablecimiento de contraseña +// de usuarios. +const ADMIN_USERS_PATH: &str = "/admin/user/users"; +// Listado, alta, edición, eliminación y asignación de permisos de roles. +const ADMIN_ROLES_PATH: &str = "/admin/user/roles"; +// Catálogo de permisos registrados, agrupado por extensión (solo lectura). +const ADMIN_PERMISSIONS_PATH: &str = "/admin/user/permissions"; + +// **< Registro en pagetop-admin (fijo) >*********************************************************** + +// Registra las páginas de usuarios, roles y permisos en el portal de `pagetop-admin`, bajo la +// sección integrada "people". Las rutas y sus handlers ya están registrados en +// `configure_router()`; este registro sólo aporta el metadato (título, sección, permiso) para que +// aparezcan en la portada `/admin`. +fn declare_admin_pages(bag: &mut PageBag) { + bag.add(AdminPage { + path: ADMIN_USERS_PATH.to_owned(), + section: "people".to_owned(), + title: Lc::t("title-admin-users", &LOCALES_USER), + description: Some(Lc::t("description-admin-users", &LOCALES_USER)), + weight: 0, + permission: Some(&permission::UserPermission::AdminUsers), + kind: AdminPageKind::View, + }); + bag.add(AdminPage { + path: ADMIN_ROLES_PATH.to_owned(), + section: "people".to_owned(), + title: Lc::t("title-admin-roles", &LOCALES_USER), + description: Some(Lc::t("description-admin-roles", &LOCALES_USER)), + weight: 10, + permission: Some(&permission::UserPermission::AdminRoles), + kind: AdminPageKind::View, + }); + bag.add(AdminPage { + path: ADMIN_PERMISSIONS_PATH.to_owned(), + section: "people".to_owned(), + title: Lc::t("title-admin-permissions", &LOCALES_USER), + description: Some(Lc::t("description-admin-permissions", &LOCALES_USER)), + weight: 20, + permission: Some(&permission::UserPermission::AdminPermissions), + kind: AdminPageKind::View, + }); +} + +// **< Roles de sistema (fijos) >******************************************************************** + +// Sembrados con id fijo en `migration/m20260629_000002_create_roles.rs` y bloqueados (`locked`); +// no se borran ni cambian de id. +const ANONYMOUS_ROLE_ID: i32 = 1; +// Se asigna automáticamente a toda cuenta en el alta (ver `auth::assign_role`). +const AUTHENTICATED_ROLE_ID: i32 = 2; + +// **< Extension >********************************************************************************** + +/// Implementa la extensión `pagetop-user`. +pub struct User; + +#[async_trait] +impl Extension for User { + fn name(&self) -> Lc { + Lc::t("extension_name", &LOCALES_USER) + } + + fn description(&self) -> Lc { + Lc::t("extension_description", &LOCALES_USER) + } + + fn dependencies(&self) -> Vec { + vec![ + &pagetop_admin::Admin, + &pagetop_seaorm::SeaORM, + &pagetop_htmx::Htmx, + ] + } + + fn actions(&self) -> Vec { + actions![ + // Comprueba permisos mediante el modelo RBAC almacenado en BD. + CheckPermission::new(middleware::check_rbac_permission), + // Registra los permisos propios de pagetop-user. + DeclarePermissions::new(permission::declare_builtin_permissions), + // Registra las páginas de usuarios, roles y permisos en el portal de pagetop-admin. + DeclareAdminPages::new(declare_admin_pages), + ] + } + + async fn initialize(&self) { + install_migrations!( + m20260629_000001_create_users, + m20260629_000002_create_roles, + m20260629_000003_create_user_roles, + m20260629_000004_create_role_permissions, + m20260629_000005_create_sessions, + m20260629_000006_create_user_tokens, + ); + permission::build_registry(); + auth::seed_initial_data().await; + #[cfg(feature = "demo-data")] + demo::seed_demo_data().await; + } + + fn configure_router(&self, router: Router) -> Router { + router + .route( + LOGIN_PATH, + web::get(handlers::auth::login_get).post(handlers::auth::login_post), + ) + .route(LOGOUT_PATH, web::post(handlers::auth::logout_post)) + .route( + REGISTER_PATH, + web::get(handlers::auth::register_get).post(handlers::auth::register_post), + ) + .route(PROFILE_PATH, web::get(handlers::account::profile_get)) + .route( + PASSWORD_RESET_PATH, + web::get(handlers::auth::password_reset_get) + .post(handlers::auth::password_reset_post), + ) + .route( + &format!("{}/{{uid}}/{{token}}", PASSWORD_RESET_PATH), + web::get(handlers::auth::password_reset_confirm_get) + .post(handlers::auth::password_reset_confirm_post), + ) + .route( + &format!("{}/{{uid}}/{{token}}", VERIFY_PATH), + web::get(handlers::auth::verify_email_get), + ) + .route(ADMIN_USERS_PATH, web::get(handlers::admin::users::list_get)) + .route( + &format!("{}/new", ADMIN_USERS_PATH), + web::get(handlers::admin::users::new_get).post(handlers::admin::users::new_post), + ) + .route( + &format!("{}/{{id}}/edit", ADMIN_USERS_PATH), + web::get(handlers::admin::users::edit_get).post(handlers::admin::users::edit_post), + ) + .route( + &format!("{}/{{id}}/view", ADMIN_USERS_PATH), + web::get(handlers::admin::users::view_get), + ) + .route( + &format!("{}/{{id}}/roles", ADMIN_USERS_PATH), + web::get(handlers::admin::users::roles_get) + .post(handlers::admin::users::roles_post), + ) + .route( + &format!("{}/{{id}}/status", ADMIN_USERS_PATH), + web::post(handlers::admin::users::status_post), + ) + .route( + &format!("{}/{{id}}/admin", ADMIN_USERS_PATH), + web::post(handlers::admin::users::admin_post), + ) + .route( + &format!("{}/{{id}}/password", ADMIN_USERS_PATH), + web::get(handlers::admin::users::password_get) + .post(handlers::admin::users::password_post), + ) + .route(ADMIN_ROLES_PATH, web::get(handlers::admin::roles::list_get)) + .route( + &format!("{}/new", ADMIN_ROLES_PATH), + web::get(handlers::admin::roles::new_get).post(handlers::admin::roles::new_post), + ) + .route( + &format!("{}/{{id}}/edit", ADMIN_ROLES_PATH), + web::get(handlers::admin::roles::edit_get).post(handlers::admin::roles::edit_post), + ) + .route( + &format!("{}/{{id}}/view", ADMIN_ROLES_PATH), + web::get(handlers::admin::roles::view_get), + ) + .route( + &format!("{}/{{id}}/delete", ADMIN_ROLES_PATH), + web::post(handlers::admin::roles::delete_post), + ) + .route( + &format!("{}/{{id}}/delete/confirm", ADMIN_ROLES_PATH), + web::get(handlers::admin::roles::delete_confirm_get), + ) + .route( + &format!("{}/{{id}}/permissions", ADMIN_ROLES_PATH), + web::get(handlers::admin::roles::permissions_get) + .post(handlers::admin::roles::permissions_post), + ) + .route( + ADMIN_PERMISSIONS_PATH, + web::get(handlers::admin::permissions::list_get), + ) + } + + fn configure_middleware(&self, router: Router) -> Router { + router.layer(web::middleware::from_fn(middleware::session_middleware)) + } +} diff --git a/extensions/pagetop-user/src/locale/en-US/common.ftl b/extensions/pagetop-user/src/locale/en-US/common.ftl new file mode 100644 index 00000000..5f7e0573 --- /dev/null +++ b/extensions/pagetop-user/src/locale/en-US/common.ftl @@ -0,0 +1,154 @@ +## pagetop-user — English (default) + +# **< Extension metadata >** + +extension_name = PageTop User +extension_description = User identity, authentication, roles and permissions for PageTop. + +# **< Page titles >** + +title-login = Sign in +title-register = Create account +title-password-reset = Reset password +title-new-password = Set new password +title-profile = My profile + +# **< Field labels >** + +field-username = Username or email +field-password = Password +field-email = Email address +field-confirm-password = Confirm password +field-new-password = New password +field-remember-me = Remember me + +# **< Buttons and links >** + +btn-login = Sign in +btn-logout = Sign out +btn-register = Create account +btn-send-reset-link = Send reset link +btn-set-password = Change password + +link-register = Create an account +link-forgot-password = Forgot your password? +link-back-to-login = Back to sign in + +# **< Messages >** + +msg-password-reset-sent = + If an account with that email exists, we have sent a reset link. + Please check your inbox. + +# **< Error messages >** + +error-invalid-credentials = Invalid username or password. +error-account-blocked = Your account is blocked. Please contact the administrator. +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-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. +error-internal = An unexpected error occurred. Please try again. + +# **< Account statuses >** + +status-active = Active +status-blocked = Blocked +status-pending = Pending email verification + +# **< Admin: page titles >** + +title-admin-users = Users +title-admin-user-new = New user +title-admin-user-edit = Edit user +title-admin-user-view = View user +title-admin-user-roles = User roles +title-admin-user-password = Reset password +title-admin-roles = Roles +title-admin-role-new = New role +title-admin-role-edit = Edit role +title-admin-role-view = View role +title-admin-role-permissions = Role permissions +title-admin-permissions = Permissions +title-user-details = User details +title-role-details = Role details + +# **< Admin: page descriptions >** + +description-admin-users = Manage user accounts and access. +description-admin-roles = Manage roles and their permissions. +description-admin-permissions = Browse the permission catalog by extension. + +# **< Admin: table columns >** + +col-username = Username +col-email = Email +col-display-name = Display name +col-roles = Roles +col-status = Status +col-actions = Actions +col-machine-name = Machine name +col-label = Label +col-type = Type +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... + +help-machine-name-immutable = + Lowercase letters, digits and underscores only. Cannot be changed after creation. + +# **< Admin: buttons and links >** + +btn-save = Save +btn-create-user = New user +btn-create-role = New role +btn-delete = Delete +btn-cancel = Cancel +btn-edit = Edit +btn-manage-roles = Manage roles +btn-manage-permissions = Manage permissions +btn-reset-password = Reset password +btn-block = Block +btn-activate = Activate +btn-grant-admin = Grant administrator +btn-revoke-admin = Revoke administrator +link-back-to-list = Back to list + +# **< Admin: confirmations and badges >** + +confirm-delete-role = Delete this role? This cannot be undone. +confirm-change-status = Change this account's status? +confirm-grant-admin = Grant unrestricted access to this account? +confirm-revoke-admin = Revoke this account's unrestricted access? +badge-system-role = System +badge-admin = Administrator +empty-users-list = No users found. +empty-roles-list = No roles found. + +# **< Admin: error messages >** + +error-role-not-found = Role not found. +error-role-machine-name-taken = This machine name is already taken. +error-invalid-machine-name = Machine name may only contain lowercase letters, digits and underscores. +error-role-locked = This role is a system role and cannot be modified. +error-role-in-use = This role has users assigned and cannot be deleted. +error-last-administrator = Cannot remove the last administrator. +error-cannot-block-self = You cannot block your own account. +error-cannot-modify-own-admin-flag = You cannot grant or revoke your own unrestricted access. +error-user-not-found = User not found. +error-unknown-permission = Unknown permission key. diff --git a/extensions/pagetop-user/src/locale/en-US/permissions.ftl b/extensions/pagetop-user/src/locale/en-US/permissions.ftl new file mode 100644 index 00000000..754ce4fe --- /dev/null +++ b/extensions/pagetop-user/src/locale/en-US/permissions.ftl @@ -0,0 +1,21 @@ +## pagetop-user — Permission catalog (English) + +# **< Group: Users >** + +group-users = Users + +perm-login = Sign in +perm-register = Register a new account +perm-view-profiles = View user profiles +perm-edit-own-profile = Edit own profile +perm-change-own-password = Change own password + +# **< Group: Administration >** + +group-administration = Administration + +perm-admin-users = Administer users +perm-admin-roles = Administer roles +perm-admin-permissions = Administer permissions +perm-block-accounts = Block and unblock accounts +perm-assign-roles = Assign roles to users diff --git a/extensions/pagetop-user/src/locale/es-ES/common.ftl b/extensions/pagetop-user/src/locale/es-ES/common.ftl new file mode 100644 index 00000000..82ae0b3e --- /dev/null +++ b/extensions/pagetop-user/src/locale/es-ES/common.ftl @@ -0,0 +1,154 @@ +## pagetop-user — Español + +# **< Metadatos de la extensión >** + +extension_name = PageTop Usuario +extension_description = Identidad de usuario, autenticación, roles y permisos para PageTop. + +# **< Títulos de página >** + +title-login = Iniciar sesión +title-register = Crear cuenta +title-password-reset = Recuperar contraseña +title-new-password = Establecer nueva contraseña +title-profile = Mi perfil + +# **< Etiquetas de campos >** + +field-username = Usuario o email +field-password = Contraseña +field-email = Dirección de email +field-confirm-password = Confirmar contraseña +field-new-password = Nueva contraseña +field-remember-me = Recuérdame + +# **< Botones y enlaces >** + +btn-login = Entrar +btn-logout = Cerrar sesión +btn-register = Crear cuenta +btn-send-reset-link = Enviar enlace +btn-set-password = Cambiar contraseña + +link-register = Crear una cuenta +link-forgot-password = ¿Olvidaste tu contraseña? +link-back-to-login = Volver al inicio de sesión + +# **< Mensajes >** + +msg-password-reset-sent = + Si existe una cuenta con ese email, hemos enviado un enlace de recuperación. + Revisa tu bandeja de entrada. + +# **< Mensajes de error >** + +error-invalid-credentials = Usuario o contraseña incorrectos. +error-account-blocked = Tu cuenta está bloqueada. Contacta con el administrador. +error-account-pending = Verifica tu dirección de email antes de iniciar sesión. +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-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. +error-internal = Se ha producido un error inesperado. Inténtalo de nuevo. + +# **< Estados de cuenta >** + +status-active = Activo +status-blocked = Bloqueado +status-pending = Pendiente de verificación de email + +# **< Administración: títulos de página >** + +title-admin-users = Usuarios +title-admin-user-new = Nuevo usuario +title-admin-user-edit = Editar usuario +title-admin-user-view = Ver usuario +title-admin-user-roles = Roles del usuario +title-admin-user-password = Restablecer contraseña +title-admin-roles = Roles +title-admin-role-new = Nuevo rol +title-admin-role-edit = Editar rol +title-admin-role-view = Ver rol +title-admin-role-permissions = Permisos del rol +title-admin-permissions = Permisos +title-user-details = Datos del usuario +title-role-details = Datos del rol + +# **< Administración: descripciones de página >** + +description-admin-users = Gestiona las cuentas de usuario y su acceso. +description-admin-roles = Gestiona los roles y sus permisos. +description-admin-permissions = Consulta el catálogo de permisos por extensión. + +# **< Administración: columnas de tabla >** + +col-username = Usuario +col-email = Email +col-display-name = Nombre visible +col-roles = Roles +col-status = Estado +col-actions = Acciones +col-machine-name = Nombre técnico +col-label = Etiqueta +col-type = Tipo +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... + +help-machine-name-immutable = + Sólo minúsculas, dígitos y guiones bajos. No se puede cambiar tras crearlo. + +# **< Administración: botones y enlaces >** + +btn-save = Guardar +btn-create-user = Nuevo usuario +btn-create-role = Nuevo rol +btn-delete = Eliminar +btn-cancel = Cancelar +btn-edit = Editar +btn-manage-roles = Gestionar roles +btn-manage-permissions = Gestionar permisos +btn-reset-password = Restablecer contraseña +btn-block = Bloquear +btn-activate = Activar +btn-grant-admin = Conceder administrador +btn-revoke-admin = Revocar administrador +link-back-to-list = Volver al listado + +# **< Administración: confirmaciones y distintivos >** + +confirm-delete-role = ¿Eliminar este rol? Esta acción no se puede deshacer. +confirm-change-status = ¿Cambiar el estado de esta cuenta? +confirm-grant-admin = ¿Conceder acceso irrestricto a esta cuenta? +confirm-revoke-admin = ¿Revocar el acceso irrestricto de esta cuenta? +badge-system-role = Sistema +badge-admin = Administrador +empty-users-list = No se han encontrado usuarios. +empty-roles-list = No se han encontrado roles. + +# **< Administración: mensajes de error >** + +error-role-not-found = Rol no encontrado. +error-role-machine-name-taken = Este nombre técnico ya está en uso. +error-invalid-machine-name = El nombre técnico sólo admite minúsculas, dígitos y guiones bajos. +error-role-locked = Este rol es de sistema y no se puede modificar. +error-role-in-use = Este rol tiene usuarios asignados y no se puede eliminar. +error-last-administrator = No se puede quitar al último administrador. +error-cannot-block-self = No puedes bloquear tu propia cuenta. +error-cannot-modify-own-admin-flag = No puedes conceder ni revocar tu propio acceso irrestricto. +error-user-not-found = Usuario no encontrado. +error-unknown-permission = Clave de permiso desconocida. diff --git a/extensions/pagetop-user/src/locale/es-ES/permissions.ftl b/extensions/pagetop-user/src/locale/es-ES/permissions.ftl new file mode 100644 index 00000000..ca065c1f --- /dev/null +++ b/extensions/pagetop-user/src/locale/es-ES/permissions.ftl @@ -0,0 +1,21 @@ +## pagetop-user — Catálogo de permisos (Español) + +# **< Grupo: Usuarios >** + +group-users = Usuarios + +perm-login = Iniciar sesión +perm-register = Registrar una cuenta nueva +perm-view-profiles = Ver perfiles de usuarios +perm-edit-own-profile = Editar el propio perfil +perm-change-own-password = Cambiar la propia contraseña + +# **< Grupo: Administración >** + +group-administration = Administración + +perm-admin-users = Administrar usuarios +perm-admin-roles = Administrar roles +perm-admin-permissions = Administrar permisos +perm-block-accounts = Bloquear y desbloquear cuentas +perm-assign-roles = Asignar roles a usuarios diff --git a/extensions/pagetop-user/src/middleware.rs b/extensions/pagetop-user/src/middleware.rs new file mode 100644 index 00000000..1ebd09c8 --- /dev/null +++ b/extensions/pagetop-user/src/middleware.rs @@ -0,0 +1,49 @@ +//! Middleware Tower para la resolución de sesión de usuario. +//! +//! Se registra globalmente en [`crate::User::configure_middleware`] y se ejecuta en todas las +//! peticiones entrantes antes de que lleguen al handler. Inserta [`pagetop::auth::CurrentUser`] y, +//! si el usuario está autenticado, el [`crate::account::Account`] con sus datos ricos en las +//! extensiones de la petición HTTP. + +use pagetop::auth::PermissionRef; +use pagetop::web::middleware::Next; +use pagetop::web::{Request, Response}; + +use crate::account::Account; +use crate::session; + +/// Resuelve la sesión del usuario e inyecta los tipos de identidad en las extensiones de la +/// petición HTTP. +/// +/// - Siempre inserta [`pagetop::auth::CurrentUser`] (anónimo o autenticado). +/// - Si hay sesión activa, inserta también el [`Account`] con roles y permisos. +pub(crate) async fn session_middleware(mut req: Request, next: Next) -> Response { + let (current_user, maybe_account) = session::resolve_session(req.headers()).await; + + req.extensions_mut().insert(current_user); + if let Some(account) = maybe_account { + req.extensions_mut().insert(account); + } + + next.run(req).await +} + +// **< check_rbac_permission >********************************************************************** + +/// Handler de la acción [`pagetop::auth::CheckPermission`] para el modelo RBAC de `pagetop-user`. +/// +/// Lee el [`Account`] inyectado por el middleware de sesión desde las extensiones de la petición +/// HTTP y concede el permiso si el account lo tiene (un administrador lo tiene concedido siempre, +/// ver [`Account::has_permission`]). +pub(crate) fn check_rbac_permission( + request: &pagetop::web::HttpRequest, + perm: PermissionRef, + granted: &mut bool, +) { + let Some(account) = request.extension::() else { + return; + }; + if account.has_permission(perm) { + *granted = true; + } +} diff --git a/extensions/pagetop-user/src/migration.rs b/extensions/pagetop-user/src/migration.rs new file mode 100644 index 00000000..53e8ec85 --- /dev/null +++ b/extensions/pagetop-user/src/migration.rs @@ -0,0 +1,8 @@ +//! Migraciones de `pagetop-user`. + +pub mod m20260629_000001_create_users; +pub mod m20260629_000002_create_roles; +pub mod m20260629_000003_create_user_roles; +pub mod m20260629_000004_create_role_permissions; +pub mod m20260629_000005_create_sessions; +pub mod m20260629_000006_create_user_tokens; diff --git a/extensions/pagetop-user/src/migration/m20260629_000001_create_users.rs b/extensions/pagetop-user/src/migration/m20260629_000001_create_users.rs new file mode 100644 index 00000000..8b34ff97 --- /dev/null +++ b/extensions/pagetop-user/src/migration/m20260629_000001_create_users.rs @@ -0,0 +1,56 @@ +use pagetop_seaorm::migration::*; + +pub struct Migration; + +#[pagetop::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .create_table( + table_auto(Users::Table) + .col(pk_auto(Users::Id)) + .col(string_len_uniq(Users::Username, 64)) + .col(string_len_uniq(Users::Email, 254)) + .col(timestamp_null(Users::EmailVerifiedAt)) + .col(string(Users::PasswordHash)) + // 0=Blocked, 1=Active, 2=Pending + .col(small_integer(Users::Status).default(1)) + .col(string_len_null(Users::Language, 16)) + .col(string_len_null(Users::Timezone, 64)) + .col(string_len_null(Users::DisplayName, 128)) + .col(timestamp_null(Users::LastLoginAt)) + .col(timestamp_null(Users::LastAccessAt)) + .col(integer(Users::FailedLoginCount).default(0)) + .col(timestamp_null(Users::LockedUntil)) + // Acceso irrestricto al sistema, sin pasar por roles ni permisos. + .col(boolean(Users::IsAdmin).default(false)) + .to_owned(), + ) + .await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(Users::Table).to_owned()) + .await + } +} + +#[derive(DeriveIden)] +pub enum Users { + Table, + Id, + Username, + Email, + EmailVerifiedAt, + PasswordHash, + Status, + Language, + Timezone, + DisplayName, + LastLoginAt, + LastAccessAt, + FailedLoginCount, + LockedUntil, + IsAdmin, +} diff --git a/extensions/pagetop-user/src/migration/m20260629_000002_create_roles.rs b/extensions/pagetop-user/src/migration/m20260629_000002_create_roles.rs new file mode 100644 index 00000000..ce76803c --- /dev/null +++ b/extensions/pagetop-user/src/migration/m20260629_000002_create_roles.rs @@ -0,0 +1,83 @@ +use pagetop_seaorm::migration::*; + +use sea_orm::{ConnectionTrait, DbBackend}; + +pub struct Migration; + +#[pagetop::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .create_table( + table_auto(Roles::Table) + .col(pk_auto(Roles::Id)) + .col(string_len_uniq(Roles::MachineName, 64)) + .col(string_len(Roles::Label, 128)) + .col(text_null(Roles::Description)) + .col(integer(Roles::Weight).default(0)) + // Los roles del sistema (anonymous, authenticated) no se borran. + .col(boolean(Roles::Locked).default(false)) + .to_owned(), + ) + .await?; + + // Filas de sistema: anonymous (1), authenticated (2). + let insert = Query::insert() + .into_table(Roles::Table) + .columns([ + Roles::Id, + Roles::MachineName, + Roles::Label, + Roles::Weight, + Roles::Locked, + ]) + .values_panic([ + 1.into(), + "anonymous".into(), + "Anonymous".into(), + 0.into(), + true.into(), + ]) + .values_panic([ + 2.into(), + "authenticated".into(), + "Authenticated".into(), + 1.into(), + true.into(), + ]) + .to_owned(); + + manager.exec_stmt(insert).await?; + + // Los IDs anteriores se insertan explícitamente; en PostgreSQL la secuencia del `serial` + // no avanza con inserciones explícitas, así que el próximo alta chocaría con estas filas. + if manager.get_database_backend() == DbBackend::Postgres { + manager + .get_connection() + .execute_unprepared( + "SELECT setval(pg_get_serial_sequence('roles', 'id'), \ + (SELECT MAX(id) FROM roles))", + ) + .await?; + } + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(Roles::Table).to_owned()) + .await + } +} + +#[derive(DeriveIden)] +pub enum Roles { + Table, + Id, + MachineName, + Label, + Description, + Weight, + Locked, +} diff --git a/extensions/pagetop-user/src/migration/m20260629_000003_create_user_roles.rs b/extensions/pagetop-user/src/migration/m20260629_000003_create_user_roles.rs new file mode 100644 index 00000000..de2eb0a5 --- /dev/null +++ b/extensions/pagetop-user/src/migration/m20260629_000003_create_user_roles.rs @@ -0,0 +1,55 @@ +use pagetop_seaorm::migration::*; + +pub struct Migration; + +#[pagetop::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .create_table( + Table::create() + .table(UserRoles::Table) + .if_not_exists() + .col(integer(UserRoles::UserId)) + .col(integer(UserRoles::RoleId)) + .primary_key( + Index::create() + .col(UserRoles::UserId) + .col(UserRoles::RoleId), + ) + .foreign_key( + ForeignKey::create() + .from(UserRoles::Table, UserRoles::UserId) + .to( + super::m20260629_000001_create_users::Users::Table, + super::m20260629_000001_create_users::Users::Id, + ) + .on_delete(ForeignKeyAction::Cascade), + ) + .foreign_key( + ForeignKey::create() + .from(UserRoles::Table, UserRoles::RoleId) + .to( + super::m20260629_000002_create_roles::Roles::Table, + super::m20260629_000002_create_roles::Roles::Id, + ) + .on_delete(ForeignKeyAction::Cascade), + ) + .to_owned(), + ) + .await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(UserRoles::Table).to_owned()) + .await + } +} + +#[derive(DeriveIden)] +pub enum UserRoles { + Table, + UserId, + RoleId, +} diff --git a/extensions/pagetop-user/src/migration/m20260629_000004_create_role_permissions.rs b/extensions/pagetop-user/src/migration/m20260629_000004_create_role_permissions.rs new file mode 100644 index 00000000..3339a305 --- /dev/null +++ b/extensions/pagetop-user/src/migration/m20260629_000004_create_role_permissions.rs @@ -0,0 +1,49 @@ +use pagetop_seaorm::migration::*; + +pub struct Migration; + +#[pagetop::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .create_table( + Table::create() + .table(RolePermissions::Table) + .if_not_exists() + .col(integer(RolePermissions::RoleId)) + // Clave del permiso como string con namespace: "provider.action" + .col(string_len(RolePermissions::PermissionKey, 190)) + .col(timestamp(RolePermissions::GrantedAt).default(Expr::current_timestamp())) + .primary_key( + Index::create() + .col(RolePermissions::RoleId) + .col(RolePermissions::PermissionKey), + ) + .foreign_key( + ForeignKey::create() + .from(RolePermissions::Table, RolePermissions::RoleId) + .to( + super::m20260629_000002_create_roles::Roles::Table, + super::m20260629_000002_create_roles::Roles::Id, + ) + .on_delete(ForeignKeyAction::Cascade), + ) + .to_owned(), + ) + .await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(RolePermissions::Table).to_owned()) + .await + } +} + +#[derive(DeriveIden)] +pub enum RolePermissions { + Table, + RoleId, + PermissionKey, + GrantedAt, +} diff --git a/extensions/pagetop-user/src/migration/m20260629_000005_create_sessions.rs b/extensions/pagetop-user/src/migration/m20260629_000005_create_sessions.rs new file mode 100644 index 00000000..943166f6 --- /dev/null +++ b/extensions/pagetop-user/src/migration/m20260629_000005_create_sessions.rs @@ -0,0 +1,71 @@ +use pagetop_seaorm::migration::*; + +pub struct Migration; + +#[pagetop::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .create_table( + Table::create() + .table(Sessions::Table) + .if_not_exists() + // sid: 64 chars hex (32 bytes aleatorios) + .col(char_len(Sessions::Sid, 64).primary_key()) + .col(integer(Sessions::UserId)) + // Datos de sesión en JSON (flash messages, etc.) + .col(text(Sessions::Data).default("{}")) + .col(timestamp_null(Sessions::LastActivityAt)) + .col(timestamp(Sessions::ExpiresAt).default(Expr::current_timestamp())) + .col(timestamp(Sessions::CreatedAt).default(Expr::current_timestamp())) + .foreign_key( + ForeignKey::create() + .from(Sessions::Table, Sessions::UserId) + .to( + super::m20260629_000001_create_users::Users::Table, + super::m20260629_000001_create_users::Users::Id, + ) + .on_delete(ForeignKeyAction::Cascade), + ) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .name("idx_sessions_user_id") + .table(Sessions::Table) + .col(Sessions::UserId) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .name("idx_sessions_expires_at") + .table(Sessions::Table) + .col(Sessions::ExpiresAt) + .to_owned(), + ) + .await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(Sessions::Table).to_owned()) + .await + } +} + +#[derive(DeriveIden)] +pub enum Sessions { + Table, + Sid, + UserId, + Data, + LastActivityAt, + ExpiresAt, + CreatedAt, +} diff --git a/extensions/pagetop-user/src/migration/m20260629_000006_create_user_tokens.rs b/extensions/pagetop-user/src/migration/m20260629_000006_create_user_tokens.rs new file mode 100644 index 00000000..a9390c0d --- /dev/null +++ b/extensions/pagetop-user/src/migration/m20260629_000006_create_user_tokens.rs @@ -0,0 +1,49 @@ +use pagetop_seaorm::migration::*; + +pub struct Migration; + +#[pagetop::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .create_table( + table_auto(UserTokens::Table) + .col(pk_auto(UserTokens::Id)) + .col(integer(UserTokens::UserId)) + // "email_verification" | "password_reset" + .col(string_len(UserTokens::Kind, 32)) + // SHA-256 del token plano (nunca se almacena el token en claro) + .col(char_len_uniq(UserTokens::TokenHash, 64)) + .col(timestamp(UserTokens::ExpiresAt).default(Expr::current_timestamp())) + .col(timestamp_null(UserTokens::ConsumedAt)) + .foreign_key( + ForeignKey::create() + .from(UserTokens::Table, UserTokens::UserId) + .to( + super::m20260629_000001_create_users::Users::Table, + super::m20260629_000001_create_users::Users::Id, + ) + .on_delete(ForeignKeyAction::Cascade), + ) + .to_owned(), + ) + .await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(UserTokens::Table).to_owned()) + .await + } +} + +#[derive(DeriveIden)] +pub enum UserTokens { + Table, + Id, + UserId, + Kind, + TokenHash, + ExpiresAt, + ConsumedAt, +} diff --git a/extensions/pagetop-user/src/password.rs b/extensions/pagetop-user/src/password.rs new file mode 100644 index 00000000..25661a7a --- /dev/null +++ b/extensions/pagetop-user/src/password.rs @@ -0,0 +1,73 @@ +//! Hashing de contraseñas con Argon2id. + +use argon2::{ + Argon2, ParamsBuilder, PasswordHash, PasswordHasher, PasswordVerifier, + password_hash::SaltString, +}; +use rand_core::OsRng; + +use crate::config::SETTINGS; +use crate::error::AuthError; + +/// Genera el hash PHC de una contraseña usando Argon2id con los parámetros configurados. +pub fn hash_password(plain: &str) -> Result { + let params = ParamsBuilder::new() + .m_cost(SETTINGS.password.argon2_m_cost) + .t_cost(SETTINGS.password.argon2_t_cost) + .p_cost(SETTINGS.password.argon2_p_cost) + .build() + .map_err(|e| AuthError::PasswordHash(e.to_string()))?; + + let argon2 = Argon2::new(argon2::Algorithm::Argon2id, argon2::Version::V0x13, params); + let salt = SaltString::generate(&mut OsRng); + argon2 + .hash_password(plain.as_bytes(), &salt) + .map(|h| h.to_string()) + .map_err(|e| AuthError::PasswordHash(e.to_string())) +} + +/// Verifica que `plain` corresponde al hash PHC almacenado. +/// +/// Devuelve `false` si el hash está malformado o la contraseña no coincide. +pub fn verify_password(plain: &str, phc: &str) -> bool { + let Ok(parsed) = PasswordHash::new(phc) else { + return false; + }; + Argon2::default() + .verify_password(plain.as_bytes(), &parsed) + .is_ok() +} + +/// Indica si el hash necesita actualizarse (parámetros de coste han cambiado). +pub fn needs_rehash(phc: &str) -> bool { + let Ok(parsed) = PasswordHash::new(phc) else { + return true; + }; + // Compara el coste de memoria con el configurado. + if let Some(m_cost) = parsed.params.get_str("m") { + let current: u32 = m_cost.parse().unwrap_or(0); + if current != SETTINGS.password.argon2_m_cost { + return true; + } + } + false +} + +/// Valida la longitud mínima y devuelve error si no se cumple. +pub fn validate_strength(plain: &str) -> Result<(), AuthError> { + let min = SETTINGS.password.min_length; + if plain.len() < min { + Err(AuthError::PasswordTooShort(min)) + } else { + Ok(()) + } +} + +/// Comprueba que `password` y `confirm_password` coinciden. +pub fn passwords_match(password: &str, confirm_password: &str) -> Result<(), AuthError> { + if password == confirm_password { + Ok(()) + } else { + Err(AuthError::PasswordMismatch) + } +} diff --git a/extensions/pagetop-user/src/permission.rs b/extensions/pagetop-user/src/permission.rs new file mode 100644 index 00000000..c4abddaa --- /dev/null +++ b/extensions/pagetop-user/src/permission.rs @@ -0,0 +1,257 @@ +//! Catálogo de permisos en memoria y acción `DeclarePermissions`. +//! +//! El catálogo se construye una sola vez durante `Extension::initialize()` a partir de +//! las acciones `DeclarePermissions` registradas por todas las extensiones. Los permisos +//! otorgados a cada rol se persisten en la tabla `role_permissions`; la definición +//! de qué permisos existen vive únicamente en memoria. + +use std::sync::OnceLock; + +use pagetop::prelude::*; + +use crate::LOCALES_USER; + +// **< DeclarePermissions >************************************************************************* + +/// Acción que las extensiones dispatcean para registrar sus permisos en el catálogo. +/// +/// Cada extensión añade una instancia `DeclarePermissions::new(fn)` en `Extension::actions()`. +/// Durante `initialize()`, [`build_registry()`] despacha todas las instancias registradas +/// y construye el catálogo global. +/// +/// `label()`, `group()` y `group_label()` los aporta el propio [`Permission`] registrado +/// (con implementación por defecto si la extensión no los sobrecarga). +/// +/// # Ejemplo +/// +/// ```rust,no_run +/// # use pagetop::auth::Permission; +/// # use pagetop::CowStr; +/// # use pagetop_user::permission::{DeclarePermissions, PermissionRegistry}; +/// #[derive(Clone, Copy, Debug)] +/// enum MyPermission { +/// DoSomething, +/// } +/// +/// impl Permission for MyPermission { +/// fn key(&self) -> CowStr { +/// match self { +/// Self::DoSomething => "myext:do_something".into(), +/// } +/// } +/// } +/// +/// fn my_permissions(registry: &mut PermissionRegistry) { +/// registry.register(&MyPermission::DoSomething); +/// } +/// // En Extension::actions(): +/// // DeclarePermissions::new(my_permissions) +/// ``` +pub struct DeclarePermissions { + pub(crate) handler: fn(&mut PermissionRegistry), +} + +impl DeclarePermissions { + pub fn new(handler: fn(&mut PermissionRegistry)) -> Self { + DeclarePermissions { handler } + } + + /// Despacha todas las acciones `DeclarePermissions` registradas construyendo el catálogo. + pub(crate) fn dispatch(registry: &mut PermissionRegistry) { + dispatch_actions( + &ActionKey::new(UniqueId::of::(), None, None), + |action: &Self| (action.handler)(registry), + ); + } +} + +impl ActionDispatcher for DeclarePermissions {} + +// **< PermissionRegistry >************************************************************************* + +/// Catálogo mutable de permisos, construido durante la fase de inicialización. +/// +/// Un `Vec` basta: el catálogo se construye una sola vez con un puñado de entradas y se recorre +/// entero en la UI de administración, así que conserva el orden de registro sin estructuras +/// adicionales y sin el coste de mantenerlas sincronizadas. +#[derive(Default)] +pub struct PermissionRegistry { + permissions: Vec, + /// `(identificador de grupo, título traducible)`, en orden de primer registro. + groups: Vec<(&'static str, Lc)>, +} + +impl PermissionRegistry { + /// Registra un permiso. Se ignora si ya está registrado. + pub fn register(&mut self, perm: PermissionRef) { + let group = perm.group(); + if !self.groups.iter().any(|(g, _)| *g == group) { + self.groups.push((group, perm.group_label())); + } + if !self.has(perm) { + self.permissions.push(perm); + } + } + + /// Comprueba si un permiso está en el catálogo. + pub fn has(&self, perm: PermissionRef) -> bool { + self.permissions.iter().any(|p| p.key() == perm.key()) + } + + /// Comprueba si una clave textual (p. ej. procedente de un formulario) corresponde a un + /// permiso del catálogo. + pub fn has_key(&self, key: &str) -> bool { + self.permissions.iter().any(|p| p.key().as_ref() == key) + } + + pub fn all(&self) -> impl Iterator + '_ { + self.permissions.iter().copied() + } + + pub fn groups(&self) -> &[(&'static str, Lc)] { + &self.groups + } + + pub fn by_group<'a>(&'a self, group: &'a str) -> impl Iterator + 'a { + self.permissions + .iter() + .copied() + .filter(move |p| p.group() == group) + } +} + +// **< Catálogo global >**************************************************************************** + +static PERMISSIONS: OnceLock = OnceLock::new(); + +/// Construye y almacena el catálogo global de permisos ejecutando todas las acciones +/// `DeclarePermissions` registradas. Se llama exactamente una vez desde `initialize()`. +pub fn build_registry() { + let mut registry = PermissionRegistry::default(); + DeclarePermissions::dispatch(&mut registry); + let _ = PERMISSIONS.set(registry); +} + +/// Devuelve el catálogo global ya construido. +/// +/// Entra en pánico si se llama antes de `build_registry()`. +pub fn registry() -> &'static PermissionRegistry { + PERMISSIONS + .get() + .expect("permission registry not initialized") +} + +// **< Permisos integrados de pagetop-user >******************************************************** + +/// Permisos propios de `pagetop-user`. +#[derive(Clone, Copy, Debug)] +pub enum UserPermission { + /// Iniciar sesión. + Login, + /// Registrar una cuenta nueva. + Register, + /// Ver perfiles de otros usuarios. + ViewProfiles, + /// Editar el perfil propio. + EditOwnProfile, + /// Cambiar la contraseña propia. + ChangeOwnPassword, + /// Acceder al mantenimiento de usuarios (listado, alta, edición). + AdminUsers, + /// Acceder al mantenimiento de roles (listado, alta, edición, borrado). + AdminRoles, + /// Acceder al listado de permisos y a la asignación de permisos a roles. + AdminPermissions, + /// Bloquear y desbloquear cuentas de usuario. + BlockAccounts, + /// Asignar roles a usuarios. + AssignRoles, +} + +impl UserPermission { + /// Todas las variantes, usado para registrarlas en el catálogo. + pub const ALL: &'static [Self] = &[ + Self::Login, + Self::Register, + Self::ViewProfiles, + Self::EditOwnProfile, + Self::ChangeOwnPassword, + Self::AdminUsers, + Self::AdminRoles, + Self::AdminPermissions, + Self::BlockAccounts, + Self::AssignRoles, + ]; +} + +impl Permission for UserPermission { + fn key(&self) -> CowStr { + match self { + Self::Login => "user:login".into(), + Self::Register => "user:register".into(), + Self::ViewProfiles => "user:view_profiles".into(), + Self::EditOwnProfile => "user:edit_own_profile".into(), + Self::ChangeOwnPassword => "user:change_own_password".into(), + Self::AdminUsers => "user:admin_users".into(), + Self::AdminRoles => "user:admin_roles".into(), + Self::AdminPermissions => "user:admin_permissions".into(), + Self::BlockAccounts => "user:block_accounts".into(), + Self::AssignRoles => "user:assign_roles".into(), + } + } + + fn label(&self) -> Lc { + let key = match self { + Self::Login => "perm-login", + Self::Register => "perm-register", + Self::ViewProfiles => "perm-view-profiles", + Self::EditOwnProfile => "perm-edit-own-profile", + Self::ChangeOwnPassword => "perm-change-own-password", + Self::AdminUsers => "perm-admin-users", + Self::AdminRoles => "perm-admin-roles", + Self::AdminPermissions => "perm-admin-permissions", + Self::BlockAccounts => "perm-block-accounts", + Self::AssignRoles => "perm-assign-roles", + }; + Lc::t(key, &LOCALES_USER) + } + + fn group(&self) -> &'static str { + match self { + Self::Login + | Self::Register + | Self::ViewProfiles + | Self::EditOwnProfile + | Self::ChangeOwnPassword => GROUP_USERS, + Self::AdminUsers + | Self::AdminRoles + | Self::AdminPermissions + | Self::BlockAccounts + | Self::AssignRoles => GROUP_ADMINISTRATION, + } + } + + fn group_label(&self) -> Lc { + builtin_group_label(self.group()) + } +} + +const GROUP_USERS: &str = "users"; +const GROUP_ADMINISTRATION: &str = "administration"; + +// Título traducible de un grupo de permisos integrado. +fn builtin_group_label(group: &str) -> Lc { + let key = match group { + GROUP_USERS => "group-users", + GROUP_ADMINISTRATION => "group-administration", + _ => unreachable!("grupo de permisos integrado desconocido: {group}"), + }; + Lc::t(key, &LOCALES_USER) +} + +/// Registra los permisos propios de `pagetop-user`. +pub fn declare_builtin_permissions(r: &mut PermissionRegistry) { + for permission in UserPermission::ALL { + r.register(permission); + } +} diff --git a/extensions/pagetop-user/src/service.rs b/extensions/pagetop-user/src/service.rs new file mode 100644 index 00000000..cccfcb76 --- /dev/null +++ b/extensions/pagetop-user/src/service.rs @@ -0,0 +1,4 @@ +//! Capa de servicio para la administración de usuarios y roles. + +pub(crate) mod role_admin; +pub(crate) mod user_admin; diff --git a/extensions/pagetop-user/src/service/role_admin.rs b/extensions/pagetop-user/src/service/role_admin.rs new file mode 100644 index 00000000..5b96bf9a --- /dev/null +++ b/extensions/pagetop-user/src/service/role_admin.rs @@ -0,0 +1,285 @@ +//! Servicio de administración de roles: listado, CRUD y permisos. + +use std::collections::HashMap; + +use pagetop::datetime::Utc; +use pagetop::html::SortDir; +use pagetop_seaorm::db::{ + ActiveModelTrait, ActiveValue, ColumnTrait, EntityTrait, Order, Paginated, PaginatorTrait, + QueryFilter, QueryOrder, QuerySelect, Set, TransactionTrait, dbconn, flatten_txn_err, paginate, +}; + +use crate::entity::{role, role_permission, user_role}; +use crate::error::AuthError; +use crate::permission; + +// **< listado >************************************************************************************** + +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub(crate) enum RoleSortField { + #[default] + Weight, + MachineName, + Label, +} + +impl RoleSortField { + pub(crate) fn from_query(s: Option<&str>) -> Self { + match s { + Some("machine_name") => RoleSortField::MachineName, + Some("label") => RoleSortField::Label, + _ => RoleSortField::Weight, + } + } + + pub(crate) fn as_str(self) -> &'static str { + match self { + RoleSortField::Weight => "weight", + RoleSortField::MachineName => "machine_name", + RoleSortField::Label => "label", + } + } +} + +#[derive(Clone, Debug)] +pub(crate) struct RoleListItem { + pub id: i32, + pub machine_name: String, + pub label: String, + pub locked: bool, + pub user_count: u64, +} + +pub(crate) struct RoleListParams { + pub sort: RoleSortField, + pub dir: SortDir, +} + +/// Devuelve todos los roles ordenados, sin paginar. Usado donde hace falta el catálogo completo +/// (p. ej. la lista de roles asignables en la administración de usuarios). +pub(crate) async fn list_roles(params: &RoleListParams) -> Result, AuthError> { + let order = if params.dir == SortDir::Desc { + Order::Desc + } else { + Order::Asc + }; + let select = role::Entity::find(); + let select = match params.sort { + RoleSortField::Weight => select.order_by(role::Column::Weight, order), + RoleSortField::MachineName => select.order_by(role::Column::MachineName, order), + RoleSortField::Label => select.order_by(role::Column::Label, order), + }; + let roles = select.all(dbconn()).await?; + role_items(roles).await +} + +pub(crate) struct RolePageParams { + pub sort: RoleSortField, + pub dir: SortDir, + pub page: u64, + pub per_page: u64, +} + +/// Devuelve una página de roles. Usado por el listado de administración de roles. +pub(crate) async fn list_roles_page( + params: &RolePageParams, +) -> Result, AuthError> { + let order = if params.dir == SortDir::Desc { + Order::Desc + } else { + Order::Asc + }; + let select = role::Entity::find(); + let select = match params.sort { + RoleSortField::Weight => select.order_by(role::Column::Weight, order), + RoleSortField::MachineName => select.order_by(role::Column::MachineName, order), + RoleSortField::Label => select.order_by(role::Column::Label, order), + }; + + paginate(select, params.page, params.per_page) + .await? + .map_items(role_items) + .await +} + +async fn role_items(roles: Vec) -> Result, AuthError> { + let role_ids: Vec = roles.iter().map(|r| r.id).collect(); + let counts: Vec<(i32, i64)> = user_role::Entity::find() + .filter(user_role::Column::RoleId.is_in(role_ids)) + .select_only() + .column(user_role::Column::RoleId) + .column_as(user_role::Column::RoleId.count(), "count") + .group_by(user_role::Column::RoleId) + .into_tuple() + .all(dbconn()) + .await?; + let counts_by_role: HashMap = counts + .into_iter() + .map(|(role_id, count)| (role_id, count as u64)) + .collect(); + + Ok(roles + .into_iter() + .map(|role| RoleListItem { + user_count: counts_by_role.get(&role.id).copied().unwrap_or(0), + id: role.id, + machine_name: role.machine_name, + label: role.label, + locked: role.locked, + }) + .collect()) +} + +// **< find_role / role_permission_keys >*************************************************************** + +pub(crate) async fn find_role(role_id: i32) -> Result { + role::Entity::find_by_id(role_id) + .one(dbconn()) + .await? + .ok_or(AuthError::RoleNotFound) +} + +pub(crate) async fn role_permission_keys(role_id: i32) -> Result, AuthError> { + let rows = role_permission::Entity::find() + .filter(role_permission::Column::RoleId.eq(role_id)) + .all(dbconn()) + .await?; + Ok(rows.into_iter().map(|r| r.permission_key).collect()) +} + +// **< create_role >********************************************************************************* + +pub(crate) struct NewRoleData<'a> { + pub machine_name: &'a str, + pub label: &'a str, + pub description: Option<&'a str>, + pub weight: i32, +} + +// Sólo minúsculas ASCII, dígitos y guiones bajos (ver el texto de ayuda del formulario, +// "help-machine-name-immutable"); el machine_name es inmutable tras la creación. +fn is_valid_machine_name(name: &str) -> bool { + !name.is_empty() + && name + .bytes() + .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_') +} + +pub(crate) async fn create_role(data: NewRoleData<'_>) -> Result { + if !is_valid_machine_name(data.machine_name) { + return Err(AuthError::InvalidMachineName); + } + + if role::Entity::find() + .filter(role::Column::MachineName.eq(data.machine_name)) + .one(dbconn()) + .await? + .is_some() + { + return Err(AuthError::RoleMachineNameTaken); + } + + let now = Utc::now().naive_utc(); + let new_role = role::ActiveModel { + id: ActiveValue::NotSet, + machine_name: Set(data.machine_name.to_owned()), + label: Set(data.label.to_owned()), + description: Set(data.description.map(str::to_owned)), + weight: Set(data.weight), + locked: Set(false), + created_at: Set(now), + updated_at: Set(now), + }; + let result = role::Entity::insert(new_role).exec(dbconn()).await?; + Ok(result.last_insert_id) +} + +// **< update_role >********************************************************************************* + +pub(crate) struct RoleUpdateData<'a> { + pub label: &'a str, + pub description: Option<&'a str>, + pub weight: i32, +} + +pub(crate) async fn update_role(role_id: i32, data: RoleUpdateData<'_>) -> Result<(), AuthError> { + let role = find_role(role_id).await?; + if role.locked { + return Err(AuthError::RoleLocked); + } + + let now = Utc::now().naive_utc(); + role::ActiveModel { + id: Set(role_id), + label: Set(data.label.to_owned()), + description: Set(data.description.map(str::to_owned)), + weight: Set(data.weight), + updated_at: Set(now), + ..Default::default() + } + .update(dbconn()) + .await?; + Ok(()) +} + +// **< delete_role >********************************************************************************* + +pub(crate) async fn delete_role(role_id: i32) -> Result<(), AuthError> { + let role = find_role(role_id).await?; + if role.locked { + return Err(AuthError::RoleLocked); + } + + let user_count = user_role::Entity::find() + .filter(user_role::Column::RoleId.eq(role_id)) + .count(dbconn()) + .await?; + if user_count > 0 { + return Err(AuthError::RoleInUse); + } + + role::Entity::delete_by_id(role_id).exec(dbconn()).await?; + Ok(()) +} + +// **< set_role_permissions >************************************************************************ + +/// Reemplaza por completo el conjunto de permisos concedidos a un rol. Permitido aunque el rol +/// esté bloqueado (`locked`): los roles de sistema también necesitan permisos gestionables. +pub(crate) async fn set_role_permissions( + role_id: i32, + permission_keys: &[String], +) -> Result<(), AuthError> { + find_role(role_id).await?; + + let registry = permission::registry(); + for key in permission_keys { + if !registry.has_key(key) { + return Err(AuthError::UnknownPermission(key.clone())); + } + } + + let now = Utc::now().naive_utc(); + let keys = permission_keys.to_vec(); + dbconn() + .transaction::<_, _, AuthError>(|txn| { + Box::pin(async move { + role_permission::Entity::delete_many() + .filter(role_permission::Column::RoleId.eq(role_id)) + .exec(txn) + .await?; + for key in keys { + role_permission::Entity::insert(role_permission::ActiveModel { + role_id: Set(role_id), + permission_key: Set(key), + granted_at: Set(now), + }) + .exec(txn) + .await?; + } + Ok(()) + }) + }) + .await + .map_err(flatten_txn_err) +} diff --git a/extensions/pagetop-user/src/service/user_admin.rs b/extensions/pagetop-user/src/service/user_admin.rs new file mode 100644 index 00000000..55ca95ab --- /dev/null +++ b/extensions/pagetop-user/src/service/user_admin.rs @@ -0,0 +1,418 @@ +//! Servicio de administración de usuarios: listado, CRUD, roles y estado. + +use std::collections::HashMap; + +use pagetop::datetime::Utc; +use pagetop::html::SortDir; +use pagetop::util; +use pagetop_seaorm::db::{ + ActiveModelTrait, ActiveValue, ColumnTrait, Condition, EntityTrait, Order, Paginated, + PaginatorTrait, QueryFilter, QueryOrder, Set, TransactionTrait, dbconn, flatten_txn_err, + paginate, +}; + +use crate::account::UserStatus; +use crate::entity::{role, user, user_role}; +use crate::error::AuthError; +use crate::password; +use crate::session; + +// **< listado >************************************************************************************** + +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub(crate) enum UserSortField { + #[default] + Username, + Email, + CreatedAt, +} + +impl UserSortField { + pub(crate) fn from_query(s: Option<&str>) -> Self { + match s { + Some("email") => UserSortField::Email, + Some("created_at") => UserSortField::CreatedAt, + _ => UserSortField::Username, + } + } + + pub(crate) fn as_str(self) -> &'static str { + match self { + UserSortField::Username => "username", + UserSortField::Email => "email", + UserSortField::CreatedAt => "created_at", + } + } +} + +#[derive(Clone, Debug)] +pub(crate) struct UserListItem { + pub id: i32, + pub username: String, + pub email: String, + pub display_name: Option, + pub status: UserStatus, + pub roles: Vec, + pub is_admin: bool, +} + +pub(crate) struct UserListParams { + pub query: Option, + pub sort: UserSortField, + pub dir: SortDir, + pub page: u64, + pub per_page: u64, +} + +/// Devuelve una página de usuarios. Usado por el listado de administración de usuarios. +pub(crate) async fn list_users( + params: &UserListParams, +) -> Result, AuthError> { + let mut select = user::Entity::find(); + + if let Some(q) = params.query.as_deref().and_then(util::non_blank) { + select = select.filter( + Condition::any() + .add(user::Column::Username.contains(q)) + .add(user::Column::Email.contains(q)) + .add(user::Column::DisplayName.contains(q)), + ); + } + + let order = if params.dir == SortDir::Desc { + Order::Desc + } else { + Order::Asc + }; + select = match params.sort { + UserSortField::Username => select.order_by(user::Column::Username, order), + UserSortField::Email => select.order_by(user::Column::Email, order), + UserSortField::CreatedAt => select.order_by(user::Column::CreatedAt, order), + }; + + paginate(select, params.page, params.per_page) + .await? + .map_items(user_items) + .await +} + +async fn user_items(users: Vec) -> Result, AuthError> { + let user_ids: Vec = users.iter().map(|u| u.id).collect(); + let role_rows = user_role::Entity::find() + .filter(user_role::Column::UserId.is_in(user_ids)) + .find_also_related(role::Entity) + .all(dbconn()) + .await?; + + let mut roles_by_user: HashMap> = HashMap::new(); + for (ur, role) in role_rows { + if let Some(role) = role { + roles_by_user + .entry(ur.user_id) + .or_default() + .push(role.machine_name); + } + } + + Ok(users + .into_iter() + .map(|u| UserListItem { + id: u.id, + username: u.username, + email: u.email, + display_name: u.display_name, + status: UserStatus::from_i16(u.status), + roles: roles_by_user.remove(&u.id).unwrap_or_default(), + is_admin: u.is_admin, + }) + .collect()) +} + +// **< find_user / user_role_ids >********************************************************************** + +pub(crate) async fn find_user(user_id: i32) -> Result { + user::Entity::find_by_id(user_id) + .one(dbconn()) + .await? + .ok_or(AuthError::UserNotFound) +} + +pub(crate) async fn user_role_ids(user_id: i32) -> Result, AuthError> { + let rows = user_role::Entity::find() + .filter(user_role::Column::UserId.eq(user_id)) + .all(dbconn()) + .await?; + Ok(rows.into_iter().map(|r| r.role_id).collect()) +} + +pub(crate) async fn user_roles(user_id: i32) -> Result, AuthError> { + let role_ids = user_role_ids(user_id).await?; + Ok(role::Entity::find() + .filter(role::Column::Id.is_in(role_ids)) + .order_by(role::Column::Weight, Order::Asc) + .all(dbconn()) + .await?) +} + +// **< create_user >********************************************************************************* + +pub(crate) struct NewUserData<'a> { + pub username: &'a str, + pub email: &'a str, + pub password: &'a str, + pub confirm_password: &'a str, + pub display_name: Option<&'a str>, + pub language: Option<&'a str>, + pub timezone: Option<&'a str>, + pub initial_role_ids: &'a [i32], + /// El *caller* es responsable de comprobar que sólo un administrador puede pasar `true`. + pub is_admin: bool, +} + +/// Da de alta un usuario administrativamente. A diferencia de `auth::register`, el usuario queda +/// activo y con el email verificado de inmediato (lo crea un administrador de confianza), y admite +/// asignar roles iniciales. +pub(crate) async fn create_user(data: NewUserData<'_>) -> Result { + password::validate_strength(data.password)?; + password::passwords_match(data.password, data.confirm_password)?; + 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 new_user = user::ActiveModel { + id: ActiveValue::NotSet, + username: Set(data.username.to_owned()), + email: Set(data.email.to_owned()), + email_verified_at: Set(Some(now)), + 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)), + display_name: Set(data.display_name.map(str::to_owned)), + last_login_at: Set(None), + last_access_at: Set(None), + failed_login_count: Set(0), + locked_until: Set(None), + is_admin: Set(data.is_admin), + created_at: Set(now), + updated_at: Set(now), + }; + let result = user::Entity::insert(new_user).exec(dbconn()).await?; + let user_id = result.last_insert_id; + + crate::auth::assign_role(user_id, crate::AUTHENTICATED_ROLE_ID).await?; + for role_id in data.initial_role_ids { + crate::auth::assign_role(user_id, *role_id).await?; + } + + Ok(user_id) +} + +// **< update_user >********************************************************************************* + +pub(crate) struct UserUpdateData<'a> { + pub username: &'a str, + pub email: &'a str, + pub display_name: Option<&'a str>, + pub language: Option<&'a str>, + pub timezone: Option<&'a str>, +} + +pub(crate) async fn update_user(user_id: i32, data: UserUpdateData<'_>) -> Result<(), AuthError> { + ensure_username_available(data.username, Some(user_id)).await?; + ensure_email_available(data.email, Some(user_id)).await?; + + let now = Utc::now().naive_utc(); + 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)), + updated_at: Set(now), + ..Default::default() + } + .update(dbconn()) + .await?; + Ok(()) +} + +// **< set_user_roles >****************************************************************************** + +/// Reemplaza por completo el conjunto de roles asignados a un usuario. +/// +/// "authenticated" ([`crate::AUTHENTICATED_ROLE_ID`]) se reintroduce siempre, esté o no en +/// `role_ids`: la UI no lo ofrece como casilla (ver `available_roles()`), pero toda cuenta +/// activa lo tiene concedido por definición y debe seguir apareciendo en `Account.roles`. +pub(crate) async fn set_user_roles(user_id: i32, role_ids: &[i32]) -> Result<(), AuthError> { + find_user(user_id).await?; + + let mut role_ids: Vec = role_ids.to_vec(); + role_ids.push(crate::AUTHENTICATED_ROLE_ID); + role_ids.sort_unstable(); + role_ids.dedup(); + + dbconn() + .transaction::<_, _, AuthError>(|txn| { + Box::pin(async move { + user_role::Entity::delete_many() + .filter(user_role::Column::UserId.eq(user_id)) + .exec(txn) + .await?; + for role_id in role_ids { + user_role::Entity::insert(user_role::ActiveModel { + user_id: Set(user_id), + role_id: Set(role_id), + }) + .exec(txn) + .await?; + } + Ok(()) + }) + }) + .await + .map_err(flatten_txn_err) +} + +// **< 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. +pub(crate) async fn set_user_status( + user_id: i32, + new_status: UserStatus, + acting_user_id: i32, +) -> Result<(), AuthError> { + find_user(user_id).await?; + + if new_status == UserStatus::Blocked { + if user_id == acting_user_id { + return Err(AuthError::CannotBlockSelf); + } + if is_last_administrator(user_id).await? { + return Err(AuthError::LastAdministrator); + } + } + + let now = Utc::now().naive_utc(); + user::ActiveModel { + id: Set(user_id), + status: Set(new_status.as_i16()), + updated_at: Set(now), + ..Default::default() + } + .update(dbconn()) + .await?; + + if new_status == UserStatus::Blocked { + session::destroy_user_sessions(user_id) + .await + .map_err(AuthError::Database)?; + } + + Ok(()) +} + +// **< 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` +/// directamente, sin pasar por `require_permission`). +/// +/// Rechaza que un administrador se automodifique el flag. No hace falta proteger aparte al +/// "último administrador": para llegar aquí quien actúa ya tiene que ser administrador, así que si +/// sólo queda uno, sólo él podría revocarse a sí mismo, y eso ya lo bloquea la comprobación +/// anterior. +pub(crate) async fn set_user_admin( + user_id: i32, + is_admin: bool, + acting_user_id: i32, +) -> Result<(), AuthError> { + find_user(user_id).await?; + + if user_id == acting_user_id { + return Err(AuthError::CannotModifyOwnAdminFlag); + } + + let now = Utc::now().naive_utc(); + user::ActiveModel { + id: Set(user_id), + is_admin: Set(is_admin), + updated_at: Set(now), + ..Default::default() + } + .update(dbconn()) + .await?; + Ok(()) +} + +// **< admin_reset_password >************************************************************************ + +/// Restablece la contraseña de un usuario como acción administrativa e invalida sus sesiones +/// activas. +pub(crate) async fn admin_reset_password( + user_id: i32, + new_password: &str, +) -> Result<(), AuthError> { + find_user(user_id).await?; + password::validate_strength(new_password)?; + let hash = password::hash_password(new_password)?; + + let now = Utc::now().naive_utc(); + user::ActiveModel { + id: Set(user_id), + password_hash: Set(hash), + updated_at: Set(now), + ..Default::default() + } + .update(dbconn()) + .await?; + + session::destroy_user_sessions(user_id) + .await + .map_err(AuthError::Database)?; + Ok(()) +} + +// **< helpers privados >**************************************************************************** + +async fn ensure_username_available( + username: &str, + exclude_id: Option, +) -> Result<(), AuthError> { + let mut query = user::Entity::find().filter(user::Column::Username.eq(username)); + if let Some(id) = exclude_id { + query = query.filter(user::Column::Id.ne(id)); + } + if query.one(dbconn()).await?.is_some() { + return Err(AuthError::UsernameTaken); + } + Ok(()) +} + +async fn ensure_email_available(email: &str, exclude_id: Option) -> Result<(), AuthError> { + let mut query = user::Entity::find().filter(user::Column::Email.eq(email)); + if let Some(id) = exclude_id { + query = query.filter(user::Column::Id.ne(id)); + } + if query.one(dbconn()).await?.is_some() { + return Err(AuthError::EmailTaken); + } + Ok(()) +} + +// Comprueba si `user_id` es actualmente el único usuario con `is_admin = true`. +async fn is_last_administrator(user_id: i32) -> Result { + let user = find_user(user_id).await?; + if !user.is_admin { + return Ok(false); + } + let admin_count = user::Entity::find() + .filter(user::Column::IsAdmin.eq(true)) + .count(dbconn()) + .await?; + Ok(admin_count <= 1) +} diff --git a/extensions/pagetop-user/src/session.rs b/extensions/pagetop-user/src/session.rs new file mode 100644 index 00000000..485e3138 --- /dev/null +++ b/extensions/pagetop-user/src/session.rs @@ -0,0 +1,224 @@ +//! Gestión de sesiones de usuario (creación, carga, destrucción). + +use pagetop::auth::CurrentUser; +use pagetop::datetime::{Duration, Utc}; +use pagetop::web::http::{HeaderMap, header}; +use pagetop_seaorm::db::{ + ActiveModelTrait, ColumnTrait, DbErr, EntityTrait, QueryFilter, Set, dbconn, +}; + +use crate::AUTHENTICATED_ROLE_ID; +use crate::account::{Account, PermissionSet, UserStatus}; +use crate::config::SETTINGS; +use crate::entity::{role, role_permission, session, user, user_role}; + +// **< Generación de session ID >******************************************************************* + +/// Genera un session ID de 64 caracteres hex (32 bytes aleatorios via OsRng). +pub fn generate_sid() -> String { + use rand_core::{OsRng, RngCore}; + let mut bytes = [0u8; 32]; + OsRng.fill_bytes(&mut bytes); + bytes_to_hex(&bytes) +} + +fn bytes_to_hex(bytes: &[u8]) -> String { + use std::fmt::Write; + let mut s = String::with_capacity(bytes.len() * 2); + for b in bytes { + write!(s, "{:02x}", b).unwrap(); + } + s +} + +// **< Cookie helpers >***************************************************************************** + +/// Construye el valor de la cabecera `Set-Cookie` para la cookie de sesión. +pub fn build_cookie(sid: &str, remember: bool) -> String { + let mut parts = vec![ + format!("{}={}", SETTINGS.session_cookie_name, sid), + "HttpOnly".into(), + "SameSite=Lax".into(), + "Path=/".into(), + ]; + if SETTINGS.secure_cookie { + parts.push("Secure".into()); + } + if remember { + parts.push(format!("Max-Age={}", SETTINGS.session_ttl_secs)); + } + parts.join("; ") +} + +/// Construye la cookie de expiración (Max-Age=0) para borrar la sesión del navegador. +pub fn expiry_cookie() -> String { + format!( + "{}=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0", + SETTINGS.session_cookie_name + ) +} + +/// Extrae el session ID de las cabeceras HTTP de la petición, si existe. +pub fn extract_sid(headers: Option<&HeaderMap>) -> Option { + let cookie_str = headers?.get(header::COOKIE)?.to_str().ok()?; + + let name = SETTINGS.session_cookie_name.as_str(); + for part in cookie_str.split(';') { + let part = part.trim(); + if let Some(value) = part.strip_prefix(name).and_then(|s| s.strip_prefix('=')) { + return Some(value.trim().to_owned()); + } + } + None +} + +// **< resolve_session >**************************************************************************** + +/// Lee la cookie de sesión de las cabeceras y resuelve el par `(CurrentUser, Option)`. +/// +/// Si no hay cookie o la sesión ha expirado, devuelve `(CurrentUser::Anonymous, None)`. +/// Se llama desde el middleware de sesión, que es async. +pub async fn resolve_session(headers: &HeaderMap) -> (CurrentUser, Option) { + let Some(sid) = extract_sid(Some(headers)) else { + return (CurrentUser::Anonymous, None); + }; + load_user_from_session(&sid).await +} + +// **< load_user_from_session >********************************************************************* + +/// Carga el par `(CurrentUser, Option)` a partir de un session ID. +/// +/// Devuelve `(CurrentUser::Anonymous, None)` si la sesión no existe o ha expirado. +pub async fn load_user_from_session(sid: &str) -> (CurrentUser, Option) { + let now = Utc::now().naive_utc(); + + // Buscar sesión activa y no expirada. + let Ok(Some(sess)) = session::Entity::find_by_id(sid).one(dbconn()).await else { + return (CurrentUser::Anonymous, None); + }; + if sess.expires_at < now { + return (CurrentUser::Anonymous, None); + } + + // Cargar usuario con estado activo. + let Ok(Some(user_model)) = user::Entity::find_by_id(sess.user_id).one(dbconn()).await else { + return (CurrentUser::Anonymous, None); + }; + if UserStatus::from_i16(user_model.status) != UserStatus::Active { + return (CurrentUser::Anonymous, None); + } + + // Cargar roles explícitos del usuario. + let Ok(user_role_rows) = user_role::Entity::find() + .filter(user_role::Column::UserId.eq(sess.user_id)) + .all(dbconn()) + .await + else { + return (CurrentUser::Anonymous, None); + }; + + let role_ids: Vec = user_role_rows.iter().map(|ur| ur.role_id).collect(); + + let Ok(role_rows) = role::Entity::find() + .filter(role::Column::Id.is_in(role_ids.clone())) + .all(dbconn()) + .await + else { + return (CurrentUser::Anonymous, None); + }; + + let is_admin = user_model.is_admin; + let role_names: Vec = role_rows.iter().map(|r| r.machine_name.clone()).collect(); + + // Cargar permisos de todos los roles (incluido "authenticated", siempre asignado). + let mut all_role_ids = role_ids; + if !all_role_ids.contains(&AUTHENTICATED_ROLE_ID) { + all_role_ids.push(AUTHENTICATED_ROLE_ID); + } + + let permissions = if is_admin { + PermissionSet::default() + } else { + let Ok(perm_rows) = role_permission::Entity::find() + .filter(role_permission::Column::RoleId.is_in(all_role_ids)) + .all(dbconn()) + .await + else { + return (CurrentUser::Anonymous, None); + }; + PermissionSet::new(perm_rows.into_iter().map(|p| p.permission_key)) + }; + + // Actualizar last_activity_at con throttle: sólo una vez por minuto. + let throttle = Duration::minutes(1); + if sess + .last_activity_at + .map(|t| now - t > throttle) + .unwrap_or(true) + { + let mut active: session::ActiveModel = sess.into(); + active.last_activity_at = Set(Some(now)); + let _ = active.update(dbconn()).await; + } + + let display_name = user_model.display_name.unwrap_or_default(); + let visible_name = if display_name.is_empty() { + user_model.username.clone() + } else { + display_name.clone() + }; + let account = Account { + id: user_model.id, + username: user_model.username, + email: user_model.email, + display_name, + status: UserStatus::from_i16(user_model.status), + roles: role_names, + permissions, + is_admin, + }; + let current_user = CurrentUser::Authenticated { + id: account.id, + display_name: visible_name, + }; + + (current_user, Some(account)) +} + +// **< create_session >***************************************************************************** + +/// 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 { + let sid = generate_sid(); + let now = Utc::now().naive_utc(); + 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 }; + + let new_session = session::ActiveModel { + sid: Set(sid.clone()), + user_id: Set(user_id), + data: Set("{}".into()), + last_activity_at: Set(Some(now)), + expires_at: Set(expires_at), + created_at: Set(now), + }; + session::Entity::insert(new_session).exec(dbconn()).await?; + Ok(sid) +} + +/// Destruye la sesión indicada (logout). +pub async fn destroy_session(sid: &str) -> Result<(), DbErr> { + session::Entity::delete_by_id(sid).exec(dbconn()).await?; + Ok(()) +} + +/// Destruye todas las sesiones de un usuario (p. ej. al cambiar contraseña). +pub async fn destroy_user_sessions(user_id: i32) -> Result<(), DbErr> { + session::Entity::delete_many() + .filter(session::Column::UserId.eq(user_id)) + .exec(dbconn()) + .await?; + Ok(()) +} diff --git a/extensions/pagetop-user/src/token.rs b/extensions/pagetop-user/src/token.rs new file mode 100644 index 00000000..4da794a5 --- /dev/null +++ b/extensions/pagetop-user/src/token.rs @@ -0,0 +1,125 @@ +//! Generación y verificación de tokens de un solo uso (reset de contraseña, verificación de +//! email...). + +use pagetop::datetime::{Duration, Utc}; +use sha2::{Digest, Sha256}; + +use pagetop_seaorm::db::{ + ActiveModelTrait, ActiveValue, ColumnTrait, EntityTrait, QueryFilter, Set, dbconn, +}; + +use crate::entity::user_token; +use crate::error::AuthError; + +// **< Generación de tokens >*********************************************************************** + +/// Genera un token URL-safe de 43 caracteres (32 bytes -> base64url sin padding). +pub fn generate_token() -> String { + use rand_core::{OsRng, RngCore}; + let mut bytes = [0u8; 32]; + OsRng.fill_bytes(&mut bytes); + // base64url sin padding (a=, == al final) para usar en URLs de forma segura. + use base64ct::{Base64UrlUnpadded, Encoding}; + Base64UrlUnpadded::encode_string(&bytes) +} + +/// Calcula el hash SHA-256 de un token y lo devuelve en hexadecimal (64 chars). +/// +/// El hash es lo que se almacena en BD; el token en claro se envía al usuario por email. +pub fn hash_token(token: &str) -> String { + let mut hasher = Sha256::new(); + hasher.update(token.as_bytes()); + let digest = hasher.finalize(); + // Convertir a hex sin depender de crates adicionales. + use std::fmt::Write; + let mut s = String::with_capacity(64); + for b in digest { + write!(s, "{:02x}", b).unwrap(); + } + s +} + +// **< TokenKind >********************************************************************************** + +/// Tipos de token que emite `pagetop-user`. +#[derive(Clone, Copy, Debug)] +pub enum TokenKind { + PasswordReset, + EmailVerification, +} + +impl TokenKind { + pub fn as_str(self) -> &'static str { + match self { + TokenKind::PasswordReset => "password_reset", + TokenKind::EmailVerification => "email_verification", + } + } + + /// TTL del token en segundos según su tipo. + pub fn ttl_secs(self) -> i64 { + match self { + TokenKind::PasswordReset => 3600, // 1 hora + TokenKind::EmailVerification => 86400 * 3, // 3 días + } + } +} + +// **< create_token >******************************************************************************* + +/// Crea un token en BD y devuelve el valor en claro para enviarlo al usuario. +/// +/// Si el usuario ya tiene un token del mismo tipo vigente, lo reemplaza. +pub async fn create_token(user_id: i32, kind: TokenKind) -> Result { + // Invalidar tokens anteriores del mismo tipo para este usuario. + user_token::Entity::delete_many() + .filter(user_token::Column::UserId.eq(user_id)) + .filter(user_token::Column::Kind.eq(kind.as_str())) + .exec(dbconn()) + .await?; + + let token = generate_token(); + let now = Utc::now().naive_utc(); + let expires_at = now + Duration::seconds(kind.ttl_secs()); + + let new_token = user_token::ActiveModel { + id: ActiveValue::NotSet, + user_id: Set(user_id), + kind: Set(kind.as_str().to_owned()), + token_hash: Set(hash_token(&token)), + expires_at: Set(expires_at), + consumed_at: Set(None), + created_at: Set(now), + updated_at: Set(now), + }; + user_token::Entity::insert(new_token).exec(dbconn()).await?; + Ok(token) +} + +// **< consume_token >****************************************************************************** + +/// Verifica que el token en claro sea válido (existe, no ha expirado y no ha sido consumido) +/// y lo marca como consumido. Devuelve el `user_id` asociado. +pub async fn consume_token(token: &str, kind: TokenKind) -> Result { + let hash = hash_token(token); + let now = Utc::now().naive_utc(); + + let row = user_token::Entity::find() + .filter(user_token::Column::TokenHash.eq(&hash)) + .filter(user_token::Column::Kind.eq(kind.as_str())) + .one(dbconn()) + .await? + .ok_or(AuthError::InvalidToken)?; + + if row.consumed_at.is_some() || row.expires_at < now { + return Err(AuthError::InvalidToken); + } + + let user_id = row.user_id; + let mut active: user_token::ActiveModel = row.into(); + active.consumed_at = Set(Some(now)); + active.updated_at = Set(now); + active.update(dbconn()).await?; + + Ok(user_id) +}