💄 (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;
}
.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 {
padding: 0.625rem 0.875rem;
margin-bottom: 1rem;

View file

@ -88,6 +88,15 @@ impl Component for RoleTable {
} else {
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::Row::new()
@ -96,6 +105,7 @@ impl Component for RoleTable {
.with_cell(Html::with(move |_cx| {
html! {
@if let Some(badge) = &system_badge { (badge) }
@if let Some(badge) = &no_permissions_badge { (badge) }
}
}))
.with_cell(role.user_count.to_string())
@ -166,10 +176,9 @@ impl RoleTable {
self
}
// URL del listado con el estado actual (orden, página): es el valor que viaja como
// `waypoint` en los enlaces de ver/editar/permisos, para poder volver exactamente a este
// mismo estado. Se construye con `cx.route()` para que preserve el parámetro `lang` cuando
// corresponda.
// URL del listado con el estado actual (orden, página): es el valor que viaja como `waypoint`
// en los enlaces de ver/editar/permisos, para poder volver exactamente a este mismo estado. Se
// construye con `cx.route()` para que preserve el parámetro `lang` cuando corresponda.
fn list_href(&self, cx: &Context) -> String {
cx.route(ADMIN_ROLES_PATH)
.alter_param("sort", self.sort().as_str())
@ -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
// está bloqueado por el sistema. Devuelve un componente `Html` para que el marcado se genere
// cuando `Table` renderice la celda, no al construir la fila.
// está bloqueado por el sistema. Devuelve un componente `Html` para que el marcado se genere cuando
// `Table` renderice la celda, no al construir la fila.
async fn actions_cell(
role: &RoleListItem,
waypoint: &Waypoint,
@ -235,10 +244,10 @@ async fn actions_cell(
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
// su propio ciclo de renderizado (`.render().await`) para que el tema activo los estilice
// igual (ver `pagetop-bootsier::theme::bs::button`), incluida la traducción de `data-dialog-*`
// que usa el botón de borrado.
// Los botones se renderizan aquí, no dentro del `Html::with()` de abajo: necesitan pasar por su
// propio ciclo de renderizado (`.render().await`) para que el tema activo los estilice igual
// (ver `pagetop-bootsier::theme::bs::button`), incluida la traducción de `data-dialog-*` que
// usa el botón de borrado.
let permissions_button = Button::anchor(
Lc::t("btn-manage-permissions", &LOCALES_USER),
permissions_href,
@ -258,8 +267,8 @@ async fn actions_cell(
.render(cx)
.await;
// Viaja como query string para que, tanto si el borrado falla como si tiene éxito, la
// tabla vuelva a mostrarse en la misma página/orden en que estaba, en vez de reiniciarse.
// Viaja como query string para que, tanto si el borrado falla como si tiene éxito, la tabla
// vuelva a mostrarse en la misma página/orden en que estaba, en vez de reiniciarse.
let confirm_href = cx
.route(role_path(id, "delete/confirm"))
.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)
}
// 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).
// Un bloque por grupo del catálogo con los permisos concedidos al rol; los grupos sin ninguno no se
// muestran. Si el rol no tiene ningún permiso, un único bloque lo indica.
fn role_view_permissions(groups: &PermissionGroups) -> Vec<Block> {
groups
let blocks: Vec<Block> = groups
.iter()
.filter(|(_, perms)| perms.iter().any(|(_, _, granted)| *granted))
.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()
.with_title(group_label.clone())
.with_child(Html::with(move |cx| {
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)) }
}
}
}
}
}))
.with_child(table)
})
.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 >********************************************************************************
@ -537,8 +539,8 @@ pub(crate) struct RolePermissionsFormData {
/// 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>`).
/// 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>,

View file

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

View file

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

View file

@ -1,6 +1,6 @@
//! Servicio de administración de roles: listado, CRUD y permisos.
use std::collections::HashMap;
use std::collections::{HashMap, HashSet};
use pagetop::prelude::*;
use pagetop_seaorm::db::{
@ -47,6 +47,7 @@ pub(crate) struct RoleListItem {
pub label: String,
pub locked: bool,
pub user_count: u64,
pub has_permissions: bool,
}
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> {
let role_ids: Vec<i32> = roles.iter().map(|r| r.id).collect();
let counts: Vec<(i32, i64)> = user_role::Entity::find()
.filter(user_role::Column::RoleId.is_in(role_ids))
.filter(user_role::Column::RoleId.is_in(role_ids.clone()))
.select_only()
.column(user_role::Column::RoleId)
.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()
.map(|(role_id, count)| (role_id, count as u64))
.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
.into_iter()
.map(|role| RoleListItem {
user_count: counts_by_role.get(&role.id).copied().unwrap_or(0),
has_permissions: with_permissions.contains(&role.id),
id: role.id,
machine_name: role.machine_name,
label: role.label,