✨ 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:
parent
043873a954
commit
def0513246
57 changed files with 7409 additions and 0 deletions
34
extensions/pagetop-user/Cargo.toml
Normal file
34
extensions/pagetop-user/Cargo.toml
Normal 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
|
||||||
201
extensions/pagetop-user/LICENSE-APACHE
Normal file
201
extensions/pagetop-user/LICENSE-APACHE
Normal 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.
|
||||||
21
extensions/pagetop-user/LICENSE-MIT
Normal file
21
extensions/pagetop-user/LICENSE-MIT
Normal 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.
|
||||||
97
extensions/pagetop-user/src/account.rs
Normal file
97
extensions/pagetop-user/src/account.rs
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
276
extensions/pagetop-user/src/auth.rs
Normal file
276
extensions/pagetop-user/src/auth.rs
Normal 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),
|
||||||
|
}
|
||||||
|
}
|
||||||
28
extensions/pagetop-user/src/component.rs
Normal file
28
extensions/pagetop-user/src/component.rs
Normal 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! {},
|
||||||
|
})
|
||||||
|
}
|
||||||
58
extensions/pagetop-user/src/component/admin.rs
Normal file
58
extensions/pagetop-user/src/component/admin.rs
Normal 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)
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
151
extensions/pagetop-user/src/component/admin/role_form.rs
Normal file
151
extensions/pagetop-user/src/component/admin/role_form.rs
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
305
extensions/pagetop-user/src/component/admin/role_table.rs
Normal file
305
extensions/pagetop-user/src/component/admin/role_table.rs
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
198
extensions/pagetop-user/src/component/admin/user_form.rs
Normal file
198
extensions/pagetop-user/src/component/admin/user_form.rs
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
304
extensions/pagetop-user/src/component/admin/user_table.rs
Normal file
304
extensions/pagetop-user/src/component/admin/user_table.rs
Normal 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",
|
||||||
|
}
|
||||||
|
}
|
||||||
112
extensions/pagetop-user/src/component/login_form.rs
Normal file
112
extensions/pagetop-user/src/component/login_form.rs
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
67
extensions/pagetop-user/src/component/password_confirm.rs
Normal file
67
extensions/pagetop-user/src/component/password_confirm.rs
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
58
extensions/pagetop-user/src/component/password_reset_form.rs
Normal file
58
extensions/pagetop-user/src/component/password_reset_form.rs
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
54
extensions/pagetop-user/src/component/register_form.rs
Normal file
54
extensions/pagetop-user/src/component/register_form.rs
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
48
extensions/pagetop-user/src/component/user_block.rs
Normal file
48
extensions/pagetop-user/src/component/user_block.rs
Normal 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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
136
extensions/pagetop-user/src/config.rs
Normal file
136
extensions/pagetop-user/src/config.rs
Normal 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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
113
extensions/pagetop-user/src/demo.rs
Normal file
113
extensions/pagetop-user/src/demo.rs
Normal 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(())
|
||||||
|
}
|
||||||
8
extensions/pagetop-user/src/entity.rs
Normal file
8
extensions/pagetop-user/src/entity.rs
Normal 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;
|
||||||
40
extensions/pagetop-user/src/entity/role.rs
Normal file
40
extensions/pagetop-user/src/entity/role.rs
Normal 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 {}
|
||||||
32
extensions/pagetop-user/src/entity/role_permission.rs
Normal file
32
extensions/pagetop-user/src/entity/role_permission.rs
Normal 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 {}
|
||||||
34
extensions/pagetop-user/src/entity/session.rs
Normal file
34
extensions/pagetop-user/src/entity/session.rs
Normal 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 {}
|
||||||
58
extensions/pagetop-user/src/entity/user.rs
Normal file
58
extensions/pagetop-user/src/entity/user.rs
Normal 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 {}
|
||||||
42
extensions/pagetop-user/src/entity/user_role.rs
Normal file
42
extensions/pagetop-user/src/entity/user_role.rs
Normal 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 {}
|
||||||
37
extensions/pagetop-user/src/entity/user_token.rs
Normal file
37
extensions/pagetop-user/src/entity/user_token.rs
Normal 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 {}
|
||||||
69
extensions/pagetop-user/src/error.rs
Normal file
69
extensions/pagetop-user/src/error.rs
Normal 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),
|
||||||
|
}
|
||||||
5
extensions/pagetop-user/src/handlers.rs
Normal file
5
extensions/pagetop-user/src/handlers.rs
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
//! Handlers HTTP de `pagetop-user`.
|
||||||
|
|
||||||
|
pub(crate) mod account;
|
||||||
|
pub(crate) mod admin;
|
||||||
|
pub(crate) mod auth;
|
||||||
137
extensions/pagetop-user/src/handlers/account.rs
Normal file
137
extensions/pagetop-user/src/handlers/account.rs
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
}
|
||||||
59
extensions/pagetop-user/src/handlers/admin.rs
Normal file
59
extensions/pagetop-user/src/handlers/admin.rs
Normal 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)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
46
extensions/pagetop-user/src/handlers/admin/permissions.rs
Normal file
46
extensions/pagetop-user/src/handlers/admin/permissions.rs
Normal 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())
|
||||||
|
}
|
||||||
584
extensions/pagetop-user/src/handlers/admin/roles.rs
Normal file
584
extensions/pagetop-user/src/handlers/admin/roles.rs
Normal 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(¶ms).await {
|
||||||
|
Ok(result) => result,
|
||||||
|
Err(_) => return Err(ErrorPage::InternalError(Some(request))),
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut table = RoleTable::new()
|
||||||
|
.with_items(result.items)
|
||||||
|
.with_sort(params.sort)
|
||||||
|
.with_dir(params.dir)
|
||||||
|
.with_page(result.page)
|
||||||
|
.with_per_page(result.per_page)
|
||||||
|
.with_total(result.total);
|
||||||
|
|
||||||
|
if request.is_htmx() {
|
||||||
|
let mut cx = Context::admin(request);
|
||||||
|
Ok(HtmxResponse::new(table.render(&mut cx).await).into_response())
|
||||||
|
} else {
|
||||||
|
let title = Lc::t("title-admin-roles", &LOCALES_USER);
|
||||||
|
Ok(Page::admin(request)
|
||||||
|
.with_title(title.clone())
|
||||||
|
.with_child(frame(title).with_child(table))
|
||||||
|
.render()
|
||||||
|
.await
|
||||||
|
.into_response())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// **< new_get / new_post >*************************************************************************
|
||||||
|
|
||||||
|
/// GET /admin/user/roles/new - Formulario de alta de rol.
|
||||||
|
pub(crate) async fn new_get(
|
||||||
|
request: HttpRequest,
|
||||||
|
web::Query(waypoint): web::Query<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(¶ms)
|
||||||
|
.await
|
||||||
|
.unwrap_or_default();
|
||||||
|
let message = result.err().as_ref().map(map_auth_error);
|
||||||
|
let mut table = RoleTable::new()
|
||||||
|
.with_items(page_result.items)
|
||||||
|
.with_sort(params.sort)
|
||||||
|
.with_dir(params.dir)
|
||||||
|
.with_page(page_result.page)
|
||||||
|
.with_per_page(page_result.per_page)
|
||||||
|
.with_total(page_result.total)
|
||||||
|
.with_message(message);
|
||||||
|
let mut cx = Context::new(request);
|
||||||
|
Ok(HtmxResponse::new(table.render(&mut cx).await).into_response())
|
||||||
|
} else {
|
||||||
|
let cx = Context::new(request);
|
||||||
|
match result {
|
||||||
|
Ok(()) => {
|
||||||
|
let target = cx
|
||||||
|
.route(ADMIN_ROLES_PATH)
|
||||||
|
.alter_param(
|
||||||
|
"sort",
|
||||||
|
RoleSortField::from_query(query.sort.as_deref()).as_str(),
|
||||||
|
)
|
||||||
|
.alter_param("dir", SortDir::from_query(query.dir.as_deref()))
|
||||||
|
.alter_param("page", query.page.unwrap_or(1).max(1).to_string())
|
||||||
|
.to_string();
|
||||||
|
Ok(Redirect::see_other(target).into_response())
|
||||||
|
}
|
||||||
|
Err(_) => Err(ErrorPage::BadRequest(cx.request().cloned())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// **< delete_confirm_get >*************************************************************************
|
||||||
|
|
||||||
|
/// GET /admin/user/roles/{id}/delete/confirm - Botón de confirmación de borrado para esta fila,
|
||||||
|
/// insertado vía htmx en el diálogo de confirmación compartido por toda la tabla (ver
|
||||||
|
/// `RoleTable`), con la URL de borrado de este rol ya incrustada.
|
||||||
|
///
|
||||||
|
/// Propaga `sort`/`dir`/`page` (recibidos como query string desde la propia fila) hacia la URL de
|
||||||
|
/// borrado, para que `delete_post` pueda volver a mostrar la misma página tras el borrado, en vez
|
||||||
|
/// de reiniciar siempre al listado por defecto.
|
||||||
|
pub(crate) async fn delete_confirm_get(
|
||||||
|
request: HttpRequest,
|
||||||
|
web::Path(id): web::Path<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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
891
extensions/pagetop-user/src/handlers/admin/users.rs
Normal file
891
extensions/pagetop-user/src/handlers/admin/users.rs
Normal 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(¶ms).await {
|
||||||
|
Ok(result) => result,
|
||||||
|
Err(_) => return Err(ErrorPage::InternalError(Some(request))),
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut table = UserTable::new()
|
||||||
|
.with_items(result.items)
|
||||||
|
.with_sort(params.sort)
|
||||||
|
.with_dir(params.dir)
|
||||||
|
.with_query(query.q.clone())
|
||||||
|
.with_page(result.page)
|
||||||
|
.with_per_page(result.per_page)
|
||||||
|
.with_total(result.total);
|
||||||
|
|
||||||
|
if request.is_htmx() {
|
||||||
|
let mut cx = Context::admin(request);
|
||||||
|
Ok(HtmxResponse::new(table.render(&mut cx).await).into_response())
|
||||||
|
} else {
|
||||||
|
let title = Lc::t("title-admin-users", &LOCALES_USER);
|
||||||
|
Ok(Page::admin(request)
|
||||||
|
.with_title(title.clone())
|
||||||
|
.with_child(
|
||||||
|
frame(title)
|
||||||
|
.with_child(search_bar(query.q))
|
||||||
|
.with_child(table),
|
||||||
|
)
|
||||||
|
.render()
|
||||||
|
.await
|
||||||
|
.into_response())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// **< search_bar >*********************************************************************************
|
||||||
|
|
||||||
|
fn search_bar(current_query: Option<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(¤t).await {
|
||||||
|
Ok(roles) => roles,
|
||||||
|
Err(_) => return Err(ErrorPage::InternalError(Some(request))),
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut page = Page::admin(request);
|
||||||
|
let back_href = waypoint.or(page.context().route(ADMIN_USERS_PATH));
|
||||||
|
|
||||||
|
let title = Lc::t("title-admin-user-roles", &LOCALES_USER);
|
||||||
|
Ok(page
|
||||||
|
.with_title(title.clone())
|
||||||
|
.with_child(
|
||||||
|
frame(title)
|
||||||
|
.with_child(
|
||||||
|
UserRolesForm::new()
|
||||||
|
.with_user_id(id)
|
||||||
|
.with_roles(roles)
|
||||||
|
.with_waypoint(waypoint),
|
||||||
|
)
|
||||||
|
.with_child(back_link(back_href)),
|
||||||
|
)
|
||||||
|
.render()
|
||||||
|
.await
|
||||||
|
.into_response())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub(crate) struct UserRolesFormData {
|
||||||
|
#[serde(default)]
|
||||||
|
role_ids: Vec<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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
350
extensions/pagetop-user/src/handlers/auth.rs
Normal file
350
extensions/pagetop-user/src/handlers/auth.rs
Normal 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()
|
||||||
|
}
|
||||||
319
extensions/pagetop-user/src/lib.rs
Normal file
319
extensions/pagetop-user/src/lib.rs
Normal 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))
|
||||||
|
}
|
||||||
|
}
|
||||||
154
extensions/pagetop-user/src/locale/en-US/common.ftl
Normal file
154
extensions/pagetop-user/src/locale/en-US/common.ftl
Normal 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.
|
||||||
21
extensions/pagetop-user/src/locale/en-US/permissions.ftl
Normal file
21
extensions/pagetop-user/src/locale/en-US/permissions.ftl
Normal 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
|
||||||
154
extensions/pagetop-user/src/locale/es-ES/common.ftl
Normal file
154
extensions/pagetop-user/src/locale/es-ES/common.ftl
Normal 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.
|
||||||
21
extensions/pagetop-user/src/locale/es-ES/permissions.ftl
Normal file
21
extensions/pagetop-user/src/locale/es-ES/permissions.ftl
Normal 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
|
||||||
49
extensions/pagetop-user/src/middleware.rs
Normal file
49
extensions/pagetop-user/src/middleware.rs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
8
extensions/pagetop-user/src/migration.rs
Normal file
8
extensions/pagetop-user/src/migration.rs
Normal 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;
|
||||||
|
|
@ -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,
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
}
|
||||||
73
extensions/pagetop-user/src/password.rs
Normal file
73
extensions/pagetop-user/src/password.rs
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
257
extensions/pagetop-user/src/permission.rs
Normal file
257
extensions/pagetop-user/src/permission.rs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
4
extensions/pagetop-user/src/service.rs
Normal file
4
extensions/pagetop-user/src/service.rs
Normal 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;
|
||||||
285
extensions/pagetop-user/src/service/role_admin.rs
Normal file
285
extensions/pagetop-user/src/service/role_admin.rs
Normal 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)
|
||||||
|
}
|
||||||
418
extensions/pagetop-user/src/service/user_admin.rs
Normal file
418
extensions/pagetop-user/src/service/user_admin.rs
Normal 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)
|
||||||
|
}
|
||||||
224
extensions/pagetop-user/src/session.rs
Normal file
224
extensions/pagetop-user/src/session.rs
Normal 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(())
|
||||||
|
}
|
||||||
125
extensions/pagetop-user/src/token.rs
Normal file
125
extensions/pagetop-user/src/token.rs
Normal 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)
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue