diff --git a/assets/scss/basic/_user.scss b/assets/scss/basic/_user.scss index 877b56a1..38e4d094 100644 --- a/assets/scss/basic/_user.scss +++ b/assets/scss/basic/_user.scss @@ -38,6 +38,19 @@ 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-admin/src/action.rs b/extensions/pagetop-admin/src/action.rs index 2d9670e8..6bfa7ce5 100644 --- a/extensions/pagetop-admin/src/action.rs +++ b/extensions/pagetop-admin/src/action.rs @@ -87,8 +87,9 @@ impl ActionBag { /// bag.add(AdminSection { /// key: "tools".to_owned(), /// path: "/admin/tools".to_owned(), -/// title: Lc::n("Tools"), -/// weight: 60, +/// title: Lc::n("Tools"), +/// permission: None, +/// weight: 60, /// }); /// } /// // En Extension::actions(): @@ -129,18 +130,10 @@ impl DeclareAdminSections { /// # Ejemplo /// /// ```rust,no_run -/// use pagetop::prelude::*; +/// use pagetop::locale::Lc; /// 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(), @@ -148,7 +141,7 @@ impl DeclareAdminSections { /// title: Lc::n("Export"), /// description: Some(Lc::n("Export site data.")), /// weight: 0, -/// permission: &ExportPermission, +/// permission: None, /// kind: AdminPageKind::View, /// }); /// } diff --git a/extensions/pagetop-admin/src/handlers.rs b/extensions/pagetop-admin/src/handlers.rs index d197514f..c9a3d979 100644 --- a/extensions/pagetop-admin/src/handlers.rs +++ b/extensions/pagetop-admin/src/handlers.rs @@ -69,6 +69,7 @@ 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) @@ -130,7 +131,7 @@ pub async fn config_form_get(request: HttpRequest) -> Result return Err(ErrorPage::NotFound(Some(request.clone()))); }; - require_permission(&request, page.permission)?; + require_permission(&request, page.permission_key())?; let title = page.title.clone(); let mut form = ConfigForm::with_schema(schema.clone()); @@ -169,7 +170,7 @@ pub async fn config_form_post( return Err(ErrorPage::NotFound(Some(request.clone()))); }; - require_permission(&request, page.permission)?; + require_permission(&request, page.permission_key())?; let mut save_error = false; diff --git a/extensions/pagetop-admin/src/lib.rs b/extensions/pagetop-admin/src/lib.rs index e2ed8718..fbf6b053 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: &MyPermission::Config, + permission: Some(&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 586cfc45..4ebbbd4b 100644 --- a/extensions/pagetop-admin/src/registry.rs +++ b/extensions/pagetop-admin/src/registry.rs @@ -14,12 +14,38 @@ 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 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. +/// Sección del panel de administración (agrupación en el sidebar). #[derive(Clone)] pub struct AdminSection { /// Identificador único de la sección (p. ej. `"config"`). @@ -28,10 +54,24 @@ 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 { @@ -45,18 +85,22 @@ pub struct AdminPage { pub description: Option, /// Peso dentro de la sección (menor = antes). pub weight: i32, - /// 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, + /// Permiso requerido para acceder (`None` = requiere [`AdminPermission::Access`]). + pub permission: Option, /// 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)) + .is_some_and(|request| has_permission(request, self.permission_key())) } } @@ -233,9 +277,11 @@ pub fn global() -> &'static AdminRegistry { pub fn can_access_admin(cx: &Context) -> bool { let reg = global(); reg.ordered_sections().into_iter().any(|section| { - reg.pages_for_section(§ion.key) - .into_iter() - .any(|page| page.is_accessible(cx)) + section.is_visible(cx) + && reg + .pages_for_section(§ion.key) + .into_iter() + .any(|page| page.is_accessible(cx)) }) } @@ -260,6 +306,9 @@ 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 b0fea446..cf4c6640 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::AdminSection; +use crate::registry::{AdminPermission, AdminSection}; /// Declara las secciones incorporadas del panel de administración. /// @@ -16,30 +16,35 @@ 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/account.rs b/extensions/pagetop-user/src/account.rs index ffe5eccc..6cf50562 100644 --- a/extensions/pagetop-user/src/account.rs +++ b/extensions/pagetop-user/src/account.rs @@ -4,8 +4,6 @@ use std::collections::HashSet; use pagetop::prelude::*; -use crate::entity::user; - // **< UserStatus >********************************************************************************* /// Estado de la cuenta de usuario. @@ -61,65 +59,39 @@ 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, Getters)] +#[derive(Clone, Debug)] pub struct Account { - /// 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, + pub id: i32, + pub username: String, + pub email: String, + pub display_name: String, + pub status: UserStatus, /// Nombres de máquina de los roles asignados explícitamente. No incluye "authenticated", que /// es implícito (ver [`Account::has_role`]). - 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, + 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, } 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 a2832d70..6d737bda 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, cambio de contraseña, semilla inicial. +//! Lógica de autenticación: login, logout, registro, semilla inicial. use pagetop::prelude::*; use pagetop_seaorm::db::{ @@ -8,10 +8,9 @@ use pagetop_seaorm::db::{ use crate::account::UserStatus; use crate::config::SETTINGS; -use crate::entity::{role_permission, user, user_role}; +use crate::entity::{user, user_role}; use crate::error::AuthError; use crate::password; -use crate::permission::UserPermission; use crate::session; // **< login >************************************************************************************** @@ -136,9 +135,7 @@ 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), @@ -174,44 +171,6 @@ 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( @@ -238,15 +197,11 @@ async fn register_failed_login( // **< seed_initial_data >************************************************************************** -/// Prepara una instalación nueva si no existe ningún usuario en la base de datos. +/// Crea el usuario administrador inicial 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; } @@ -291,9 +246,7 @@ 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), @@ -312,28 +265,6 @@ async fn do_seed() { ); } } - 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 - ); + Err(e) => eprintln!("pagetop-user seed error: {}", e), } } diff --git a/extensions/pagetop-user/src/component.rs b/extensions/pagetop-user/src/component.rs index 7f94f830..6746e469 100644 --- a/extensions/pagetop-user/src/component.rs +++ b/extensions/pagetop-user/src/component.rs @@ -3,75 +3,21 @@ 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 b01025c7..01835906 100644 --- a/extensions/pagetop-user/src/component/admin.rs +++ b/extensions/pagetop-user/src/component/admin.rs @@ -7,6 +7,7 @@ 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; @@ -14,6 +15,7 @@ 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::*; @@ -39,10 +41,10 @@ pub(crate) type PermissionGroups = Vec<(Lc, Vec)>; // **< HELPERS >************************************************************************************ -// `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`. +// `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`. 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/role_table.rs b/extensions/pagetop-user/src/component/admin/role_table.rs index 79ae47f4..1516f975 100644 --- a/extensions/pagetop-user/src/component/admin/role_table.rs +++ b/extensions/pagetop-user/src/component/admin/role_table.rs @@ -81,22 +81,14 @@ impl Component for RoleTable { for role in self.items() { let system_badge = if role.locked { Some( - Badge::warning(Lc::t("badge-system-role", &LOCALES_USER)) + Badge::labeled(Lc::t("badge-system-role", &LOCALES_USER)) + .with_prop(PropsOp::add_classes("user-admin-badge-system")) .render(cx) .await, ) } 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() @@ -105,7 +97,6 @@ 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()) @@ -120,7 +111,11 @@ impl Component for RoleTable { Ok(html! { div (self.props().unpack(cx)) { - (Button::anchor(Lc::t("btn-create-role", &LOCALES_USER), new_href).render(cx).await) + div class="user-admin-actions" { + a href=(new_href) { + (Lc::t("btn-create-role", &LOCALES_USER).using(cx)) + } + } @if let Some(message) = self.message() { div class="user-form-error" role="alert" { (message.clone().using(cx)) } } @@ -176,9 +171,10 @@ 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()) @@ -228,8 +224,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, @@ -244,10 +240,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, @@ -267,8 +263,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/component/admin/user_form.rs b/extensions/pagetop-user/src/component/admin/user_form.rs index 0f50aee5..42ccd0e2 100644 --- a/extensions/pagetop-user/src/component/admin/user_form.rs +++ b/extensions/pagetop-user/src/component/admin/user_form.rs @@ -1,23 +1,49 @@ -//! Formulario de alta/edición de usuario, también usado para que cada usuario edite su perfil. +//! Formulario de alta/edición de usuario. + +use std::collections::BTreeMap; +use std::sync::LazyLock; 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)] @@ -30,17 +56,12 @@ 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, - theme: String, - /// Roles asignables (excluye "anonymous" y "authenticated"); si no hay ninguno, no se muestra. + /// Roles asignables (excluye "anonymous" y "authenticated"); sólo se renderiza en modo `New`. 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, } @@ -54,10 +75,7 @@ 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() @@ -71,7 +89,6 @@ 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( @@ -88,54 +105,33 @@ impl Component for UserForm { .with_label(Lc::t("field-display-name", &LOCALES_USER)), ) .with_child( - form::SelectLanguage::new() + form::input::Field::text() .with_name("language") - .with_label(Lc::t("field-language", &LOCALES_USER)) - .with_selected(self.language()), + .with_value(self.language()) + .with_label(Lc::t("field-language", &LOCALES_USER)), ) - .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()), - ); + .with_child(timezone_field(self.timezone())); if *self.mode() == UserFormMode::New { form = form.with_child(PasswordConfirm::new()); - } 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.roles().is_empty() { + form = form.with_child(roles_fieldset(self.roles())); + } - 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()), - ); + 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()), + ); + } } // En modo `Edit`, "Guardar" se renderiza fuera del formulario, junto al resto de acciones - // 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 { + // 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 { form = form.with_child( Button::submit(Lc::t("btn-save", &LOCALES_USER)) .with_style(button::Style::Solid(Intent::Primary)), @@ -185,11 +181,6 @@ 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 @@ -200,11 +191,6 @@ 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 @@ -215,13 +201,34 @@ 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 } } + +// `