Compare commits

...

2 commits

17 changed files with 359 additions and 101 deletions

View file

@ -425,6 +425,11 @@ body {
.dropdown-menu.show {
display: block;
}
/* Anchors the menu's right edge to the button's right edge, so it opens inwards. */
.dropdown-menu-end {
left: auto;
right: 0;
}
/* Options and headers stay on one line, so the menu grows to fit the longest one. */
.dropdown-item {

View file

@ -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;

View file

@ -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(&section.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))
}

View file

@ -56,6 +56,11 @@ pub trait DropdownBootsier {
fn with_direction(self, direction: Direction) -> Self;
/// Configura la alineación horizontal (con posible comportamiento *responsive* adicional).
///
/// Si se deja en [`MenuAlign::Start`] (por defecto), se respeta [`with_menu_end()`]; cualquier
/// otra alineación prevalece sobre él.
///
/// [`with_menu_end()`]: pagetop::base::component::Dropdown::with_menu_end
fn with_menu_align(self, align: MenuAlign) -> Self;
/// Configura la posición del menú.
@ -135,9 +140,15 @@ pub(crate) async fn render(
let direction = dropdown
.props()
.extra_or(EXTRA_DIRECTION, Direction::default());
let menu_align = dropdown
let menu_align = match dropdown
.props()
.extra_or(EXTRA_MENU_ALIGN, MenuAlign::default());
.extra_or(EXTRA_MENU_ALIGN, MenuAlign::default())
{
// `with_menu_end()` del núcleo se traduce a `MenuAlign::End`, pero sólo si no hay una
// alineación propia diferente a la predeterminada (`Start`), que prevalece.
MenuAlign::Start if *dropdown.menu_end() => MenuAlign::End,
align => align,
};
let menu_position = dropdown
.props()
.extra_or(EXTRA_MENU_POSITION, MenuPosition::default());

View file

@ -169,6 +169,11 @@ pub(crate) async fn item_render(item: &Item, cx: &mut Context) -> Result<Markup,
.lookup(cx)
.unwrap_or_else(|| "Dropdown".to_string())
});
let menu_classes = if *dd.menu_end() {
"dropdown-menu dropdown-menu-end"
} else {
"dropdown-menu"
};
html! {
li (item.props().unpack(cx)) {
a
@ -181,7 +186,7 @@ pub(crate) async fn item_render(item: &Item, cx: &mut Context) -> Result<Markup,
{
(title)
}
ul class="dropdown-menu" {
ul class=(menu_classes) {
(items)
}
}

View file

@ -63,6 +63,59 @@ async fn dropdown_menu_align_combines_breakpoint_classes() {
);
}
async fn dropdown_menu_html(dropdown: bs::Dropdown) -> String {
let mut dropdown = dropdown
.with_title(Lc::n("Menu"))
.with_item(bs::dropdown::Item::link(Lc::n("Home"), "/"));
dropdown.render(&mut cx()).await.into_string()
}
#[pagetop::test]
async fn dropdown_menu_end_maps_to_the_end_alignment() {
let html = dropdown_menu_html(bs::Dropdown::new().with_menu_end(true)).await;
assert!(html.contains(r#"<ul class="dropdown-menu dropdown-menu-end">"#));
// Without `with_menu_end()` the menu keeps the default (start) alignment.
let html = dropdown_menu_html(bs::Dropdown::new()).await;
assert!(html.contains(r#"<ul class="dropdown-menu">"#));
assert!(!html.contains("dropdown-menu-end"));
}
#[pagetop::test]
async fn dropdown_menu_end_is_ignored_without_title() {
// A titleless menu is static (no button to align to), so it never gets the end alignment.
let mut dropdown = bs::Dropdown::new()
.with_menu_end(true)
.with_item(bs::dropdown::Item::link(Lc::n("Home"), "/"));
let html = dropdown.render(&mut cx()).await.into_string();
assert!(html.contains(r#"<ul class="dropdown-menu">"#));
assert!(!html.contains("dropdown-menu-end"));
}
#[pagetop::test]
async fn dropdown_own_menu_align_wins_over_menu_end() {
let html = dropdown_menu_html(
bs::Dropdown::new()
.with_menu_end(true)
.with_menu_align(bs::dropdown::MenuAlign::StartAt(Breakpoint::Md)),
)
.await;
assert!(html.contains("dropdown-menu-md-start"));
assert!(!html.contains("dropdown-menu-end"));
}
#[pagetop::test]
async fn nav_dropdown_menu_end_adds_the_end_class() {
let mut nav = bs::Nav::new().with_item(bs::nav::Item::dropdown(
bs::Dropdown::new()
.with_title(Lc::n("Menu"))
.with_menu_end(true)
.with_item(bs::dropdown::Item::link(Lc::n("Home"), "/")),
));
let html = nav.render(&mut cx()).await.into_string();
assert!(html.contains(r#"<ul class="dropdown-menu dropdown-menu-end">"#));
}
#[pagetop::test]
async fn navbar_expands_at_md_by_default_and_at_the_chosen_breakpoint() {
let item =

View file

@ -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::*;

View file

@ -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 `<li>` 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<Markup, ComponentError> {
Ok(account_menu(cx).render(cx).await)
}
}

View file

@ -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<Markup, ComponentError> {
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))
}
}
}
}
})
}
}

View file

@ -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)
}

View file

@ -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};
}

View file

@ -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 =

View file

@ -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 =

View file

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

View file

@ -37,6 +37,8 @@ pub struct Dropdown {
/// Devuelve el estilo visual del botón.
#[getters(copy)]
button_style: button::Style,
/// Devuelve si el menú se alinea al final del botón (por defecto, al inicio).
menu_end: bool,
/// Devuelve la lista de elementos del menú.
items: Children,
}
@ -124,12 +126,23 @@ impl Component for Dropdown {
(&title)
}
}
ul class="dropdown-menu" { (items) }
ul class=(self.menu_classes()) { (items) }
}
})
}
}
impl Dropdown {
// Clases del `<ul>` del menú, compartidas con el renderizado de `nav::Item::dropdown()`.
pub(crate) fn menu_classes(&self) -> &'static str {
if *self.menu_end() {
"dropdown-menu dropdown-menu-end"
} else {
"dropdown-menu"
}
}
}
#[builder_impl]
impl Dropdown {
// **< Dropdown BUILDER >***********************************************************************
@ -170,6 +183,16 @@ impl Dropdown {
self
}
/// Alinea el menú al final del botón (por defecto, al inicio).
///
/// Útil cuando el desplegable está pegado al borde final de la página, p. ej. el último de una
/// [`Navbar`], para que el menú se abra hacia el interior y no se salga de la pantalla. No
/// tiene efecto en un menú sin título, que es estático y no tiene botón al que alinearse.
pub fn with_menu_end(mut self, end: bool) -> Self {
self.menu_end = end;
self
}
/// Añade un nuevo elemento al menú o modifica la lista de elementos del menú con una operación
/// [`TypedOp`].
///

View file

@ -163,7 +163,7 @@ impl Component for Item {
{
(title)
}
ul class="dropdown-menu" {
ul class=(dd.menu_classes()) {
(items)
}
}

View file

@ -0,0 +1,50 @@
use pagetop::prelude::*;
fn dropdown() -> Dropdown {
Dropdown::new()
.with_title(Lc::n("Menu"))
.with_item(dropdown::Item::link(Lc::n("Home"), "/"))
}
#[pagetop::test]
async fn menu_is_aligned_to_the_start_by_default() {
let html = dropdown()
.render(&mut Context::default())
.await
.into_string();
assert!(html.contains(r#"<ul class="dropdown-menu">"#));
assert!(!html.contains("dropdown-menu-end"));
}
#[pagetop::test]
async fn menu_end_adds_the_end_class() {
let html = dropdown()
.with_menu_end(true)
.render(&mut Context::default())
.await
.into_string();
assert!(html.contains(r#"<ul class="dropdown-menu dropdown-menu-end">"#));
}
#[pagetop::test]
async fn menu_end_applies_to_a_dropdown_inside_a_nav() {
let mut nav = Nav::new().with_item(nav::Item::dropdown(dropdown().with_menu_end(true)));
let html = nav.render(&mut Context::default()).await.into_string();
assert!(html.contains(r#"<ul class="dropdown-menu dropdown-menu-end">"#));
}
#[pagetop::test]
async fn menu_end_is_ignored_without_title() {
let html = Dropdown::new()
.with_menu_end(true)
.with_item(dropdown::Item::link(Lc::n("Home"), "/"))
.render(&mut Context::default())
.await
.into_string();
assert!(html.contains(r#"<ul class="dropdown-menu">"#));
assert!(!html.contains("dropdown-menu-end"));
}