Añade pagetop-user, con autenticación y RBAC

Identidad de usuario, sesiones en base de datos, roles y permisos (RBAC)
con "authenticated" implícito, contraseñas con Argon2id, tokens de
un solo uso para verificación de email y restablecimiento de contraseña,
y la UI de administración completa (usuarios, roles, permisos) sobre
pagetop-admin.

Incluye datos de demostración opcionales tras la feature `demo-data`.
This commit is contained in:
Manuel Cillero 2026-08-28 20:59:17 +02:00
parent 043873a954
commit def0513246
57 changed files with 7409 additions and 0 deletions

View file

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

View file

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

View file

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

View file

@ -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<String>);
impl PermissionSet {
pub fn new(keys: impl IntoIterator<Item = String>) -> 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<Item = String>) {
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::<Account>()`](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<String>,
/// 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)
}
}

View file

@ -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<String, AuthError> {
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<i32, AuthError> {
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),
}
}

View file

@ -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<Lc>) -> Html {
Html::with(move |cx| match &error {
Some(e) => html! { div.user-form-error role="alert" { (e.clone().using(cx)) } },
None => html! {},
})
}

View file

@ -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 `<form>` 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<PermissionItem>)>;
// **< 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)
}

View file

@ -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<Lc>,
/// 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<Markup, ComponentError> {
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<Option<Lc>>) -> Self {
self.error = error.into();
self
}
#[builder_fn]
pub(crate) fn with_waypoint(mut self, waypoint: impl Into<Waypoint>) -> Self {
self.waypoint = waypoint.into();
self
}
}

View file

@ -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<Lc>,
role_id: Option<i32>,
/// 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<Markup, ComponentError> {
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<Option<Lc>>) -> Self {
self.error = error.into();
self
}
#[builder_fn]
pub(crate) fn with_role_id(mut self, role_id: impl Into<Option<i32>>) -> Self {
self.role_id = role_id.into();
self
}
#[builder_fn]
pub(crate) fn with_waypoint(mut self, waypoint: impl Into<Waypoint>) -> Self {
self.waypoint = waypoint.into();
self
}
#[builder_fn]
pub(crate) fn with_machine_name(mut self, machine_name: impl Into<String>) -> Self {
self.machine_name = machine_name.into();
self
}
#[builder_fn]
pub(crate) fn with_label(mut self, label: impl Into<String>) -> Self {
self.label = label.into();
self
}
#[builder_fn]
pub(crate) fn with_description(mut self, description: impl Into<String>) -> Self {
self.description = description.into();
self
}
#[builder_fn]
pub(crate) fn with_weight(mut self, weight: i32) -> Self {
self.weight = weight;
self
}
}

View file

@ -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<Lc>,
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<Markup, ComponentError> {
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<Option<Lc>>) -> 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<Waypoint>) -> Self {
self.waypoint = waypoint.into();
self
}
}

View file

@ -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<RoleListItem>,
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<Lc>,
}
#[async_trait]
impl Component for RoleTable {
fn new() -> Self {
Self::default()
}
fn id(&self) -> Option<String> {
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<Markup, ComponentError> {
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<RoleListItem>) -> Self {
self.items = items;
self
}
#[builder_fn]
pub(crate) fn with_message(mut self, message: impl Into<Option<Lc>>) -> 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)
}
}
})
}

View file

@ -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<Lc>,
user_id: Option<i32>,
/// 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<Markup, ComponentError> {
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 `<form>`.
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<Option<Lc>>) -> Self {
self.error = error.into();
self
}
#[builder_fn]
pub(crate) fn with_user_id(mut self, user_id: impl Into<Option<i32>>) -> Self {
self.user_id = user_id.into();
self
}
#[builder_fn]
pub(crate) fn with_waypoint(mut self, waypoint: impl Into<Waypoint>) -> Self {
self.waypoint = waypoint.into();
self
}
#[builder_fn]
pub(crate) fn with_username(mut self, username: impl Into<String>) -> Self {
self.username = username.into();
self
}
#[builder_fn]
pub(crate) fn with_email(mut self, email: impl Into<String>) -> Self {
self.email = email.into();
self
}
#[builder_fn]
pub(crate) fn with_display_name(mut self, display_name: impl Into<String>) -> Self {
self.display_name = display_name.into();
self
}
#[builder_fn]
pub(crate) fn with_language(mut self, language: impl Into<String>) -> Self {
self.language = language.into();
self
}
#[builder_fn]
pub(crate) fn with_timezone(mut self, timezone: impl Into<String>) -> 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
}
}

View file

@ -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<Lc>,
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<Markup, ComponentError> {
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<Option<Lc>>) -> 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<Waypoint>) -> Self {
self.waypoint = waypoint.into();
self
}
}

View file

@ -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<UserListItem>,
sort: UserSortField,
dir: SortDir,
query: Option<String>,
page: u64,
per_page: u64,
total: u64,
}
#[async_trait]
impl Component for UserTable {
fn new() -> Self {
Self::default()
}
fn id(&self) -> Option<String> {
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<Markup, ComponentError> {
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<UserListItem>) -> 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<Option<String>>) -> 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",
}
}

View file

@ -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<Lc>,
/// 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<Markup, ComponentError> {
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<Option<Lc>>) -> Self {
self.error = error.into();
self
}
#[builder_fn]
pub fn with_waypoint(mut self, waypoint: impl Into<Waypoint>) -> Self {
self.waypoint = waypoint.into();
self
}
}

View file

@ -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<Markup, ComponentError> {
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
}
}

View file

@ -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<Lc>,
}
#[async_trait]
impl Component for PasswordResetConfirmForm {
fn new() -> Self {
Self::default()
}
async fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
// 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<Option<Lc>>) -> Self {
self.error = error.into();
self
}
}

View file

@ -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<Lc>,
}
#[async_trait]
impl Component for PasswordResetForm {
fn new() -> Self {
Self::default()
}
async fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
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<Option<Lc>>) -> Self {
self.error = error.into();
self
}
}

View file

@ -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<Lc>,
}
#[async_trait]
impl Component for RegisterForm {
fn new() -> Self {
Self::default()
}
async fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
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<Option<Lc>>) -> Self {
self.error = error.into();
self
}
}

View file

@ -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<Markup, ComponentError> {
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))
}
}
}
}
})
}
}

View file

@ -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<Settings> = 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<String>,
}
/// 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 }
}
}

View file

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

View file

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

View file

@ -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<String>,
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<super::role_permission::Entity> for Entity {
fn to() -> RelationDef {
Relation::RolePermissions.def()
}
}
impl Related<super::user_role::Entity> for Entity {
fn to() -> RelationDef {
Relation::UserRoles.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View file

@ -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<super::role::Entity> for Entity {
fn to() -> RelationDef {
Relation::Role.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View file

@ -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<NaiveDateTime>,
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<super::user::Entity> for Entity {
fn to() -> RelationDef {
Relation::User.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View file

@ -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<NaiveDateTime>,
pub password_hash: String,
pub status: i16,
pub language: Option<String>,
pub timezone: Option<String>,
pub display_name: Option<String>,
pub last_login_at: Option<NaiveDateTime>,
pub last_access_at: Option<NaiveDateTime>,
pub failed_login_count: i32,
pub locked_until: Option<NaiveDateTime>,
/// 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<super::user_role::Entity> for Entity {
fn to() -> RelationDef {
Relation::UserRoles.def()
}
}
impl Related<super::session::Entity> for Entity {
fn to() -> RelationDef {
Relation::Sessions.def()
}
}
impl Related<super::user_token::Entity> for Entity {
fn to() -> RelationDef {
Relation::UserTokens.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View file

@ -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<super::user::Entity> for Entity {
fn to() -> RelationDef {
Relation::User.def()
}
}
impl Related<super::role::Entity> for Entity {
fn to() -> RelationDef {
Relation::Role.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View file

@ -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<NaiveDateTime>,
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<super::user::Entity> for Entity {
fn to() -> RelationDef {
Relation::User.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View file

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

View file

@ -0,0 +1,5 @@
//! Handlers HTTP de `pagetop-user`.
pub(crate) mod account;
pub(crate) mod admin;
pub(crate) mod auth;

View file

@ -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<Markup>)> = 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)
}
}
}
}
}
}
}))
}

View file

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

View file

@ -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<Response, ErrorPage> {
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())
}

View file

@ -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<String>,
#[serde(default)]
dir: Option<String>,
#[serde(default)]
page: Option<u64>,
}
// **< 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<RolesQuery>,
) -> Result<Response, ErrorPage> {
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(&params).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<Waypoint>,
) -> Result<Response, ErrorPage> {
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<Waypoint>,
web::Form(form): web::Form<NewRoleFormData>,
) -> Result<Response, ErrorPage> {
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<i32>,
web::Query(waypoint): web::Query<Waypoint>,
) -> Result<Response, ErrorPage> {
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<i32>,
web::Query(waypoint): web::Query<Waypoint>,
web::Form(form): web::Form<EditRoleFormData>,
) -> Result<Response, ErrorPage> {
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<i32>,
web::Query(waypoint): web::Query<Waypoint>,
) -> Result<Response, ErrorPage> {
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<Block> {
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<i32>,
web::Query(query): web::Query<RolesQuery>,
) -> Result<Response, ErrorPage> {
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(&params)
.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<i32>,
web::Query(query): web::Query<RolesQuery>,
) -> Result<Response, ErrorPage> {
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<i32>,
web::Query(waypoint): web::Query<Waypoint>,
) -> Result<Response, ErrorPage> {
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<String>,
}
/// 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<T>`).
pub(crate) async fn permissions_post(
request: HttpRequest,
web::Path(id): web::Path<i32>,
web::Query(waypoint): web::Query<Waypoint>,
raw: web::RawForm,
) -> Result<Response, ErrorPage> {
require_permission(&request, &UserPermission::AdminRoles)?;
require_permission(&request, &UserPermission::AdminPermissions)?;
let Ok(form) = serde_qs::from_bytes::<RolePermissionsFormData>(&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())
}
}
}

