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

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