Compare commits

...

6 commits

Author SHA1 Message Date
f819181872 ✨ (user): Asigna roles desde la edición de usuario 2026-10-05 00:36:47 +02:00
806ed9e0dc 💄 (user): Muestra sólo permisos concedidos al rol 2026-10-05 00:10:53 +02:00
f337119219 💥 (admin): Exige un permiso propio en cada página 2026-10-04 23:50:58 +02:00
2299e63585 🔧 (tools): Añade pagetop-admin/user al changelog 2026-10-04 23:38:40 +02:00
70744a51a5 ✨ (auth): Añade idioma y tema preferidos al usuario
- 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.
2026-10-04 23:09:07 +02:00
35f2b66161 💄 (user): Usa componentes base en Administración
Sustituye marcado propio y clases sin estilo por componentes de base,
para que la interfaz se vea igual con cualquier tema.
2026-10-03 12:25:15 +02:00
62 changed files with 2534 additions and 950 deletions

View file

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

View file

@ -88,7 +88,6 @@ impl ActionBag {
/// key: "tools".to_owned(),
/// path: "/admin/tools".to_owned(),
/// title: Lc::n("Tools"),
/// permission: None,
/// weight: 60,
/// });
/// }
@ -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,
/// });
/// }

View file

@ -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(&section.key)
@ -131,7 +130,7 @@ pub async fn config_form_get(request: HttpRequest) -> Result<Markup, ErrorPage>
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;

View file

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

View file

@ -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<PermissionRef>,
/// 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<Lc>,
/// Peso dentro de la sección (menor = antes).
pub weight: i32,
/// Permiso requerido para acceder (`None` = requiere [`AdminPermission::Access`]).
pub permission: Option<PermissionRef>,
/// 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,9 +233,7 @@ 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(&section.key)
reg.pages_for_section(&section.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(&section.key)
.into_iter()

View file

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

View file

@ -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::<Account>()`](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<String>,
/// 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<String>,
// 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<String>, 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)
}
}

View file

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

View file

@ -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 `<br>`,
// 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<Lc>) -> Html {

View file

@ -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<PermissionItem>)>;
// **< 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 {

View file

@ -81,14 +81,22 @@ 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,
)
} 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()
@ -97,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())
@ -111,11 +120,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)) }
}
@ -171,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())
@ -224,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,
@ -240,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,
@ -263,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())

View file

@ -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<Vec<(&'static str, Vec<&'static str>)>> = 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(&region) || (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,20 +88,42 @@ 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() {
} 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.mode() != UserFormMode::Profile && !self.roles().is_empty() {
form = form.with_child(roles_fieldset(self.roles()));
}
if *self.allow_admin_field() {
if *self.mode() == UserFormMode::New && *self.allow_admin_field() {
form = form.with_child(
form::Checkbox::check()
.with_name("is_admin")
@ -126,12 +131,11 @@ impl Component for UserForm {
.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 `<form>`.
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 `<form>`.
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<String>) -> Self {
self.about = about.into();
self
}
pub(crate) fn with_language(mut self, language: impl Into<String>) -> Self {
self.language = language.into();
self
@ -191,6 +200,11 @@ impl UserForm {
self
}
pub(crate) fn with_theme(mut self, theme: impl Into<String>) -> 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
}
}
// `<select>` de zona horaria: primero la opción de usar la predeterminada de la aplicación y luego
// un `<optgroup>` por región. La etiqueta de cada opción es el nombre IANA completo.
fn timezone_field(selected: &str) -> form::select::Field {
let mut field = form::select::Field::new()
.with_name("timezone")
.with_label(Lc::t("field-timezone", &LOCALES_USER))
.with_item(
form::select::Item::new(
"",
Lc::t("field-timezone-site-default", &LOCALES_USER)
.with_arg("tz", Timezone::default_tz().name()),
)
.with_selected(selected.is_empty()),
);
for (region, names) in TZ_BY_REGION.iter() {
let mut group = form::select::Group::new(Lc::n(*region));
for name in names {
group = group.with_item(
form::select::Item::new(*name, Lc::n(*name)).with_selected(*name == selected),
);
}
field = field.with_group(group);
}
field
}

View file

@ -1,69 +0,0 @@
//! Formulario de asignación de roles a un usuario. Reemplaza siempre el conjunto completo.
use pagetop::prelude::*;
use crate::LOCALES_USER;
use crate::user_path;
use crate::component::error_banner;
use super::roles_fieldset;
#[derive(AutoDefault, Clone, Debug, Getters)]
pub(crate) struct UserRolesForm {
user_id: i32,
error: Option<Lc>,
roles: Vec<(i32, String, bool)>,
/// Listado de origen al que volver tras guardar (orden, búsqueda, página).
waypoint: Waypoint,
}
#[async_trait]
impl Component for UserRolesForm {
fn new() -> Self {
Self::default()
}
async fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
let action = user_path(self.user_id(), "roles");
let action = self.waypoint().append_to(cx.route(action));
let mut form = Form::new()
.with_id("user-roles-form")
.with_action(action)
.with_method(form::Method::Post)
.with_child(error_banner(self.error().cloned()))
.with_child(roles_fieldset(self.roles()))
.with_child(
Button::submit(Lc::t("btn-save", &LOCALES_USER))
.with_style(button::Style::Solid(Intent::Primary)),
);
Ok(form.render(cx).await)
}
}
#[builder_impl]
impl UserRolesForm {
// **< UserRolesForm BUILDER >******************************************************************
pub(crate) fn with_user_id(mut self, user_id: i32) -> Self {
self.user_id = user_id;
self
}
pub(crate) fn with_error(mut self, error: impl Into<Option<Lc>>) -> Self {
self.error = error.into();
self
}
pub(crate) fn with_roles(mut self, roles: Vec<(i32, String, bool)>) -> Self {
self.roles = roles;
self
}
pub(crate) fn with_waypoint(mut self, waypoint: impl Into<Waypoint>) -> Self {
self.waypoint = waypoint.into();
self
}
}

View file

@ -6,9 +6,7 @@ use pagetop_htmx::hx_table::sort_link;
use crate::ADMIN_USERS_PATH;
use crate::LOCALES_USER;
use crate::account::UserStatus;
use crate::handlers::admin::users::available_roles;
use crate::permission::UserPermission;
use crate::account::{Account, UserStatus};
use crate::service::user_admin::{UserListItem, UserSortField};
use crate::user_path;
@ -66,14 +64,10 @@ impl Component for UserTable {
.with_empty(Lc::t("empty-users-list", &LOCALES_USER));
let waypoint = Waypoint::from(self.list_href(cx));
let can_assign_roles = cx
let viewer_is_admin = cx
.request()
.is_some_and(|r| has_permission(r, &UserPermission::AssignRoles));
// Sólo hace falta consultar si hay algún rol asignable cuando el botón vaya a mostrarse.
let has_assignable_roles = can_assign_roles
&& available_roles(&[])
.await
.is_ok_and(|roles| !roles.is_empty());
.and_then(|r| r.extension::<Account>())
.is_some_and(|a| a.is_admin());
for user in self.items() {
let status = user.status;
@ -85,10 +79,7 @@ impl Component for UserTable {
.with_cell(user.display_name.as_deref().unwrap_or("-"))
.with_cell(roles_cell(user, cx).await)
.with_cell(Lc::t(status_key(status), &LOCALES_USER))
.with_cell(
actions_cell(user, &waypoint, can_assign_roles, has_assignable_roles, cx)
.await,
),
.with_cell(actions_cell(user, &waypoint, viewer_is_admin, cx).await),
);
}
@ -96,11 +87,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)
}
@ -211,19 +198,20 @@ fn username_cell(user: &UserListItem, waypoint: &Waypoint) -> Html {
})
}
// Construye la celda de acciones: editar siempre, y gestionar roles sólo si el usuario autenticado
// tiene permiso para asignarlos; en ese caso, deshabilitado si no hay ningún rol asignable en todo
// el sistema. Devuelve un componente `Html` para que el marcado se genere cuando `Table` renderice
// la celda, no al construir la fila.
// Construye la celda de acciones con el botón de edición, salvo en la cuenta de un administrador
// si quien mira no lo es (no puede gestionarla, ver `handlers::admin::users::require_manageable`).
// Devuelve un componente `Html` para que el marcado se genere cuando `Table` renderice la celda, no
// al construir la fila.
async fn actions_cell(
user: &UserListItem,
waypoint: &Waypoint,
can_assign_roles: bool,
has_assignable_roles: bool,
viewer_is_admin: bool,
cx: &mut Context,
) -> Html {
let id = user.id;
let edit_href = waypoint.append_to(cx.route(user_path(id, "edit")));
if user.is_admin && !viewer_is_admin {
return Html::default();
}
let edit_href = waypoint.append_to(cx.route(user_path(user.id, "edit")));
// El botón se renderiza aquí, no dentro del `Html::with()` de abajo: necesita pasar por su
// propio ciclo de renderizado (`.render().await`) para que el tema activo lo estilice igual
@ -234,68 +222,33 @@ async fn actions_cell(
.render(cx)
.await;
let roles_button = if can_assign_roles {
let roles_href = waypoint.append_to(cx.route(user_path(id, "roles")));
Some(
Button::anchor(Lc::t("btn-manage-roles", &LOCALES_USER), roles_href)
.with_style(button::Style::Solid(Intent::Neutral))
.with_size(button::Size::Small)
.with_disabled(!has_assignable_roles)
.render(cx)
.await,
)
} else {
None
};
Html::with(move |_cx| {
html! {
(edit_button)
@if let Some(roles_button) = &roles_button {
" "
(roles_button)
}
}
})
Html::with(move |_cx| edit_button.clone())
}
// Construye la celda de roles: la insignia de administrador y las insignias de cada rol asignado,
// o "-" si el usuario no tiene ningún rol ni es administrador. Los badges se renderizan aquí mismo
// (con el `cx` del ciclo de renderizado de `Table`); el resto del marcado se difiere al `Html` que
// se devuelve, igual que el resto de celdas.
// Construye la celda de roles: sólo la insignia de administrador si lo es (tiene acceso sin
// restricciones, así que sus roles no añaden nada), o las insignias de cada rol asignado, o "-" si
// no tiene ninguno. Los badges se renderizan aquí mismo (con el `cx` del ciclo de renderizado de
// `Table`); el resto del marcado se difiere al `Html` que se devuelve, igual que el resto de celdas.
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"))
.render(cx)
.await,
)
} else {
None
};
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"))
let mut badges = Vec::with_capacity(user.roles.len().max(1));
if user.is_admin {
badges.push(
Badge::severe(Lc::t("badge-admin", &LOCALES_USER))
.render(cx)
.await,
);
} else {
for role in &user.roles {
badges.push(Badge::neutral(Lc::n(role.clone())).render(cx).await);
}
}
let is_admin = user.is_admin;
Html::with(move |_cx| {
html! {
@if let Some(badge) = &admin_badge {
(badge)
" "
}
@if role_badges.is_empty() {
@if !is_admin { "-" }
@if badges.is_empty() {
"-"
} @else {
@for badge in &role_badges {
@for badge in &badges {
(badge)
" "
}

View file

@ -0,0 +1,56 @@
//! Formulario para que el usuario autenticado cambie su propia contraseña.
use pagetop::prelude::*;
use crate::{LOCALES_USER, PROFILE_PASSWORD_PATH};
use crate::component::{PasswordConfirm, error_banner};
/// Pide la contraseña actual además de la nueva y su confirmación, a diferencia del
/// restablecimiento por un administrador.
#[derive(AutoDefault, Clone, Debug, Getters)]
pub(crate) struct ChangePasswordForm {
error: Option<Lc>,
}
#[async_trait]
impl Component for ChangePasswordForm {
fn new() -> Self {
Self::default()
}
async fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
let mut form = Form::new()
.with_id("user-change-password-form")
.with_action(cx.route(PROFILE_PASSWORD_PATH))
.with_method(form::Method::Post)
.with_child(error_banner(self.error().cloned()))
.with_child(
form::input::Field::password()
.with_name("current_password")
.with_label(Lc::t("field-current-password", &LOCALES_USER))
.with_autocomplete(Some(form::Autocomplete::current_password()))
.with_required(true),
)
.with_child(
PasswordConfirm::new()
.with_password_label(Lc::t("field-new-password", &LOCALES_USER)),
)
.with_child(
Button::submit(Lc::t("btn-set-password", &LOCALES_USER))
.with_style(button::Style::Solid(Intent::Primary)),
);
Ok(form.render(cx).await)
}
}
#[builder_impl]
impl ChangePasswordForm {
// **< ChangePasswordForm BUILDER >*************************************************************
pub(crate) fn with_error(mut self, error: impl Into<Option<Lc>>) -> Self {
self.error = error.into();
self
}
}

View file

@ -1,5 +1,5 @@
//! Par de campos "contraseña" + "confirmar contraseña", reutilizado en los formularios de
//! registro, alta y restablecimiento de contraseña.
//! registro, alta, restablecimiento y cambio de contraseña.
use pagetop::prelude::*;
@ -20,8 +20,8 @@ pub(crate) struct PasswordConfirm {
#[async_trait]
impl Component for PasswordConfirm {
// Las etiquetas por defecto cubren los dos casos más habituales (alta de cuenta, alta de
// usuario desde administración); `with_password_label()` cubre el caso distinto
// (restablecimiento de contraseña por un administrador).
// usuario desde administración); `with_password_label()` cubre los casos distintos
// (restablecimiento por un administrador y cambio de la contraseña propia).
fn new() -> Self {
Self {
password_label: Lc::t("field-password", &LOCALES_USER),

View file

@ -0,0 +1,109 @@
//! Nombre de un usuario enlazado a su perfil público.
use pagetop::prelude::*;
use crate::Account;
use crate::permission::UserPermission;
use crate::profile_path;
/// Componente para mostrar el **nombre de un usuario**, enlazado a su perfil público cuando quien
/// mira puede verlo.
///
/// Se renderiza como enlace a `/user/{id}` si quien hace la petición tiene `user:view_profiles` o
/// es el propio usuario; si no, como texto. Las propiedades del componente (identificador, clases,
/// atributos) se aplican en ambos casos.
///
/// Recibe el identificador y el nombre que el llamador ya tiene de su propia consulta, así que no
/// consulta la base de datos al renderizarse. El nombre suele ser el nombre visible del usuario o,
/// si no tiene, su nombre de usuario.
///
/// # Ejemplo
///
/// ```rust,no_run
/// # use pagetop::prelude::*;
/// use pagetop_user::prelude::*;
///
/// // En una celda de tabla, p. ej. el autor de un cambio en un historial.
/// let row = table::Row::new()
/// .with_cell(UserName::of(42, "Ana Pérez"))
/// .with_cell("Cambio de ubicación");
/// ```
#[derive(AutoDefault, Clone, Debug, Getters)]
pub struct UserName {
/// Devuelve identificador, clases CSS, atributos HTML y valores extra del componente.
props: Props,
/// Devuelve el identificador del usuario.
user_id: i32,
/// Devuelve el nombre que se muestra.
name: String,
}
#[async_trait]
impl Component for UserName {
fn new() -> Self {
Self::default()
}
fn id(&self) -> Option<String> {
self.props.get_id()
}
fn setup(&mut self, _cx: &mut Context) {
self.alter_prop(PropsOp::prepend_classes("user-name"));
}
async fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
let user_id = self.user_id();
let can_view = cx.request().is_some_and(|request| {
request
.extension::<Account>()
.is_some_and(|a| a.id() == user_id)
|| has_permission(request, &UserPermission::ViewProfiles)
});
Ok(if can_view {
let href = cx.route(profile_path(user_id));
html! { a (self.props().unpack(cx)) href=(href) { (self.name()) } }
} else {
html! { span (self.props().unpack(cx)) { (self.name()) } }
})
}
}
#[builder_impl]
impl UserName {
/// Crea el nombre del usuario `user_id` con el texto indicado.
pub fn of(user_id: i32, name: impl Into<String>) -> Self {
Self {
user_id,
name: name.into(),
..Default::default()
}
}
// **< UserName BUILDER >***********************************************************************
/// Establece el identificador único del componente; igual a `with_prop(PropsOp::set_id(id))`.
pub fn with_id(mut self, id: impl Into<CowStr>) -> Self {
self.props.alter_id(id);
self
}
/// Modifica identificador, clases CSS, atributos HTML o valores extra del componente.
pub fn with_prop(mut self, op: impl Into<PropsOp>) -> Self {
self.props.alter_prop(op);
self
}
/// Establece el identificador del usuario.
pub fn with_user_id(mut self, user_id: i32) -> Self {
self.user_id = user_id;
self
}
/// Establece el nombre que se muestra.
pub fn with_name(mut self, name: impl Into<String>) -> Self {
self.name = name.into();
self
}
}

View file

@ -90,7 +90,9 @@ async fn create_demo_users(role_ids: &[i32]) -> Result<(), AuthError> {
status: Set(UserStatus::Active.as_i16()),
language: Set(None),
timezone: Set(None),
theme: Set(None),
display_name: Set(Some(format!("Demo User {n:02}"))),
about: Set(None),
last_login_at: Set(None),
last_access_at: Set(None),
failed_login_count: Set(0),

View file

@ -14,7 +14,11 @@ pub struct Model {
pub status: i16,
pub language: Option<String>,
pub timezone: Option<String>,
/// Nombre corto del tema preferido (p. ej. `"Bootsier"`), si tiene uno.
pub theme: Option<String>,
pub display_name: Option<String>,
/// Texto libre "Sobre mí" que el propio usuario escribe en su perfil.
pub about: Option<String>,
pub last_login_at: Option<DateTimeUtc>,
pub last_access_at: Option<DateTimeUtc>,
pub failed_login_count: i32,

View file

@ -40,6 +40,15 @@ pub enum AuthError {
#[error("invalid IANA timezone identifier")]
InvalidTimezone,
#[error("unsupported language identifier")]
InvalidLanguage,
#[error("unknown or disabled theme")]
InvalidTheme,
#[error("about text must be at most {0} characters")]
AboutTooLong(usize),
#[error("user not found")]
UserNotFound,

View file

@ -1,22 +1,44 @@
//! Handler HTTP para el perfil del propio usuario autenticado.
//! Handlers HTTP para el perfil del propio usuario autenticado (consulta, edición de sus datos y
//! cambio de contraseña) y para el perfil público de cualquier usuario.
use serde::Deserialize;
use pagetop::prelude::*;
use crate::account::UserStatus;
use crate::component::admin::status_key;
use crate::account::{Account, UserStatus};
use crate::auth;
use crate::component::admin::{UserForm, UserFormMode, status_key};
use crate::component::{ChangePasswordForm, language_name, multiline_text, theme_name};
use crate::entity::{role, user};
use crate::error::AuthError;
use crate::handlers::admin::map_auth_error;
use crate::password;
use crate::permission::UserPermission;
use crate::service::user_admin;
use crate::{LOCALES_USER, LOGIN_PATH, PROFILE_PATH};
use crate::session;
use crate::{LOCALES_USER, LOGIN_PATH, PROFILE_EDIT_PATH, PROFILE_PASSWORD_PATH, PROFILE_PATH};
// **< current_user_id / login_redirect >***********************************************************
// Identificador del usuario autenticado; el middleware de sesión sólo inserta `Account` si hay una
// sesión activa.
fn current_user_id(request: &HttpRequest) -> Option<i32> {
request.extension::<Account>().map(|a| a.id())
}
// Redirección al formulario de inicio de sesión conservando `next` como URL de retorno.
fn login_redirect(request: HttpRequest, next: &'static str) -> Response {
let cx = Context::new(request);
Redirect::see_other(cx.route(LOGIN_PATH).with_param("next", next)).into_response()
}
// **< profile_get >********************************************************************************
/// GET /user - Perfil del usuario autenticado. Redirige al formulario de inicio de sesión si no hay
/// sesión activa, conservando la URL de retorno.
pub(crate) async fn profile_get(request: HttpRequest) -> Response {
let cx = Context::new(request.clone());
let Some(id) = cx.current_user().id() else {
let target = cx.route(LOGIN_PATH).with_param("next", PROFILE_PATH);
return Redirect::see_other(target).into_response();
let Some(id) = current_user_id(&request) else {
return login_redirect(request, PROFILE_PATH);
};
let user = match user_admin::find_user(id).await {
@ -28,19 +50,50 @@ pub(crate) async fn profile_get(request: HttpRequest) -> Response {
Err(_) => return ErrorPage::InternalError(Some(request)).into_response(),
};
let status = UserStatus::from_i16(user.status);
let can_edit = has_permission(&request, &UserPermission::EditOwnProfile);
let can_change_password = has_permission(&request, &UserPermission::ChangeOwnPassword);
let mut page = Page::new(request);
let details_block = profile_details(&user, status, page.context()).await;
let roles_block = profile_roles(&roles, page.context()).await;
let actions = profile_actions(can_edit, can_change_password, page.context());
page.with_title(Lc::t("title-profile", &LOCALES_USER))
.with_child(details_block)
.with_child(actions)
.with_child(roles_block)
.render()
.await
.into_response()
}
// Botones para editar el perfil y cambiar la contraseña, cada uno sólo si se tiene el permiso.
// Sin ninguno de los dos, el contenedor queda vacío y no se renderiza.
fn profile_actions(can_edit: bool, can_change_password: bool, cx: &Context) -> Flex {
let mut actions = Flex::new()
.with_wrap(flex::Behavior::Wrap)
.with_gap(align::Gap::Both(UnitValue::RelRem(0.5)));
if can_edit {
actions = actions.with_child(
Button::anchor(
Lc::t("btn-edit-profile", &LOCALES_USER),
cx.route(PROFILE_EDIT_PATH),
)
.with_style(button::Style::Solid(Intent::Primary)),
);
}
if can_change_password {
actions = actions.with_child(
Button::anchor(
Lc::t("btn-set-password", &LOCALES_USER),
cx.route(PROFILE_PASSWORD_PATH),
)
.with_style(button::Style::Solid(Intent::Neutral)),
);
}
actions
}
// Bloque de sólo lectura con los datos de perfil del usuario autenticado.
async fn profile_details(user: &user::Model, status: UserStatus, cx: &mut Context) -> Block {
let mut table = Table::new()
@ -60,25 +113,44 @@ async fn profile_details(user: &user::Model, status: UserStatus, cx: &mut Contex
.with_cell(Lc::t("field-display-name", &LOCALES_USER))
.with_cell(user.display_name.as_deref().unwrap_or("-")),
)
.with_row(
table::Row::new()
.with_cell(Lc::t("field-about", &LOCALES_USER))
.with_cell(multiline_text(
user.about.clone().unwrap_or_else(|| "-".into()),
)),
)
.with_row(
table::Row::new()
.with_cell(Lc::t("field-language", &LOCALES_USER))
.with_cell(user.language.as_deref().unwrap_or("-")),
.with_cell(language_name(user.language.as_deref())),
)
.with_row(
table::Row::new()
.with_cell(Lc::t("field-timezone", &LOCALES_USER))
.with_cell(user.timezone.as_deref().unwrap_or("-")),
)
.with_row(
table::Row::new()
.with_cell(Lc::t("field-theme", &LOCALES_USER))
.with_cell(theme_name(user.theme.as_deref())),
)
.with_row(
table::Row::new()
.with_cell(Lc::t("col-status", &LOCALES_USER))
.with_cell(Lc::t(status_key(status), &LOCALES_USER)),
)
.with_row(
table::Row::new()
.with_cell(Lc::t("field-member-since", &LOCALES_USER))
.with_cell(cx.format_date(
user.created_at.with_timezone(&cx.timezone()).date_naive(),
DateFormat::Long,
)),
);
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(
@ -97,12 +169,16 @@ async fn profile_details(user: &user::Model, status: UserStatus, cx: &mut Contex
// administración, no enlaza cada rol a su pantalla de detalle: un usuario sin permisos de
// administración no puede acceder a ella.
async fn profile_roles(roles: &[role::Model], cx: &mut Context) -> Block {
// Un bloque sin hijos no se renderiza.
if roles.is_empty() {
return Block::new();
}
let mut items: Vec<(String, Option<Markup>)> = Vec::with_capacity(roles.len());
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,
)
@ -116,9 +192,6 @@ async fn profile_roles(roles: &[role::Model], cx: &mut Context) -> Block {
.with_title(Lc::t("field-roles", &LOCALES_USER))
.with_child(Html::with(move |_cx| {
html! {
@if items.is_empty() {
"-"
} @else {
ul class="user-profile-roles" {
@for (label, system_badge) in &items {
li {
@ -131,6 +204,261 @@ async fn profile_roles(roles: &[role::Model], cx: &mut Context) -> Block {
}
}
}
}
}))
}
// **< public_profile_get >*************************************************************************
/// GET /user/{id} - Perfil público de un usuario: nombre de usuario, nombre visible, "Sobre mí" y
/// fecha de alta. Requiere `user:view_profiles`, salvo para ver el propio. El perfil de una cuenta
/// que no está activa sólo lo ve quien administra usuarios.
pub(crate) async fn public_profile_get(
request: HttpRequest,
web::Path(id): web::Path<i32>,
) -> Result<Response, ErrorPage> {
// El permiso se comprueba antes de buscar al usuario para no revelar qué ids existen.
if current_user_id(&request) != Some(id) {
require_permission(&request, &UserPermission::ViewProfiles)?;
}
let Ok(user) = user_admin::find_user(id).await else {
return Err(ErrorPage::NotFound(Some(request)));
};
if UserStatus::from_i16(user.status) != UserStatus::Active
&& !has_permission(&request, &UserPermission::AdminUsers)
{
return Err(ErrorPage::NotFound(Some(request)));
}
let title = match util::non_blank(user.display_name.as_deref().unwrap_or_default()) {
Some(name) => name.to_owned(),
None => user.username.clone(),
};
let mut page = Page::new(request);
let details = public_profile_details(&user, page.context());
Ok(page
.with_title(Lc::n(title))
.with_child(details)
.render()
.await
.into_response())
}
// Bloque con los únicos datos que se muestran a otros usuarios; nunca el email, los roles, el
// estado ni la zona horaria.
fn public_profile_details(user: &user::Model, cx: &Context) -> Block {
let member_since = cx.format_date(
user.created_at.with_timezone(&cx.timezone()).date_naive(),
DateFormat::Long,
);
let table = Table::new()
.with_prop(PropsOp::add_classes("user-admin-table"))
.with_row(
table::Row::new()
.with_cell(Lc::t("field-username-admin", &LOCALES_USER))
.with_cell(user.username.as_str()),
)
.with_row(
table::Row::new()
.with_cell(Lc::t("field-display-name", &LOCALES_USER))
.with_cell(user.display_name.as_deref().unwrap_or("-")),
)
.with_row(
table::Row::new()
.with_cell(Lc::t("field-about", &LOCALES_USER))
.with_cell(multiline_text(
user.about.clone().unwrap_or_else(|| "-".into()),
)),
)
.with_row(
table::Row::new()
.with_cell(Lc::t("field-member-since", &LOCALES_USER))
.with_cell(member_since),
);
Block::new().with_child(table)
}
// **< profile_edit_get / profile_edit_post >*******************************************************
/// GET /user/edit - Formulario de edición del perfil propio.
pub(crate) async fn profile_edit_get(request: HttpRequest) -> Result<Response, ErrorPage> {
let Some(id) = current_user_id(&request) else {
return Ok(login_redirect(request, PROFILE_EDIT_PATH));
};
require_permission(&request, &UserPermission::EditOwnProfile)?;
let Ok(user) = user_admin::find_user(id).await else {
return Err(ErrorPage::NotFound(Some(request)));
};
let form = UserForm::new()
.with_username(user.username)
.with_email(user.email)
.with_display_name(user.display_name.unwrap_or_default())
.with_about(user.about.unwrap_or_default())
.with_language(user.language.unwrap_or_default())
.with_timezone(user.timezone.unwrap_or_default())
.with_theme(user.theme.unwrap_or_default());
Ok(render_profile_edit(request, form).await)
}
#[derive(Deserialize)]
pub(crate) struct ProfileFormData {
#[serde(default)]
username: String,
email: String,
#[serde(default)]
display_name: String,
#[serde(default)]
about: String,
#[serde(default)]
language: String,
#[serde(default)]
timezone: String,
#[serde(default)]
theme: String,
}
/// POST /user/edit - Guarda los datos del perfil propio. El nombre de usuario sólo cambia con
/// `user:change_own_username`; roles, estado y acceso irrestricto no se tocan nunca desde aquí.
pub(crate) async fn profile_edit_post(
request: HttpRequest,
web::Form(form): web::Form<ProfileFormData>,
) -> Result<Response, ErrorPage> {
let Some(id) = current_user_id(&request) else {
return Ok(login_redirect(request, PROFILE_EDIT_PATH));
};
require_permission(&request, &UserPermission::EditOwnProfile)?;
let Ok(user) = user_admin::find_user(id).await else {
return Err(ErrorPage::NotFound(Some(request)));
};
// Sin permiso se conserva el nombre actual, aunque la petición se haya manipulado a mano.
let username = if has_permission(&request, &UserPermission::ChangeOwnUsername) {
form.username
} else {
user.username
};
let result = user_admin::update_user(
id,
user_admin::UserUpdateData {
username: &username,
email: &form.email,
display_name: util::non_blank(&form.display_name),
about: util::non_blank(&form.about),
language: util::non_blank(&form.language),
timezone: util::non_blank(&form.timezone),
theme: util::non_blank(&form.theme),
},
)
.await;
match result {
Ok(()) => {
let cx = Context::new(request);
Ok(Redirect::see_other(cx.route(PROFILE_PATH)).into_response())
}
Err(err) => {
let form = UserForm::new()
.with_username(username)
.with_email(form.email)
.with_display_name(form.display_name)
.with_about(form.about)
.with_language(form.language)
.with_timezone(form.timezone)
.with_theme(form.theme)
.with_error(map_auth_error(&err));
Ok(render_profile_edit(request, form).await)
}
}
}
// Página de edición del perfil propio con el formulario ya relleno.
async fn render_profile_edit(request: HttpRequest, form: UserForm) -> Response {
let can_change_username = has_permission(&request, &UserPermission::ChangeOwnUsername);
let form = form
.with_mode(UserFormMode::Profile)
.with_allow_username_field(can_change_username);
render_profile_page(request, Lc::t("title-profile-edit", &LOCALES_USER), form).await
}
// **< password_get / password_post >***************************************************************
/// GET /user/password - Formulario de cambio de la contraseña propia.
pub(crate) async fn password_get(request: HttpRequest) -> Result<Response, ErrorPage> {
if current_user_id(&request).is_none() {
return Ok(login_redirect(request, PROFILE_PASSWORD_PATH));
}
require_permission(&request, &UserPermission::ChangeOwnPassword)?;
let title = Lc::t("title-profile-password", &LOCALES_USER);
Ok(render_profile_page(request, title, ChangePasswordForm::new()).await)
}
#[derive(Deserialize)]
pub(crate) struct ChangePasswordFormData {
current_password: String,
password: String,
confirm_password: String,
}
/// POST /user/password - Cambia la contraseña propia tras comprobar la actual y cierra el resto de
/// sesiones abiertas del usuario, conservando la actual.
pub(crate) async fn password_post(
request: HttpRequest,
web::Form(form): web::Form<ChangePasswordFormData>,
) -> Result<Response, ErrorPage> {
let Some(id) = current_user_id(&request) else {
return Ok(login_redirect(request, PROFILE_PASSWORD_PATH));
};
require_permission(&request, &UserPermission::ChangeOwnPassword)?;
let sid = session::extract_sid(Some(request.headers()));
let result = match password::passwords_match(&form.password, &form.confirm_password) {
Ok(()) => {
auth::change_own_password(id, &form.current_password, &form.password, sid.as_deref())
.await
}
Err(err) => Err(err),
};
match result {
Ok(()) => {
let cx = Context::new(request);
Ok(Redirect::see_other(cx.route(PROFILE_PATH)).into_response())
}
Err(err) => {
let error = match err {
AuthError::InvalidCredentials => Lc::t("error-current-password", &LOCALES_USER),
err => map_auth_error(&err),
};
let title = Lc::t("title-profile-password", &LOCALES_USER);
let form = ChangePasswordForm::new().with_error(error);
Ok(render_profile_page(request, title, form).await)
}
}
}
// **< render_profile_page >************************************************************************
// Página del perfil propio con un formulario y un botón para volver al perfil sin guardar.
async fn render_profile_page(
request: HttpRequest,
title: Lc,
form: impl Component + 'static,
) -> Response {
let mut page = Page::new(request);
let cancel = Button::anchor(
Lc::t("btn-cancel", &LOCALES_USER),
page.context().route(PROFILE_PATH),
);
page.with_title(title.clone())
.with_child(
Block::new()
.with_title(title)
.with_child(form)
.with_child(cancel),
)
.render()
.await
.into_response()
}

View file

@ -29,6 +29,11 @@ pub(crate) fn map_auth_error(err: &AuthError) -> Lc {
}
AuthError::PasswordMismatch => Lc::t("error-password-mismatch", &LOCALES_USER),
AuthError::InvalidTimezone => Lc::t("error-invalid-timezone", &LOCALES_USER),
AuthError::InvalidLanguage => Lc::t("error-invalid-language", &LOCALES_USER),
AuthError::InvalidTheme => Lc::t("error-invalid-theme", &LOCALES_USER),
AuthError::AboutTooLong(n) => {
Lc::t("error-about-too-long", &LOCALES_USER).with_arg("n", n.to_string())
}
AuthError::UsernameTaken => Lc::t("error-username-taken", &LOCALES_USER),
AuthError::EmailTaken => Lc::t("error-email-taken", &LOCALES_USER),
AuthError::UserNotFound => Lc::t("error-user-not-found", &LOCALES_USER),

View file

@ -21,26 +21,19 @@ pub(crate) async fn list_get(request: HttpRequest) -> Result<Response, ErrorPage
let mut content = frame(title.clone());
for (group, group_label) in registry.groups_sorted(page.context()) {
let items: Vec<(CowStr, Lc)> = 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

View file

@ -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(
@ -349,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<Block> {
groups
let blocks: Vec<Block> = 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 >********************************************************************************
@ -538,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<T>`).
/// 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<T>`).
pub(crate) async fn permissions_post(
request: HttpRequest,
web::Path(id): web::Path<i32>,

View file

@ -11,9 +11,9 @@ use crate::AUTHENTICATED_ROLE_ID;
use crate::LOCALES_USER;
use crate::account::{Account, UserStatus};
use crate::component::admin::{
AdminPasswordForm, USER_ADMIN_FORM_ID, UserForm, UserFormMode, UserRolesForm, UserTable,
status_key,
AdminPasswordForm, USER_ADMIN_FORM_ID, UserForm, UserFormMode, UserTable, status_key,
};
use crate::component::{language_name, multiline_text, theme_name};
use crate::config::SETTINGS;
use crate::entity::{role, user};
use crate::error::AuthError;
@ -112,11 +112,8 @@ fn search_bar(current_query: Option<String>) -> Html {
// **< available_roles >****************************************************************************
// Roles asignables desde la UI de usuarios: excluye "anonymous" (nunca se asigna explícitamente)
// y "authenticated" (implícito, nunca se asigna). `pub(crate)` porque también la usa
// `component::admin::user_table` para decidir si hay algo que gestionar.
pub(crate) async fn available_roles(
selected: &[i32],
) -> Result<Vec<(i32, String, bool)>, AuthError> {
// y "authenticated" (implícito, nunca se asigna).
async fn available_roles(selected: &[i32]) -> Result<Vec<(i32, String, bool)>, AuthError> {
let items = role_admin::list_roles(&role_admin::RoleListParams {
sort: role_admin::RoleSortField::Weight,
dir: SortDir::Asc,
@ -132,6 +129,27 @@ pub(crate) async fn available_roles(
.collect())
}
// Identificadores de rol enviados en el formulario de alta o edición.
fn parse_role_ids(role_ids: &[String]) -> Vec<i32> {
role_ids.iter().filter_map(|s| s.parse().ok()).collect()
}
// **< require_manageable >*************************************************************************
// Carga el usuario y comprueba que quien hace la petición puede gestionarlo. La cuenta de un
// administrador sólo la gestiona otro administrador: si no, quien administra usuarios podría
// apoderarse de ella (p. ej. restableciendo su contraseña) y obtener acceso irrestricto, que sólo
// un administrador puede conceder.
async fn require_manageable(request: &HttpRequest, id: i32) -> Result<user::Model, ErrorPage> {
let Ok(user) = user_admin::find_user(id).await else {
return Err(ErrorPage::NotFound(Some(request.clone())));
};
if user.is_admin && !request.extension::<Account>().is_some_and(|a| a.is_admin()) {
return Err(ErrorPage::AccessDenied(Some(request.clone())));
}
Ok(user)
}
// **< new_get / new_post >*************************************************************************
/// GET /admin/user/users/new - Formulario de alta de usuario.
@ -146,7 +164,7 @@ pub(crate) async fn new_get(
};
// El campo "administrador" sólo se ofrece si quien da de alta ya es administrador: no es un
// permiso del catálogo (igual que conceder/revocar en la edición, ver `set_user_admin`).
let allow_admin_field = request.extension::<Account>().is_some_and(|a| a.is_admin);
let allow_admin_field = request.extension::<Account>().is_some_and(|a| a.is_admin());
let mut page = Page::admin(request);
let back_href = waypoint.or(page.context().route(ADMIN_USERS_PATH));
let title = Lc::t("title-admin-user-new", &LOCALES_USER);
@ -181,6 +199,8 @@ pub(crate) struct NewUserFormData {
#[serde(default)]
timezone: String,
#[serde(default)]
theme: String,
#[serde(default)]
role_ids: Vec<String>,
#[serde(default)]
is_admin: bool,
@ -199,16 +219,11 @@ pub(crate) async fn new_post(
let Ok(form) = serde_qs::from_bytes::<NewUserFormData>(&raw.0) else {
return Err(ErrorPage::BadRequest(Some(request)));
};
let allow_admin_field = request.extension::<Account>().is_some_and(|a| a.is_admin);
let allow_admin_field = request.extension::<Account>().is_some_and(|a| a.is_admin());
// Nunca fiarse sólo de que el campo esté presente en el formulario: sólo se concede si quien
// envía la petición ya es administrador, aunque alguien manipulase la petición a mano.
let is_admin = form.is_admin && allow_admin_field;
let role_ids: Vec<i32> = form
.role_ids
.iter()
.filter_map(|s| s.parse().ok())
.collect();
let role_ids = parse_role_ids(&form.role_ids);
let result = user_admin::create_user(user_admin::NewUserData {
username: &form.username,
@ -218,6 +233,7 @@ pub(crate) async fn new_post(
display_name: util::non_blank(&form.display_name),
language: util::non_blank(&form.language),
timezone: util::non_blank(&form.timezone),
theme: util::non_blank(&form.theme),
initial_role_ids: &role_ids,
is_admin,
})
@ -240,6 +256,7 @@ pub(crate) async fn new_post(
.with_display_name(form.display_name)
.with_language(form.language)
.with_timezone(form.timezone)
.with_theme(form.theme)
.with_roles(roles)
.with_allow_admin_field(allow_admin_field)
.with_is_admin(is_admin)
@ -262,6 +279,7 @@ pub(crate) async fn new_post(
// **< edit_get / edit_post >***********************************************************************
// Pantalla de edición con el formulario relleno con los datos guardados del usuario.
async fn render_user_edit(
request: HttpRequest,
id: i32,
@ -272,26 +290,54 @@ async fn render_user_edit(
Ok(user) => user,
Err(_) => return ErrorPage::NotFound(Some(request)).into_response(),
};
let status = UserStatus::from_i16(user.status);
// El botón de conceder/revocar sólo se muestra si quien lo ve ya es administrador y no está
// viendo su propio perfil: no es un permiso del catálogo, y nadie puede automodificarse el
// flag (ver `set_user_admin`).
let can_toggle_admin = request
.extension::<Account>()
.is_some_and(|a| a.is_admin && a.id != id);
let has_assignable_roles = match available_roles(&[]).await {
Ok(roles) => !roles.is_empty(),
let current = match user_admin::user_role_ids(id).await {
Ok(ids) => ids,
Err(_) => return ErrorPage::InternalError(Some(request)).into_response(),
};
let roles = match available_roles(&current).await {
Ok(roles) => roles,
Err(_) => return ErrorPage::InternalError(Some(request)).into_response(),
};
let status = UserStatus::from_i16(user.status);
let is_admin = user.is_admin;
let form = UserForm::new()
.with_username(user.username)
.with_email(user.email)
.with_display_name(user.display_name.unwrap_or_default())
.with_about(user.about.unwrap_or_default())
.with_language(user.language.unwrap_or_default())
.with_timezone(user.timezone.unwrap_or_default())
.with_theme(user.theme.unwrap_or_default())
.with_roles(roles)
.with_error(error);
render_edit_page(request, id, status, is_admin, form, waypoint).await
}
// Pantalla de edición del usuario `id` con `form` ya relleno, sea con los datos guardados o con los
// enviados que no se pudieron guardar. Siempre lleva la botonera de `edit_actions()`, porque en
// modo `Edit` el botón "Guardar" está en ella y no dentro del formulario.
async fn render_edit_page(
request: HttpRequest,
id: i32,
status: UserStatus,
is_admin: bool,
form: UserForm,
waypoint: Waypoint,
) -> Response {
// El botón de conceder/revocar sólo se muestra si quien lo ve ya es administrador y no está
// viendo su propio perfil: no es un permiso del catálogo, y nadie puede automodificarse el flag
// (ver `set_user_admin`).
let can_toggle_admin = request
.extension::<Account>()
.is_some_and(|a| a.is_admin() && a.id() != id);
let mut page = Page::admin(request);
let back_href = waypoint.or(page.context().route(ADMIN_USERS_PATH));
let title = Lc::t("title-admin-user-edit", &LOCALES_USER);
let actions = edit_actions(
id,
status,
user.is_admin,
is_admin,
can_toggle_admin,
has_assignable_roles,
&waypoint,
page.context(),
);
@ -300,15 +346,8 @@ async fn render_user_edit(
.with_child(
frame(title)
.with_child(
UserForm::new()
.with_mode(UserFormMode::Edit)
form.with_mode(UserFormMode::Edit)
.with_user_id(Some(id))
.with_username(user.username)
.with_email(user.email)
.with_display_name(user.display_name.unwrap_or_default())
.with_language(user.language.unwrap_or_default())
.with_timezone(user.timezone.unwrap_or_default())
.with_error(error)
.with_waypoint(waypoint.clone()),
)
.with_child(actions)
@ -319,24 +358,23 @@ async fn render_user_edit(
.into_response()
}
// Enlaces a las pantallas dedicadas (roles, restablecer contraseña) y botones de bloqueo/activación
// y de concesión/revocación de acceso irrestricto (este último sólo si `can_toggle_admin`). El
// listado de origen (`waypoint`) se arrastra a todas ellas para que, al volver aquí, esta misma
// pantalla siga sabiendo devolver al listado en el estado en que se dejó.
// Enlace a la pantalla de restablecer contraseña y botones de bloqueo/activación y de
// concesión/revocación de acceso irrestricto (este último sólo si `can_toggle_admin`). El listado
// de origen (`waypoint`) se arrastra a todas ellas para que, al volver aquí, esta misma pantalla
// siga sabiendo devolver al listado en el estado en que se dejó.
//
// "Guardar" (envía el `<form>` de `UserForm` vía el atributo `form`, ver `USER_ADMIN_FORM_ID`),
// "Gestionar roles" y "Restablecer contraseña" son botones sueltos; bloqueo/activación y
// concesión/revocación de admin (este último sólo si `can_toggle_admin`) van cada uno en su propio
// `Form`, con un campo `Hidden` para el nuevo valor, para conservar el envío nativo sin JavaScript,
// mejorado con `hx-post`/`hx-confirm`. Todos son hijos directos del mismo `Container` con Flex, que
// los alinea en fila con espaciado uniforme sin que ninguno tenga que ser forzosamente un `Button`
// suelto (ver PAGETOP.md, "Preferir componentes a `html!` en bruto").
// "Guardar" (envía el `<form>` de `UserForm` vía el atributo `form`, ver `USER_ADMIN_FORM_ID`) y
// "Restablecer contraseña" son botones sueltos; bloqueo/activación y concesión/revocación de admin
// (este último sólo si `can_toggle_admin`) van cada uno en su propio `Form`, con un campo `Hidden`
// para el nuevo valor, para conservar el envío nativo sin JavaScript, mejorado con
// `hx-post`/`hx-confirm`. Todos son hijos directos del mismo `Container` con Flex, que los alinea
// en fila con espaciado uniforme sin que ninguno tenga que ser forzosamente un `Button` suelto (ver
// PAGETOP.md, "Preferir componentes a `html!` en bruto").
fn edit_actions(
user_id: i32,
status: UserStatus,
target_is_admin: bool,
can_toggle_admin: bool,
has_assignable_roles: bool,
waypoint: &Waypoint,
cx: &mut Context,
) -> Flex {
@ -350,7 +388,6 @@ fn edit_actions(
("true", "btn-grant-admin", "confirm-grant-admin")
};
let roles_href = waypoint.append_to(cx.route(user_path(user_id, "roles")));
let password_href = waypoint.append_to(cx.route(user_path(user_id, "password")));
let status_action = waypoint.append_to(cx.route(user_path(user_id, "status")));
let admin_action = waypoint.append_to(cx.route(user_path(user_id, "admin")));
@ -377,11 +414,6 @@ fn edit_actions(
.with_style(button::Style::Solid(Intent::Primary))
.with_prop(PropsOp::set("form", USER_ADMIN_FORM_ID)),
)
.with_child(
Button::anchor(Lc::t("btn-manage-roles", &LOCALES_USER), roles_href)
.with_style(button::Style::Solid(Intent::Neutral))
.with_disabled(!has_assignable_roles),
)
.with_child(
Button::anchor(Lc::t("btn-reset-password", &LOCALES_USER), password_href)
.with_style(button::Style::Solid(Intent::Neutral)),
@ -389,12 +421,14 @@ fn edit_actions(
.with_child(status_form);
if can_toggle_admin {
let admin_button = Button::submit(Lc::t(admin_label_key, &LOCALES_USER))
.with_style(button::Style::Solid(Intent::Severe));
let mut admin_form = Form::new()
.with_action(admin_action.clone())
.with_method(form::Method::Post)
.with_prop(PropsOp::set(hx::POST, admin_action.to_string()))
.with_child(form::Hidden::field("is_admin", next_is_admin))
.with_child(Button::submit(Lc::t(admin_label_key, &LOCALES_USER)));
.with_child(admin_button);
if let Some(confirm) = Lc::t(admin_confirm_key, &LOCALES_USER).lookup(cx) {
admin_form = admin_form.with_prop(PropsOp::set(hx::CONFIRM, confirm));
}
@ -411,6 +445,7 @@ pub(crate) async fn edit_get(
web::Query(waypoint): web::Query<Waypoint>,
) -> Result<Response, ErrorPage> {
require_permission(&request, &UserPermission::AdminUsers)?;
require_manageable(&request, id).await?;
Ok(render_user_edit(request, id, None, waypoint).await)
}
@ -421,31 +456,49 @@ pub(crate) struct EditUserFormData {
#[serde(default)]
display_name: String,
#[serde(default)]
about: String,
#[serde(default)]
language: String,
#[serde(default)]
timezone: String,
#[serde(default)]
theme: String,
#[serde(default)]
role_ids: Vec<String>,
}
/// POST /admin/user/users/{id}/edit - Actualiza los datos de perfil de un usuario.
/// POST /admin/user/users/{id}/edit - Actualiza los datos de perfil y los roles de un usuario.
///
/// Usa `RawForm` + `serde_qs` por los roles, igual que `new_post`.
pub(crate) async fn edit_post(
request: HttpRequest,
web::Path(id): web::Path<i32>,
web::Query(waypoint): web::Query<Waypoint>,
web::Form(form): web::Form<EditUserFormData>,
raw: web::RawForm,
) -> Result<Response, ErrorPage> {
require_permission(&request, &UserPermission::AdminUsers)?;
let user = require_manageable(&request, id).await?;
let Ok(form) = serde_qs::from_bytes::<EditUserFormData>(&raw.0) else {
return Err(ErrorPage::BadRequest(Some(request)));
};
let role_ids = parse_role_ids(&form.role_ids);
let result = user_admin::update_user(
let mut result = user_admin::update_user(
id,
user_admin::UserUpdateData {
username: &form.username,
email: &form.email,
display_name: util::non_blank(&form.display_name),
about: util::non_blank(&form.about),
language: util::non_blank(&form.language),
timezone: util::non_blank(&form.timezone),
theme: util::non_blank(&form.theme),
},
)
.await;
if result.is_ok() {
result = user_admin::set_user_roles(id, &role_ids).await;
}
match result {
Ok(()) => {
@ -454,29 +507,19 @@ pub(crate) async fn edit_post(
Ok(Redirect::see_other(target).into_response())
}
Err(err) => {
let mut page = Page::admin(request);
let back_href = waypoint.or(page.context().route(ADMIN_USERS_PATH));
let form_component = UserForm::new()
.with_mode(UserFormMode::Edit)
.with_user_id(Some(id))
let roles = available_roles(&role_ids).await.unwrap_or_default();
let status = UserStatus::from_i16(user.status);
let form = UserForm::new()
.with_username(form.username)
.with_email(form.email)
.with_display_name(form.display_name)
.with_about(form.about)
.with_language(form.language)
.with_timezone(form.timezone)
.with_error(map_auth_error(&err))
.with_waypoint(waypoint);
let title = Lc::t("title-admin-user-edit", &LOCALES_USER);
Ok(page
.with_title(title.clone())
.with_child(
frame(title)
.with_child(form_component)
.with_child(back_link(back_href)),
)
.render()
.await
.into_response())
.with_theme(form.theme)
.with_roles(roles)
.with_error(map_auth_error(&err));
Ok(render_edit_page(request, id, status, user.is_admin, form, waypoint).await)
}
}
}
@ -505,7 +548,7 @@ pub(crate) async fn view_get(
let mut page = Page::admin(request);
let back_href = waypoint.or(page.context().route(ADMIN_USERS_PATH));
let details_block = user_view_details(&user, status, page.context()).await;
let roles_block = user_view_roles(&roles, page.context()).await;
let roles_block = user_view_roles(&roles, user.is_admin, page.context()).await;
let title = Lc::t("title-admin-user-view", &LOCALES_USER);
Ok(page
@ -540,25 +583,44 @@ async fn user_view_details(user: &user::Model, status: UserStatus, cx: &mut Cont
.with_cell(Lc::t("field-display-name", &LOCALES_USER))
.with_cell(user.display_name.as_deref().unwrap_or("-")),
)
.with_row(
table::Row::new()
.with_cell(Lc::t("field-about", &LOCALES_USER))
.with_cell(multiline_text(
user.about.clone().unwrap_or_else(|| "-".into()),
)),
)
.with_row(
table::Row::new()
.with_cell(Lc::t("field-language", &LOCALES_USER))
.with_cell(user.language.as_deref().unwrap_or("-")),
.with_cell(language_name(user.language.as_deref())),
)
.with_row(
table::Row::new()
.with_cell(Lc::t("field-timezone", &LOCALES_USER))
.with_cell(user.timezone.as_deref().unwrap_or("-")),
)
.with_row(
table::Row::new()
.with_cell(Lc::t("field-theme", &LOCALES_USER))
.with_cell(theme_name(user.theme.as_deref())),
)
.with_row(
table::Row::new()
.with_cell(Lc::t("col-status", &LOCALES_USER))
.with_cell(Lc::t(status_key(status), &LOCALES_USER)),
)
.with_row(
table::Row::new()
.with_cell(Lc::t("field-member-since", &LOCALES_USER))
.with_cell(cx.format_date(
user.created_at.with_timezone(&cx.timezone()).date_naive(),
DateFormat::Long,
)),
);
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(
@ -575,150 +637,63 @@ 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<Markup>)> = Vec::with_capacity(roles.len());
async fn user_view_roles(roles: &[role::Model], is_admin: bool, cx: &mut Context) -> Block {
// Un bloque sin hijos no se renderiza.
if roles.is_empty() {
return Block::new();
}
let mut table = Table::new().with_prop(PropsOp::add_classes("user-admin-table"));
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));
}
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) }
}
}
}
}
}
}
}
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) } }
})),
);
}
// **< roles_get / roles_post >*********************************************************************
/// GET /admin/user/users/{id}/roles - Formulario de asignación de roles de un usuario.
pub(crate) async fn roles_get(
request: HttpRequest,
web::Path(id): web::Path<i32>,
web::Query(waypoint): web::Query<Waypoint>,
) -> Result<Response, ErrorPage> {
require_permission(&request, &UserPermission::AdminUsers)?;
require_permission(&request, &UserPermission::AssignRoles)?;
if user_admin::find_user(id).await.is_err() {
return Err(ErrorPage::NotFound(Some(request)));
let mut block = Block::new()
.with_title(Lc::t("field-roles", &LOCALES_USER))
.with_child(table);
if is_admin {
block = block.with_child(Html::with(|cx| {
html! { p { (Lc::t("help-admin-roles", &LOCALES_USER).using(cx)) } }
}));
}
block
}
let current = match user_admin::user_role_ids(id).await {
Ok(ids) => ids,
Err(_) => return Err(ErrorPage::InternalError(Some(request))),
};
let roles = match available_roles(&current).await {
Ok(roles) => roles,
Err(_) => return Err(ErrorPage::InternalError(Some(request))),
};
let mut page = Page::admin(request);
let back_href = waypoint.or(page.context().route(ADMIN_USERS_PATH));
let title = Lc::t("title-admin-user-roles", &LOCALES_USER);
Ok(page
.with_title(title.clone())
.with_child(
frame(title)
.with_child(
UserRolesForm::new()
.with_user_id(id)
.with_roles(roles)
.with_waypoint(waypoint),
// Cabecera con los datos básicos del usuario en la pantalla de restablecimiento de contraseña, que
// de otro modo no lo identifica.
fn user_summary(user: &user::Model) -> Table {
Table::new()
.with_prop(PropsOp::add_classes("user-admin-table"))
.with_row(
table::Row::new()
.with_cell(Lc::t("field-username-admin", &LOCALES_USER))
.with_cell(user.username.as_str()),
)
.with_child(back_link(back_href)),
.with_row(
table::Row::new()
.with_cell(Lc::t("field-display-name", &LOCALES_USER))
.with_cell(user.display_name.as_deref().unwrap_or("-")),
)
.render()
.await
.into_response())
}
#[derive(Deserialize)]
pub(crate) struct UserRolesFormData {
#[serde(default)]
role_ids: Vec<String>,
}
/// POST /admin/user/users/{id}/roles - Reemplaza el conjunto de roles asignados a un usuario.
pub(crate) async fn roles_post(
request: HttpRequest,
web::Path(id): web::Path<i32>,
web::Query(waypoint): web::Query<Waypoint>,
raw: web::RawForm,
) -> Result<Response, ErrorPage> {
require_permission(&request, &UserPermission::AdminUsers)?;
require_permission(&request, &UserPermission::AssignRoles)?;
let Ok(form) = serde_qs::from_bytes::<UserRolesFormData>(&raw.0) else {
return Err(ErrorPage::BadRequest(Some(request)));
};
let role_ids: Vec<i32> = form
.role_ids
.iter()
.filter_map(|s| s.parse().ok())
.collect();
match user_admin::set_user_roles(id, &role_ids).await {
Ok(()) => {
let cx = Context::admin(request);
let target = waypoint.or(cx.route(ADMIN_USERS_PATH));
Ok(Redirect::see_other(target).into_response())
}
Err(err) => {
let roles = available_roles(&role_ids).await.unwrap_or_default();
let mut page = Page::admin(request);
let back_href = waypoint.or(page.context().route(ADMIN_USERS_PATH));
let form_component = UserRolesForm::new()
.with_user_id(id)
.with_roles(roles)
.with_error(map_auth_error(&err))
.with_waypoint(waypoint);
let title = Lc::t("title-admin-user-roles", &LOCALES_USER);
Ok(page
.with_title(title.clone())
.with_child(
frame(title)
.with_child(form_component)
.with_child(back_link(back_href)),
)
.render()
.await
.into_response())
}
}
}
// **< status_post >********************************************************************************
@ -737,6 +712,7 @@ pub(crate) async fn status_post(
) -> Result<Response, ErrorPage> {
require_permission(&request, &UserPermission::AdminUsers)?;
require_permission(&request, &UserPermission::BlockAccounts)?;
require_manageable(&request, id).await?;
let Some(account) = request.extension::<Account>().cloned() else {
return Err(ErrorPage::AccessDenied(Some(request)));
};
@ -750,7 +726,7 @@ pub(crate) async fn status_post(
// del propio `<form>`. `HtmxResponse::redirect()` fuerza una navegación real en el cliente.
let is_htmx = request.is_htmx();
match user_admin::set_user_status(id, new_status, account.id).await {
match user_admin::set_user_status(id, new_status, account.id()).await {
Ok(()) => {
let cx = Context::admin(request);
let edit_href = waypoint.append_to(cx.route(user_path(id, "edit")));
@ -774,7 +750,7 @@ pub(crate) struct AdminFormData {
/// POST /admin/user/users/{id}/admin - Concede o revoca el acceso irrestricto (`is_admin`).
///
/// No pasa por `require_permission`: conceder o revocar este flag no es un permiso del catálogo,
/// se comprueba directamente contra `account.is_admin` para que sólo un administrador pueda
/// se comprueba directamente contra `account.is_admin()` para que sólo un administrador pueda
/// tocarlo (un permiso concedido vía rol nunca basta).
pub(crate) async fn admin_post(
request: HttpRequest,
@ -785,7 +761,7 @@ pub(crate) async fn admin_post(
let Some(account) = request.extension::<Account>().cloned() else {
return Err(ErrorPage::AccessDenied(Some(request)));
};
if !account.is_admin {
if !account.is_admin() {
return Err(ErrorPage::AccessDenied(Some(request)));
}
@ -797,7 +773,7 @@ pub(crate) async fn admin_post(
// (cabecera `HX-Redirect`) para que navegue de verdad a la URL, en vez de intentar un `swap`.
let is_htmx = request.is_htmx();
match user_admin::set_user_admin(id, new_is_admin, account.id).await {
match user_admin::set_user_admin(id, new_is_admin, account.id()).await {
Ok(()) => {
let cx = Context::admin(request);
let edit_href = waypoint.append_to(cx.route(user_path(id, "edit")));
@ -821,9 +797,7 @@ pub(crate) async fn password_get(
web::Query(waypoint): web::Query<Waypoint>,
) -> Result<Response, ErrorPage> {
require_permission(&request, &UserPermission::AdminUsers)?;
if user_admin::find_user(id).await.is_err() {
return Err(ErrorPage::NotFound(Some(request)));
}
let user = require_manageable(&request, id).await?;
let mut page = Page::admin(request);
let edit_href = waypoint.append_to(page.context().route(user_path(id, "edit")));
let title = Lc::t("title-admin-user-password", &LOCALES_USER);
@ -831,6 +805,7 @@ pub(crate) async fn password_get(
.with_title(title.clone())
.with_child(
frame(title)
.with_child(user_summary(&user))
.with_child(
AdminPasswordForm::new()
.with_user_id(id)
@ -858,6 +833,7 @@ pub(crate) async fn password_post(
web::Form(form): web::Form<AdminPasswordFormData>,
) -> Result<Response, ErrorPage> {
require_permission(&request, &UserPermission::AdminUsers)?;
let user = require_manageable(&request, id).await?;
let result = match password::passwords_match(&form.password, &form.confirm_password) {
Ok(()) => user_admin::admin_reset_password(id, &form.password).await,
@ -878,6 +854,7 @@ pub(crate) async fn password_post(
.with_title(title.clone())
.with_child(
frame(title)
.with_child(user_summary(&user))
.with_child(
AdminPasswordForm::new()
.with_user_id(id)

View file

@ -94,6 +94,7 @@ pub use permission::{DeclarePermissions, PermissionRegistry};
/// Prelude de `pagetop-user`.
pub mod prelude {
pub use crate::component::LoginForm;
pub use crate::component::UserName;
pub use crate::component::{AccountMenu, account_menu};
pub use crate::error::AuthError;
pub use crate::{Account, DeclarePermissions, UserStatus};
@ -114,6 +115,15 @@ const LOGOUT_PATH: &str = "/user/logout";
const REGISTER_PATH: &str = "/user/register";
// GET - perfil del usuario autenticado; redirige a LOGIN_PATH si no hay sesión activa.
const PROFILE_PATH: &str = "/user";
// Con el sufijo `/{id}`: GET - perfil público del usuario (requiere `user:view_profiles`, salvo el
// propio).
// GET - muestra el formulario de edición del perfil propio (requiere `user:edit_own_profile`).
// POST - guarda los cambios.
const PROFILE_EDIT_PATH: &str = "/user/edit";
// GET - muestra el formulario de cambio de la contraseña propia (requiere
// `user:change_own_password`).
// POST - aplica la nueva contraseña tras comprobar la actual.
const PROFILE_PASSWORD_PATH: &str = "/user/password";
// GET - muestra el formulario de solicitud de restablecimiento.
// POST - inicia el flujo (envío del token).
// Con el sufijo `/{uid}/{token}`: GET - muestra el formulario de nueva contraseña.
@ -132,6 +142,11 @@ const ADMIN_ROLES_PATH: &str = "/admin/user/roles";
// Catálogo de permisos registrados, agrupado por extensión (solo lectura).
const ADMIN_PERMISSIONS_PATH: &str = "/admin/user/permissions";
// Ruta del perfil público del usuario `id`: `{PROFILE_PATH}/{id}`.
fn profile_path(id: i32) -> String {
util::join!(PROFILE_PATH, "/", id.to_string())
}
// Ruta de una acción sobre el usuario `id`: `{ADMIN_USERS_PATH}/{id}/{tail}`.
fn user_path(id: i32, tail: &str) -> String {
util::join!(ADMIN_USERS_PATH, "/", id.to_string(), "/", tail)
@ -155,7 +170,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 +179,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 +188,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,
});
}
@ -247,6 +262,19 @@ impl Extension for User {
web::get(handlers::auth::register_get).post(handlers::auth::register_post),
)
.route(PROFILE_PATH, web::get(handlers::account::profile_get))
.route(
&format!("{}/{{id}}", PROFILE_PATH),
web::get(handlers::account::public_profile_get),
)
.route(
PROFILE_EDIT_PATH,
web::get(handlers::account::profile_edit_get)
.post(handlers::account::profile_edit_post),
)
.route(
PROFILE_PASSWORD_PATH,
web::get(handlers::account::password_get).post(handlers::account::password_post),
)
.route(
PASSWORD_RESET_PATH,
web::get(handlers::auth::password_reset_get)
@ -274,11 +302,6 @@ impl Extension for User {
&format!("{}/{{id}}/view", ADMIN_USERS_PATH),
web::get(handlers::admin::users::view_get),
)
.route(
&format!("{}/{{id}}/roles", ADMIN_USERS_PATH),
web::get(handlers::admin::users::roles_get)
.post(handlers::admin::users::roles_post),
)
.route(
&format!("{}/{{id}}/status", ADMIN_USERS_PATH),
web::post(handlers::admin::users::status_post),

View file

@ -12,6 +12,8 @@ title-register = Create account
title-password-reset = Reset password
title-new-password = Set new password
title-profile = My profile
title-profile-edit = Edit my profile
title-profile-password = Change my password
# **< Field labels >**
@ -20,6 +22,7 @@ field-password = Password
field-email = Email address
field-confirm-password = Confirm password
field-new-password = New password
field-current-password = Current password
field-remember-me = Remember me
# **< Buttons and links >**
@ -29,6 +32,7 @@ btn-logout = Sign out
btn-register = Create account
btn-send-reset-link = Send reset link
btn-set-password = Change password
btn-edit-profile = Edit profile
link-register = Create an account
link-forgot-password = Forgot your password?
@ -56,9 +60,13 @@ error-account-locked = Too many failed attempts. Please try again later.
error-password-mismatch = Passwords do not match.
error-password-too-short = Password must be at least { $n } characters.
error-invalid-timezone = Invalid timezone.
error-invalid-language = Invalid language.
error-invalid-theme = Invalid theme.
error-about-too-long = The "About me" text must be at most { $n } characters.
error-username-taken = This username is already taken.
error-email-taken = This email address is already registered.
error-token-invalid = This link is invalid or has expired.
error-current-password = The current password is incorrect.
error-internal = An unexpected error occurred. Please try again.
# **< Account statuses >**
@ -73,7 +81,6 @@ title-admin-users = Users
title-admin-user-new = New user
title-admin-user-edit = Edit user
title-admin-user-view = View user
title-admin-user-roles = User roles
title-admin-user-password = Reset password
title-admin-roles = Roles
title-admin-role-new = New role
@ -107,9 +114,12 @@ col-users-count = Users
field-username-admin = Username
field-display-name = Display name
field-about = About me
field-member-since = Member since
field-language = Language
field-timezone = Timezone
field-timezone-site-default = Use site timezone ({ $tz })
field-theme = Theme
value-theme-site-default = Site theme: { $theme }
field-machine-name = Machine name
field-label = Label
field-description = Description
@ -121,6 +131,9 @@ field-search-users = Search by username, email or name...
help-machine-name-immutable =
Lowercase letters, digits and underscores only. Cannot be changed after creation.
help-admin-roles =
As an administrator they have every permission. These roles will apply if they stop being one.
# **< Admin: buttons and links >**
btn-save = Save
@ -129,7 +142,6 @@ btn-create-role = New role
btn-delete = Delete
btn-cancel = Cancel
btn-edit = Edit
btn-manage-roles = Manage roles
btn-manage-permissions = Manage permissions
btn-reset-password = Reset password
btn-block = Block
@ -146,8 +158,10 @@ 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 >**

View file

@ -4,18 +4,16 @@
group-users = User management
perm-login = Sign in
perm-register = Register a new account
perm-view-profiles = View user profiles
perm-edit-own-profile = Edit own profile
perm-change-own-username = Change own username
perm-change-own-password = Change own password
# **< Group: Administration >**
group-administration = Administration
perm-admin-users = Administer users
perm-admin-users = Administer users and assign their roles
perm-admin-roles = Administer roles
perm-admin-permissions = Administer permissions
perm-block-accounts = Block and unblock accounts
perm-assign-roles = Assign roles to users

View file

@ -12,6 +12,8 @@ title-register = Crear cuenta
title-password-reset = Recuperar contraseña
title-new-password = Establecer nueva contraseña
title-profile = Mi perfil
title-profile-edit = Editar mi perfil
title-profile-password = Cambiar mi contraseña
# **< Etiquetas de campos >**
@ -20,6 +22,7 @@ field-password = Contraseña
field-email = Dirección de email
field-confirm-password = Confirmar contraseña
field-new-password = Nueva contraseña
field-current-password = Contraseña actual
field-remember-me = Recuérdame
# **< Botones y enlaces >**
@ -29,6 +32,7 @@ btn-logout = Cerrar sesión
btn-register = Crear cuenta
btn-send-reset-link = Enviar enlace
btn-set-password = Cambiar contraseña
btn-edit-profile = Editar perfil
link-register = Crear una cuenta
link-forgot-password = ¿Olvidaste tu contraseña?
@ -56,9 +60,13 @@ error-account-locked = Demasiados intentos fallidos. Inténtalo de nuevo m
error-password-mismatch = Las contraseñas no coinciden.
error-password-too-short = La contraseña debe tener al menos { $n } caracteres.
error-invalid-timezone = Zona horaria no válida.
error-invalid-language = Idioma no válido.
error-invalid-theme = Tema no válido.
error-about-too-long = El texto «Sobre mí» no puede superar { $n } caracteres.
error-username-taken = Este nombre de usuario ya está en uso.
error-email-taken = Esta dirección de email ya está registrada.
error-token-invalid = Este enlace no es válido o ha caducado.
error-current-password = La contraseña actual no es correcta.
error-internal = Se ha producido un error inesperado. Inténtalo de nuevo.
# **< Estados de cuenta >**
@ -73,7 +81,6 @@ title-admin-users = Usuarios
title-admin-user-new = Nuevo usuario
title-admin-user-edit = Editar usuario
title-admin-user-view = Ver usuario
title-admin-user-roles = Roles del usuario
title-admin-user-password = Restablecer contraseña
title-admin-roles = Roles
title-admin-role-new = Nuevo rol
@ -107,9 +114,12 @@ col-users-count = Usuarios
field-username-admin = Usuario
field-display-name = Nombre visible
field-about = Sobre mí
field-member-since = Miembro desde
field-language = Idioma
field-timezone = Zona horaria
field-timezone-site-default = Usar la zona horaria del sitio ({ $tz })
field-theme = Tema
value-theme-site-default = Tema del sitio: { $theme }
field-machine-name = Nombre técnico
field-label = Etiqueta
field-description = Descripción
@ -121,6 +131,9 @@ field-search-users = Buscar por usuario, email o nombre...
help-machine-name-immutable =
Sólo minúsculas, dígitos y guiones bajos. No se puede cambiar tras crearlo.
help-admin-roles =
Como administrador tiene todos los permisos. Estos roles se aplicarán si deja de serlo.
# **< Administración: botones y enlaces >**
btn-save = Guardar
@ -129,7 +142,6 @@ btn-create-role = Nuevo rol
btn-delete = Eliminar
btn-cancel = Cancelar
btn-edit = Editar
btn-manage-roles = Gestionar roles
btn-manage-permissions = Gestionar permisos
btn-reset-password = Restablecer contraseña
btn-block = Bloquear
@ -146,8 +158,10 @@ 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 >**

View file

@ -4,18 +4,16 @@
group-users = Gestión de usuarios
perm-login = Iniciar sesión
perm-register = Registrar una cuenta nueva
perm-view-profiles = Ver perfiles de usuarios
perm-edit-own-profile = Editar el propio perfil
perm-change-own-username = Cambiar el propio nombre de usuario
perm-change-own-password = Cambiar la propia contraseña
# **< Grupo: Administración >**
group-administration = Administración
perm-admin-users = Administrar usuarios
perm-admin-users = Administrar usuarios y asignarles roles
perm-admin-roles = Administrar roles
perm-admin-permissions = Administrar permisos
perm-block-accounts = Bloquear y desbloquear cuentas
perm-assign-roles = Asignar roles a usuarios

View file

@ -17,7 +17,10 @@ impl MigrationTrait for Migration {
.col(small_integer(Users::Status).default(1))
.col(string_len_null(Users::Language, 16))
.col(string_len_null(Users::Timezone, 64))
.col(string_len_null(Users::Theme, 64))
.col(string_len_null(Users::DisplayName, 128))
// Texto libre "Sobre mí" que el propio usuario escribe en su perfil.
.col(text_null(Users::About))
.col(timestamp_null(Users::LastLoginAt))
.col(timestamp_null(Users::LastAccessAt))
.col(integer(Users::FailedLoginCount).default(0))
@ -47,7 +50,9 @@ pub enum Users {
Status,
Language,
Timezone,
Theme,
DisplayName,
About,
LastLoginAt,
LastAccessAt,
FailedLoginCount,

View file

@ -162,17 +162,22 @@ pub fn registry() -> &'static PermissionRegistry {
/// Permisos propios de `pagetop-user`.
#[derive(Clone, Copy, Debug)]
pub enum UserPermission {
/// Iniciar sesión.
Login,
/// Registrar una cuenta nueva.
Register,
/// Ver perfiles de otros usuarios.
/// Ver el perfil público de otros usuarios (`/user/{id}`): nombre de usuario, nombre visible,
/// "Sobre mí" y fecha de alta.
ViewProfiles,
/// Editar el perfil propio.
/// Editar los datos del perfil propio: email, nombre visible, "Sobre mí", idioma, zona horaria
/// y tema. Nunca los roles, el estado de la cuenta ni el acceso irrestricto.
EditOwnProfile,
/// Cambiar el nombre de usuario propio. Complementa a `EditOwnProfile`: el campo está en el
/// mismo formulario, así que sin aquel no tiene efecto.
ChangeOwnUsername,
/// Cambiar la contraseña propia.
ChangeOwnPassword,
/// Acceder al mantenimiento de usuarios (listado, alta, edición).
/// Acceder al mantenimiento de usuarios (listado, alta, edición, asignación de roles,
/// restablecimiento de contraseñas).
///
/// Es un permiso sensible: quien lo tiene puede asignar cualquier rol y hacerse con cualquier
/// cuenta que no sea de administrador, así que sólo debe concederse a personas de confianza.
AdminUsers,
/// Acceder al mantenimiento de roles (listado, alta, edición, borrado).
AdminRoles,
@ -180,70 +185,59 @@ pub enum UserPermission {
AdminPermissions,
/// Bloquear y desbloquear cuentas de usuario.
BlockAccounts,
/// Asignar roles a usuarios.
AssignRoles,
}
impl UserPermission {
/// Todas las variantes, usado para registrarlas en el catálogo.
pub const ALL: &'static [Self] = &[
Self::Login,
Self::Register,
Self::ViewProfiles,
Self::EditOwnProfile,
Self::ChangeOwnUsername,
Self::ChangeOwnPassword,
Self::AdminUsers,
Self::AdminRoles,
Self::AdminPermissions,
Self::BlockAccounts,
Self::AssignRoles,
];
}
impl Permission for UserPermission {
fn key(&self) -> CowStr {
match self {
Self::Login => "user:login".into(),
Self::Register => "user:register".into(),
Self::ViewProfiles => "user:view_profiles".into(),
Self::EditOwnProfile => "user:edit_own_profile".into(),
Self::ChangeOwnUsername => "user:change_own_username".into(),
Self::ChangeOwnPassword => "user:change_own_password".into(),
Self::AdminUsers => "user:admin_users".into(),
Self::AdminRoles => "user:admin_roles".into(),
Self::AdminPermissions => "user:admin_permissions".into(),
Self::BlockAccounts => "user:block_accounts".into(),
Self::AssignRoles => "user:assign_roles".into(),
}
}
fn label(&self) -> Lc {
let key = match self {
Self::Login => "perm-login",
Self::Register => "perm-register",
Self::ViewProfiles => "perm-view-profiles",
Self::EditOwnProfile => "perm-edit-own-profile",
Self::ChangeOwnUsername => "perm-change-own-username",
Self::ChangeOwnPassword => "perm-change-own-password",
Self::AdminUsers => "perm-admin-users",
Self::AdminRoles => "perm-admin-roles",
Self::AdminPermissions => "perm-admin-permissions",
Self::BlockAccounts => "perm-block-accounts",
Self::AssignRoles => "perm-assign-roles",
};
Lc::t(key, &LOCALES_USER)
}
fn group(&self) -> &'static str {
match self {
Self::Login
| Self::Register
| Self::ViewProfiles
Self::ViewProfiles
| Self::EditOwnProfile
| Self::ChangeOwnUsername
| Self::ChangeOwnPassword => GROUP_USERS,
Self::AdminUsers
| Self::AdminRoles
| Self::AdminPermissions
| Self::BlockAccounts
| Self::AssignRoles => GROUP_ADMINISTRATION,
Self::AdminUsers | Self::AdminRoles | Self::AdminPermissions | Self::BlockAccounts => {
GROUP_ADMINISTRATION
}
}
}

View file

@ -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<role::Model>) -> Result<Vec<RoleListItem>, AuthError> {
let role_ids: Vec<i32> = 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<role::Model>) -> Result<Vec<RoleListItem>, AuthEr
.into_iter()
.map(|(role_id, count)| (role_id, count as u64))
.collect();
let with_permissions: HashSet<i32> = 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,

View file

@ -162,6 +162,7 @@ pub(crate) struct NewUserData<'a> {
pub display_name: Option<&'a str>,
pub language: Option<&'a str>,
pub timezone: Option<&'a str>,
pub theme: Option<&'a str>,
pub initial_role_ids: &'a [i32],
/// El *caller* es responsable de comprobar que sólo un administrador puede pasar `true`.
pub is_admin: bool,
@ -173,7 +174,9 @@ pub(crate) struct NewUserData<'a> {
pub(crate) async fn create_user(data: NewUserData<'_>) -> Result<i32, AuthError> {
password::validate_strength(data.password)?;
password::passwords_match(data.password, data.confirm_password)?;
let language = validate_language(data.language)?;
let timezone = validate_timezone(data.timezone)?;
let theme = validate_theme(data.theme)?;
ensure_username_available(data.username, None).await?;
ensure_email_available(data.email, None).await?;
@ -187,9 +190,11 @@ pub(crate) async fn create_user(data: NewUserData<'_>) -> Result<i32, AuthError>
email_verified_at: Set(Some(now)),
password_hash: Set(hash),
status: Set(UserStatus::Active.as_i16()),
language: Set(data.language.map(str::to_owned)),
language: Set(language.map(str::to_owned)),
timezone: Set(timezone.map(str::to_owned)),
theme: Set(theme.map(str::to_owned)),
display_name: Set(data.display_name.map(str::to_owned)),
about: Set(None),
last_login_at: Set(None),
last_access_at: Set(None),
failed_login_count: Set(0),
@ -210,16 +215,35 @@ pub(crate) async fn create_user(data: NewUserData<'_>) -> Result<i32, AuthError>
// **< update_user >********************************************************************************
/// Longitud máxima, en caracteres, del texto "Sobre mí". `u16` porque también limita el campo del
/// formulario (`maxlength`).
pub(crate) const ABOUT_MAX_CHARS: u16 = 2000;
pub(crate) struct UserUpdateData<'a> {
pub username: &'a str,
pub email: &'a str,
pub display_name: Option<&'a str>,
pub about: Option<&'a str>,
pub language: Option<&'a str>,
pub timezone: Option<&'a str>,
pub theme: Option<&'a str>,
}
pub(crate) async fn update_user(user_id: i32, data: UserUpdateData<'_>) -> Result<(), AuthError> {
let language = validate_language(data.language)?;
let timezone = validate_timezone(data.timezone)?;
let theme = validate_theme(data.theme)?;
// El navegador envía los saltos de línea de un `<textarea>` como `\r\n`, pero `maxlength` puede
// contarlos como un único carácter: se normalizan antes de medir para no rechazar un texto que
// el formulario sí admitió.
let about = data.about.map(|about| about.replace("\r\n", "\n"));
let max_about = usize::from(ABOUT_MAX_CHARS);
if about
.as_deref()
.is_some_and(|about| about.chars().count() > max_about)
{
return Err(AuthError::AboutTooLong(max_about));
}
ensure_username_available(data.username, Some(user_id)).await?;
ensure_email_available(data.email, Some(user_id)).await?;
@ -229,8 +253,10 @@ pub(crate) async fn update_user(user_id: i32, data: UserUpdateData<'_>) -> Resul
username: Set(data.username.to_owned()),
email: Set(data.email.to_owned()),
display_name: Set(data.display_name.map(str::to_owned)),
language: Set(data.language.map(str::to_owned)),
about: Set(about),
language: Set(language.map(str::to_owned)),
timezone: Set(timezone.map(str::to_owned)),
theme: Set(theme.map(str::to_owned)),
updated_at: Set(now),
..Default::default()
}
@ -320,7 +346,7 @@ pub(crate) async fn set_user_status(
// **< set_user_admin >*****************************************************************************
/// Concede o revoca el acceso irrestricto (`is_admin`). No es un permiso del catálogo: sólo un
/// administrador puede concederlo o revocarlo (el handler comprueba `account.is_admin`
/// administrador puede concederlo o revocarlo (el handler comprueba `account.is_admin()`
/// directamente, sin pasar por `require_permission`).
///
/// Rechaza que un administrador se automodifique el flag. No hace falta proteger aparte al
@ -380,12 +406,44 @@ pub(crate) async fn admin_reset_password(
// **< HELPERS >************************************************************************************
// Devuelve la zona sin espacios, tal como debe guardarse. Una zona ausente o en blanco es válida y
// devuelve `None`: equivale a usar la predeterminada de la aplicación.
// Devuelve el idioma sin espacios, tal como debe guardarse. Ha de ser uno de los identificadores
// que ofrece el selector (`Locale::supported_languages()`); uno ausente o en blanco es válido y
// devuelve `None`: equivale a usar el predeterminado de la aplicación.
fn validate_language(language: Option<&str>) -> Result<Option<&str>, AuthError> {
let language = language.and_then(util::non_blank);
if let Some(code) = language
&& !Locale::supported_languages()
.iter()
.any(|(langid, _)| langid.to_string() == code)
{
return Err(AuthError::InvalidLanguage);
}
Ok(language)
}
// Devuelve el nombre corto del tema tal como debe guardarse, el que declara el propio tema aunque
// llegue con otras mayúsculas. Ha de ser uno de los temas habilitados; uno ausente o en blanco es
// válido y devuelve `None`: equivale a usar el predeterminado de la aplicación.
fn validate_theme(theme: Option<&str>) -> Result<Option<&'static str>, AuthError> {
match theme.and_then(util::non_blank) {
Some(name) => theme_by_short_name(name)
.map(|theme| Some(theme.short_name()))
.ok_or(AuthError::InvalidTheme),
None => Ok(None),
}
}
// Devuelve la zona sin espacios, tal como debe guardarse. Ha de ser una de las que ofrece el
// selector (`Timezone::supported_by_region()`); una ausente o en blanco es válida y devuelve
// `None`: equivale a usar la predeterminada de la aplicación.
fn validate_timezone(timezone: Option<&str>) -> Result<Option<&str>, AuthError> {
let timezone = timezone.and_then(util::non_blank);
if let Some(tz) = timezone {
tz.parse::<Tz>().map_err(|_| AuthError::InvalidTimezone)?;
if let Some(tz) = timezone
&& !Timezone::supported_by_region()
.iter()
.any(|(_, names)| names.contains(&tz))
{
return Err(AuthError::InvalidTimezone);
}
Ok(timezone)
}

View file

@ -75,11 +75,11 @@ pub fn extract_sid(headers: Option<&web::http::HeaderMap>) -> Option<String> {
/// Lee la cookie de sesión de las cabeceras y resuelve el par `(CurrentUser, Option<Account>)`.
///
/// Si no hay cookie o la sesión ha expirado, devuelve `(CurrentUser::Anonymous, None)`.
/// Si no hay cookie o la sesión ha expirado, devuelve `(CurrentUser::anonymous(), None)`.
/// Se llama desde el middleware de sesión, que es async.
pub async fn resolve_session(headers: &web::http::HeaderMap) -> (CurrentUser, Option<Account>) {
let Some(sid) = extract_sid(Some(headers)) else {
return (CurrentUser::Anonymous, None);
return (CurrentUser::anonymous(), None);
};
load_user_from_session(&sid).await
}
@ -88,24 +88,25 @@ pub async fn resolve_session(headers: &web::http::HeaderMap) -> (CurrentUser, Op
/// Carga el par `(CurrentUser, Option<Account>)` a partir de un session ID.
///
/// Devuelve `(CurrentUser::Anonymous, None)` si la sesión no existe o ha expirado.
/// Devuelve `(CurrentUser::anonymous(), None)` si la sesión no existe o ha expirado.
pub async fn load_user_from_session(sid: &str) -> (CurrentUser, Option<Account>) {
let now = Utc::now();
// Buscar sesión activa y no expirada.
let Ok(Some(sess)) = session::Entity::find_by_id(sid).one(dbconn()).await else {
return (CurrentUser::Anonymous, None);
return (CurrentUser::anonymous(), None);
};
if sess.expires_at < now {
return (CurrentUser::Anonymous, None);
return (CurrentUser::anonymous(), None);
}
// Cargar usuario con estado activo.
let Ok(Some(user_model)) = user::Entity::find_by_id(sess.user_id).one(dbconn()).await else {
return (CurrentUser::Anonymous, None);
let Ok(Some(mut user_model)) = user::Entity::find_by_id(sess.user_id).one(dbconn()).await
else {
return (CurrentUser::anonymous(), None);
};
if UserStatus::from_i16(user_model.status) != UserStatus::Active {
return (CurrentUser::Anonymous, None);
return (CurrentUser::anonymous(), None);
}
// Cargar roles explícitos del usuario.
@ -114,7 +115,7 @@ pub async fn load_user_from_session(sid: &str) -> (CurrentUser, Option<Account>)
.all(dbconn())
.await
else {
return (CurrentUser::Anonymous, None);
return (CurrentUser::anonymous(), None);
};
let role_ids: Vec<i32> = user_role_rows.iter().map(|ur| ur.role_id).collect();
@ -124,7 +125,7 @@ pub async fn load_user_from_session(sid: &str) -> (CurrentUser, Option<Account>)
.all(dbconn())
.await
else {
return (CurrentUser::Anonymous, None);
return (CurrentUser::anonymous(), None);
};
let is_admin = user_model.is_admin;
@ -145,7 +146,7 @@ pub async fn load_user_from_session(sid: &str) -> (CurrentUser, Option<Account>)
.all(dbconn())
.await
else {
return (CurrentUser::Anonymous, None);
return (CurrentUser::anonymous(), None);
};
PermissionSet::new(perm_rows.into_iter().map(|p| p.permission_key))
};
@ -162,31 +163,14 @@ pub async fn load_user_from_session(sid: &str) -> (CurrentUser, Option<Account>)
let _ = active.update(dbconn()).await;
}
let display_name = user_model.display_name.unwrap_or_default();
let visible_name = if display_name.is_empty() {
user_model.username.clone()
} else {
display_name.clone()
};
let account = Account {
id: user_model.id,
username: user_model.username,
email: user_model.email,
display_name,
status: UserStatus::from_i16(user_model.status),
roles: role_names,
permissions,
is_admin,
};
let timezone = user_model
.timezone
.as_deref()
.and_then(|tz| tz.parse().ok());
let current_user = CurrentUser::Authenticated {
id: account.id,
display_name: visible_name,
timezone,
};
let language = user_model.language.take();
let timezone = user_model.timezone.take();
let theme = user_model.theme.take();
let account = Account::new(user_model, role_names, permissions);
let current_user = CurrentUser::authenticated(account.id(), account.display())
.with_language(language.as_deref())
.with_timezone(timezone.as_deref())
.with_theme(theme.as_deref());
(current_user, Some(account))
}
@ -227,3 +211,14 @@ pub async fn destroy_user_sessions(user_id: i32) -> Result<(), DbErr> {
.await?;
Ok(())
}
/// Destruye todas las sesiones de un usuario salvo `keep_sid` (p. ej. cuando el propio usuario
/// cambia su contraseña, para no cerrarle la sesión desde la que lo hace).
pub async fn destroy_other_sessions(user_id: i32, keep_sid: &str) -> Result<(), DbErr> {
session::Entity::delete_many()
.filter(session::Column::UserId.eq(user_id))
.filter(session::Column::Sid.ne(keep_sid))
.exec(dbconn())
.await?;
Ok(())
}

View file

@ -0,0 +1,38 @@
// Verifies how `UserName` renders when nobody can view the profile. Rendering the link needs a
// request with a session or with `user:view_profiles` granted, so it is not covered here.
use pagetop_user::prelude::*;
use pagetop::prelude::*;
#[pagetop::test]
async fn without_a_request_the_name_is_plain_text() {
let mut cx = Context::default();
let html = UserName::of(42, "Ana").render(&mut cx).await.into_string();
assert_eq!(html, r#"<span class="user-name">Ana</span>"#);
}
#[pagetop::test]
async fn the_name_is_escaped() {
let mut cx = Context::default();
let html = UserName::of(42, "<b>Ana</b>")
.render(&mut cx)
.await
.into_string();
assert!(html.contains("&lt;b&gt;Ana&lt;/b&gt;"));
assert!(!html.contains("<b>"));
}
#[pagetop::test]
async fn props_are_applied() {
let mut cx = Context::default();
let html = UserName::of(42, "Ana")
.with_prop(PropsOp::add_classes("author"))
.render(&mut cx)
.await
.into_string();
assert!(html.contains(r#"class="user-name author""#));
}

View file

@ -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.
#[derive(AutoDefault, Clone, Debug, Getters)]
pub struct CurrentUser {
/// Devuelve el identificador del usuario, o `None` si es anónimo.
#[getters(copy)]
id: Option<i32>,
// Siempre `Some` en un usuario autenticado y `None` en uno anónimo, igual que `id`.
#[getters(skip)]
display_name: Option<String>,
/// 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<Tz>,
},
/// 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<ThemeRef>,
}
#[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<String>) -> 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<Option<&'a str>>) -> 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<Option<&'a str>>) -> 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<Option<&'a str>>) -> 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<i32> {
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<HttpRequest>` 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(())

View file

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

View file

@ -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<String> {
self.field().id()
}
async fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
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<CowStr>) -> 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<PropsOp>) -> Self {
self.field.alter_prop(op);
self
}
/// Establece el nombre del campo.
pub fn with_name(mut self, name: impl AsRef<str>) -> 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<String>) -> Self {
self.selected = selected.into();
self
}
}

View file

@ -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<String> {
self.field().id()
}
async fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
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<CowStr>) -> 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<PropsOp>) -> Self {
self.field.alter_prop(op);
self
}
/// Establece el nombre del campo.
pub fn with_name(mut self, name: impl AsRef<str>) -> 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<String>) -> Self {
self.selected = selected.into();
self
}
}

View file

@ -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<String> {
self.field().id()
}
async fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
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<CowStr>) -> 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<PropsOp>) -> Self {
self.field.alter_prop(op);
self
}
/// Establece el nombre del campo.
pub fn with_name(mut self, name: impl AsRef<str>) -> 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<String>) -> Self {
self.selected = selected.into();
self
}
}

View file

@ -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>, // Favicon, si se ha definido.
preloads : Assets<Preload>, // Recursos para precarga.
stylesheets : Assets<StyleSheet>, // 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::<Preload>::new(),
stylesheets: Assets::<StyleSheet>::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::<CurrentUser>())
.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<HttpRequest>) -> 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<T: 'static>(&self, key: &'static str) -> Result<&T, ContextError> {
let (any, type_name) = self.params.get(key).ok_or(ContextError::ParamNotFound)?;
any.downcast_ref::<T>()

View file

@ -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<C: Contextual>(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<HttpRequest>) -> 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:

View file

@ -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<ExtensionRef>, 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 {

View file

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

View file

@ -7,27 +7,50 @@ use std::sync::LazyLock;
// **< TEMAS >**************************************************************************************
pub static THEMES: LazyLock<RwLock<Vec<ThemeRef>>> = LazyLock::new(|| RwLock::new(Vec::new()));
static THEMES: LazyLock<RwLock<Vec<ThemeRef>>> = 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<ThemeRef> {
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<ThemeRef> {
THEMES
.read()
.iter()
.find(|t| t.short_name().eq_ignore_ascii_case(short_name))
.copied()
}
// **< TEMA PREDETERMINADO >************************************************************************
pub static DEFAULT_THEME: LazyLock<ThemeRef> =
static DEFAULT_THEME: LazyLock<ThemeRef> =
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<ThemeRef> {
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
}

View file

@ -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()`].

View file

@ -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<Option<Tz>> = 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<Vec<(&'static str, Vec<&'static str>)>> = 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(&region) || (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
}
}

View file

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

View file

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

View file

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

View file

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

View file

@ -27,3 +27,38 @@ pub(super) static LANGUAGES: LazyLock<HashMap<&str, (LanguageIdentifier, &str)>>
"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<Vec<(&'static LanguageIdentifier, &'static str)>> =
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"
);
}
}
}

View file

@ -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::<CurrentUser>())
.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=...`

View file

@ -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<T: 'static>(&self, key: &'static str) -> Result<&T, ContextError> {
self.context.param(key)
}

View file

@ -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<ThemeRef> {
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();

View file

@ -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#"<option value="es-ES" selected>"#));
}
#[pagetop::test]
async fn optional_field_offers_the_site_language_first() {
let mut field = form::SelectLanguage::new().with_selected("es-ES");
let html = field.render(&mut Context::default()).await.into_string();
let site = html.find(r#"<option value="">Use the site language: "#);
let first_language = html.find(r#"<option value="en"#);
assert!(site.is_some());
assert!(site < first_language);
}
#[pagetop::test]
async fn optional_field_selects_the_site_language_when_nothing_is_selected() {
let mut field = form::SelectLanguage::new();
let html = field.render(&mut Context::default()).await.into_string();
assert!(html.contains(r#"<option value="" selected>Use the site language: "#));
}
#[pagetop::test]
async fn required_field_asks_to_choose_when_nothing_is_selected() {
let mut field = form::SelectLanguage::new().with_required(true);
let html = field.render(&mut Context::default()).await.into_string();
assert!(html.contains(r#"<option value="" selected>Choose a language...</option>"#));
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#"<option value="es-ES" selected>"#));
assert!(html.contains(r#"<option value="">Use the site language: "#));
}
}
#[pagetop::test]
async fn unknown_selected_value_falls_back_to_the_empty_option() {
let mut optional = form::SelectLanguage::new().with_selected("xx-XX");
let html = optional.render(&mut Context::default()).await.into_string();
assert!(html.contains(r#"<option value="" selected>Use the site language: "#));
let mut required = form::SelectLanguage::new()
.with_required(true)
.with_selected("xx-XX");
let html = required.render(&mut Context::default()).await.into_string();
assert!(html.contains(r#"<option value="" selected>Choose a language...</option>"#));
}
#[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)</option>")
.expect("Spanish name");
let english = html
.find(">Inglés (Estados Unidos)</option>")
.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)</option>")
.expect("Spanish name");
let english = html
.find(">English (United States)</option>")
.expect("English name");
assert!(english < spanish);
}

View file

@ -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<ThemeRef> {
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#"<option value="Aurora""#).expect("Aurora");
let basic = html
.find(r#"<option value="Basic" selected>"#)
.expect("Basic");
assert!(aurora < basic);
assert!(!html.contains("disabled"));
}
#[pagetop::test]
async fn optional_field_offers_the_site_theme_first() {
setup().await;
let mut field = form::SelectTheme::new();
let html = field.render(&mut Context::default()).await.into_string();
let site = html.find(r#"<option value="" selected>Use the site theme: "#);
let first_theme = html.find(r#"<option value="Aurora""#);
assert!(site.is_some());
assert!(site < first_theme);
}
#[pagetop::test]
async fn required_field_asks_to_choose_only_when_nothing_valid_is_selected() {
setup().await;
let mut empty = form::SelectTheme::new().with_required(true);
let html = empty.render(&mut Context::default()).await.into_string();
assert!(html.contains(r#"<option value="" selected>Choose a theme...</option>"#));
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="""#));
}

View file

@ -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#"<option value="Basic" selected>"#));
assert!(!html.contains(r#"value="""#));
}

View file

@ -0,0 +1,63 @@
use pagetop::prelude::*;
#[pagetop::test]
async fn groups_zones_by_region_and_marks_the_selected_one() {
let mut field = form::SelectTimezone::new()
.with_name("timezone")
.with_selected("Europe/Madrid");
let html = field.render(&mut Context::default()).await.into_string();
assert!(html.contains(r#"<optgroup label="Europe">"#));
assert!(html.contains(r#"<option value="Europe/Madrid" selected>"#));
assert!(html.contains(r#"value="Etc/UTC""#));
}
#[pagetop::test]
async fn region_labels_are_translated() {
let mut field = form::SelectTimezone::new();
let mut cx = Context::default().with_langid(&Locale::resolve("es-ES"));
let html = field.render(&mut cx).await.into_string();
assert!(html.contains(r#"<optgroup label="Europa">"#));
assert!(html.contains(r#"<optgroup label="Otras">"#));
}
#[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#"<option value="" selected>Use the site time zone: "#);
let first_group = html.find("<optgroup");
assert!(site.is_some());
assert!(site < first_group);
}
#[pagetop::test]
async fn required_field_asks_to_choose_only_when_nothing_valid_is_selected() {
let mut empty = form::SelectTimezone::new().with_required(true);
let html = empty.render(&mut Context::default()).await.into_string();
assert!(html.contains(r#"<option value="" selected>Choose a time zone...</option>"#));
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#"<option value="" selected>Choose a time zone...</option>"#));
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="""#));
}

View file

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

View file

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