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

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

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

View file

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