View file

@ -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<String>,
#[serde(default)]
sort: Option<String>,
#[serde(default)]
dir: Option<String>,
#[serde(default)]
page: Option<u64>,
}
// **< 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<UsersQuery>,
) -> Result<Response, ErrorPage> {
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(&params).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<String>) -> 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<Vec<(i32, String, bool)>, 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<Waypoint>,
) -> Result<Response, ErrorPage> {
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::<Account>().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<String>,
#[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<T>`).
pub(crate) async fn new_post(
request: HttpRequest,
web::Query(waypoint): web::Query<Waypoint>,
raw: web::RawForm,
) -> Result<Response, ErrorPage> {
require_permission(&request, &UserPermission::AdminUsers)?;
let Ok(form) = serde_qs::from_bytes::<NewUserFormData>(&raw.0) else {
return Err(ErrorPage::BadRequest(Some(request)));
};
let allow_admin_field = request.extension::<Account>().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<i32> = 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<Lc>,
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::<Account>()
.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 `<form>` 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<i32>,
web::Query(waypoint): web::Query<Waypoint>,
) -> Result<Response, ErrorPage> {
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<i32>,
web::Query(waypoint): web::Query<Waypoint>,
web::Form(form): web::Form<EditUserFormData>,
) -> Result<Response, ErrorPage> {
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<i32>,
web::Query(waypoint): web::Query<Waypoint>,
) -> Result<Response, ErrorPage> {
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<Markup>)> = 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<i32>,
web::Query(waypoint): web::Query<Waypoint>,
) -> Result<Response, ErrorPage> {
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(&current).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<String>,
}
/// 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<i32>,
web::Query(waypoint): web::Query<Waypoint>,
raw: web::RawForm,
) -> Result<Response, ErrorPage> {
require_permission(&request, &UserPermission::AdminUsers)?;
require_permission(&request, &UserPermission::AssignRoles)?;
let Ok(form) = serde_qs::from_bytes::<UserRolesFormData>(&raw.0) else {
return Err(ErrorPage::BadRequest(Some(request)));
};
let role_ids: Vec<i32> = 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<i32>,
web::Query(waypoint): web::Query<Waypoint>,
web::Form(form): web::Form<StatusFormData>,
) -> Result<Response, ErrorPage> {
require_permission(&request, &UserPermission::AdminUsers)?;
require_permission(&request, &UserPermission::BlockAccounts)?;
let Some(account) = request.extension::<Account>().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 `<form>`. `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<i32>,
web::Query(waypoint): web::Query<Waypoint>,
web::Form(form): web::Form<AdminFormData>,
) -> Result<Response, ErrorPage> {
let Some(account) = request.extension::<Account>().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 `<form>` (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<i32>,
web::Query(waypoint): web::Query<Waypoint>,
) -> Result<Response, ErrorPage> {
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<i32>,
web::Query(waypoint): web::Query<Waypoint>,
web::Form(form): web::Form<AdminPasswordFormData>,
) -> Result<Response, ErrorPage> {
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())
}
}
}

View file

@ -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<Waypoint>,
) -> 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::<CurrentUser>()
.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<Waypoint>,
web::Form(form): web::Form<LoginFormData>,
) -> 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<Markup, ErrorPage> {
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<RegisterFormData>,
) -> 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<Markup, ErrorPage> {
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<PasswordResetFormData>,
) -> Result<Markup, ErrorPage> {
// 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<Markup, ErrorPage> {
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<PasswordResetConfirmFormData>,
) -> 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<RoutePath>, 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()
}

View file

@ -0,0 +1,319 @@
/*!
<div align="center">
<h1>PageTop User</h1>
<p>Gestión de usuarios, autenticación, roles y permisos para <strong>PageTop</strong>.</p>
</div>
## 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<ExtensionRef> {
vec![&pagetop_user::User]
}
fn configure_router(&self, router: Router) -> Router {
router.route("/dashboard", web::get(dashboard))
}
}
async fn dashboard(request: HttpRequest) -> Result<Markup, ErrorPage> {
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<ExtensionRef> {
vec![
&pagetop_admin::Admin,
&pagetop_seaorm::SeaORM,
&pagetop_htmx::Htmx,
]
}
fn actions(&self) -> Vec<ActionBox> {
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))
}
}

View file

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

View file

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

View file

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

View file

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

View file

@ -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::<Account>() else {
return;
};
if account.has_permission(perm) {
*granted = true;
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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<String, AuthError> {
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)
}
}

View file

@ -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::<Self>(), 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<PermissionRef>,
/// `(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<Item = PermissionRef> + '_ {
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<Item = PermissionRef> + 'a {
self.permissions
.iter()
.copied()
.filter(move |p| p.group() == group)
}
}
// **< Catálogo global >****************************************************************************
static PERMISSIONS: OnceLock<PermissionRegistry> = 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);
}
}

View file

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

View file

@ -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<Vec<RoleListItem>, 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<Paginated<RoleListItem>, 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<role::Model>) -> Result<Vec<RoleListItem>, AuthError> {
let role_ids: Vec<i32> = 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<i32, u64> = 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::Model, AuthError> {
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<Vec<String>, 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<i32, AuthError> {
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)
}

View file

@ -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<String>,
pub status: UserStatus,
pub roles: Vec<String>,
pub is_admin: bool,
}
pub(crate) struct UserListParams {
pub query: Option<String>,
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<Paginated<UserListItem>, 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<user::Model>) -> Result<Vec<UserListItem>, AuthError> {
let user_ids: Vec<i32> = 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<i32, Vec<String>> = 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::Model, AuthError> {
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<Vec<i32>, 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<Vec<role::Model>, 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<i32, AuthError> {
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<i32> = 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<i32>,
) -> 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<i32>) -> 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<bool, AuthError> {
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)
}

View file

@ -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<String> {
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<Account>)`.
///
/// 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<Account>) {
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<Account>)` 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<Account>) {
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<i32> = 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<String> = 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<String, DbErr> {
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(())
}

View file

@ -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<String, AuthError> {
// 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<i32, AuthError> {
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)
}