diff --git a/server/src/auth.rs b/server/src/auth.rs index 1f6704e..47b9cc5 100644 --- a/server/src/auth.rs +++ b/server/src/auth.rs @@ -78,6 +78,11 @@ pub struct AuthenticatedWebUser { /// Mirrors [`AuthenticatedUser::window_hours`] so the same policy /// is available to web handlers without a second config lookup. pub window_hours: u32, + /// Human-readable label rendered in the page header (browser title + /// stays neutral). Resolved from the user's `display_name` field + /// in users.toml, falling back to `slug` if absent. Pre-resolved + /// here so handlers and templates don't repeat the fallback logic. + pub display_name: String, /// Session's CSRF token. Rendered into every state-changing form /// as a hidden field so the `CsrfForm` extractor can validate it /// on the matching POST. Cloned out of the session record here so @@ -140,17 +145,17 @@ where // does not carry. A missing record can only happen if users.toml // was edited while a session was still live; fall back to the // serde default so the request stays functional. - let window_hours = config - .users - .iter() - .find(|u| u.slug == session.slug) - .map(|u| u.window_hours) - .unwrap_or(72); + let user_record = config.users.iter().find(|u| u.slug == session.slug); + let window_hours = user_record.map(|u| u.window_hours).unwrap_or(72); + let display_name = user_record + .map(|u| u.display_name().to_owned()) + .unwrap_or_else(|| session.slug.clone()); Ok(Self { slug: session.slug.clone(), role: session.role.clone(), data_dir, window_hours, + display_name, csrf_token: session.csrf_token.clone(), }) } diff --git a/server/src/config.rs b/server/src/config.rs index 1cbc19b..cfc4372 100644 --- a/server/src/config.rs +++ b/server/src/config.rs @@ -39,6 +39,11 @@ pub struct User { pub api_key: String, pub web_password: String, pub role: String, + /// Optional human-readable display name shown in the web UI in + /// place of the slug (e.g. "Dr. Müller"). Falls back to `slug` + /// when omitted — see [`User::display_name`]. + #[serde(default)] + pub display_name: Option, /// ASR input language code (e.g. "de", "en"). Forwarded as the /// `language` form/query field to the active ASR backend /// (Whisper or Canary). Missing field → backend default ("de"). @@ -63,6 +68,16 @@ pub struct User { pub preview_lines: u32, } +impl User { + /// Borrow the human-readable display name for this user. Returns + /// `display_name` when set, otherwise falls back to `slug`. Used + /// by web handlers to render the page header without the caller + /// having to repeat the fallback logic. + pub fn display_name(&self) -> &str { + self.display_name.as_deref().unwrap_or(&self.slug) + } +} + /// Wrapper for deserializing the [[user]] array from TOML. #[derive(Deserialize)] struct UsersFile { @@ -206,13 +221,18 @@ fn load_users(path: &str) -> (Vec, HashMap) { (parsed.user, api_keys) } -/// Build the api_key → slug lookup map. Rejects duplicate slugs (collide on -/// data directories) and duplicate api_keys (ambiguous authentication). +/// Build the api_key → slug lookup map. Rejects malformed slugs (would +/// silently fail at request time when no matching data directory exists), +/// duplicate slugs (collide on data directories) and duplicate api_keys +/// (ambiguous authentication). pub fn validate_and_index_users(users: &[User]) -> Result, String> { let mut slugs: std::collections::HashSet<&str> = std::collections::HashSet::new(); let mut api_keys: HashMap = HashMap::new(); for u in users { + if let Err(why) = crate::validate::validate_slug(&u.slug) { + return Err(format!("invalid slug `{}`: {why}", u.slug)); + } if !slugs.insert(u.slug.as_str()) { return Err(format!("duplicate slug: {}", u.slug)); } @@ -270,6 +290,7 @@ mod tests { api_key: api_key.into(), web_password: String::new(), role: "doctor".into(), + display_name: None, language: None, retention: RetentionUserSettings::default(), window_hours: default_window_hours(), @@ -421,6 +442,49 @@ mod tests { assert!(err.contains("duplicate slug"), "got: {err}"); } + #[test] + fn rejects_malformed_slug_at_boot() { + // "Brummel" with uppercase B used to slip past the loader and + // only surfaced as a runtime "no data directory" oddity. Boot + // must now fail loudly so the operator fixes users.toml before + // requests start arriving. + let users = vec![u("Brummel", "k1")]; + let err = validate_and_index_users(&users).unwrap_err(); + assert!(err.contains("invalid slug"), "got: {err}"); + assert!(err.contains("Brummel"), "got: {err}"); + } + + #[test] + fn parses_user_without_display_name_falls_back_to_slug() { + let toml_str = r#" + [[user]] + slug = "dr_a" + api_key = "k1" + web_password = "" + role = "doctor" + "#; + let parsed: UsersFile = toml::from_str(toml_str).unwrap(); + let u = &parsed.user[0]; + assert!(u.display_name.is_none()); + assert_eq!(u.display_name(), "dr_a"); + } + + #[test] + fn parses_user_with_display_name() { + let toml_str = r#" + [[user]] + slug = "dr_a" + api_key = "k1" + web_password = "" + role = "doctor" + display_name = "Dr. Müller" + "#; + let parsed: UsersFile = toml::from_str(toml_str).unwrap(); + let u = &parsed.user[0]; + assert_eq!(u.display_name.as_deref(), Some("Dr. Müller")); + assert_eq!(u.display_name(), "Dr. Müller"); + } + #[test] fn parses_user_without_window_hours_defaults_to_72() { let toml_str = r#" diff --git a/server/src/routes/user_web.rs b/server/src/routes/user_web.rs index e760e57..92a0d6b 100644 --- a/server/src/routes/user_web.rs +++ b/server/src/routes/user_web.rs @@ -270,7 +270,10 @@ struct DateGroup { #[derive(Template)] #[template(path = "my_cases.html")] struct MyCasesTemplate { - slug: String, + /// Human-readable label rendered in the `

`. Resolved upstream + /// from `users.toml` (`display_name` field, falling back to `slug`) + /// so the template stays presentation-only. + display_name: String, groups: Vec, total: usize, /// True iff the session user is an admin — toggles full case_id display. @@ -572,16 +575,17 @@ pub async fn handle_my_cases( // flip (new transcripts in, stale document, ...). auto_trigger::try_enqueue_all_for_user(&user_root, &pipeline.analyze_tx, &events_tx).await; + // Single users.toml lookup feeds both the retention sweep below and + // the per-user preview-lines cap further down. Cheaper than walking + // `config.users` twice for the same slug on every page render. + let user_record = config.users.iter().find(|u| u.slug == user.slug); + let retention = user_record.map(|u| u.retention.clone()).unwrap_or_default(); + let preview_lines = user_record.map(|u| u.preview_lines).unwrap_or(2); + // Lazy retention sweep: at most one disk scan per /web/cases visit, // reusing the user's retention policy from users.toml. Runs before // the listing scan so any auto-close/auto-purge actions are // reflected in the same response. - let retention = config - .users - .iter() - .find(|u| u.slug == user.slug) - .map(|u| u.retention.clone()) - .unwrap_or_default(); crate::retention::sweep_user_retention(&user_root, &user.slug, &retention, &events_tx).await; let show_closed = query.include_closed(); @@ -610,15 +614,9 @@ pub async fn handle_my_cases( }; let groups = group_by_utc_date(cases, &closed_extra); let is_admin = user.is_admin(); - let preview_lines = config - .users - .iter() - .find(|u| u.slug == user.slug) - .map(|u| u.preview_lines) - .unwrap_or(2); MyCasesTemplate { csrf_token: user.csrf_token, - slug: user.slug, + display_name: user.display_name, groups, total, is_admin, diff --git a/server/templates/my_cases.html b/server/templates/my_cases.html index 3668889..daeef43 100644 --- a/server/templates/my_cases.html +++ b/server/templates/my_cases.html @@ -4,7 +4,7 @@ -Doctate — Meine Fälle +Doctate — Fälle