🔒️ (admin): Deniega /admin sin páginas accesibles

`render_sections()` devuelve `Option<Container>`, con `None` si no queda
ninguna sección visible con páginas accesibles. `dashboard()` y
`section_page()` responden con `AccessDenied` en ese caso, sin crear un
permiso nuevo.
This commit is contained in:
Manuel Cillero 2026-09-26 07:43:22 +02:00
parent 3f3634fdab
commit f265f217bd

View file

@ -4,27 +4,25 @@ use pagetop::prelude::*;
use crate::LOCALES_ADMIN; use crate::LOCALES_ADMIN;
use crate::component::{AdminFrame, ConfigForm}; use crate::component::{AdminFrame, ConfigForm};
use crate::registry::{self, AdminPageKind, AdminPermission, AdminSection}; use crate::registry::{self, AdminPageKind, AdminSection};
use crate::settings; use crate::settings;
// **< Dashboard >********************************************************************************** // **< Dashboard >**********************************************************************************
/// GET /admin - Dashboard de administración: lista todas las secciones disponibles. /// GET /admin - Dashboard de administración: lista todas las secciones disponibles.
///
/// Devuelve [`ErrorPage::AccessDenied`] si el usuario actual no tiene acceso a ninguna página.
pub async fn dashboard(request: HttpRequest) -> Result<Markup, ErrorPage> { pub async fn dashboard(request: HttpRequest) -> Result<Markup, ErrorPage> {
let reg = registry::global();
let cx = Context::new(request.clone()); let cx = Context::new(request.clone());
let sections = reg.ordered_sections(); let sections = registry::global().ordered_sections();
let title = Lc::t("dashboard-title", &LOCALES_ADMIN); let title = Lc::t("dashboard-title", &LOCALES_ADMIN);
let content = render_sections(&cx, &sections); let content = render_sections(&cx, &sections)
.ok_or_else(|| ErrorPage::AccessDenied(Some(request.clone())))?;
Page::admin(request) Page::admin(request)
.with_title(title.clone()) .with_title(title.clone())
.with_child( .with_child(AdminFrame::new().with_title(title).with_child(content))
AdminFrame::new()
.with_title(title)
.with_child(Html::with(move |_| content.clone())),
)
.render() .render()
.await .await
} }
@ -37,6 +35,9 @@ pub async fn dashboard(request: HttpRequest) -> Result<Markup, ErrorPage> {
/// con ninguna [`AdminPage`](crate::registry::AdminPage) registrada explícitamente por una /// con ninguna [`AdminPage`](crate::registry::AdminPage) registrada explícitamente por una
/// extensión; si una extensión reclama esa ruta con su propia página, esa página gana y este /// extensión; si una extensión reclama esa ruta con su propia página, esa página gana y este
/// handler nunca llega a montarse para ella. /// handler nunca llega a montarse para ella.
///
/// Devuelve [`ErrorPage::AccessDenied`] si el usuario actual no tiene acceso a ninguna de sus
/// páginas.
pub async fn section_page(request: HttpRequest) -> Result<Markup, ErrorPage> { pub async fn section_page(request: HttpRequest) -> Result<Markup, ErrorPage> {
let path = request.path().to_owned(); let path = request.path().to_owned();
let reg = registry::global(); let reg = registry::global();
@ -46,22 +47,14 @@ pub async fn section_page(request: HttpRequest) -> Result<Markup, ErrorPage> {
.find(|s| s.path == path) .find(|s| s.path == path)
.ok_or_else(|| ErrorPage::NotFound(Some(request.clone())))?; .ok_or_else(|| ErrorPage::NotFound(Some(request.clone())))?;
require_permission(
&request,
section.permission.unwrap_or(&AdminPermission::Access),
)?;
let cx = Context::new(request.clone()); let cx = Context::new(request.clone());
let title = section.title.clone(); let title = section.title.clone();
let content = render_sections(&cx, &[section]); let content = render_sections(&cx, &[section])
.ok_or_else(|| ErrorPage::AccessDenied(Some(request.clone())))?;
Page::admin(request) Page::admin(request)
.with_title(title.clone()) .with_title(title.clone())
.with_child( .with_child(AdminFrame::new().with_title(title).with_child(content))
AdminFrame::new()
.with_title(title)
.with_child(Html::with(move |_| content.clone())),
)
.render() .render()
.await .await
} }
@ -69,29 +62,45 @@ pub async fn section_page(request: HttpRequest) -> Result<Markup, ErrorPage> {
// **< Render compartido >************************************************************************** // **< Render compartido >**************************************************************************
// Rejilla de secciones con sus páginas (título + descripción). La usan tanto `dashboard()` (todas // Rejilla de secciones con sus páginas (título + descripción). La usan tanto `dashboard()` (todas
// las secciones) como `section_page()` (una sola), filtrando siempre por permiso del usuario actual. // las secciones) como `section_page()` (una sola), filtrando siempre por permiso del usuario
fn render_sections(cx: &Context, sections: &[&AdminSection]) -> Markup { // actual. Devuelve `None` si no queda ninguna sección visible con páginas accesibles.
fn render_sections(cx: &Context, candidates: &[&'static AdminSection]) -> Option<Container> {
let reg = registry::global(); let reg = registry::global();
html! { let sections_with_pages: Vec<_> = candidates
div.admin-dashboard-sections { .iter()
@for section in sections { .copied()
@if section.is_visible(cx) { .filter(|section| section.is_visible(cx))
@let pages: Vec<_> = reg .map(|section| {
let pages: Vec<_> = reg
.pages_for_section(&section.key) .pages_for_section(&section.key)
.into_iter() .into_iter()
.filter(|p| p.is_accessible(cx)) .filter(|p| p.is_accessible(cx))
.collect(); .collect();
@if !pages.is_empty() { (section, pages)
div.admin-dashboard-section { })
h2.admin-dashboard-section-title { .filter(|(_, pages)| !pages.is_empty())
.collect();
if sections_with_pages.is_empty() {
return None;
}
let mut sections = Container::new().with_prop(PropsOp::add_classes("admin-dashboard-sections"));
for (section, pages) in sections_with_pages {
sections = sections.with_child(
Container::new()
.with_prop(PropsOp::add_classes("admin-dashboard-section"))
.with_child(Html::with(move |cx| {
html! {
h2 class="admin-dashboard-section-title" {
a href=(cx.route(section.path.as_str())) { (section.title.using(cx)) } a href=(cx.route(section.path.as_str())) { (section.title.using(cx)) }
} }
ul.admin-dashboard-section-links { ul class="admin-dashboard-section-links" {
@for page in pages { @for page in &pages {
li.admin-dashboard-link { li class="admin-dashboard-link" {
a href=(cx.route(page.path.as_str())) { (page.title.using(cx)) } a href=(cx.route(page.path.as_str())) {
(page.title.using(cx))
}
@if let Some(description) = &page.description { @if let Some(description) = &page.description {
span.admin-dashboard-link-desc { span class="admin-dashboard-link-desc" {
" - " (description.using(cx)) " - " (description.using(cx))
} }
} }
@ -99,11 +108,10 @@ fn render_sections(cx: &Context, sections: &[&AdminSection]) -> Markup {
} }
} }
} }
})),
);
} }
} Some(sections)
}
}
}
} }
// **< Config form - GET >************************************************************************** // **< Config form - GET >**************************************************************************