feat: Add display name to user profiles
Introduce `display_name` field to `User` struct for human-readable names in the UI. This falls back to the user's `slug` if not provided. Update `AuthenticatedWebUser` and `MyCasesTemplate` to use this new field for rendering. Add tests for parsing `display_name` and ensure fallback logic works correctly. Update `users.toml.example` to document the new field.
This commit is contained in:
+66
-2
@@ -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<String>,
|
||||
/// 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<User>, HashMap<String, String>) {
|
||||
(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<HashMap<String, String>, String> {
|
||||
let mut slugs: std::collections::HashSet<&str> = std::collections::HashSet::new();
|
||||
let mut api_keys: HashMap<String, String> = 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#"
|
||||
|
||||
Reference in New Issue
Block a user