✨ (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.
This commit is contained in:
Manuel Cillero 2026-10-04 23:09:07 +02:00
parent 35f2b66161
commit 70744a51a5
25 changed files with 1162 additions and 190 deletions

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>()