From 35f2b661619ed3f5444d2790753250ca1a49fbf0 Mon Sep 17 00:00:00 2001 From: Manuel Cillero Date: Sat, 3 Oct 2026 12:25:15 +0200 Subject: [PATCH 1/6] =?UTF-8?q?=F0=9F=92=84=20(user):=20Usa=20componentes?= =?UTF-8?q?=20base=20en=20Administraci=C3=B3n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sustituye marcado propio y clases sin estilo por componentes de base, para que la interfaz se vea igual con cualquier tema. --- .../src/component/admin/role_table.rs | 9 +--- .../src/component/admin/user_table.rs | 16 ++---- .../pagetop-user/src/handlers/account.rs | 6 +-- .../src/handlers/admin/permissions.rs | 33 +++++------- .../pagetop-user/src/handlers/admin/roles.rs | 3 +- .../pagetop-user/src/handlers/admin/users.rs | 50 +++++++------------ 6 files changed, 39 insertions(+), 78 deletions(-) diff --git a/extensions/pagetop-user/src/component/admin/role_table.rs b/extensions/pagetop-user/src/component/admin/role_table.rs index 1516f975..aaa6c68d 100644 --- a/extensions/pagetop-user/src/component/admin/role_table.rs +++ b/extensions/pagetop-user/src/component/admin/role_table.rs @@ -81,8 +81,7 @@ impl Component for RoleTable { for role in self.items() { let system_badge = if role.locked { Some( - Badge::labeled(Lc::t("badge-system-role", &LOCALES_USER)) - .with_prop(PropsOp::add_classes("user-admin-badge-system")) + Badge::warning(Lc::t("badge-system-role", &LOCALES_USER)) .render(cx) .await, ) @@ -111,11 +110,7 @@ impl Component for RoleTable { Ok(html! { div (self.props().unpack(cx)) { - div class="user-admin-actions" { - a href=(new_href) { - (Lc::t("btn-create-role", &LOCALES_USER).using(cx)) - } - } + (Button::anchor(Lc::t("btn-create-role", &LOCALES_USER), new_href).render(cx).await) @if let Some(message) = self.message() { div class="user-form-error" role="alert" { (message.clone().using(cx)) } } diff --git a/extensions/pagetop-user/src/component/admin/user_table.rs b/extensions/pagetop-user/src/component/admin/user_table.rs index b0e64cc1..5843f7ef 100644 --- a/extensions/pagetop-user/src/component/admin/user_table.rs +++ b/extensions/pagetop-user/src/component/admin/user_table.rs @@ -96,11 +96,7 @@ impl Component for UserTable { Ok(html! { div (self.props().unpack(cx)) { - div class="user-admin-actions" { - a href=(new_href) { - (Lc::t("btn-create-user", &LOCALES_USER).using(cx)) - } - } + (Button::anchor(Lc::t("btn-create-user", &LOCALES_USER), new_href).render(cx).await) (table.render(cx).await) (pager) } @@ -266,8 +262,7 @@ async fn actions_cell( async fn roles_cell(user: &UserListItem, cx: &mut Context) -> Html { let admin_badge = if user.is_admin { Some( - Badge::labeled(Lc::t("badge-admin", &LOCALES_USER)) - .with_prop(PropsOp::add_classes("user-admin-badge-admin")) + Badge::severe(Lc::t("badge-admin", &LOCALES_USER)) .render(cx) .await, ) @@ -277,12 +272,7 @@ async fn roles_cell(user: &UserListItem, cx: &mut Context) -> Html { let mut role_badges = Vec::with_capacity(user.roles.len()); for role in &user.roles { - role_badges.push( - Badge::labeled(Lc::n(role.clone())) - .with_prop(PropsOp::add_classes("user-admin-badge")) - .render(cx) - .await, - ); + role_badges.push(Badge::neutral(Lc::n(role.clone())).render(cx).await); } let is_admin = user.is_admin; diff --git a/extensions/pagetop-user/src/handlers/account.rs b/extensions/pagetop-user/src/handlers/account.rs index f89f7fd8..01b99374 100644 --- a/extensions/pagetop-user/src/handlers/account.rs +++ b/extensions/pagetop-user/src/handlers/account.rs @@ -77,8 +77,7 @@ async fn profile_details(user: &user::Model, status: UserStatus, cx: &mut Contex ); if user.is_admin { - let badge = Badge::labeled(Lc::t("badge-admin", &LOCALES_USER)) - .with_prop(PropsOp::add_classes("user-admin-badge-admin")) + let badge = Badge::severe(Lc::t("badge-admin", &LOCALES_USER)) .render(cx) .await; table = table.with_row( @@ -101,8 +100,7 @@ async fn profile_roles(roles: &[role::Model], cx: &mut Context) -> Block { for r in roles { let system_badge = if r.locked { Some( - Badge::labeled(Lc::t("badge-system-role", &LOCALES_USER)) - .with_prop(PropsOp::add_classes("user-admin-badge-system")) + Badge::warning(Lc::t("badge-system-role", &LOCALES_USER)) .render(cx) .await, ) diff --git a/extensions/pagetop-user/src/handlers/admin/permissions.rs b/extensions/pagetop-user/src/handlers/admin/permissions.rs index b92e8804..d4d6d0bb 100644 --- a/extensions/pagetop-user/src/handlers/admin/permissions.rs +++ b/extensions/pagetop-user/src/handlers/admin/permissions.rs @@ -21,26 +21,19 @@ pub(crate) async fn list_get(request: HttpRequest) -> Result = registry - .by_group(group) - .map(|permission| (permission.key(), permission.label())) - .collect(); - content = content.with_child(Block::new().with_title(group_label.clone()).with_child( - Html::with(move |cx| { - html! { - table class="user-admin-table" { - tbody { - @for (key, label) in &items { - tr { - td { (label.using(cx)) } - td class="user-admin-permission-key" { (key) } - } - } - } - } - } - }), - )); + let mut table = Table::new().with_prop(PropsOp::add_classes("user-admin-table")); + for permission in registry.by_group(group) { + table = table.with_row( + table::Row::new() + .with_cell(permission.label()) + .with_cell(permission.key().as_ref()), + ); + } + content = content.with_child( + Block::new() + .with_title(group_label.clone()) + .with_child(table), + ); } Ok(page diff --git a/extensions/pagetop-user/src/handlers/admin/roles.rs b/extensions/pagetop-user/src/handlers/admin/roles.rs index e5fb83a7..023621a4 100644 --- a/extensions/pagetop-user/src/handlers/admin/roles.rs +++ b/extensions/pagetop-user/src/handlers/admin/roles.rs @@ -333,8 +333,7 @@ async fn role_view_details(role: &role::Model, cx: &mut Context) -> Block { ); if role.locked { - let badge = Badge::labeled(Lc::t("badge-system-role", &LOCALES_USER)) - .with_prop(PropsOp::add_classes("user-admin-badge-system")) + let badge = Badge::warning(Lc::t("badge-system-role", &LOCALES_USER)) .render(cx) .await; table = table.with_row( diff --git a/extensions/pagetop-user/src/handlers/admin/users.rs b/extensions/pagetop-user/src/handlers/admin/users.rs index 6aa0f853..3326c8b0 100644 --- a/extensions/pagetop-user/src/handlers/admin/users.rs +++ b/extensions/pagetop-user/src/handlers/admin/users.rs @@ -557,8 +557,7 @@ async fn user_view_details(user: &user::Model, status: UserStatus, cx: &mut Cont ); if user.is_admin { - let badge = Badge::labeled(Lc::t("badge-admin", &LOCALES_USER)) - .with_prop(PropsOp::add_classes("user-admin-badge-admin")) + let badge = Badge::severe(Lc::t("badge-admin", &LOCALES_USER)) .render(cx) .await; table = table.with_row( @@ -576,49 +575,36 @@ async fn user_view_details(user: &user::Model, status: UserStatus, cx: &mut Cont // Bloque de sólo lectura con los roles asignados al usuario, cada uno enlazado a su propia // pantalla de vista. async fn user_view_roles(roles: &[role::Model], cx: &mut Context) -> Block { - let mut items: Vec<(i32, String, String, Option)> = Vec::with_capacity(roles.len()); + let mut table = Table::new() + .with_prop(PropsOp::add_classes("user-admin-table")) + .with_empty(Lc::n("-")); + for r in roles { let system_badge = if r.locked { Some( - Badge::labeled(Lc::t("badge-system-role", &LOCALES_USER)) - .with_prop(PropsOp::add_classes("user-admin-badge-system")) + Badge::warning(Lc::t("badge-system-role", &LOCALES_USER)) .render(cx) .await, ) } else { None }; - items.push((r.id, r.machine_name.clone(), r.label.clone(), system_badge)); + let (id, label) = (r.id, r.label.clone()); + table = table.with_row( + table::Row::new() + .with_cell(Html::with(move |cx| { + html! { a href=(cx.route(role_path(id, "view"))) { (label.as_str()) } } + })) + .with_cell(r.machine_name.as_str()) + .with_cell(Html::with(move |_| { + html! { @if let Some(badge) = &system_badge { (badge) } } + })), + ); } Block::new() .with_title(Lc::t("field-roles", &LOCALES_USER)) - .with_child(Html::with(move |cx| { - html! { - @if items.is_empty() { - "-" - } @else { - table class="user-admin-table" { - tbody { - @for (id, machine_name, label, system_badge) in &items { - @let href = cx.route(role_path(*id, "view")).to_string(); - tr { - td { - a href=(href) { - (label.as_str()) - } - } - td { (machine_name.as_str()) } - td { - @if let Some(badge) = system_badge { (badge) } - } - } - } - } - } - } - } - })) + .with_child(table) } // **< roles_get / roles_post >********************************************************************* From 70744a51a5a0250ae48c2bde177ed8e15cf63e46 Mon Sep 17 00:00:00 2001 From: Manuel Cillero Date: Sun, 4 Oct 2026 23:09:07 +0200 Subject: [PATCH 2/6] =?UTF-8?q?=E2=9C=A8=20(auth):=20A=C3=B1ade=20idioma?= =?UTF-8?q?=20y=20tema=20preferidos=20al=20usuario?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CurrentUser pasa de enum a struct con `id` opcional (anónimo = None) y preferencias de idioma, zona horaria y tema validadas en `with_*()`. - `RequestLocale` tiene en cuenta el idioma preferido del usuario, y Context usa su tema si lo tiene (`default_theme()` en otro caso). - Nuevos componentes `form::SelectLanguage`, `form::SelectTheme` y `form::SelectTimezone`, con `Timezone::supported_by_region()` para listar y validar las zonas IANA ofrecidas. --- src/auth.rs | 178 +++++++++++++------- src/base/component/form.rs | 9 + src/base/component/form/select_language.rs | 130 ++++++++++++++ src/base/component/form/select_theme.rs | 140 +++++++++++++++ src/base/component/form/select_timezone.rs | 142 ++++++++++++++++ src/core/component/context.rs | 40 ++--- src/core/component/context/contextual.rs | 26 +-- src/core/extension/all.rs | 11 +- src/core/theme.rs | 1 + src/core/theme/all.rs | 53 ++++-- src/core/theme/definition.rs | 6 + src/datetime/definition.rs | 60 ++++++- src/global/lang_negotiation.rs | 22 ++- src/locale/definition.rs | 31 +++- src/locale/en-US/base.ftl | 21 +++ src/locale/es-ES/base.ftl | 21 +++ src/locale/languages.rs | 35 ++++ src/locale/request.rs | 55 +++--- src/response/page.rs | 16 +- tests/auth.rs | 107 +++++++++--- tests/component_form_select_language.rs | 101 +++++++++++ tests/component_form_select_theme.rs | 64 +++++++ tests/component_form_select_theme_single.rs | 14 ++ tests/component_form_select_timezone.rs | 63 +++++++ tests/datetime.rs | 6 +- 25 files changed, 1162 insertions(+), 190 deletions(-) create mode 100644 src/base/component/form/select_language.rs create mode 100644 src/base/component/form/select_theme.rs create mode 100644 src/base/component/form/select_timezone.rs create mode 100644 tests/component_form_select_language.rs create mode 100644 tests/component_form_select_theme.rs create mode 100644 tests/component_form_select_theme_single.rs create mode 100644 tests/component_form_select_timezone.rs diff --git a/src/auth.rs b/src/auth.rs index 019d369f..b748d1c8 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -17,11 +17,14 @@ //! [`Context`]: crate::core::component::Context use crate::core::action::{ActionDispatcher, try_dispatch_actions}; +use crate::core::theme::{ThemeRef, theme_by_short_name}; use crate::datetime::{Timezone, Tz}; -use crate::locale::Lc; +use crate::locale::{LanguageIdentifier, Lc, Locale}; use crate::response::ErrorPage; use crate::web::HttpRequest; -use crate::{CowStr, Weight}; +use crate::{AutoDefault, CowStr, Getters, Weight, builder_impl}; + +use std::ops::ControlFlow; // **< CurrentUser >******************************************************************************** @@ -29,76 +32,133 @@ use crate::{CowStr, Weight}; /// /// Se almacena automáticamente en el [`Context`] a partir de la petición HTTP. La identidad se /// extrae de las extensiones de la petición, que una extensión de autenticación inyecta mediante su -/// middleware. +/// middleware. Sin extensión de autenticación, o si ésta no inyecta ninguna identidad, el usuario +/// es anónimo ([`CurrentUser::anonymous()`], que también es el valor por defecto). /// /// Se accede usando [`Contextual::current_user()`]. /// +/// Los usuarios pueden tener idioma, zona horaria y tema preferidos. Se asignan con su valor en +/// bruto y se validan al asignarlos: un idioma no soportado, una zona horaria desconocida o un tema +/// no habilitado en la aplicación se descartan y el dato queda sin valor, como si el usuario no +/// tuviera ninguno. Así, las preferencias de un `CurrentUser` son siempre válidas. +/// /// Los datos extendidos del usuario autenticado (roles, permisos, cuenta completa, ...) son /// responsabilidad de la extensión de autenticación y se obtienen a través de /// [`HttpRequest::extension`]. /// +/// # Ejemplo +/// +/// ```rust,no_run +/// # use pagetop::prelude::*; +/// let user = CurrentUser::authenticated(42, "Alice") +/// .with_language("es-ES") +/// .with_timezone("Europe/Madrid"); +/// ``` +/// /// [`Context`]: crate::core::component::Context /// [`Contextual::current_user()`]: crate::core::component::Contextual::current_user /// [`HttpRequest::extension`]: crate::web::HttpRequest::extension -#[derive(Clone, Debug)] -pub enum CurrentUser { - /// Usuario no autenticado. - Anonymous, - /// Usuario autenticado con su identificador, nombre visible y zona horaria propia. - Authenticated { - /// Identificador único del usuario en el sistema. - id: i32, - /// Nombre visible del usuario. - display_name: String, - /// Zona horaria del usuario, si tiene una configurada y es válida. En otro caso valdrá - /// `None` y [`timezone()`](Self::timezone) devolverá la zona horaria predeterminada de la - /// aplicación. - timezone: Option, - }, +#[derive(AutoDefault, Clone, Debug, Getters)] +pub struct CurrentUser { + /// Devuelve el identificador del usuario, o `None` si es anónimo. + #[getters(copy)] + id: Option, + // Siempre `Some` en un usuario autenticado y `None` en uno anónimo, igual que `id`. + #[getters(skip)] + display_name: Option, + /// Devuelve el idioma preferido del usuario, o `None` si no tiene ninguno. + /// + /// Lo tiene en cuenta [`RequestLocale`](crate::locale::RequestLocale) al decidir el idioma de + /// la petición. + #[getters(copy)] + language: Option<&'static LanguageIdentifier>, + // Ver `timezone()`, que devuelve la zona horaria efectiva. + #[getters(skip)] + timezone: Option, + /// Devuelve el tema preferido del usuario, o `None` si no tiene ninguno. + /// + /// Lo tiene en cuenta el [`Context`](crate::core::component::Context) de la petición al elegir + /// el tema con el que se renderiza. + #[getters(copy)] + theme: Option, } +#[builder_impl] impl CurrentUser { + /// Crea un usuario anónimo, sin idioma, zona horaria ni tema preferidos. + pub fn anonymous() -> Self { + Self::default() + } + + /// Crea un usuario autenticado, sin idioma, zona horaria ni tema preferidos. + pub fn authenticated(id: i32, display_name: impl Into) -> Self { + CurrentUser { + id: Some(id), + display_name: Some(display_name.into()), + ..Self::default() + } + } + + // **< CurrentUser BUILDER >******************************************************************** + + /// Asigna el idioma preferido a partir de su identificador (p. ej. `"es-ES"` o `"es"`). + /// + /// Se resuelve con [`Locale::resolve()`](crate::locale::Locale::resolve); si el idioma no está + /// soportado por la aplicación, o es `None`, el usuario queda sin idioma preferido. + pub fn with_language<'a>(mut self, language: impl Into>) -> Self { + self.language = language + .into() + .and_then(|language| Locale::resolve(language).as_option()); + self + } + + /// Asigna la zona horaria a partir de su nombre IANA (p. ej. `"Europe/Madrid"`). + /// + /// Si el nombre no corresponde a ninguna zona horaria conocida, o es `None`, el usuario queda + /// sin zona horaria propia. + pub fn with_timezone<'a>(mut self, timezone: impl Into>) -> Self { + self.timezone = timezone.into().and_then(|timezone| timezone.parse().ok()); + self + } + + /// Asigna el tema preferido a partir de su nombre corto (p. ej. `"basic"`). + /// + /// Se busca con [`theme_by_short_name()`](crate::core::theme::theme_by_short_name); si el tema + /// no está habilitado en la aplicación, o es `None`, el usuario queda sin tema preferido. + pub fn with_theme<'a>(mut self, theme: impl Into>) -> Self { + self.theme = theme.into().and_then(theme_by_short_name); + self + } + + // **< CurrentUser GETTERS >******************************************************************** + /// Devuelve `true` si el usuario no está autenticado. pub fn is_anonymous(&self) -> bool { - matches!(self, CurrentUser::Anonymous) + self.id.is_none() } /// Devuelve `true` si el usuario está autenticado. pub fn is_authenticated(&self) -> bool { - matches!(self, CurrentUser::Authenticated { .. }) - } - - /// Devuelve el identificador del usuario, o `None` si es anónimo. - pub fn id(&self) -> Option { - match self { - CurrentUser::Anonymous => None, - CurrentUser::Authenticated { id, .. } => Some(*id), - } + self.id.is_some() } /// Devuelve el nombre visible del usuario, o `None` si es anónimo. pub fn display_name(&self) -> Option<&str> { - match self { - CurrentUser::Anonymous => None, - CurrentUser::Authenticated { display_name, .. } => Some(display_name), - } + self.display_name.as_deref() } /// Devuelve la zona horaria efectiva del usuario. /// - /// Un usuario autenticado devuelve la suya si tiene una configurada y es válida; en cualquier - /// otro caso (incluido el usuario anónimo), devuelve [`Timezone::default_tz()`]. + /// Devuelve la suya si tiene una; en otro caso, devuelve [`Timezone::default_tz()`]. /// - /// Normalmente se resuelve una sola vez, al construir el `Context` de la petición. A partir de - /// ese momento el renderizado del documento no vuelve a llamarlo porque usa el valor ya - /// resuelto vía [`Contextual::timezone()`](crate::core::component::Contextual::timezone). + /// Normalmente se resuelve una sola vez, al construir el [`Context`] de la petición. A partir + /// de ese momento el renderizado del documento no vuelve a llamarlo porque usa el valor ya + /// resuelto vía [`Contextual::timezone()`]. + /// + /// [`Context`]: crate::core::component::Context + /// [`Contextual::timezone()`]: crate::core::component::Contextual::timezone pub fn timezone(&self) -> Tz { - match self { - CurrentUser::Anonymous => Timezone::default_tz(), - CurrentUser::Authenticated { timezone, .. } => { - timezone.unwrap_or_else(Timezone::default_tz) - } - } + self.timezone.unwrap_or_else(Timezone::default_tz) } } @@ -113,8 +173,7 @@ impl CurrentUser { /// # Ejemplo /// /// ```rust,no_run -/// # use pagetop::auth::Permission; -/// # use pagetop::CowStr; +/// # use pagetop::prelude::*; /// #[derive(Clone, Copy, Debug)] /// pub enum MyPermission { /// EditPosts, @@ -222,21 +281,6 @@ impl CheckPermission { self.weight = value; self } - - // Despacha las acciones registradas con salida anticipada en cuanto una concede el permiso. - #[inline] - pub(crate) fn check(request: &HttpRequest, perm: PermissionRef) -> bool { - let mut granted = false; - try_dispatch_actions(|action: &Self| { - (action.f)(request, perm, &mut granted); - if granted { - std::ops::ControlFlow::Break(()) - } else { - std::ops::ControlFlow::Continue(()) - } - }); - granted - } } // **< has_permission >***************************************************************************** @@ -267,7 +311,17 @@ impl CheckPermission { /// } /// ``` pub fn has_permission(request: &HttpRequest, perm: PermissionRef) -> bool { - CheckPermission::check(request, perm) + // Despacha las acciones registradas con salida anticipada en cuanto una concede el permiso. + let mut granted = false; + try_dispatch_actions(|action: &CheckPermission| { + (action.f)(request, perm, &mut granted); + if granted { + ControlFlow::Break(()) + } else { + ControlFlow::Continue(()) + } + }); + granted } // **< require_permission >************************************************************************* @@ -303,8 +357,6 @@ pub fn has_permission(request: &HttpRequest, perm: PermissionRef) -> bool { /// .await /// } /// ``` -// `ErrorPage` incluye `Option` en cada variante y es el tipo de error ya establecido -// para toda la respuesta HTTP; boxearlo aquí sólo para esta función no compensa. pub fn require_permission(request: &HttpRequest, perm: PermissionRef) -> Result<(), ErrorPage> { if has_permission(request, perm) { Ok(()) diff --git a/src/base/component/form.rs b/src/base/component/form.rs index 6e927367..f61ff573 100644 --- a/src/base/component/form.rs +++ b/src/base/component/form.rs @@ -18,6 +18,15 @@ pub mod radio; pub mod select; +mod select_language; +pub use select_language::SelectLanguage; + +mod select_theme; +pub use select_theme::SelectTheme; + +mod select_timezone; +pub use select_timezone::SelectTimezone; + pub mod input; mod number; diff --git a/src/base/component/form/select_language.rs b/src/base/component/form/select_language.rs new file mode 100644 index 00000000..f2743c1b --- /dev/null +++ b/src/base/component/form/select_language.rs @@ -0,0 +1,130 @@ +use crate::prelude::*; + +/// Componente para **elegir un idioma** de la lista de idiomas soportados por PageTop. +/// +/// Ofrece un elemento por cada idioma de [`Locale::supported_languages()`], con su identificador +/// como valor (p. ej. `"es-ES"`) y su nombre traducido como etiqueta, ordenados por ese nombre en +/// el idioma de la página. Se renderiza como cualquier [`form::select::Field`]. +/// +/// La primera opción, con valor vacío, depende de si el campo es obligatorio: +/// +/// - Si no lo es (por defecto), siempre se incluye y propone usar el idioma del sitio. Quedarse sin +/// idioma propio es válido y significa usar el predeterminado de la aplicación. Se selecciona +/// cuando el valor elegido no corresponde a ningún idioma de la lista. +/// - Si lo es ([`with_required(true)`](Self::with_required)), sólo se incluye cuando el valor +/// seleccionado no corresponde a ningún idioma de la lista, y pide elegir uno; así el navegador +/// no deja enviar el formulario sin elegir un idioma. El valor recibido debe validarse +/// igualmente en el servidor. +/// +/// # Ejemplo +/// +/// ```rust,no_run +/// # use pagetop::prelude::*; +/// let language = form::SelectLanguage::new() +/// .with_name("language") +/// .with_label(Lc::n("Language")) +/// .with_selected("es-ES"); +/// ``` +#[derive(AutoDefault, Clone, Debug, Getters)] +pub struct SelectLanguage { + /// Devuelve la lista de selección interna con la configuración común (nombre, etiqueta, ayuda, + /// propiedades...), todavía sin opciones; éstas se añaden al renderizar. + field: form::select::Field, + /// Devuelve el identificador del idioma seleccionado. + selected: String, +} + +#[async_trait] +impl Component for SelectLanguage { + fn new() -> Self { + Self::default() + } + + fn id(&self) -> Option { + self.field().id() + } + + async fn prepare(&self, cx: &mut Context) -> Result { + let mut field = self.field().clone(); + let mut languages = Locale::supported_languages(); + languages.sort_by_cached_key(|(_, name)| name.collation_key(&*cx)); + + let selected = Locale::resolve(self.selected()).as_option(); + let known = selected.is_some(); + if !field.required() { + let default_langid = Locale::default_langid(); + let default_name = languages + .iter() + .find(|(langid, _)| *langid == default_langid) + .and_then(|(_, name)| name.lookup(cx)) + .unwrap_or_else(|| default_langid.to_string()); + let label = Lc::l("select_language_site_default").with_arg("language", default_name); + field.alter_item(form::select::Item::new("", label).with_selected(!known)); + } else if !known { + let label = Lc::l("select_language_placeholder"); + field.alter_item(form::select::Item::new("", label).with_selected(true)); + } + + for (langid, name) in languages { + let item = form::select::Item::new(langid.to_string(), name); + field.alter_item(item.with_selected(selected == Some(langid))); + } + Ok(field.render(cx).await) + } +} + +#[builder_impl] +impl SelectLanguage { + // **< SelectLanguage BUILDER >***************************************************************** + + /// Establece el identificador único del componente; igual a `with_prop(PropsOp::set_id(id))`. + pub fn with_id(mut self, id: impl Into) -> Self { + self.field.alter_id(id); + self + } + + /// Modifica identificador, clases CSS, atributos HTML o valores extra del componente. + pub fn with_prop(mut self, op: impl Into) -> Self { + self.field.alter_prop(op); + self + } + + /// Establece el nombre del campo. + pub fn with_name(mut self, name: impl AsRef) -> Self { + self.field.alter_name(name); + self + } + + /// Establece la etiqueta del campo. + pub fn with_label(mut self, label: Lc) -> Self { + self.field.alter_label(label); + self + } + + /// Establece el texto de ayuda del campo. + pub fn with_help_text(mut self, help_text: Lc) -> Self { + self.field.alter_help_text(help_text); + self + } + + /// Establece si el campo es obligatorio, lo que cambia su primera opción (ver + /// [`SelectLanguage`]). + pub fn with_required(mut self, required: bool) -> Self { + self.field.alter_required(required); + self + } + + /// Establece si el campo está deshabilitado. + pub fn with_disabled(mut self, disabled: bool) -> Self { + self.field.alter_disabled(disabled); + self + } + + /// Establece el identificador del idioma seleccionado (p. ej. `"es-ES"`). Se resuelve con + /// [`Locale::resolve()`], así que también acepta alias o variantes (`"es"`, `"es-es"`...) del + /// mismo idioma. + pub fn with_selected(mut self, selected: impl Into) -> Self { + self.selected = selected.into(); + self + } +} diff --git a/src/base/component/form/select_theme.rs b/src/base/component/form/select_theme.rs new file mode 100644 index 00000000..b67bf989 --- /dev/null +++ b/src/base/component/form/select_theme.rs @@ -0,0 +1,140 @@ +use crate::prelude::*; + +/// Componente para **elegir un tema** de los temas habilitados en la aplicación. +/// +/// Ofrece un elemento por cada tema de [`enabled_themes()`], con su nombre corto como valor (p. ej. +/// `"Bootsier"`) y su nombre traducido como etiqueta, ordenados por ese nombre en el idioma de la +/// página. Se renderiza como cualquier [`form::select::Field`]. +/// +/// Si sólo hay un tema habilitado, la lista se muestra deshabilitada con ese tema seleccionado (un +/// campo deshabilitado no se envía con el formulario). Si hay varios, la primera opción, con valor +/// vacío, depende de si el campo es obligatorio: +/// +/// - Si no lo es (por defecto), siempre se incluye y propone usar el tema del sitio. Quedarse sin +/// tema propio es válido y significa usar el predeterminado de la aplicación. Se selecciona +/// cuando el valor elegido no corresponde a ningún tema de la lista (sin distinguir mayúsculas y +/// minúsculas). +/// - Si lo es ([`with_required(true)`](Self::with_required)), sólo se incluye cuando el valor +/// seleccionado no corresponde a ningún tema de la lista, y pide elegir uno; así el navegador no +/// deja enviar el formulario sin elegir un tema. El valor recibido debe validarse igualmente en +/// el servidor. +/// +/// # Ejemplo +/// +/// ```rust,no_run +/// # use pagetop::prelude::*; +/// let theme = form::SelectTheme::new() +/// .with_name("theme") +/// .with_label(Lc::n("Theme")) +/// .with_selected("Basic"); +/// ``` +#[derive(AutoDefault, Clone, Debug, Getters)] +pub struct SelectTheme { + /// Devuelve la lista de selección interna con la configuración común (nombre, etiqueta, ayuda, + /// propiedades...), todavía sin opciones; éstas se añaden al renderizar. + field: form::select::Field, + /// Devuelve el nombre corto del tema seleccionado. + selected: String, +} + +#[async_trait] +impl Component for SelectTheme { + fn new() -> Self { + Self::default() + } + + fn id(&self) -> Option { + self.field().id() + } + + async fn prepare(&self, cx: &mut Context) -> Result { + let mut field = self.field().clone(); + let mut themes = enabled_themes(); + + if let [theme] = themes[..] { + field.alter_disabled(true); + field.alter_item( + form::select::Item::new(theme.short_name(), theme.name()).with_selected(true), + ); + return Ok(field.render(cx).await); + } + + themes.sort_by_cached_key(|theme| theme.name().collation_key(&*cx)); + let is_selected = + |theme: ThemeRef| theme.short_name().eq_ignore_ascii_case(self.selected()); + + let known = themes.iter().any(|theme| is_selected(*theme)); + if !field.required() { + let default_theme = default_theme(); + let default_name = default_theme + .name() + .lookup(cx) + .unwrap_or_else(|| default_theme.short_name().to_owned()); + let label = Lc::l("select_theme_site_default").with_arg("theme", default_name); + field.alter_item(form::select::Item::new("", label).with_selected(!known)); + } else if !known { + let label = Lc::l("select_theme_placeholder"); + field.alter_item(form::select::Item::new("", label).with_selected(true)); + } + + for theme in themes { + let item = form::select::Item::new(theme.short_name(), theme.name()); + field.alter_item(item.with_selected(is_selected(theme))); + } + Ok(field.render(cx).await) + } +} + +#[builder_impl] +impl SelectTheme { + // **< SelectTheme BUILDER >******************************************************************** + + /// Establece el identificador único del componente; igual a `with_prop(PropsOp::set_id(id))`. + pub fn with_id(mut self, id: impl Into) -> Self { + self.field.alter_id(id); + self + } + + /// Modifica identificador, clases CSS, atributos HTML o valores extra del componente. + pub fn with_prop(mut self, op: impl Into) -> Self { + self.field.alter_prop(op); + self + } + + /// Establece el nombre del campo. + pub fn with_name(mut self, name: impl AsRef) -> Self { + self.field.alter_name(name); + self + } + + /// Establece la etiqueta del campo. + pub fn with_label(mut self, label: Lc) -> Self { + self.field.alter_label(label); + self + } + + /// Establece el texto de ayuda del campo. + pub fn with_help_text(mut self, help_text: Lc) -> Self { + self.field.alter_help_text(help_text); + self + } + + /// Establece si el campo es obligatorio, lo que cambia su primera opción (ver [`SelectTheme`]). + pub fn with_required(mut self, required: bool) -> Self { + self.field.alter_required(required); + self + } + + /// Establece si el campo está deshabilitado. Con un solo tema habilitado lo está siempre. + pub fn with_disabled(mut self, disabled: bool) -> Self { + self.field.alter_disabled(disabled); + self + } + + /// Establece el nombre corto del tema seleccionado (p. ej. `"Bootsier"`). Vacío, o uno que no + /// esté habilitado, selecciona la primera opción (ver [`SelectTheme`]). + pub fn with_selected(mut self, selected: impl Into) -> Self { + self.selected = selected.into(); + self + } +} diff --git a/src/base/component/form/select_timezone.rs b/src/base/component/form/select_timezone.rs new file mode 100644 index 00000000..5064d9b2 --- /dev/null +++ b/src/base/component/form/select_timezone.rs @@ -0,0 +1,142 @@ +use crate::prelude::*; + +/// Componente para crear una **lista de selección de zonas horarias** IANA. +/// +/// Ofrece las zonas horarias de [`Timezone::supported_by_region()`] agrupadas por región, con el +/// nombre de la región traducido (*"Europa"*, *"América"*...) y el nombre IANA completo como valor +/// y como etiqueta (p. ej. `"Europe/Madrid"`). No admite opciones libres. Se renderiza como +/// cualquier [`form::select::Field`]. +/// +/// La primera opción, con valor vacío, depende de si el campo es obligatorio: +/// +/// - Si no lo es (por defecto), siempre se incluye y propone usar la zona horaria del sitio. +/// Quedarse sin zona propia es válido y significa usar la predeterminada de la aplicación. Se +/// selecciona cuando el valor elegido no corresponde a ninguna zona de la lista. +/// - Si lo es ([`with_required(true)`](Self::with_required)), sólo se incluye cuando el valor +/// seleccionado no corresponde a ninguna zona de la lista, y pide elegir una; así el navegador no +/// deja enviar el formulario sin elegir una zona horaria. El valor recibido debe validarse +/// igualmente en el servidor. +/// +/// # Ejemplo +/// +/// ```rust,no_run +/// # use pagetop::prelude::*; +/// let timezone = form::SelectTimezone::new() +/// .with_name("timezone") +/// .with_label(Lc::n("Time zone")) +/// .with_selected("Europe/Madrid"); +/// ``` +#[derive(AutoDefault, Clone, Debug, Getters)] +pub struct SelectTimezone { + /// Devuelve la lista de selección interna con la configuración común (nombre, etiqueta, ayuda, + /// propiedades...), todavía sin opciones; éstas se añaden al renderizar. + field: form::select::Field, + /// Devuelve el nombre IANA de la zona horaria seleccionada. + selected: String, +} + +#[async_trait] +impl Component for SelectTimezone { + fn new() -> Self { + Self::default() + } + + fn id(&self) -> Option { + self.field().id() + } + + async fn prepare(&self, cx: &mut Context) -> Result { + let mut field = self.field().clone(); + let regions = Timezone::supported_by_region(); + let known = regions + .iter() + .any(|(_, names)| names.contains(&self.selected())); + if !field.required() { + let label = Lc::l("select_timezone_site_default") + .with_arg("timezone", Timezone::default_tz().name()); + field.alter_item(form::select::Item::new("", label).with_selected(!known)); + } else if !known { + let label = Lc::l("select_timezone_placeholder"); + field.alter_item(form::select::Item::new("", label).with_selected(true)); + } + for (region, names) in regions { + let mut group = form::select::Group::new(match *region { + "Africa" => Lc::l("timezone_region_africa"), + "America" => Lc::l("timezone_region_america"), + "Antarctica" => Lc::l("timezone_region_antarctica"), + "Arctic" => Lc::l("timezone_region_arctic"), + "Asia" => Lc::l("timezone_region_asia"), + "Atlantic" => Lc::l("timezone_region_atlantic"), + "Australia" => Lc::l("timezone_region_australia"), + "Etc" => Lc::l("timezone_region_etc"), + "Europe" => Lc::l("timezone_region_europe"), + "Indian" => Lc::l("timezone_region_indian"), + "Pacific" => Lc::l("timezone_region_pacific"), + _ => Lc::n(*region), + }); + for name in names { + let selected = *name == self.selected(); + group.alter_item( + form::select::Item::new(*name, Lc::n(*name)).with_selected(selected), + ); + } + field.alter_group(group); + } + Ok(field.render(cx).await) + } +} + +#[builder_impl] +impl SelectTimezone { + // **< SelectTimezone BUILDER >***************************************************************** + + /// Establece el identificador único del componente; igual a `with_prop(PropsOp::set_id(id))`. + pub fn with_id(mut self, id: impl Into) -> Self { + self.field.alter_id(id); + self + } + + /// Modifica identificador, clases CSS, atributos HTML o valores extra del componente. + pub fn with_prop(mut self, op: impl Into) -> Self { + self.field.alter_prop(op); + self + } + + /// Establece el nombre del campo. + pub fn with_name(mut self, name: impl AsRef) -> Self { + self.field.alter_name(name); + self + } + + /// Establece la etiqueta del campo. + pub fn with_label(mut self, label: Lc) -> Self { + self.field.alter_label(label); + self + } + + /// Establece el texto de ayuda del campo. + pub fn with_help_text(mut self, help_text: Lc) -> Self { + self.field.alter_help_text(help_text); + self + } + + /// Establece si el campo es obligatorio, lo que cambia su primera opción (ver + /// [`SelectTimezone`]). + pub fn with_required(mut self, required: bool) -> Self { + self.field.alter_required(required); + self + } + + /// Establece si el campo está deshabilitado. + pub fn with_disabled(mut self, disabled: bool) -> Self { + self.field.alter_disabled(disabled); + self + } + + /// Establece el nombre IANA de la zona horaria seleccionada (p. ej. `"Europe/Madrid"`). Vacía, + /// o una que no esté en la lista, selecciona la primera opción (ver [`SelectTimezone`]). + pub fn with_selected(mut self, selected: impl Into) -> Self { + self.selected = selected.into(); + self + } +} diff --git a/src/core/component/context.rs b/src/core/component/context.rs index 03c050c9..9104a9e2 100644 --- a/src/core/component/context.rs +++ b/src/core/component/context.rs @@ -1,8 +1,7 @@ use crate::auth::CurrentUser; use crate::core::TypeInfo; use crate::core::component::{ChildOp, Component, MessageLevel, StatusMessage}; -use crate::core::theme::all::DEFAULT_THEME; -use crate::core::theme::{ChildrenInRegions, CoreRegions, CoreTemplates}; +use crate::core::theme::{ChildrenInRegions, CoreRegions, CoreTemplates, default_theme}; use crate::core::theme::{RegionRef, TemplateRef, ThemeRef}; use crate::datetime::Tz; use crate::html::{Assets, Favicon, JavaScript, Preload, ResponsiveStyles, StyleSheet}; @@ -32,7 +31,7 @@ pub use contextual::Contextual; /// [`Page::new()`](crate::response::Page::new) o [`Page::admin()`](crate::response::Page::admin)), /// y es la única vía por la que un componente, una acción o el tema activo conocen: la petición /// HTTP de origen, el idioma negociado y la zona horaria efectiva, el usuario autenticado -/// ([`current_user()`](Contextual::current_user)), la plantilla y el tema en uso, y los recursos +/// ([`current_user()`](Contextual::current_user)), el tema y la plantilla en uso, y los recursos /// (favicon, hojas de estilo, scripts) acumulados hasta ese momento. Otros datos que los /// componentes necesiten durante el renderizado pueden ser parámetros dinámicos tipados con /// [`with_param()`](Contextual::with_param)/[`param()`](Contextual::param). @@ -99,8 +98,8 @@ pub struct Context { locale : RequestLocale, // Idioma asociado a la petición. current_user: CurrentUser, // Identidad del usuario actual. timezone : Tz, // Zona horaria efectiva del documento. - template : TemplateRef, // Plantilla usada para renderizar. theme : ThemeRef, // Referencia al tema usado para renderizar. + template : TemplateRef, // Plantilla usada para renderizar. favicon : Option, // Favicon, si se ha definido. preloads : Assets, // Recursos para precarga. stylesheets : Assets, // Hojas de estilo CSS. @@ -129,13 +128,14 @@ impl Context { let locale = RequestLocale::from_request(request.as_ref()); let current_user = Self::resolve_current_user(request.as_ref()); let timezone = current_user.timezone(); + let theme = current_user.theme().unwrap_or_else(default_theme); Context { request, locale, current_user, timezone, + theme, template, - theme : *DEFAULT_THEME, favicon : None, preloads : Assets::::new(), stylesheets: Assets::::new(), @@ -169,12 +169,12 @@ impl Context { } // Extrae el `CurrentUser` inyectado por middleware en las extensiones de la petición, o - // `CurrentUser::Anonymous` si no hay petición o ninguna extensión de autenticación está activa. + // un usuario anónimo si no hay petición o ninguna extensión de autenticación está activa. fn resolve_current_user(request: Option<&HttpRequest>) -> CurrentUser { request .and_then(|r| r.extension::()) .cloned() - .unwrap_or(CurrentUser::Anonymous) + .unwrap_or_default() } // **< Context RENDER >************************************************************************* @@ -320,8 +320,9 @@ impl Context { /// Permite a [`Context`] actuar como proveedor de idioma. /// -/// Internamente delega en [`RequestLocale`], que tiene en cuenta la petición HTTP, la configuración -/// global de idioma de la aplicación, la cabecera `Accept-Language` y/o el idioma de respaldo. +/// Internamente delega en [`RequestLocale`], que tiene en cuenta la petición HTTP (parámetro +/// `?lang` e idioma preferido del usuario), la configuración global de idioma de la aplicación, la +/// cabecera `Accept-Language` y/o el idioma de respaldo. /// /// Todo ello según la negociación indicada en [`global::SETTINGS.app.lang_negotiation`]. Esto /// permite que el [`Context`] se use como fuente de idioma coherente en [`Lc::lookup()`] o @@ -344,11 +345,12 @@ impl Contextual for Context { fn with_request(mut self, request: Option) -> Self { self.request = request; - // Recalcula el *locale*, el usuario actual y la zona horaria según la nueva petición y la - // política de negociación configurada. + // Recalcula el *locale*, el usuario actual, la zona horaria y el tema según la nueva + // petición y la política de negociación configurada. self.locale = RequestLocale::from_request(self.request.as_ref()); self.current_user = Self::resolve_current_user(self.request.as_ref()); self.timezone = self.current_user.timezone(); + self.theme = self.current_user.theme().unwrap_or_else(default_theme); self } @@ -362,13 +364,13 @@ impl Contextual for Context { self } - fn with_template(mut self, template: TemplateRef) -> Self { - self.template = template; + fn with_theme(mut self, theme: ThemeRef) -> Self { + self.theme = theme; self } - fn with_theme(mut self, theme: ThemeRef) -> Self { - self.theme = theme; + fn with_template(mut self, template: TemplateRef) -> Self { + self.template = template; self } @@ -452,14 +454,14 @@ impl Contextual for Context { self.timezone } - fn template(&self) -> TemplateRef { - self.template - } - fn theme(&self) -> ThemeRef { self.theme } + fn template(&self) -> TemplateRef { + self.template + } + fn param(&self, key: &'static str) -> Result<&T, ContextError> { let (any, type_name) = self.params.get(key).ok_or(ContextError::ParamNotFound)?; any.downcast_ref::() diff --git a/src/core/component/context/contextual.rs b/src/core/component/context/contextual.rs index 4312999d..1737bd22 100644 --- a/src/core/component/context/contextual.rs +++ b/src/core/component/context/contextual.rs @@ -21,7 +21,7 @@ const ISO_DATETIME: &str = "%Y-%m-%dT%H:%M:%S%:z"; /// - Almacenar la **petición HTTP** de origen. /// - Conocer la **identidad del usuario actual** ([`current_user()`](Self::current_user)) y la /// **zona horaria efectiva** del documento ([`timezone()`](Self::timezone)). -/// - Seleccionar la **plantilla** y el **tema** de renderizado. +/// - Seleccionar el **tema** y la **plantilla** de renderizado. /// - Administrar **recursos** del documento como el icono [`Favicon`], las hojas de estilo /// [`StyleSheet`] o los scripts [`JavaScript`], directamente o mediante una operación /// [`AssetsOp`]. @@ -40,8 +40,8 @@ const ISO_DATETIME: &str = "%Y-%m-%dT%H:%M:%S%:z"; /// # use pagetop_aliner::Aliner; /// fn prepare_context(cx: C) -> C { /// cx.with_langid(&Locale::resolve("es-ES")) -/// .with_template(&CoreTemplates::Standard) /// .with_theme(&Aliner) +/// .with_template(&CoreTemplates::Standard) /// .with_assets(Favicon::new().with_icon("/favicon.ico")) /// .with_assets(StyleSheet::from("/css/app.css")) /// .with_assets(JavaScript::defer("/js/app.js")) @@ -67,22 +67,24 @@ pub trait Contextual: LangId { /// /// Al asociar la petición, recalcula el idioma ([`RequestLocale::from_request()`]), establece /// el usuario actual ([`current_user()`]) y, a partir de éste, asigna la zona horaria efectiva - /// ([`timezone()`]), descartando en el proceso cualquier idioma o zona horaria anteriores. + /// ([`timezone()`]) y el tema ([`theme()`]), descartando en el proceso cualquier idioma, zona + /// horaria o tema anteriores. /// - /// Si sabes que vas a forzar el idioma o la zona horaria, llama a `with_request()` primero en - /// la cadena de construcción, nunca después. + /// Si sabes que vas a forzar el idioma, la zona horaria o el tema, llama a `with_request()` + /// primero en la cadena de construcción, nunca después. /// /// [`RequestLocale::from_request()`]: crate::locale::RequestLocale::from_request /// [`current_user()`]: Self::current_user /// [`timezone()`]: Self::timezone + /// [`theme()`]: Self::theme fn with_request(self, request: Option) -> Self; - /// Especifica la plantilla para renderizar el documento. - fn with_template(self, template: TemplateRef) -> Self; - /// Especifica el tema para renderizar el documento. fn with_theme(self, theme: ThemeRef) -> Self; + /// Especifica la plantilla para renderizar el documento. + fn with_template(self, template: TemplateRef) -> Self; + /// Añade o modifica un parámetro dinámico del contexto. /// /// El valor se almacena junto con el nombre de su tipo, lo que permite generar mensajes de @@ -125,7 +127,7 @@ pub trait Contextual: LangId { /// /// Si ninguna extensión de autenticación ha inyectado un /// [`CurrentUser`](crate::auth::CurrentUser) en las extensiones de la petición HTTP, devuelve - /// `&CurrentUser::Anonymous`. + /// un usuario anónimo ([`CurrentUser::anonymous()`](crate::auth::CurrentUser::anonymous)). /// /// # Ejemplo /// @@ -154,12 +156,12 @@ pub trait Contextual: LangId { /// [`CurrentUser::timezone()`]: crate::auth::CurrentUser::timezone fn timezone(&self) -> Tz; - /// Devuelve la plantilla configurada para renderizar el documento. - fn template(&self) -> TemplateRef; - /// Devuelve el tema que se usará para renderizar el documento. fn theme(&self) -> ThemeRef; + /// Devuelve la plantilla configurada para renderizar el documento. + fn template(&self) -> TemplateRef; + /// Recupera una *referencia tipada* al parámetro solicitado. /// /// Devuelve: diff --git a/src/core/extension/all.rs b/src/core/extension/all.rs index 3ec7e3cb..dccf0ab8 100644 --- a/src/core/extension/all.rs +++ b/src/core/extension/all.rs @@ -1,7 +1,7 @@ use crate::core::action::publish_actions; use crate::core::extension::ExtensionRef; use crate::core::theme::ThemeRef; -use crate::core::theme::all::THEMES; +use crate::core::theme::all::register_theme; use crate::web::Router; use crate::{global, serve_static_files, trace, web}; @@ -47,14 +47,7 @@ fn add_to_enabled(list: &mut Vec, extension: ExtensionRef) { // Comprueba si la extensión tiene un tema asociado que deba registrarse. if let Some(theme) = extension.theme() { check_theme_parent_chain(theme); - - let mut registered_themes = THEMES.write(); - // Asegura que el tema no esté ya registrado para evitar duplicados. - if !registered_themes - .iter() - .any(|t| t.type_id() == theme.type_id()) - { - registered_themes.push(theme); + if register_theme(theme) { trace::debug!("Enabling \"{}\" theme", theme.short_name()); } } else { diff --git a/src/core/theme.rs b/src/core/theme.rs index af688407..c85b0fe2 100644 --- a/src/core/theme.rs +++ b/src/core/theme.rs @@ -170,3 +170,4 @@ pub(crate) use regions::ChildrenInRegions; pub use regions::InRegion; pub(crate) mod all; +pub use all::{default_theme, enabled_themes, theme_by_short_name}; diff --git a/src/core/theme/all.rs b/src/core/theme/all.rs index 4774ee6e..a4e20079 100644 --- a/src/core/theme/all.rs +++ b/src/core/theme/all.rs @@ -7,27 +7,50 @@ use std::sync::LazyLock; // **< TEMAS >************************************************************************************** -pub static THEMES: LazyLock>> = LazyLock::new(|| RwLock::new(Vec::new())); +static THEMES: LazyLock>> = LazyLock::new(|| RwLock::new(Vec::new())); + +// Registra el tema si no lo estaba ya, para evitar duplicados. Devuelve `true` si lo ha añadido. +pub(crate) fn register_theme(theme: ThemeRef) -> bool { + let mut themes = THEMES.write(); + if themes.iter().any(|t| t.type_id() == theme.type_id()) { + return false; + } + themes.push(theme); + true +} + +/// Devuelve los temas habilitados en la aplicación, en el orden en que se registraron. +pub fn enabled_themes() -> Vec { + THEMES.read().clone() +} + +/// Devuelve el tema identificado por su [`short_name()`](crate::core::AnyInfo::short_name), si está +/// habilitado, sin distinguir mayúsculas y minúsculas. +pub fn theme_by_short_name(short_name: &str) -> Option { + THEMES + .read() + .iter() + .find(|t| t.short_name().eq_ignore_ascii_case(short_name)) + .copied() +} // **< TEMA PREDETERMINADO >************************************************************************ -pub static DEFAULT_THEME: LazyLock = +static DEFAULT_THEME: LazyLock = LazyLock::new(|| match theme_by_short_name(&global::SETTINGS.app.theme) { Some(theme) => theme, None => &crate::base::theme::Basic, }); -// **< TEMA POR NOMBRE >**************************************************************************** - -/// Devuelve el tema identificado por su [`short_name()`](AnyInfo::short_name). -pub fn theme_by_short_name(short_name: &'static str) -> Option { - let short_name = short_name.to_lowercase(); - match THEMES - .read() - .iter() - .find(|t| t.short_name().to_lowercase() == short_name) - { - Some(theme) => Some(*theme), - _ => None, - } +/// Devuelve el tema predeterminado de la aplicación: el configurado en `app.theme` si está +/// habilitado o, en otro caso, [`Basic`](crate::base::theme::Basic). +/// +/// Es el tema del sitio, no necesariamente el que se usa en una petición concreta: para renderizar, +/// el tema efectivo es el de [`Contextual::theme()`], que tiene en cuenta el tema preferido del +/// usuario ([`CurrentUser::theme()`]). +/// +/// [`Contextual::theme()`]: crate::core::component::Contextual::theme +/// [`CurrentUser::theme()`]: crate::auth::CurrentUser::theme +pub fn default_theme() -> ThemeRef { + *DEFAULT_THEME } diff --git a/src/core/theme/definition.rs b/src/core/theme/definition.rs index 63c9101d..e8934daf 100644 --- a/src/core/theme/definition.rs +++ b/src/core/theme/definition.rs @@ -431,6 +431,12 @@ pub trait Theme: Extension + Send + Sync { /// Referencia estática a un tema. pub type ThemeRef = &'static dyn Theme; +impl std::fmt::Debug for dyn Theme { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.short_name()) + } +} + // **< setup_component! >*************************************************************************** /// Modifica un componente dentro de [`Theme::setup_component()`]. diff --git a/src/datetime/definition.rs b/src/datetime/definition.rs index 739cab5c..b143ba49 100644 --- a/src/datetime/definition.rs +++ b/src/datetime/definition.rs @@ -1,7 +1,8 @@ use crate::{global, trace, util}; -use super::Tz; +use super::{TZ_VARIANTS, Tz}; +use std::collections::BTreeMap; use std::sync::LazyLock; // Identificador de zona horaria configurado para la aplicación, si es válido. @@ -17,6 +18,36 @@ static CONFIG_TZ: LazyLock> = LazyLock::new(|| { // Zona horaria de respaldo, garantizada incluso sin configuración válida. const FALLBACK_TZ: Tz = Tz::UTC; +// Regiones de la base IANA que sólo contienen alias heredados (fichero `backward`), todos con una +// zona canónica equivalente en otra región (p. ej. `US/Eastern` es `America/New_York`). +const LEGACY_REGIONS: [&str; 5] = ["Brazil", "Canada", "Chile", "Mexico", "US"]; + +// Zonas horarias IANA agrupadas por región (lo anterior a la primera `/`), ordenadas por región y +// nombre. Se descartan los nombres sin región (alias heredados como `GB`, `Japan` o `EST5EDT`) y +// las regiones de `LEGACY_REGIONS`. De `Etc` sólo se conserva `Etc/UTC`, cuyo grupo va al final: +// el resto son zonas de desfase fijo (`Etc/GMT+1`...) que no representan ningún lugar. Siguen +// apareciendo los alias heredados que viven dentro de una región normal (p. ej. `Asia/Calcutta` +// junto a `Asia/Kolkata`): `chrono-tz` no distingue zonas canónicas de enlaces y filtrarlos +// exigiría mantener a mano una lista de casi 180 nombres. +static TZ_BY_REGION: LazyLock)>> = LazyLock::new(|| { + let mut regions: BTreeMap<&'static str, Vec<&'static str>> = BTreeMap::new(); + for tz in TZ_VARIANTS.iter() { + let name = tz.name(); + let Some((region, _)) = name.split_once('/') else { + continue; + }; + if LEGACY_REGIONS.contains(®ion) || (region == "Etc" && name != "Etc/UTC") { + continue; + } + regions.entry(region).or_default().push(name); + } + for names in regions.values_mut() { + names.sort_unstable(); + } + let etc = regions.remove_entry("Etc"); + regions.into_iter().chain(etc).collect() +}); + /// Zona horaria configurada para la aplicación. /// /// Resuelve [`global::SETTINGS.app.timezone`](crate::global::App::timezone) contra la base IANA de @@ -69,4 +100,31 @@ impl Timezone { pub fn default_tz() -> Tz { Self::try_tz().unwrap_or(FALLBACK_TZ) } + + /// Devuelve las zonas horarias IANA que se ofrecen para elegir, agrupadas por región. + /// + /// Cada grupo es la región (lo anterior a la primera `/`, p. ej. `"Europe"`) con los nombres + /// completos de sus zonas (p. ej. `"Europe/Madrid"`), ordenados por región y nombre; el grupo + /// `"Etc"`, sólo con `"Etc/UTC"`, va al final. Se excluyen los nombres sin región (`"UTC"`, + /// `"Japan"`...), las regiones formadas sólo por alias heredados (`"US"`, `"Canada"`...) y las + /// zonas de desfase fijo (`"Etc/GMT+1"`...). Es la lista que ofrece + /// [`form::SelectTimezone`](crate::base::component::form::SelectTimezone), útil también para + /// validar el valor recibido. + /// + /// # Ejemplo + /// + /// ```rust + /// # use pagetop::prelude::*; + /// let is_supported = |name: &str| { + /// Timezone::supported_by_region() + /// .iter() + /// .any(|(_, names)| names.contains(&name)) + /// }; + /// + /// assert!(is_supported("Europe/Madrid")); + /// assert!(!is_supported("US/Eastern")); + /// ``` + pub fn supported_by_region() -> &'static [(&'static str, Vec<&'static str>)] { + &TZ_BY_REGION + } } diff --git a/src/global/lang_negotiation.rs b/src/global/lang_negotiation.rs index 1e24e6c4..a24ed7c2 100644 --- a/src/global/lang_negotiation.rs +++ b/src/global/lang_negotiation.rs @@ -11,21 +11,25 @@ use serde::{Deserialize, Deserializer}; #[derive(AutoDefault, Clone, Copy, Debug, Eq, PartialEq)] pub enum LangNegotiation { /// Usa todas las fuentes disponibles para determinar el idioma, en este orden: comprueba el - /// parámetro `?lang` de la URL; si no está presente o no es válido, usa la cabecera HTTP - /// `Accept-Language`; si tampoco está disponible o no es válido, usa el idioma configurado en - /// [`global::SETTINGS.app.language`](crate::global::App::language) o, en su defecto, el idioma - /// de respaldo. Es el comportamiento por defecto. + /// parámetro `?lang` de la URL; si no está presente o no es válido, usa el idioma preferido del + /// usuario ([`CurrentUser::language()`]); si no tiene ninguno, usa el idioma configurado en + /// [`global::SETTINGS.app.language`]; si tampoco está disponible o no es válido, usa la + /// cabecera HTTP `Accept-Language` o, en su defecto, el idioma de respaldo. Es el + /// comportamiento por defecto. + /// + /// [`CurrentUser::language()`]: crate::auth::CurrentUser::language + /// [`global::SETTINGS.app.language`]: crate::global::App::language #[default] Full, /// Igual que `LangNegotiation::Full`, pero sin tener en cuenta el parámetro `?lang` de la URL. - /// El idioma depende únicamente de la cabecera `Accept-Language` del navegador y, en última - /// instancia, de la configuración o idioma de respaldo. + /// El idioma depende, en este orden, del idioma preferido del usuario, de la configuración, de + /// la cabecera `Accept-Language` del navegador y, en última instancia, del idioma de respaldo. NoQuery, - /// Usa sólo la configuración o, en su defecto, el idioma de respaldo; ignora la cabecera - /// `Accept-Language` y el parámetro de la URL. Este modo proporciona un comportamiento estable - /// con idioma fijo. + /// Usa sólo la configuración o, en su defecto, el idioma de respaldo; ignora el idioma del + /// usuario, la cabecera `Accept-Language` y el parámetro de la URL. Este modo proporciona un + /// comportamiento estable con idioma fijo. ConfigOnly, } diff --git a/src/locale/definition.rs b/src/locale/definition.rs index e9014239..4cbf3ff3 100644 --- a/src/locale/definition.rs +++ b/src/locale/definition.rs @@ -1,7 +1,7 @@ use crate::{global, trace, util}; -use super::languages::LANGUAGES; -use super::{LanguageIdentifier, langid}; +use super::languages::{LANGUAGES, SUPPORTED}; +use super::{LanguageIdentifier, Lc, langid}; use std::sync::LazyLock; @@ -139,6 +139,33 @@ impl Locale { } } + /// Devuelve los idiomas soportados por PageTop con su nombre traducible, ordenados por + /// identificador. + /// + /// Incluye una entrada por identificador canónico (p. ej. `"es-ES"`), sin los alias de idioma + /// base (`"es"`) que [`Locale::resolve()`] también acepta. El nombre es, por tanto, el de la + /// variante regional (p. ej. *"Español (España)"*, no *"Español"*). Útil para ofrecer un + /// selector de idioma. + /// + /// # Ejemplo + /// + /// ```rust + /// # use pagetop::prelude::*; + /// let codes: Vec = Locale::supported_languages() + /// .iter() + /// .map(|(langid, _)| langid.to_string()) + /// .collect(); + /// + /// assert!(codes.contains(&"es-ES".to_string())); + /// assert!(!codes.contains(&"es".to_string())); + /// ``` + pub fn supported_languages() -> Vec<(&'static LanguageIdentifier, Lc)> { + SUPPORTED + .iter() + .map(|(langid, key)| (*langid, Lc::l(*key))) + .collect() + } + // **< Locale HELPERS >************************************************************************* /// Inicializa el idioma por defecto que utilizará la aplicación. diff --git a/src/locale/en-US/base.ftl b/src/locale/en-US/base.ftl index 8255e2f2..a4935e9d 100644 --- a/src/locale/en-US/base.ftl +++ b/src/locale/en-US/base.ftl @@ -11,6 +11,27 @@ dropdown_default_title = Dropdown # Form components. field_required = This field is required +select_language_site_default = Use the site language: { $language } +select_language_placeholder = Choose a language... + +select_theme_site_default = Use the site theme: { $theme } +select_theme_placeholder = Choose a theme... + +select_timezone_site_default = Use the site time zone: { $timezone } +select_timezone_placeholder = Choose a time zone... + +timezone_region_africa = Africa +timezone_region_america = America +timezone_region_antarctica = Antarctica +timezone_region_arctic = Arctic +timezone_region_asia = Asia +timezone_region_atlantic = Atlantic +timezone_region_australia = Australia +timezone_region_europe = Europe +timezone_region_indian = Indian Ocean +timezone_region_pacific = Pacific +timezone_region_etc = Other + # Intro component. intro_default_title = Hello, world! intro_default_slogan = Discover⚡{ $app } diff --git a/src/locale/es-ES/base.ftl b/src/locale/es-ES/base.ftl index 906c77fd..ce914366 100644 --- a/src/locale/es-ES/base.ftl +++ b/src/locale/es-ES/base.ftl @@ -11,6 +11,27 @@ dropdown_default_title = Menú desplegable # Form components. field_required = Este campo es obligatorio +select_language_site_default = Usar el idioma del sitio: { $language } +select_language_placeholder = Elige un idioma... + +select_theme_site_default = Usar el tema del sitio: { $theme } +select_theme_placeholder = Elige un tema... + +select_timezone_site_default = Usar la zona horaria del sitio: { $timezone } +select_timezone_placeholder = Elige una zona horaria... + +timezone_region_africa = África +timezone_region_america = América +timezone_region_antarctica = Antártida +timezone_region_arctic = Ártico +timezone_region_asia = Asia +timezone_region_atlantic = Atlántico +timezone_region_australia = Australia +timezone_region_europe = Europa +timezone_region_indian = Océano Índico +timezone_region_pacific = Pacífico +timezone_region_etc = Otras + # Intro component. intro_default_title = ¡Hola, mundo! intro_default_slogan = Descubre⚡{ $app } diff --git a/src/locale/languages.rs b/src/locale/languages.rs index 13b5e913..80515a2b 100644 --- a/src/locale/languages.rs +++ b/src/locale/languages.rs @@ -27,3 +27,38 @@ pub(super) static LANGUAGES: LazyLock> "es-es" => ( langid!("es-ES"), "spanish_spain" ), ] }); + +// Idiomas soportados sin alias: una entrada por identificador canónico (la de `LANGUAGES` cuyo +// código coincide con él, p. ej. "es-es" y no "es"), ordenadas por identificador, con la clave de +// su nombre. +pub(super) static SUPPORTED: LazyLock> = + LazyLock::new(|| { + let mut supported: Vec<_> = LANGUAGES + .iter() + .filter(|(code, (langid, _))| langid.to_string().eq_ignore_ascii_case(code)) + .map(|(_, (langid, key))| (langid, *key)) + .collect(); + supported.sort_by_cached_key(|(langid, _)| langid.to_string()); + supported + }); + +// Un idioma añadido a `LANGUAGES` sólo con su alias (p. ej. "ca" sin "ca-es") lo aceptaría +// `Locale::resolve()`, pero quedaría fuera de `SUPPORTED` y, con él, del selector de idioma. +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn every_language_has_its_canonical_entry() { + for (code, (langid, _)) in LANGUAGES.iter() { + assert_eq!( + SUPPORTED + .iter() + .filter(|(supported, _)| *supported == langid) + .count(), + 1, + "language \"{code}\" has no canonical entry \"{langid}\" in LANGUAGES" + ); + } + } +} diff --git a/src/locale/request.rs b/src/locale/request.rs index 19dc91b3..b55b5035 100644 --- a/src/locale/request.rs +++ b/src/locale/request.rs @@ -1,3 +1,4 @@ +use crate::auth::CurrentUser; use crate::global; use crate::util; use crate::web::HttpRequest; @@ -16,8 +17,8 @@ use super::{LangId, LanguageIdentifier, Locale}; /// /// [`LangNegotiation`]: crate::global::LangNegotiation pub struct RequestLocale { - // Idioma elegido por la aplicación para esta petición, combinando la configuración, la cabecera - // `Accept-Language` y/o el idioma de respaldo. + // Idioma elegido por la aplicación para esta petición, combinando el idioma del usuario, la + // configuración, la cabecera `Accept-Language` y/o el idioma de respaldo. base: &'static LanguageIdentifier, // Idioma finalmente aplicado a la petición (puede coincidir con `base` o no). effective: &'static LanguageIdentifier, @@ -34,19 +35,25 @@ impl RequestLocale { /// /// - [`LangNegotiation::Full`] determina el idioma en este orden: /// 1. Parámetro de *query* `?lang=...`, si existe y corresponde a un idioma soportado. + /// 2. Idioma preferido del usuario ([`CurrentUser::language()`]), si tiene uno. + /// 3. [`Locale::try_langid()`], si la aplicación tiene un idioma por defecto válido. + /// 4. Cabecera `Accept-Language`, si puede resolverse con [`Locale::resolve()`]. + /// 5. Idioma de respaldo. + /// + /// - [`LangNegotiation::NoQuery`] descarta el uso del parámetro `?lang=...` y determina el + /// idioma en este orden: + /// 1. Idioma preferido del usuario ([`CurrentUser::language()`]), si tiene uno. /// 2. [`Locale::try_langid()`], si la aplicación tiene un idioma por defecto válido. /// 3. Cabecera `Accept-Language`, si puede resolverse con [`Locale::resolve()`]. /// 4. Idioma de respaldo. /// - /// - [`LangNegotiation::NoQuery`] descarta el uso del parámetro `?lang=...` y determina el - /// idioma en este orden: - /// 1. [`Locale::try_langid()`], si la aplicación tiene un idioma por defecto válido. - /// 2. Cabecera `Accept-Language`, si puede resolverse con [`Locale::resolve()`]. - /// 3. Idioma de respaldo. - /// /// - [`LangNegotiation::ConfigOnly`] sólo usa la configuración de la aplicación mediante - /// [`Locale::default_langid()`], sin consultar la cabecera `Accept-Language` ni el parámetro - /// `?lang`. Este modo también aplica el idioma de respaldo si es necesario. + /// [`Locale::default_langid()`], sin consultar el idioma del usuario, la cabecera + /// `Accept-Language` ni el parámetro `?lang`. Este modo también aplica el idioma de respaldo + /// si es necesario. + /// + /// El idioma del usuario se lee del [`CurrentUser`] que la extensión de autenticación inserta + /// en las extensiones de la petición. /// /// En todos los casos, el idioma resultante es siempre un [`LanguageIdentifier`] soportado por /// la aplicación y será el que PageTop utilice para renderizar la respuesta de la petición. @@ -65,10 +72,14 @@ impl RequestLocale { Locale::default_langid() } global::LangNegotiation::Full | global::LangNegotiation::NoQuery => { - if let Some(default) = Locale::try_langid() { - default + let user_language = request + .and_then(|req| req.extension::()) + .and_then(CurrentUser::language); + if let Some(langid) = user_language.or_else(Locale::try_langid) { + langid } else { - // Sin idioma por defecto, se evalúa la cabecera `Accept-Language`. + // Sin idioma del usuario ni por defecto, se evalúa la cabecera + // `Accept-Language`. request .and_then(|req| req.headers().get("Accept-Language")) .and_then(|value| value.to_str().ok()) @@ -147,9 +158,10 @@ impl RequestLocale { /// Fuerza el idioma que se utilizará para las traducciones de esta petición. /// - /// Este método permite sustituir el idioma calculado (por configuración, cabecera, `?lang`, - /// etc.) por otro idioma. Normalmente se usa cuando quieres que toda la respuesta se genere en - /// un idioma concreto, independientemente de cómo se haya llegado a él. + /// Este método permite sustituir el idioma calculado (por `?lang`, idioma del usuario, + /// configuración, cabecera, etc.) por otro idioma. Normalmente se usa cuando quieres que toda + /// la respuesta se genere en un idioma concreto, independientemente de cómo se haya llegado a + /// él. #[inline] pub fn with_langid(&mut self, language: &impl LangId) -> &mut Self { self.effective = language.langid(); @@ -162,11 +174,12 @@ impl RequestLocale { /// El comportamiento depende de la estrategia configurada en [`LangNegotiation`]: /// /// - En modo [`LangNegotiation::Full`] devuelve `true` cuando la respuesta se está generando en - /// un idioma distinto del que la aplicación habría elegido automáticamente a partir de la - /// configuración, el navegador y el idioma de respaldo. En la práctica suele significar que - /// el usuario ha pedido expresamente otro idioma (por ejemplo, con `?lang=...`) o que se ha - /// forzado con [`with_langid()`](Self::with_langid), y por tanto es recomendable propagar - /// `lang=...` en los enlaces para mantener esa preferencia mientras se navega. + /// un idioma distinto del que la aplicación habría elegido automáticamente a partir del + /// idioma del usuario, la configuración, el navegador y el idioma de respaldo. En la práctica + /// suele significar que el usuario ha pedido expresamente otro idioma (por ejemplo, con + /// `?lang=...`) o que se ha forzado con [`with_langid()`](Self::with_langid), y por tanto es + /// recomendable propagar `lang=...` en los enlaces para mantener esa preferencia mientras se + /// navega. /// /// - En modos [`LangNegotiation::NoQuery`] y [`LangNegotiation::ConfigOnly`] siempre devuelve /// `false`, ya que en estas estrategias la aplicación no utiliza el parámetro `?lang=...` diff --git a/src/response/page.rs b/src/response/page.rs index 126226ee..bfcde2d5 100644 --- a/src/response/page.rs +++ b/src/response/page.rs @@ -286,13 +286,13 @@ impl Contextual for Page { self } - fn with_template(mut self, template: TemplateRef) -> Self { - self.context.alter_template(template); + fn with_theme(mut self, theme: ThemeRef) -> Self { + self.context.alter_theme(theme); self } - fn with_theme(mut self, theme: ThemeRef) -> Self { - self.context.alter_theme(theme); + fn with_template(mut self, template: TemplateRef) -> Self { + self.context.alter_template(template); self } @@ -336,14 +336,14 @@ impl Contextual for Page { self.context.timezone() } - fn template(&self) -> TemplateRef { - self.context.template() - } - fn theme(&self) -> ThemeRef { self.context.theme() } + fn template(&self) -> TemplateRef { + self.context.template() + } + fn param(&self, key: &'static str) -> Result<&T, ContextError> { self.context.param(key) } diff --git a/tests/auth.rs b/tests/auth.rs index f07849fb..add2ea01 100644 --- a/tests/auth.rs +++ b/tests/auth.rs @@ -4,7 +4,7 @@ use pagetop::prelude::*; #[pagetop::test] async fn anonymous_reports_itself_correctly() { - let user = CurrentUser::Anonymous; + let user = CurrentUser::anonymous(); assert!(user.is_anonymous()); assert!(!user.is_authenticated()); assert_eq!(user.id(), None); @@ -14,11 +14,7 @@ async fn anonymous_reports_itself_correctly() { #[pagetop::test] async fn authenticated_reports_itself_correctly() { let madrid: Tz = "Europe/Madrid".parse().unwrap(); - let user = CurrentUser::Authenticated { - id: 42, - display_name: "Alice".to_owned(), - timezone: Some(madrid), - }; + let user = CurrentUser::authenticated(42, "Alice").with_timezone("Europe/Madrid"); assert!(!user.is_anonymous()); assert!(user.is_authenticated()); assert_eq!(user.id(), Some(42)); @@ -28,14 +24,39 @@ async fn authenticated_reports_itself_correctly() { #[pagetop::test] async fn authenticated_falls_back_to_default_timezone_when_none() { - let user = CurrentUser::Authenticated { - id: 42, - display_name: "Alice".to_owned(), - timezone: None, - }; + let user = CurrentUser::authenticated(42, "Alice"); assert_eq!(user.timezone(), Timezone::default_tz()); } +#[pagetop::test] +async fn authenticated_discards_invalid_preferences() { + let user = CurrentUser::authenticated(42, "Alice") + .with_language("xx-XX") + .with_timezone("Mars/Olympus") + .with_theme("NotRegistered"); + assert_eq!(user.language(), None); + assert_eq!(user.timezone(), Timezone::default_tz()); + assert!(user.theme().is_none()); +} + +#[pagetop::test] +async fn anonymous_can_have_preferences() { + let madrid: Tz = "Europe/Madrid".parse().unwrap(); + let user = CurrentUser::anonymous() + .with_language("es-ES") + .with_timezone("Europe/Madrid"); + assert!(user.is_anonymous()); + assert_eq!(user.language(), Locale::resolve("es-ES").as_option()); + assert_eq!(user.timezone(), madrid); +} + +#[pagetop::test] +async fn authenticated_resolves_language_to_a_supported_one() { + let user = CurrentUser::authenticated(42, "Alice").with_language("es"); + assert_eq!(user.language(), Locale::resolve("es").as_option()); + assert!(user.language().is_some()); +} + // **< Context::current_user() >******************************************************************** #[pagetop::test] @@ -47,11 +68,7 @@ async fn current_user_defaults_to_anonymous() { #[pagetop::test] async fn current_user_propagates_from_request_extensions() { let req = web::test::TestRequest::get() - .with_extension(CurrentUser::Authenticated { - id: 7, - display_name: "Bob".to_owned(), - timezone: None, - }) + .with_extension(CurrentUser::authenticated(7, "Bob")) .to_http_request(); let cx = Context::new(req); let user = cx.current_user(); @@ -60,6 +77,52 @@ async fn current_user_propagates_from_request_extensions() { assert_eq!(user.display_name(), Some("Bob")); } +#[pagetop::test] +async fn user_language_sets_the_request_language() { + let req = web::test::TestRequest::get() + .header("Accept-Language", "en-US") + .with_extension(CurrentUser::authenticated(7, "Bob").with_language("es-ES")) + .to_http_request(); + let cx = Context::new(req); + assert_eq!(cx.langid().to_string(), "es-ES"); +} + +#[pagetop::test] +async fn lang_query_overrides_user_language() { + let req = web::test::TestRequest::get() + .uri("/?lang=en-US") + .with_extension(CurrentUser::authenticated(7, "Bob").with_language("es-ES")) + .to_http_request(); + let cx = Context::new(req); + assert_eq!(cx.langid().to_string(), "en-US"); +} + +struct UserTheme; + +impl Extension for UserTheme { + fn theme(&self) -> Option { + Some(&UserTheme) + } +} + +impl Theme for UserTheme {} + +#[pagetop::test] +async fn user_theme_sets_the_context_theme() { + let _ = Application::prepare(&UserTheme).await; + let req = web::test::TestRequest::get() + .with_extension(CurrentUser::authenticated(7, "Bob").with_theme("UserTheme")) + .to_http_request(); + let cx = Context::new(req); + assert_eq!(cx.theme().short_name(), "UserTheme"); +} + +#[pagetop::test] +async fn context_uses_the_default_theme_without_user_theme() { + let cx = Context::default(); + assert_eq!(cx.theme().short_name(), "Basic"); +} + // **< HttpRequest::extension() >******************************************************************* #[pagetop::test] @@ -71,11 +134,7 @@ async fn request_extension_returns_none_for_unknown_type() { #[pagetop::test] async fn request_extension_returns_injected_value() { let req = web::test::TestRequest::get() - .with_extension(CurrentUser::Authenticated { - id: 1, - display_name: "Carol".to_owned(), - timezone: None, - }) + .with_extension(CurrentUser::authenticated(1, "Carol")) .to_http_request(); let user = req @@ -91,11 +150,7 @@ async fn request_extension_returns_injected_value() { #[pagetop::test] async fn page_new_propagates_current_user_from_request_extensions() { let req = web::test::TestRequest::get() - .with_extension(CurrentUser::Authenticated { - id: 5, - display_name: "Dave".to_owned(), - timezone: None, - }) + .with_extension(CurrentUser::authenticated(5, "Dave")) .to_http_request(); let page = Page::new(req); let user = page.current_user(); diff --git a/tests/component_form_select_language.rs b/tests/component_form_select_language.rs new file mode 100644 index 00000000..db9fd091 --- /dev/null +++ b/tests/component_form_select_language.rs @@ -0,0 +1,101 @@ +use pagetop::prelude::*; + +#[pagetop::test] +async fn offers_every_supported_language_and_marks_the_selected_one() { + let mut field = form::SelectLanguage::new() + .with_name("language") + .with_selected("es-ES"); + let html = field.render(&mut Context::default()).await.into_string(); + + for (langid, _) in Locale::supported_languages() { + assert!(html.contains(&format!(r#"value="{langid}""#))); + } + assert!(html.contains(r#""#)); + assert!(!html.contains("Use the site language")); +} + +#[pagetop::test] +async fn required_field_has_no_empty_option_when_a_language_is_selected() { + let mut field = form::SelectLanguage::new() + .with_required(true) + .with_selected("es-ES"); + let html = field.render(&mut Context::default()).await.into_string(); + + assert!(!html.contains(r#"value="""#)); +} + +#[pagetop::test] +async fn language_aliases_select_their_canonical_language() { + for alias in ["es", "es-es", "ES-ES"] { + let mut field = form::SelectLanguage::new().with_selected(alias); + let html = field.render(&mut Context::default()).await.into_string(); + + assert!(html.contains(r#""#)); +} + +#[pagetop::test] +async fn languages_are_sorted_by_their_translated_name() { + let mut field = form::SelectLanguage::new(); + + let mut cx = Context::default().with_langid(&Locale::resolve("es-ES")); + let html = field.render(&mut cx).await.into_string(); + let spanish = html + .find(">Español (España)") + .expect("Spanish name"); + let english = html + .find(">Inglés (Estados Unidos)") + .expect("English name"); + assert!(spanish < english); + + let mut cx = Context::default().with_langid(&Locale::resolve("en-US")); + let html = field.render(&mut cx).await.into_string(); + let spanish = html + .find(">Spanish (Spain)") + .expect("Spanish name"); + let english = html + .find(">English (United States)") + .expect("English name"); + assert!(english < spanish); +} diff --git a/tests/component_form_select_theme.rs b/tests/component_form_select_theme.rs new file mode 100644 index 00000000..0de20229 --- /dev/null +++ b/tests/component_form_select_theme.rs @@ -0,0 +1,64 @@ +// With more than one enabled theme. The single-theme case lives in its own test binary, because the +// theme registry is global to the process. + +use pagetop::prelude::*; + +struct Aurora; + +impl Extension for Aurora { + fn name(&self) -> Lc { + Lc::n("Aurora") + } + + fn theme(&self) -> Option { + Some(&Aurora) + } +} + +impl Theme for Aurora {} + +async fn setup() { + let _ = Application::prepare(&Aurora).await; +} + +#[pagetop::test] +async fn themes_are_sorted_by_name_and_the_selected_one_is_marked() { + setup().await; + let mut field = form::SelectTheme::new() + .with_name("theme") + .with_selected("Basic"); + let html = field.render(&mut Context::default()).await.into_string(); + + let aurora = html.find(r#""#)); + + let mut chosen = form::SelectTheme::new() + .with_required(true) + .with_selected("Aurora"); + let html = chosen.render(&mut Context::default()).await.into_string(); + assert!(!html.contains(r#"value="""#)); +} diff --git a/tests/component_form_select_theme_single.rs b/tests/component_form_select_theme_single.rs new file mode 100644 index 00000000..8de7b0d4 --- /dev/null +++ b/tests/component_form_select_theme_single.rs @@ -0,0 +1,14 @@ +// With only the default theme enabled. See `component_form_select_theme.rs` for several themes. + +use pagetop::prelude::*; + +#[pagetop::test] +async fn single_theme_is_shown_disabled_and_selected() { + Application::new().await; + let mut field = form::SelectTheme::new().with_name("theme"); + let html = field.render(&mut Context::default()).await.into_string(); + + assert!(html.contains("disabled")); + assert!(html.contains(r#""#)); + assert!(html.contains(r#""#)); + assert!(html.contains(r#""#)); +} + +#[pagetop::test] +async fn legacy_and_fixed_offset_zones_are_not_offered() { + let mut field = form::SelectTimezone::new(); + let html = field.render(&mut Context::default()).await.into_string(); + + assert!(!html.contains(r#"value="US/Eastern""#)); + assert!(!html.contains(r#"value="Japan""#)); + assert!(!html.contains(r#"value="Etc/GMT+1""#)); +} + +#[pagetop::test] +async fn optional_field_offers_the_site_time_zone_first() { + let mut field = form::SelectTimezone::new(); + let html = field.render(&mut Context::default()).await.into_string(); + + let site = html.find(r#""#)); + + let mut unknown = form::SelectTimezone::new() + .with_required(true) + .with_selected("Mars/Olympus"); + let html = unknown.render(&mut Context::default()).await.into_string(); + assert!(html.contains(r#""#)); + + let mut chosen = form::SelectTimezone::new() + .with_required(true) + .with_selected("Europe/Madrid"); + let html = chosen.render(&mut Context::default()).await.into_string(); + assert!(!html.contains(r#"value="""#)); +} diff --git a/tests/datetime.rs b/tests/datetime.rs index 3078cec9..6f8552f7 100644 --- a/tests/datetime.rs +++ b/tests/datetime.rs @@ -23,11 +23,7 @@ async fn resolve_uses_the_timezone_already_resolved_by_the_authenticated_user() let madrid: Tz = "Europe/Madrid".parse().unwrap(); let req = web::test::TestRequest::get() - .with_extension(CurrentUser::Authenticated { - id: 1, - display_name: "Alice".to_owned(), - timezone: Some(madrid), - }) + .with_extension(CurrentUser::authenticated(1, "Alice").with_timezone("Europe/Madrid")) .to_http_request(); let cx = Context::new(req); assert_eq!(cx.timezone(), madrid); From 2299e63585cdd92676aa1a2148a9635cd3c6a343 Mon Sep 17 00:00:00 2001 From: Manuel Cillero Date: Sun, 4 Oct 2026 23:38:40 +0200 Subject: [PATCH 3/6] =?UTF-8?q?=F0=9F=94=A7=20(tools):=20A=C3=B1ade=20page?= =?UTF-8?q?top-admin/user=20al=20changelog?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tools/changelog.sh | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tools/changelog.sh b/tools/changelog.sh index 81c85747..ee673e27 100755 --- a/tools/changelog.sh +++ b/tools/changelog.sh @@ -63,12 +63,18 @@ case "$CRATE" in --exclude-path "helpers/pagetop-minimal/**/*" --exclude-path "helpers/pagetop-statics/**/*" # Extensions + --exclude-path "extensions/pagetop-admin/**/*" --exclude-path "extensions/pagetop-aliner/**/*" --exclude-path "extensions/pagetop-bootsier/**/*" --exclude-path "extensions/pagetop-htmx/**/*" --exclude-path "extensions/pagetop-seaorm/**/*" + --exclude-path "extensions/pagetop-user/**/*" ) ;; + pagetop-admin) + CHANGELOG_FILE="extensions/pagetop-admin/CHANGELOG.md" + PATH_FLAGS=(--include-path "extensions/pagetop-admin/**/*") + ;; pagetop-aliner) CHANGELOG_FILE="extensions/pagetop-aliner/CHANGELOG.md" PATH_FLAGS=(--include-path "extensions/pagetop-aliner/**/*") @@ -85,6 +91,10 @@ case "$CRATE" in CHANGELOG_FILE="extensions/pagetop-seaorm/CHANGELOG.md" PATH_FLAGS=(--include-path "extensions/pagetop-seaorm/**/*") ;; + pagetop-user) + CHANGELOG_FILE="extensions/pagetop-user/CHANGELOG.md" + PATH_FLAGS=(--include-path "extensions/pagetop-user/**/*") + ;; *) echo "Error: unsupported crate '$CRATE'" >&2 exit 1 From f33711921955dda53d627f20c8ed00dd63f49693 Mon Sep 17 00:00:00 2001 From: Manuel Cillero Date: Sun, 4 Oct 2026 23:50:58 +0200 Subject: [PATCH 4/6] =?UTF-8?q?=F0=9F=92=A5=20(admin):=20Exige=20un=20perm?= =?UTF-8?q?iso=20propio=20en=20cada=20p=C3=A1gina?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- extensions/pagetop-admin/src/action.rs | 17 ++++-- extensions/pagetop-admin/src/handlers.rs | 5 +- extensions/pagetop-admin/src/lib.rs | 4 +- extensions/pagetop-admin/src/registry.rs | 71 ++++-------------------- extensions/pagetop-admin/src/seed.rs | 7 +-- extensions/pagetop-user/src/lib.rs | 6 +- 6 files changed, 31 insertions(+), 79 deletions(-) diff --git a/extensions/pagetop-admin/src/action.rs b/extensions/pagetop-admin/src/action.rs index 6bfa7ce5..2d9670e8 100644 --- a/extensions/pagetop-admin/src/action.rs +++ b/extensions/pagetop-admin/src/action.rs @@ -87,9 +87,8 @@ impl ActionBag { /// bag.add(AdminSection { /// key: "tools".to_owned(), /// path: "/admin/tools".to_owned(), -/// title: Lc::n("Tools"), -/// permission: None, -/// weight: 60, +/// title: Lc::n("Tools"), +/// weight: 60, /// }); /// } /// // En Extension::actions(): @@ -130,10 +129,18 @@ impl DeclareAdminSections { /// # Ejemplo /// /// ```rust,no_run -/// use pagetop::locale::Lc; +/// use pagetop::prelude::*; /// use pagetop_admin::action::{DeclareAdminPages, PageBag}; /// use pagetop_admin::registry::{AdminPage, AdminPageKind}; /// +/// struct ExportPermission; +/// +/// impl Permission for ExportPermission { +/// fn key(&self) -> CowStr { +/// "tools:export".into() +/// } +/// } +/// /// fn declare_pages(bag: &mut PageBag) { /// bag.add(AdminPage { /// path: "/admin/tools/export".to_owned(), @@ -141,7 +148,7 @@ impl DeclareAdminSections { /// title: Lc::n("Export"), /// description: Some(Lc::n("Export site data.")), /// weight: 0, -/// permission: None, +/// permission: &ExportPermission, /// kind: AdminPageKind::View, /// }); /// } diff --git a/extensions/pagetop-admin/src/handlers.rs b/extensions/pagetop-admin/src/handlers.rs index c9a3d979..d197514f 100644 --- a/extensions/pagetop-admin/src/handlers.rs +++ b/extensions/pagetop-admin/src/handlers.rs @@ -69,7 +69,6 @@ fn render_sections(cx: &Context, candidates: &[&'static AdminSection]) -> Option let sections_with_pages: Vec<_> = candidates .iter() .copied() - .filter(|section| section.is_visible(cx)) .map(|section| { let pages: Vec<_> = reg .pages_for_section(§ion.key) @@ -131,7 +130,7 @@ pub async fn config_form_get(request: HttpRequest) -> Result return Err(ErrorPage::NotFound(Some(request.clone()))); }; - require_permission(&request, page.permission_key())?; + require_permission(&request, page.permission)?; let title = page.title.clone(); let mut form = ConfigForm::with_schema(schema.clone()); @@ -170,7 +169,7 @@ pub async fn config_form_post( return Err(ErrorPage::NotFound(Some(request.clone()))); }; - require_permission(&request, page.permission_key())?; + require_permission(&request, page.permission)?; let mut save_error = false; diff --git a/extensions/pagetop-admin/src/lib.rs b/extensions/pagetop-admin/src/lib.rs index fbf6b053..e2ed8718 100644 --- a/extensions/pagetop-admin/src/lib.rs +++ b/extensions/pagetop-admin/src/lib.rs @@ -50,7 +50,7 @@ fn declare_pages(bag: &mut PageBag) { title: Lc::n("My App"), description: Some(Lc::n("Configure My App.")), weight: 0, - permission: Some(&MyPermission::Config), + permission: &MyPermission::Config, kind: AdminPageKind::View, }); } @@ -63,7 +63,7 @@ enum MyPermission { impl Permission for MyPermission { fn key(&self) -> CowStr { match self { - Self::Config => "myapp.config".into(), + Self::Config => "myapp:config".into(), } } } diff --git a/extensions/pagetop-admin/src/registry.rs b/extensions/pagetop-admin/src/registry.rs index 4ebbbd4b..586cfc45 100644 --- a/extensions/pagetop-admin/src/registry.rs +++ b/extensions/pagetop-admin/src/registry.rs @@ -14,38 +14,12 @@ use crate::action::{ }; use crate::settings::SettingsSchema; -// **< AdminPermission >**************************************************************************** - -/// Permisos propios de `pagetop-admin`. -#[derive(Clone, Copy, Debug)] -pub enum AdminPermission { - /// Acceso por defecto a una página de administración que no declara un permiso propio. - Access, - /// Acceso a la sección integrada "people". - AccessPeople, - /// Acceso a la sección integrada "structure". - AccessStructure, - /// Acceso a la sección integrada "config". - AccessConfig, - /// Acceso a la sección integrada "reports". - AccessReports, -} - -impl Permission for AdminPermission { - fn key(&self) -> CowStr { - match self { - Self::Access => "admin:access".into(), - Self::AccessPeople => "admin.access_people".into(), - Self::AccessStructure => "admin.access_structure".into(), - Self::AccessConfig => "admin.access_config".into(), - Self::AccessReports => "admin.access_reports".into(), - } - } -} - // **< Tipos del registro >************************************************************************* -/// Sección del panel de administración (agrupación en el sidebar). +/// Sección del panel de administración (agrupación de páginas en el menú y en el *dashboard*). +/// +/// Una sección no tiene permiso propio: se muestra a quien pueda acceder a alguna de sus páginas, y +/// se oculta si no le queda ninguna. #[derive(Clone)] pub struct AdminSection { /// Identificador único de la sección (p. ej. `"config"`). @@ -54,24 +28,10 @@ pub struct AdminSection { pub path: String, /// Título visible en el sidebar. pub title: Lc, - /// Permiso requerido para ver la sección (`None` = siempre visible). - pub permission: Option, /// Peso para ordenar en el sidebar (menor = antes). pub weight: i32, } -impl AdminSection { - /// Devuelve `true` si el usuario actual puede ver esta sección. - pub fn is_visible(&self, cx: &Context) -> bool { - match self.permission { - None => true, - Some(permission) => cx - .request() - .is_some_and(|request| has_permission(request, permission)), - } - } -} - /// Página del panel de administración. #[derive(Clone)] pub struct AdminPage { @@ -85,22 +45,18 @@ pub struct AdminPage { pub description: Option, /// Peso dentro de la sección (menor = antes). pub weight: i32, - /// Permiso requerido para acceder (`None` = requiere [`AdminPermission::Access`]). - pub permission: Option, + /// Permiso requerido para acceder. Es obligatorio: cada página declara el suyo, y es también + /// el que decide si su sección se muestra. + pub permission: PermissionRef, /// Tipo de página y datos asociados. pub kind: AdminPageKind, } impl AdminPage { - /// Permiso efectivo: el declarado, o [`AdminPermission::Access`] si no se especificó ninguno. - pub fn permission_key(&self) -> PermissionRef { - self.permission.unwrap_or(&AdminPermission::Access) - } - /// Devuelve `true` si el usuario actual puede acceder a esta página. pub fn is_accessible(&self, cx: &Context) -> bool { cx.request() - .is_some_and(|request| has_permission(request, self.permission_key())) + .is_some_and(|request| has_permission(request, self.permission)) } } @@ -277,11 +233,9 @@ pub fn global() -> &'static AdminRegistry { 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)) + reg.pages_for_section(§ion.key) + .into_iter() + .any(|page| page.is_accessible(cx)) }) } @@ -306,9 +260,6 @@ pub fn admin_navbar(cx: &Context) -> Navbar { )); for section in reg.ordered_sections() { - if !section.is_visible(cx) { - continue; - } let pages: Vec<_> = reg .pages_for_section(§ion.key) .into_iter() diff --git a/extensions/pagetop-admin/src/seed.rs b/extensions/pagetop-admin/src/seed.rs index cf4c6640..b0fea446 100644 --- a/extensions/pagetop-admin/src/seed.rs +++ b/extensions/pagetop-admin/src/seed.rs @@ -3,7 +3,7 @@ use pagetop::prelude::*; use crate::ADMIN_BASE_PATH; use crate::LOCALES_ADMIN; use crate::action::SectionBag; -use crate::registry::{AdminPermission, AdminSection}; +use crate::registry::AdminSection; /// Declara las secciones incorporadas del panel de administración. /// @@ -16,35 +16,30 @@ pub(crate) fn declare_default_sections(bag: &mut SectionBag) { key: "people".to_owned(), path: format!("{}/people", base), title: Lc::t("section-people", &LOCALES_ADMIN), - permission: Some(&AdminPermission::AccessPeople), weight: 10, }); bag.add(AdminSection { key: "structure".to_owned(), path: format!("{}/structure", base), title: Lc::t("section-structure", &LOCALES_ADMIN), - permission: Some(&AdminPermission::AccessStructure), weight: 20, }); bag.add(AdminSection { key: "config".to_owned(), path: format!("{}/config", base), title: Lc::t("section-config", &LOCALES_ADMIN), - permission: Some(&AdminPermission::AccessConfig), weight: 30, }); bag.add(AdminSection { key: "reports".to_owned(), path: format!("{}/reports", base), title: Lc::t("section-reports", &LOCALES_ADMIN), - permission: Some(&AdminPermission::AccessReports), weight: 40, }); bag.add(AdminSection { key: "help".to_owned(), path: format!("{}/help", base), title: Lc::t("section-help", &LOCALES_ADMIN), - permission: None, weight: 50, }); } diff --git a/extensions/pagetop-user/src/lib.rs b/extensions/pagetop-user/src/lib.rs index 6724df04..4147b1ca 100644 --- a/extensions/pagetop-user/src/lib.rs +++ b/extensions/pagetop-user/src/lib.rs @@ -155,7 +155,7 @@ fn declare_admin_pages(bag: &mut PageBag) { title: Lc::t("title-admin-users", &LOCALES_USER), description: Some(Lc::t("description-admin-users", &LOCALES_USER)), weight: 0, - permission: Some(&permission::UserPermission::AdminUsers), + permission: &permission::UserPermission::AdminUsers, kind: AdminPageKind::View, }); bag.add(AdminPage { @@ -164,7 +164,7 @@ fn declare_admin_pages(bag: &mut PageBag) { title: Lc::t("title-admin-roles", &LOCALES_USER), description: Some(Lc::t("description-admin-roles", &LOCALES_USER)), weight: 10, - permission: Some(&permission::UserPermission::AdminRoles), + permission: &permission::UserPermission::AdminRoles, kind: AdminPageKind::View, }); bag.add(AdminPage { @@ -173,7 +173,7 @@ fn declare_admin_pages(bag: &mut PageBag) { title: Lc::t("title-admin-permissions", &LOCALES_USER), description: Some(Lc::t("description-admin-permissions", &LOCALES_USER)), weight: 20, - permission: Some(&permission::UserPermission::AdminPermissions), + permission: &permission::UserPermission::AdminPermissions, kind: AdminPageKind::View, }); } From 806ed9e0dc6c1159afa6a81ee0270fe315883b83 Mon Sep 17 00:00:00 2001 From: Manuel Cillero Date: Mon, 5 Oct 2026 00:10:53 +0200 Subject: [PATCH 5/6] =?UTF-8?q?=F0=9F=92=84=20(user):=20Muestra=20s=C3=B3l?= =?UTF-8?q?o=20permisos=20concedidos=20al=20rol?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- assets/scss/basic/_user.scss | 13 ------ .../src/component/admin/role_table.rs | 33 +++++++++----- .../pagetop-user/src/handlers/admin/roles.rs | 44 ++++++++++--------- .../pagetop-user/src/locale/en-US/common.ftl | 18 ++++---- .../pagetop-user/src/locale/es-ES/common.ftl | 18 ++++---- .../pagetop-user/src/service/role_admin.rs | 16 ++++++- 6 files changed, 78 insertions(+), 64 deletions(-) diff --git a/assets/scss/basic/_user.scss b/assets/scss/basic/_user.scss index 38e4d094..877b56a1 100644 --- a/assets/scss/basic/_user.scss +++ b/assets/scss/basic/_user.scss @@ -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; diff --git a/extensions/pagetop-user/src/component/admin/role_table.rs b/extensions/pagetop-user/src/component/admin/role_table.rs index aaa6c68d..79ae47f4 100644 --- a/extensions/pagetop-user/src/component/admin/role_table.rs +++ b/extensions/pagetop-user/src/component/admin/role_table.rs @@ -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()) diff --git a/extensions/pagetop-user/src/handlers/admin/roles.rs b/extensions/pagetop-user/src/handlers/admin/roles.rs index 023621a4..0dc5032c 100644 --- a/extensions/pagetop-user/src/handlers/admin/roles.rs +++ b/extensions/pagetop-user/src/handlers/admin/roles.rs @@ -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 { - groups + let blocks: Vec = 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`). +/// 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`). pub(crate) async fn permissions_post( request: HttpRequest, web::Path(id): web::Path, diff --git a/extensions/pagetop-user/src/locale/en-US/common.ftl b/extensions/pagetop-user/src/locale/en-US/common.ftl index e6220586..8467f8a4 100644 --- a/extensions/pagetop-user/src/locale/en-US/common.ftl +++ b/extensions/pagetop-user/src/locale/en-US/common.ftl @@ -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 >** diff --git a/extensions/pagetop-user/src/locale/es-ES/common.ftl b/extensions/pagetop-user/src/locale/es-ES/common.ftl index a010099e..3b709b0c 100644 --- a/extensions/pagetop-user/src/locale/es-ES/common.ftl +++ b/extensions/pagetop-user/src/locale/es-ES/common.ftl @@ -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 >** diff --git a/extensions/pagetop-user/src/service/role_admin.rs b/extensions/pagetop-user/src/service/role_admin.rs index 569b080e..6d6fdbab 100644 --- a/extensions/pagetop-user/src/service/role_admin.rs +++ b/extensions/pagetop-user/src/service/role_admin.rs @@ -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) -> Result, AuthError> { let role_ids: Vec = 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) -> Result, AuthEr .into_iter() .map(|(role_id, count)| (role_id, count as u64)) .collect(); + let with_permissions: HashSet = 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, From f81918187260a9e03533757bc0ad884484efb452 Mon Sep 17 00:00:00 2001 From: Manuel Cillero Date: Mon, 5 Oct 2026 00:36:47 +0200 Subject: [PATCH 6/6] =?UTF-8?q?=E2=9C=A8=20(user):=20Asigna=20roles=20desd?= =?UTF-8?q?e=20la=20edici=C3=B3n=20de=20usuario?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- extensions/pagetop-user/src/account.rs | 60 ++- extensions/pagetop-user/src/auth.rs | 77 +++- extensions/pagetop-user/src/component.rs | 54 +++ .../pagetop-user/src/component/admin.rs | 10 +- .../src/component/admin/user_form.rs | 145 ++++--- .../src/component/admin/user_roles_form.rs | 69 ---- .../src/component/admin/user_table.rs | 95 ++--- .../src/component/change_password_form.rs | 56 +++ .../src/component/password_confirm.rs | 6 +- .../pagetop-user/src/component/user_name.rs | 109 ++++++ extensions/pagetop-user/src/demo.rs | 2 + extensions/pagetop-user/src/entity/user.rs | 4 + extensions/pagetop-user/src/error.rs | 9 + .../pagetop-user/src/handlers/account.rs | 370 +++++++++++++++++- extensions/pagetop-user/src/handlers/admin.rs | 5 + .../pagetop-user/src/handlers/admin/users.rs | 363 +++++++++-------- extensions/pagetop-user/src/lib.rs | 33 +- .../pagetop-user/src/locale/en-US/common.ftl | 28 +- .../src/locale/en-US/permissions.ftl | 6 +- .../pagetop-user/src/locale/es-ES/common.ftl | 28 +- .../src/locale/es-ES/permissions.ftl | 6 +- .../m20260629_000001_create_users.rs | 5 + extensions/pagetop-user/src/permission.rs | 46 +-- .../pagetop-user/src/service/user_admin.rs | 72 +++- extensions/pagetop-user/src/session.rs | 67 ++-- extensions/pagetop-user/tests/user_name.rs | 38 ++ 26 files changed, 1219 insertions(+), 544 deletions(-) delete mode 100644 extensions/pagetop-user/src/component/admin/user_roles_form.rs create mode 100644 extensions/pagetop-user/src/component/change_password_form.rs create mode 100644 extensions/pagetop-user/src/component/user_name.rs create mode 100644 extensions/pagetop-user/tests/user_name.rs diff --git a/extensions/pagetop-user/src/account.rs b/extensions/pagetop-user/src/account.rs index 6cf50562..ffe5eccc 100644 --- a/extensions/pagetop-user/src/account.rs +++ b/extensions/pagetop-user/src/account.rs @@ -4,6 +4,8 @@ use std::collections::HashSet; use pagetop::prelude::*; +use crate::entity::user; + // **< UserStatus >********************************************************************************* /// Estado de la cuenta de usuario. @@ -59,39 +61,65 @@ impl PermissionSet { /// Se almacena en las extensiones de la petición HTTP durante la fase de middleware y se accede /// desde los handlers o desde handlers de [`CheckPermission`] mediante /// [`HttpRequest::extension::()`](pagetop::web::HttpRequest::extension). -#[derive(Clone, Debug)] +#[derive(Clone, Debug, Getters)] pub struct Account { - pub id: i32, - pub username: String, - pub email: String, - pub display_name: String, - pub status: UserStatus, + /// Identificador del usuario. + id: i32, + /// Nombre de usuario. + username: String, + /// Correo electrónico. + email: String, + /// Nombre para mostrar; cadena vacía si no está definido (ver [`Account::display`]). + display_name: String, + /// Estado de la cuenta. + #[getters(copy)] + status: UserStatus, /// Nombres de máquina de los roles asignados explícitamente. No incluye "authenticated", que /// es implícito (ver [`Account::has_role`]). - pub roles: Vec, - /// Unión de permisos de todos sus roles. - pub permissions: PermissionSet, - /// `true` si alguno de sus roles tiene `is_admin = true`. - pub is_admin: bool, + roles: Vec, + // Unión de permisos de sus roles y del rol implícito "authenticated". Vacío si es + // administrador: no se cargan porque `has_permission()` ya concede todos. Sin getter para que + // la única consulta posible sea `has_permission()`. + #[getters(skip)] + permissions: PermissionSet, + /// `true` si el usuario tiene acceso sin restricciones (`user.is_admin`), con independencia de + /// sus roles. + #[getters(copy)] + is_admin: bool, } impl Account { + // Sólo se construye desde la carga de sesión, para que ningún otro crate pueda fabricar una + // cuenta e inyectarla en la petición. + pub(crate) fn new(user: user::Model, roles: Vec, permissions: PermissionSet) -> Self { + Account { + id: user.id, + username: user.username, + email: user.email, + display_name: user.display_name.unwrap_or_default(), + status: UserStatus::from_i16(user.status), + roles, + permissions, + is_admin: user.is_admin, + } + } + /// Comprueba si la cuenta tiene el permiso indicado, teniendo en cuenta el flag `is_admin`. pub fn has_permission(&self, perm: PermissionRef) -> bool { - self.is_admin || self.permissions.contains(perm.key().as_ref()) + self.is_admin() || self.permissions.contains(perm.key().as_ref()) } /// Devuelve el nombre visible: `display_name` si está definido, o `username`. pub fn display(&self) -> &str { - if self.display_name.is_empty() { - &self.username + if self.display_name().is_empty() { + self.username() } else { - &self.display_name + self.display_name() } } /// Comprueba si la cuenta tiene el rol indicado ("authenticated" siempre se cumple). pub fn has_role(&self, machine_name: &str) -> bool { - machine_name == "authenticated" || self.roles.iter().any(|r| r == machine_name) + machine_name == "authenticated" || self.roles().iter().any(|r| r == machine_name) } } diff --git a/extensions/pagetop-user/src/auth.rs b/extensions/pagetop-user/src/auth.rs index 6d737bda..a2832d70 100644 --- a/extensions/pagetop-user/src/auth.rs +++ b/extensions/pagetop-user/src/auth.rs @@ -1,4 +1,4 @@ -//! Lógica de autenticación: login, logout, registro, semilla inicial. +//! Lógica de autenticación: login, logout, registro, cambio de contraseña, semilla inicial. use pagetop::prelude::*; use pagetop_seaorm::db::{ @@ -8,9 +8,10 @@ use pagetop_seaorm::db::{ use crate::account::UserStatus; use crate::config::SETTINGS; -use crate::entity::{user, user_role}; +use crate::entity::{role_permission, user, user_role}; use crate::error::AuthError; use crate::password; +use crate::permission::UserPermission; use crate::session; // **< login >************************************************************************************** @@ -135,7 +136,9 @@ pub async fn register( status: Set(status.as_i16()), language: Set(None), timezone: Set(None), + theme: Set(None), display_name: Set(None), + about: Set(None), last_login_at: Set(None), last_access_at: Set(None), failed_login_count: Set(0), @@ -171,6 +174,44 @@ pub async fn assign_role(user_id: i32, role_id: i32) -> Result<(), AuthError> { Ok(()) } +// **< change_own_password >************************************************************************ + +/// Cambia la contraseña del propio usuario tras comprobar la actual, y cierra el resto de sus +/// sesiones abiertas conservando la indicada en `current_sid` (la de quien hace el cambio). +/// +/// Devuelve [`AuthError::InvalidCredentials`] si la contraseña actual no es correcta. +pub(crate) async fn change_own_password( + user_id: i32, + current_password: &str, + new_password: &str, + current_sid: Option<&str>, +) -> Result<(), AuthError> { + let user_model = user::Entity::find_by_id(user_id) + .one(dbconn()) + .await? + .ok_or(AuthError::UserNotFound)?; + if !password::verify_password(current_password, &user_model.password_hash) { + return Err(AuthError::InvalidCredentials); + } + password::validate_strength(new_password)?; + let hash = password::hash_password(new_password)?; + + user::ActiveModel { + id: Set(user_id), + password_hash: Set(hash), + updated_at: Set(Utc::now()), + ..Default::default() + } + .update(dbconn()) + .await?; + + match current_sid { + Some(sid) => session::destroy_other_sessions(user_id, sid).await?, + None => session::destroy_user_sessions(user_id).await?, + } + Ok(()) +} + // **< register_failed_login >********************************************************************** async fn register_failed_login( @@ -197,11 +238,15 @@ async fn register_failed_login( // **< seed_initial_data >************************************************************************** -/// Crea el usuario administrador inicial si no existe ningún usuario en la base de datos. +/// Prepara una instalación nueva si no existe ningún usuario en la base de datos. /// /// Se llama desde `Extension::initialize()`. Si la tabla está vacía, crea el administrador /// con las credenciales configuradas en `[user.seed]`. La contraseña se genera aleatoriamente /// si no está configurada, y se imprime por stdout una sola vez para que el operador la recoja. +/// +/// Concede además al rol "authenticated" los permisos que todo usuario espera tener sobre su +/// propia cuenta: editar su perfil y cambiar su contraseña. Sólo en la instalación nueva; después +/// los gestiona el administrador desde la interfaz. pub(crate) async fn seed_initial_data() { do_seed().await; } @@ -246,7 +291,9 @@ async fn do_seed() { status: Set(UserStatus::Active.as_i16()), language: Set(None), timezone: Set(None), + theme: Set(None), display_name: Set(Some("Administrator".into())), + about: Set(None), last_login_at: Set(None), last_access_at: Set(None), failed_login_count: Set(0), @@ -265,6 +312,28 @@ async fn do_seed() { ); } } - Err(e) => eprintln!("pagetop-user seed error: {}", e), + Err(e) => { + eprintln!("pagetop-user seed error: {}", e); + return; + } + } + + let defaults = [ + UserPermission::EditOwnProfile, + UserPermission::ChangeOwnPassword, + ]; + let rows = defaults.map(|perm| role_permission::ActiveModel { + role_id: Set(crate::AUTHENTICATED_ROLE_ID), + permission_key: Set(perm.key().into_owned()), + granted_at: Set(now), + }); + if let Err(e) = role_permission::Entity::insert_many(rows) + .exec(dbconn()) + .await + { + eprintln!( + "pagetop-user seed error: failed to grant default permissions: {}", + e + ); } } diff --git a/extensions/pagetop-user/src/component.rs b/extensions/pagetop-user/src/component.rs index 6746e469..7f94f830 100644 --- a/extensions/pagetop-user/src/component.rs +++ b/extensions/pagetop-user/src/component.rs @@ -3,21 +3,75 @@ pub(crate) mod admin; mod account_menu; +mod change_password_form; mod login_form; mod password_confirm; mod password_reset_confirm_form; mod password_reset_form; mod register_form; +mod user_name; pub use account_menu::{AccountMenu, account_menu}; +pub(crate) use change_password_form::ChangePasswordForm; 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_name::UserName; use pagetop::prelude::*; +use crate::LOCALES_USER; + +// Texto libre de varias líneas como contenido seguro: cada salto de línea se convierte en `
`, +// sin depender del CSS del tema. Se usa para mostrar el "Sobre mí" del usuario. +pub(crate) fn multiline_text(text: String) -> Html { + Html::with(move |_| { + html! { + @for (i, line) in text.lines().enumerate() { + @if i > 0 { br; } + (line) + } + } + }) +} + +// Nombre traducido del idioma guardado en el perfil de un usuario, o "-" si no tiene ninguno. Un +// identificador que ya no esté entre los soportados se muestra tal cual. +pub(crate) fn language_name(language: Option<&str>) -> Lc { + let Some(code) = language else { + return Lc::n("-"); + }; + Locale::supported_languages() + .into_iter() + .find(|(langid, _)| langid.to_string() == code) + .map_or_else(|| Lc::n(code.to_owned()), |(_, name)| name) +} + +// Nombre traducido del tema guardado en el perfil de un usuario o, si no tiene ninguno, el del tema +// predeterminado del sitio, que es el que se le aplica. Un tema que ya no esté habilitado se +// muestra con el nombre guardado. +pub(crate) fn theme_name(theme: Option<&str>) -> table::Cell { + let Some(name) = theme else { + // Los argumentos de `Lc` son texto fijo: el nombre del tema se traduce al renderizar. + return Html::with(|cx| { + let theme = default_theme(); + let name = theme + .name() + .lookup(cx) + .unwrap_or_else(|| theme.short_name().to_owned()); + Lc::t("value-theme-site-default", &LOCALES_USER) + .with_arg("theme", name) + .using(cx) + }) + .into(); + }; + theme_by_short_name(name) + .map_or_else(|| Lc::n(name.to_owned()), |theme| theme.name()) + .into() +} + // Banner de error de formulario; se renderiza vacío si `error` es `None`. Compartido por los // formularios de autenticación y por los de administración. pub(crate) fn error_banner(error: Option) -> Html { diff --git a/extensions/pagetop-user/src/component/admin.rs b/extensions/pagetop-user/src/component/admin.rs index 01835906..b01025c7 100644 --- a/extensions/pagetop-user/src/component/admin.rs +++ b/extensions/pagetop-user/src/component/admin.rs @@ -7,7 +7,6 @@ mod role_form; mod role_permissions_form; mod role_table; mod user_form; -mod user_roles_form; mod user_table; pub(crate) use admin_password_form::AdminPasswordForm; @@ -15,7 +14,6 @@ pub(crate) use role_form::{RoleForm, RoleFormMode}; pub(crate) use role_permissions_form::RolePermissionsForm; pub(crate) use role_table::RoleTable; pub(crate) use user_form::{UserForm, UserFormMode}; -pub(crate) use user_roles_form::UserRolesForm; pub(crate) use user_table::{UserTable, status_key}; use pagetop::prelude::*; @@ -41,10 +39,10 @@ pub(crate) type PermissionGroups = Vec<(Lc, Vec)>; // **< HELPERS >************************************************************************************ -// `Fieldset` con las casillas para asignar roles (usado en el alta de usuario y en la pantalla de -// asignación de roles). El rol "authenticated" no se lista como casilla ni se envía: todo usuario -// autenticado lo tiene concedido por definición (ver `session::load_user_from_session`), sin -// necesidad de una fila en `user_role`. +// `Fieldset` con las casillas para asignar roles (usado en el alta y en la edición de usuario). El +// rol "authenticated" no se lista como casilla ni se envía: todo usuario autenticado lo tiene +// concedido por definición (ver `session::load_user_from_session`), sin necesidad de una fila en +// `user_role`. pub(crate) fn roles_fieldset(roles: &[(i32, String, bool)]) -> form::Fieldset { let mut field = form::check::Field::new().with_name("role_ids"); for (role_id, label, checked) in roles { diff --git a/extensions/pagetop-user/src/component/admin/user_form.rs b/extensions/pagetop-user/src/component/admin/user_form.rs index 42ccd0e2..0f50aee5 100644 --- a/extensions/pagetop-user/src/component/admin/user_form.rs +++ b/extensions/pagetop-user/src/component/admin/user_form.rs @@ -1,49 +1,23 @@ -//! Formulario de alta/edición de usuario. - -use std::collections::BTreeMap; -use std::sync::LazyLock; +//! Formulario de alta/edición de usuario, también usado para que cada usuario edite su perfil. use pagetop::prelude::*; -use crate::ADMIN_USERS_PATH; use crate::LOCALES_USER; use crate::user_path; +use crate::{ADMIN_USERS_PATH, PROFILE_EDIT_PATH}; use crate::component::{PasswordConfirm, error_banner}; +use crate::service::user_admin::ABOUT_MAX_CHARS; use super::{USER_ADMIN_FORM_ID, roles_fieldset}; -// Regiones de la base IANA que sólo contienen alias heredados (fichero `backward`), todos con una -// zona canónica equivalente en otra región (p. ej. `US/Eastern` es `America/New_York`). -const LEGACY_REGIONS: [&str; 5] = ["Brazil", "Canada", "Chile", "Mexico", "US"]; - -// Zonas horarias IANA canónicas agrupadas por región (lo anterior a la primera `/`), ordenadas por -// región y nombre. Se descartan los alias heredados: los nombres sin región (`GB`, `Japan`, -// `EST5EDT`...), los de `LEGACY_REGIONS` y los de `Etc` salvo `Etc/UTC`, cuyo grupo va al final. -static TZ_BY_REGION: LazyLock)>> = LazyLock::new(|| { - let mut regions: BTreeMap<&'static str, Vec<&'static str>> = BTreeMap::new(); - for tz in TZ_VARIANTS.iter() { - let name = tz.name(); - let Some((region, _)) = name.split_once('/') else { - continue; - }; - if LEGACY_REGIONS.contains(®ion) || (region == "Etc" && name != "Etc/UTC") { - continue; - } - regions.entry(region).or_default().push(name); - } - for names in regions.values_mut() { - names.sort_unstable(); - } - let etc = regions.remove_entry("Etc"); - regions.into_iter().chain(etc).collect() -}); - #[derive(AutoDefault, Clone, Copy, Debug, PartialEq)] pub(crate) enum UserFormMode { #[default] New, Edit, + /// Edición del perfil propio: sin roles ni casilla de administrador. + Profile, } #[derive(AutoDefault, Clone, Debug, Getters)] @@ -56,12 +30,17 @@ pub(crate) struct UserForm { username: String, email: String, display_name: String, + /// Texto "Sobre mí"; sólo se edita en los modos `Edit` y `Profile`, no en el alta. + about: String, language: String, timezone: String, - /// Roles asignables (excluye "anonymous" y "authenticated"); sólo se renderiza en modo `New`. + theme: String, + /// Roles asignables (excluye "anonymous" y "authenticated"); si no hay ninguno, no se muestra. roles: Vec<(i32, String, bool)>, /// Si se ofrece la casilla "administrador"; sólo cuando quien da de alta ya es administrador. allow_admin_field: bool, + /// En modo `Profile`, si el nombre de usuario es editable; si no, se muestra de sólo lectura. + allow_username_field: bool, is_admin: bool, } @@ -75,7 +54,10 @@ impl Component for UserForm { let action = match self.mode() { UserFormMode::New => util::join!(ADMIN_USERS_PATH, "/new"), UserFormMode::Edit => user_path(self.user_id().copied().unwrap_or_default(), "edit"), + UserFormMode::Profile => PROFILE_EDIT_PATH.into(), }; + let username_readonly = + *self.mode() == UserFormMode::Profile && !*self.allow_username_field(); let action = self.waypoint().append_to(cx.route(action)); let mut form = Form::new() @@ -89,6 +71,7 @@ impl Component for UserForm { .with_value(self.username()) .with_label(Lc::t("field-username-admin", &LOCALES_USER)) .with_required(true) + .with_readonly(username_readonly) .with_maxlength(Some(64)), ) .with_child( @@ -105,33 +88,54 @@ impl Component for UserForm { .with_label(Lc::t("field-display-name", &LOCALES_USER)), ) .with_child( - form::input::Field::text() + form::SelectLanguage::new() .with_name("language") - .with_value(self.language()) - .with_label(Lc::t("field-language", &LOCALES_USER)), + .with_label(Lc::t("field-language", &LOCALES_USER)) + .with_selected(self.language()), ) - .with_child(timezone_field(self.timezone())); + .with_child( + form::SelectTimezone::new() + .with_name("timezone") + .with_label(Lc::t("field-timezone", &LOCALES_USER)) + .with_selected(self.timezone()), + ) + .with_child( + form::SelectTheme::new() + .with_name("theme") + .with_label(Lc::t("field-theme", &LOCALES_USER)) + .with_selected(self.theme()), + ); if *self.mode() == UserFormMode::New { form = form.with_child(PasswordConfirm::new()); - if !self.roles().is_empty() { - form = form.with_child(roles_fieldset(self.roles())); - } + } else { + form = form.with_child( + form::Textarea::new() + .with_name("about") + .with_value(self.about()) + .with_label(Lc::t("field-about", &LOCALES_USER)) + .with_rows(Some(5)) + .with_maxlength(Some(ABOUT_MAX_CHARS)), + ); + } - if *self.allow_admin_field() { - form = form.with_child( - form::Checkbox::check() - .with_name("is_admin") - .with_label(Lc::t("field-is-admin", &LOCALES_USER)) - .with_checked(*self.is_admin()), - ); - } + if *self.mode() != UserFormMode::Profile && !self.roles().is_empty() { + form = form.with_child(roles_fieldset(self.roles())); + } + + if *self.mode() == UserFormMode::New && *self.allow_admin_field() { + form = form.with_child( + form::Checkbox::check() + .with_name("is_admin") + .with_label(Lc::t("field-is-admin", &LOCALES_USER)) + .with_checked(*self.is_admin()), + ); } // En modo `Edit`, "Guardar" se renderiza fuera del formulario, junto al resto de acciones - // de la pantalla (ver `USER_ADMIN_FORM_ID`); en modo `New` no hay ninguna botonera con la - // que agruparlo, así que se queda aquí, dentro del propio `
`. - if *self.mode() == UserFormMode::New { + // de la pantalla (ver `USER_ADMIN_FORM_ID`); en los demás modos no hay ninguna botonera con + // la que agruparlo, así que se queda aquí, dentro del propio ``. + if *self.mode() != UserFormMode::Edit { form = form.with_child( Button::submit(Lc::t("btn-save", &LOCALES_USER)) .with_style(button::Style::Solid(Intent::Primary)), @@ -181,6 +185,11 @@ impl UserForm { self } + pub(crate) fn with_about(mut self, about: impl Into) -> Self { + self.about = about.into(); + self + } + pub(crate) fn with_language(mut self, language: impl Into) -> Self { self.language = language.into(); self @@ -191,6 +200,11 @@ impl UserForm { self } + pub(crate) fn with_theme(mut self, theme: impl Into) -> Self { + self.theme = theme.into(); + self + } + pub(crate) fn with_roles(mut self, roles: Vec<(i32, String, bool)>) -> Self { self.roles = roles; self @@ -201,34 +215,13 @@ impl UserForm { self } + pub(crate) fn with_allow_username_field(mut self, allow_username_field: bool) -> Self { + self.allow_username_field = allow_username_field; + self + } + pub(crate) fn with_is_admin(mut self, is_admin: bool) -> Self { self.is_admin = is_admin; self } } - -// `