✨ 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
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()
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue