💄 (user): Muestra sólo permisos concedidos al rol

This commit is contained in:
Manuel Cillero 2026-10-05 00:10:53 +02:00
parent f337119219
commit 806ed9e0dc
6 changed files with 78 additions and 64 deletions

View file

@ -38,19 +38,6 @@
font-size: 0.9375rem; font-size: 0.9375rem;
} }
.user-admin-permission-list {
list-style: none;
padding-left: 0;
}
.user-admin-permission-granted {
font-weight: 600;
}
.user-admin-permission-missing {
color: var(--val-color--text--muted);
}
.user-form-error { .user-form-error {
padding: 0.625rem 0.875rem; padding: 0.625rem 0.875rem;
margin-bottom: 1rem; margin-bottom: 1rem;

View file

@ -88,6 +88,15 @@ impl Component for RoleTable {
} else { } else {
None None
}; };
let no_permissions_badge = if role.locked || role.has_permissions {
None
} else {
Some(
Badge::neutral(Lc::t("badge-no-permissions", &LOCALES_USER))
.render(cx)
.await,
)
};
table.alter_row( table.alter_row(
table::Row::new() table::Row::new()
@ -96,6 +105,7 @@ impl Component for RoleTable {
.with_cell(Html::with(move |_cx| { .with_cell(Html::with(move |_cx| {
html! { html! {
@if let Some(badge) = &system_badge { (badge) } @if let Some(badge) = &system_badge { (badge) }
@if let Some(badge) = &no_permissions_badge { (badge) }
} }
})) }))
.with_cell(role.user_count.to_string()) .with_cell(role.user_count.to_string())
@ -166,10 +176,9 @@ impl RoleTable {
self self
} }
// URL del listado con el estado actual (orden, página): es el valor que viaja como // URL del listado con el estado actual (orden, página): es el valor que viaja como `waypoint`
// `waypoint` en los enlaces de ver/editar/permisos, para poder volver exactamente a este // en los enlaces de ver/editar/permisos, para poder volver exactamente a este mismo estado. Se
// mismo estado. Se construye con `cx.route()` para que preserve el parámetro `lang` cuando // construye con `cx.route()` para que preserve el parámetro `lang` cuando corresponda.
// corresponda.
fn list_href(&self, cx: &Context) -> String { fn list_href(&self, cx: &Context) -> String {
cx.route(ADMIN_ROLES_PATH) cx.route(ADMIN_ROLES_PATH)
.alter_param("sort", self.sort().as_str()) .alter_param("sort", self.sort().as_str())
@ -219,8 +228,8 @@ fn label_cell(role: &RoleListItem, waypoint: &Waypoint) -> Html {
} }
// Construye la celda de acciones: gestionar permisos siempre, y editar/borrar sólo si el rol no // 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 // está bloqueado por el sistema. Devuelve un componente `Html` para que el marcado se genere cuando
// cuando `Table` renderice la celda, no al construir la fila. // `Table` renderice la celda, no al construir la fila.
async fn actions_cell( async fn actions_cell(
role: &RoleListItem, role: &RoleListItem,
waypoint: &Waypoint, waypoint: &Waypoint,
@ -235,10 +244,10 @@ async fn actions_cell(
let permissions_href = waypoint.append_to(cx.route(role_path(id, "permissions"))); let permissions_href = waypoint.append_to(cx.route(role_path(id, "permissions")));
// Los botones se renderizan aquí, no dentro del `Html::with()` de abajo: necesitan pasar por // Los botones se renderizan aquí, no dentro del `Html::with()` de abajo: necesitan pasar por su
// su propio ciclo de renderizado (`.render().await`) para que el tema activo los estilice // propio ciclo de renderizado (`.render().await`) para que el tema activo los estilice igual
// igual (ver `pagetop-bootsier::theme::bs::button`), incluida la traducción de `data-dialog-*` // (ver `pagetop-bootsier::theme::bs::button`), incluida la traducción de `data-dialog-*` que
// que usa el botón de borrado. // usa el botón de borrado.
let permissions_button = Button::anchor( let permissions_button = Button::anchor(
Lc::t("btn-manage-permissions", &LOCALES_USER), Lc::t("btn-manage-permissions", &LOCALES_USER),
permissions_href, permissions_href,
@ -258,8 +267,8 @@ async fn actions_cell(
.render(cx) .render(cx)
.await; .await;
// Viaja como query string para que, tanto si el borrado falla como si tiene éxito, la // Viaja como query string para que, tanto si el borrado falla como si tiene éxito, la tabla
// tabla vuelva a mostrarse en la misma página/orden en que estaba, en vez de reiniciarse. // vuelva a mostrarse en la misma página/orden en que estaba, en vez de reiniciarse.
let confirm_href = cx let confirm_href = cx
.route(role_path(id, "delete/confirm")) .route(role_path(id, "delete/confirm"))
.alter_param("sort", sort.as_str()) .alter_param("sort", sort.as_str())

View file

@ -348,31 +348,33 @@ async fn role_view_details(role: &role::Model, cx: &mut Context) -> Block {
.with_child(table) .with_child(table)
} }
// Un bloque por grupo del catálogo de permisos: cada permiso concedido se marca con la clase // Un bloque por grupo del catálogo con los permisos concedidos al rol; los grupos sin ninguno no se
// `user-admin-permission-granted` (negrita, vía CSS del tema); el resto con // muestran. Si el rol no tiene ningún permiso, un único bloque lo indica.
// `user-admin-permission-missing` (gris claro, vía CSS del tema).
fn role_view_permissions(groups: &PermissionGroups) -> Vec<Block> { fn role_view_permissions(groups: &PermissionGroups) -> Vec<Block> {
groups let blocks: Vec<Block> = groups
.iter() .iter()
.filter(|(_, perms)| perms.iter().any(|(_, _, granted)| *granted))
.map(|(group_label, perms)| { .map(|(group_label, perms)| {
let perms = perms.clone(); let mut table = Table::new().with_prop(PropsOp::add_classes("user-admin-table"));
for (_key, label, _) in perms.iter().filter(|(_, _, granted)| *granted) {
table = table.with_row(table::Row::new().with_cell(label.clone()));
}
Block::new() Block::new()
.with_title(group_label.clone()) .with_title(group_label.clone())
.with_child(Html::with(move |cx| { .with_child(table)
html! {
ul class="user-admin-permission-list" {
@for (_key, label, granted) in &perms {
@if *granted {
li class="user-admin-permission-granted" { (label.using(cx)) }
} @else {
li class="user-admin-permission-missing" { (label.using(cx)) }
}
}
}
}
}))
}) })
.collect() .collect();
if blocks.is_empty() {
return vec![
Block::new()
.with_title(Lc::t("title-admin-permissions", &LOCALES_USER))
.with_child(
Table::new().with_empty(Lc::t("empty-role-permissions", &LOCALES_USER)),
),
];
}
blocks
} }
// **< delete_post >******************************************************************************** // **< delete_post >********************************************************************************
@ -537,8 +539,8 @@ pub(crate) struct RolePermissionsFormData {
/// POST /admin/user/roles/{id}/permissions - Reemplaza el conjunto de permisos de un rol. /// 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`, /// Usa `RawForm` + `serde_qs` en lugar de `axum::extract::Form` (basado en `serde_urlencoded`, que
/// que no deserializa claves repetidas como `permission_keys=a&permission_keys=b` en un `Vec<T>`). /// no deserializa claves repetidas como `permission_keys=a&permission_keys=b` en un `Vec<T>`).
pub(crate) async fn permissions_post( pub(crate) async fn permissions_post(
request: HttpRequest, request: HttpRequest,
web::Path(id): web::Path<i32>, web::Path(id): web::Path<i32>,

View file

@ -140,14 +140,16 @@ link-back-to-list = Back to list
# **< Admin: confirmations and badges >** # **< Admin: confirmations and badges >**
confirm-delete-role = Delete this role? This cannot be undone. confirm-delete-role = Delete this role? This cannot be undone.
confirm-change-status = Change this account's status? confirm-change-status = Change this account's status?
confirm-grant-admin = Grant unrestricted access to this account? confirm-grant-admin = Grant unrestricted access to this account?
confirm-revoke-admin = Revoke this account's unrestricted access? confirm-revoke-admin = Revoke this account's unrestricted access?
badge-system-role = System badge-system-role = System
badge-admin = Administrator badge-admin = Administrator
empty-users-list = No users found. badge-no-permissions = No permissions
empty-roles-list = No roles found. empty-users-list = No users found.
empty-roles-list = No roles found.
empty-role-permissions = This role has no permissions.
# **< Admin: error messages >** # **< Admin: error messages >**

View file

@ -140,14 +140,16 @@ link-back-to-list = Volver al listado
# **< Administración: confirmaciones y distintivos >** # **< Administración: confirmaciones y distintivos >**
confirm-delete-role = ¿Eliminar este rol? Esta acción no se puede deshacer. confirm-delete-role = ¿Eliminar este rol? Esta acción no se puede deshacer.
confirm-change-status = ¿Cambiar el estado de esta cuenta? confirm-change-status = ¿Cambiar el estado de esta cuenta?
confirm-grant-admin = ¿Conceder acceso irrestricto a esta cuenta? confirm-grant-admin = ¿Conceder acceso irrestricto a esta cuenta?
confirm-revoke-admin = ¿Revocar el acceso irrestricto de esta cuenta? confirm-revoke-admin = ¿Revocar el acceso irrestricto de esta cuenta?
badge-system-role = Sistema badge-system-role = Sistema
badge-admin = Administrador badge-admin = Administrador
empty-users-list = No se han encontrado usuarios. badge-no-permissions = Sin permisos
empty-roles-list = No se han encontrado roles. empty-users-list = No se han encontrado usuarios.
empty-roles-list = No se han encontrado roles.
empty-role-permissions = Este rol no tiene permisos.
# **< Administración: mensajes de error >** # **< Administración: mensajes de error >**

View file

@ -1,6 +1,6 @@
//! Servicio de administración de roles: listado, CRUD y permisos. //! Servicio de administración de roles: listado, CRUD y permisos.
use std::collections::HashMap; use std::collections::{HashMap, HashSet};
use pagetop::prelude::*; use pagetop::prelude::*;
use pagetop_seaorm::db::{ use pagetop_seaorm::db::{
@ -47,6 +47,7 @@ pub(crate) struct RoleListItem {
pub label: String, pub label: String,
pub locked: bool, pub locked: bool,
pub user_count: u64, pub user_count: u64,
pub has_permissions: bool,
} }
pub(crate) struct RoleListParams { pub(crate) struct RoleListParams {
@ -104,7 +105,7 @@ pub(crate) async fn list_roles_page(
async fn role_items(roles: Vec<role::Model>) -> Result<Vec<RoleListItem>, AuthError> { 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 role_ids: Vec<i32> = roles.iter().map(|r| r.id).collect();
let counts: Vec<(i32, i64)> = user_role::Entity::find() let counts: Vec<(i32, i64)> = user_role::Entity::find()
.filter(user_role::Column::RoleId.is_in(role_ids)) .filter(user_role::Column::RoleId.is_in(role_ids.clone()))
.select_only() .select_only()
.column(user_role::Column::RoleId) .column(user_role::Column::RoleId)
.column_as(user_role::Column::RoleId.count(), "count") .column_as(user_role::Column::RoleId.count(), "count")
@ -116,11 +117,22 @@ async fn role_items(roles: Vec<role::Model>) -> Result<Vec<RoleListItem>, AuthEr
.into_iter() .into_iter()
.map(|(role_id, count)| (role_id, count as u64)) .map(|(role_id, count)| (role_id, count as u64))
.collect(); .collect();
let with_permissions: HashSet<i32> = role_permission::Entity::find()
.filter(role_permission::Column::RoleId.is_in(role_ids))
.select_only()
.column(role_permission::Column::RoleId)
.distinct()
.into_tuple()
.all(dbconn())
.await?
.into_iter()
.collect();
Ok(roles Ok(roles
.into_iter() .into_iter()
.map(|role| RoleListItem { .map(|role| RoleListItem {
user_count: counts_by_role.get(&role.id).copied().unwrap_or(0), user_count: counts_by_role.get(&role.id).copied().unwrap_or(0),
has_permissions: with_permissions.contains(&role.id),
id: role.id, id: role.id,
machine_name: role.machine_name, machine_name: role.machine_name,
label: role.label, label: role.label,