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);
+}