From 9e2fc2ccfa97a4695d3687646c0a8adfdd83ef0c Mon Sep 17 00:00:00 2001 From: Manuel Cillero Date: Sat, 26 Sep 2026 16:57:57 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20(user):=20A=C3=B1ade=20el=20men?= =?UTF-8?q?=C3=BA=20de=20cuenta=20AccountMenu?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- extensions/pagetop-admin/src/lib.rs | 5 +- extensions/pagetop-admin/src/registry.rs | 20 ++++- extensions/pagetop-user/src/component.rs | 4 +- .../src/component/account_menu.rs | 87 +++++++++++++++++++ .../pagetop-user/src/component/user_block.rs | 48 ---------- extensions/pagetop-user/src/handlers/auth.rs | 17 ++++ extensions/pagetop-user/src/lib.rs | 3 +- .../pagetop-user/src/locale/en-US/common.ftl | 47 +++++----- .../pagetop-user/src/locale/es-ES/common.ftl | 47 +++++----- extensions/pagetop-user/tests/account_menu.rs | 25 ++++++ 10 files changed, 207 insertions(+), 96 deletions(-) create mode 100644 extensions/pagetop-user/src/component/account_menu.rs delete mode 100644 extensions/pagetop-user/src/component/user_block.rs create mode 100644 extensions/pagetop-user/tests/account_menu.rs diff --git a/extensions/pagetop-admin/src/lib.rs b/extensions/pagetop-admin/src/lib.rs index 7e3d3e35..fbf6b053 100644 --- a/extensions/pagetop-admin/src/lib.rs +++ b/extensions/pagetop-admin/src/lib.rs @@ -100,8 +100,9 @@ use pagetop_seaorm::install_migrations; include_locales!(LOCALES_ADMIN); /// Ruta raíz del panel de administración. No es configurable: otras piezas del ecosistema (p. ej. -/// las rutas de `pagetop-user`, `/admin/user/...`) ya asumen este valor de forma literal. -pub(crate) const ADMIN_BASE_PATH: &str = "/admin"; +/// las rutas de `pagetop-user`, `/admin/user/...`) ya asumen este valor de forma literal. Es +/// pública para que otras extensiones enlacen al panel sin repetir la ruta. +pub const ADMIN_BASE_PATH: &str = "/admin"; pub mod action; pub mod component; diff --git a/extensions/pagetop-admin/src/registry.rs b/extensions/pagetop-admin/src/registry.rs index 5bc315fd..3adcfd50 100644 --- a/extensions/pagetop-admin/src/registry.rs +++ b/extensions/pagetop-admin/src/registry.rs @@ -269,6 +269,22 @@ pub fn global() -> &'static AdminRegistry { REGISTRY.get().expect("AdminRegistry not initialized") } +/// Devuelve `true` si el usuario de la petición actual tiene acceso a alguna página del panel, es +/// decir, si `/admin` no le devolvería un acceso denegado. +/// +/// Permite a otras extensiones (p. ej. el menú de cuenta de `pagetop-user`) ofrecer el enlace a +/// la administración sólo cuando procede. +pub fn can_access_admin(cx: &Context) -> bool { + let reg = global(); + reg.ordered_sections().into_iter().any(|section| { + section.is_visible(cx) + && reg + .pages_for_section(§ion.key) + .into_iter() + .any(|page| page.is_accessible(cx)) + }) +} + // **< Menú de administración >********************************************************************* /// Construye la barra de navegación de administración para el usuario de la petición actual: un @@ -308,7 +324,5 @@ pub fn admin_navbar(cx: &Context) -> Navbar { nav = nav.with_item(nav::Item::dropdown(menu)); } - Navbar::simple() - .with_position(navbar::Position::StickyTop) - .with_item(navbar::Item::nav(nav)) + Navbar::simple().with_item(navbar::Item::nav(nav)) } diff --git a/extensions/pagetop-user/src/component.rs b/extensions/pagetop-user/src/component.rs index 179ac779..6746e469 100644 --- a/extensions/pagetop-user/src/component.rs +++ b/extensions/pagetop-user/src/component.rs @@ -2,19 +2,19 @@ pub(crate) mod admin; +mod account_menu; mod login_form; mod password_confirm; mod password_reset_confirm_form; mod password_reset_form; mod register_form; -mod user_block; +pub use account_menu::{AccountMenu, account_menu}; pub use login_form::LoginForm; pub(crate) use password_confirm::PasswordConfirm; pub use password_reset_confirm_form::PasswordResetConfirmForm; pub use password_reset_form::PasswordResetForm; pub use register_form::RegisterForm; -pub use user_block::UserBlock; use pagetop::prelude::*; diff --git a/extensions/pagetop-user/src/component/account_menu.rs b/extensions/pagetop-user/src/component/account_menu.rs new file mode 100644 index 00000000..b2931274 --- /dev/null +++ b/extensions/pagetop-user/src/component/account_menu.rs @@ -0,0 +1,87 @@ +//! Menú de cuenta (iniciar y cerrar sesión). + +use pagetop::prelude::*; +use pagetop_admin::registry::can_access_admin; +use pagetop_htmx::prelude::*; + +use crate::config::SETTINGS; +use crate::{LOCALES_USER, LOGIN_PATH, LOGOUT_PATH, PROFILE_PATH, REGISTER_PATH}; + +/// Construye el menú de cuenta como un [`Nav`] justificado al final de su contenedor. +/// +/// Para un usuario anónimo ofrece un enlace de inicio de sesión y, si el registro está permitido, +/// otro para crear una cuenta. Para un usuario autenticado, un desplegable titulado con su nombre +/// visible, con su perfil, la administración (si tiene acceso a ella) y la opción de cerrar +/// sesión. Se construye con componentes del núcleo, sin depender de ningún tema, para +/// usarlo dentro de una [`Navbar`]. +/// +/// El cierre de sesión se envía con una petición htmx que requiere JavaScript en el navegador. +/// +/// # Ejemplo +/// +/// ```rust,no_run +/// use pagetop::prelude::*; +/// use pagetop_user::prelude::*; +/// +/// # fn build(cx: &Context) -> Navbar { +/// Navbar::simple().with_item(navbar::Item::nav(account_menu(cx))) +/// # } +/// ``` +pub fn account_menu(cx: &Context) -> Nav { + let nav = Nav::new().with_prop(FlexItem::push_end()); + let user = cx.current_user(); + if user.is_authenticated() { + let title = match user.display_name() { + Some(name) => Lc::n(name.to_string()), + None => Lc::t("menu-account", &LOCALES_USER), + }; + // El cierre de sesión requiere POST; el atributo `hx-post` viaja en el `
  • ` del elemento + // (ver `logout_post`, que responde con `HX-Redirect` a las peticiones htmx). + let logout = dropdown::Item::button(Lc::t("btn-logout", &LOCALES_USER)) + .with_prop(PropsOp::set(hx::POST, cx.route(LOGOUT_PATH).to_string())); + let mut menu = Dropdown::new() + .with_title(title) + .with_menu_end(true) + .with_item(dropdown::Item::link( + Lc::t("menu-profile", &LOCALES_USER), + cx.route(PROFILE_PATH), + )); + if can_access_admin(cx) { + menu = menu.with_item(dropdown::Item::link( + Lc::t("menu-admin", &LOCALES_USER), + cx.route(pagetop_admin::ADMIN_BASE_PATH), + )); + } + nav.with_item(nav::Item::dropdown( + menu.with_item(dropdown::Item::divider()).with_item(logout), + )) + } else { + let nav = nav.with_item(nav::Item::link( + Lc::t("menu-login", &LOCALES_USER), + cx.route(LOGIN_PATH), + )); + if SETTINGS.allow_registration { + nav.with_item(nav::Item::link( + Lc::t("link-register", &LOCALES_USER), + cx.route(REGISTER_PATH), + )) + } else { + nav + } + } +} + +/// Componente que renderiza el menú de cuenta, ver [`account_menu()`]. +#[derive(AutoDefault, Clone, Debug)] +pub struct AccountMenu; + +#[async_trait] +impl Component for AccountMenu { + fn new() -> Self { + Self + } + + async fn prepare(&self, cx: &mut Context) -> Result { + Ok(account_menu(cx).render(cx).await) + } +} diff --git a/extensions/pagetop-user/src/component/user_block.rs b/extensions/pagetop-user/src/component/user_block.rs deleted file mode 100644 index 62945e52..00000000 --- a/extensions/pagetop-user/src/component/user_block.rs +++ /dev/null @@ -1,48 +0,0 @@ -//! Componente de bloque de usuario (login/logout en la cabecera). - -use pagetop::prelude::*; - -use crate::config::SETTINGS; -use crate::{LOCALES_USER, LOGIN_PATH, LOGOUT_PATH, REGISTER_PATH}; - -#[derive(AutoDefault, Clone, Debug)] -pub struct UserBlock; - -#[async_trait] -impl Component for UserBlock { - fn new() -> Self { - Self - } - - async fn prepare(&self, cx: &mut Context) -> Result { - let user = cx.current_user(); - Ok(if user.is_authenticated() { - let display = user.display_name().unwrap_or("?"); - html! { - nav class="user-block" { - span class="user-name" { (display) } - " · " - form class="user-logout-inline" method="post" action=(cx.route(LOGOUT_PATH)) { - button type="submit" { - (Lc::t("btn-logout", &LOCALES_USER).using(cx)) - } - } - } - } - } else { - html! { - nav class="user-block" { - a href=(cx.route(LOGIN_PATH)) { - (Lc::t("btn-login", &LOCALES_USER).using(cx)) - } - @if SETTINGS.allow_registration { - " · " - a href=(cx.route(REGISTER_PATH)) { - (Lc::t("link-register", &LOCALES_USER).using(cx)) - } - } - } - } - }) - } -} diff --git a/extensions/pagetop-user/src/handlers/auth.rs b/extensions/pagetop-user/src/handlers/auth.rs index 351880cf..38306f04 100644 --- a/extensions/pagetop-user/src/handlers/auth.rs +++ b/extensions/pagetop-user/src/handlers/auth.rs @@ -3,6 +3,7 @@ use serde::Deserialize; use pagetop::prelude::*; +use pagetop_htmx::prelude::*; use crate::auth; use crate::component::{LoginForm, PasswordResetConfirmForm, PasswordResetForm, RegisterForm}; @@ -119,6 +120,22 @@ pub async fn logout_post(request: HttpRequest) -> Response { auth::logout(&sid).await.ok(); } let expiry = session::expiry_cookie(); + if request.is_htmx() { + // La petición sale de un elemento con `hx-post` sin destino: `HX-Redirect` fuerza una + // navegación real en vez de insertar la página de login dentro del elemento. + let mut response = HtmxResponse::empty() + .redirect(cx.route(LOGIN_PATH)) + .into_response(); + match expiry.parse() { + Ok(value) => { + response + .headers_mut() + .insert(web::http::header::SET_COOKIE, value); + } + Err(_) => trace::warn!("logout: invalid session expiry cookie, header discarded"), + } + return response; + } redirect_with_cookie(cx.route(LOGIN_PATH), &expiry) } diff --git a/extensions/pagetop-user/src/lib.rs b/extensions/pagetop-user/src/lib.rs index a4607e2c..9ef1138b 100644 --- a/extensions/pagetop-user/src/lib.rs +++ b/extensions/pagetop-user/src/lib.rs @@ -93,7 +93,8 @@ pub use permission::{DeclarePermissions, PermissionRegistry}; /// Prelude de `pagetop-user`. pub mod prelude { - pub use crate::component::{LoginForm, UserBlock}; + pub use crate::component::LoginForm; + pub use crate::component::{AccountMenu, account_menu}; pub use crate::error::AuthError; pub use crate::{Account, DeclarePermissions, UserStatus}; } diff --git a/extensions/pagetop-user/src/locale/en-US/common.ftl b/extensions/pagetop-user/src/locale/en-US/common.ftl index 5f7e0573..7ff7e288 100644 --- a/extensions/pagetop-user/src/locale/en-US/common.ftl +++ b/extensions/pagetop-user/src/locale/en-US/common.ftl @@ -34,6 +34,13 @@ link-register = Create an account link-forgot-password = Forgot your password? link-back-to-login = Back to sign in +# **< Account menu >** + +menu-account = Account +menu-admin = Administration +menu-login = Sign in +menu-profile = Profile + # **< Messages >** msg-password-reset-sent = @@ -42,16 +49,16 @@ msg-password-reset-sent = # **< Error messages >** -error-invalid-credentials = Invalid username or password. -error-account-blocked = Your account is blocked. Please contact the administrator. -error-account-pending = Please verify your email address before signing in. -error-account-locked = Too many failed attempts. Please try again later. -error-password-mismatch = Passwords do not match. -error-password-too-short = Password must be at least { $n } characters. -error-username-taken = This username is already taken. -error-email-taken = This email address is already registered. -error-token-invalid = This link is invalid or has expired. -error-internal = An unexpected error occurred. Please try again. +error-invalid-credentials = Invalid username or password. +error-account-blocked = Your account is blocked. Please contact the administrator. +error-account-pending = Please verify your email address before signing in. +error-account-locked = Too many failed attempts. Please try again later. +error-password-mismatch = Passwords do not match. +error-password-too-short = Password must be at least { $n } characters. +error-username-taken = This username is already taken. +error-email-taken = This email address is already registered. +error-token-invalid = This link is invalid or has expired. +error-internal = An unexpected error occurred. Please try again. # **< Account statuses >** @@ -123,8 +130,8 @@ btn-edit = Edit btn-manage-roles = Manage roles btn-manage-permissions = Manage permissions btn-reset-password = Reset password -btn-block = Block -btn-activate = Activate +btn-block = Block +btn-activate = Activate btn-grant-admin = Grant administrator btn-revoke-admin = Revoke administrator link-back-to-list = Back to list @@ -142,13 +149,13 @@ empty-roles-list = No roles found. # **< Admin: error messages >** -error-role-not-found = Role not found. -error-role-machine-name-taken = This machine name is already taken. -error-invalid-machine-name = Machine name may only contain lowercase letters, digits and underscores. +error-role-not-found = Role not found. +error-role-machine-name-taken = This machine name is already taken. +error-invalid-machine-name = Machine name may only contain lowercase letters, digits and underscores. error-role-locked = This role is a system role and cannot be modified. -error-role-in-use = This role has users assigned and cannot be deleted. -error-last-administrator = Cannot remove the last administrator. -error-cannot-block-self = You cannot block your own account. +error-role-in-use = This role has users assigned and cannot be deleted. +error-last-administrator = Cannot remove the last administrator. +error-cannot-block-self = You cannot block your own account. error-cannot-modify-own-admin-flag = You cannot grant or revoke your own unrestricted access. -error-user-not-found = User not found. -error-unknown-permission = Unknown permission key. +error-user-not-found = User not found. +error-unknown-permission = Unknown permission key. diff --git a/extensions/pagetop-user/src/locale/es-ES/common.ftl b/extensions/pagetop-user/src/locale/es-ES/common.ftl index 82ae0b3e..48e81615 100644 --- a/extensions/pagetop-user/src/locale/es-ES/common.ftl +++ b/extensions/pagetop-user/src/locale/es-ES/common.ftl @@ -34,6 +34,13 @@ link-register = Crear una cuenta link-forgot-password = ¿Olvidaste tu contraseña? link-back-to-login = Volver al inicio de sesión +# **< Menú de cuenta >** + +menu-account = Cuenta +menu-admin = Administración +menu-login = Iniciar sesión +menu-profile = Perfil + # **< Mensajes >** msg-password-reset-sent = @@ -42,16 +49,16 @@ msg-password-reset-sent = # **< Mensajes de error >** -error-invalid-credentials = Usuario o contraseña incorrectos. -error-account-blocked = Tu cuenta está bloqueada. Contacta con el administrador. -error-account-pending = Verifica tu dirección de email antes de iniciar sesión. -error-account-locked = Demasiados intentos fallidos. Inténtalo de nuevo más tarde. -error-password-mismatch = Las contraseñas no coinciden. -error-password-too-short = La contraseña debe tener al menos { $n } caracteres. -error-username-taken = Este nombre de usuario ya está en uso. -error-email-taken = Esta dirección de email ya está registrada. -error-token-invalid = Este enlace no es válido o ha caducado. -error-internal = Se ha producido un error inesperado. Inténtalo de nuevo. +error-invalid-credentials = Usuario o contraseña incorrectos. +error-account-blocked = Tu cuenta está bloqueada. Contacta con el administrador. +error-account-pending = Verifica tu dirección de email antes de iniciar sesión. +error-account-locked = Demasiados intentos fallidos. Inténtalo de nuevo más tarde. +error-password-mismatch = Las contraseñas no coinciden. +error-password-too-short = La contraseña debe tener al menos { $n } caracteres. +error-username-taken = Este nombre de usuario ya está en uso. +error-email-taken = Esta dirección de email ya está registrada. +error-token-invalid = Este enlace no es válido o ha caducado. +error-internal = Se ha producido un error inesperado. Inténtalo de nuevo. # **< Estados de cuenta >** @@ -123,8 +130,8 @@ btn-edit = Editar btn-manage-roles = Gestionar roles btn-manage-permissions = Gestionar permisos btn-reset-password = Restablecer contraseña -btn-block = Bloquear -btn-activate = Activar +btn-block = Bloquear +btn-activate = Activar btn-grant-admin = Conceder administrador btn-revoke-admin = Revocar administrador link-back-to-list = Volver al listado @@ -142,13 +149,13 @@ empty-roles-list = No se han encontrado roles. # **< Administración: mensajes de error >** -error-role-not-found = Rol no encontrado. -error-role-machine-name-taken = Este nombre técnico ya está en uso. -error-invalid-machine-name = El nombre técnico sólo admite minúsculas, dígitos y guiones bajos. +error-role-not-found = Rol no encontrado. +error-role-machine-name-taken = Este nombre técnico ya está en uso. +error-invalid-machine-name = El nombre técnico sólo admite minúsculas, dígitos y guiones bajos. error-role-locked = Este rol es de sistema y no se puede modificar. -error-role-in-use = Este rol tiene usuarios asignados y no se puede eliminar. -error-last-administrator = No se puede quitar al último administrador. -error-cannot-block-self = No puedes bloquear tu propia cuenta. +error-role-in-use = Este rol tiene usuarios asignados y no se puede eliminar. +error-last-administrator = No se puede quitar al último administrador. +error-cannot-block-self = No puedes bloquear tu propia cuenta. error-cannot-modify-own-admin-flag = No puedes conceder ni revocar tu propio acceso irrestricto. -error-user-not-found = Usuario no encontrado. -error-unknown-permission = Clave de permiso desconocida. +error-user-not-found = Usuario no encontrado. +error-unknown-permission = Clave de permiso desconocida. diff --git a/extensions/pagetop-user/tests/account_menu.rs b/extensions/pagetop-user/tests/account_menu.rs new file mode 100644 index 00000000..bc948d8a --- /dev/null +++ b/extensions/pagetop-user/tests/account_menu.rs @@ -0,0 +1,25 @@ +// Verifies the account menu offered to an anonymous user. The authenticated menu needs a logged-in +// user and an initialized admin registry, so it is not covered here. + +use pagetop_user::prelude::*; + +use pagetop::prelude::*; + +#[pagetop::test] +async fn anonymous_user_gets_the_login_and_register_links() { + let mut cx = Context::default(); + let html = account_menu(&cx).render(&mut cx).await.into_string(); + + assert!(html.contains(r#"href="/user/login""#)); + assert!(html.contains(r#"href="/user/register""#)); + assert!(!html.contains("dropdown")); +} + +#[pagetop::test] +async fn component_renders_the_same_menu_as_the_function() { + let mut cx = Context::default(); + let from_fn = account_menu(&cx).render(&mut cx).await.into_string(); + let from_component = AccountMenu::new().render(&mut cx).await.into_string(); + + assert_eq!(from_fn, from_component); +}