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:
+11
-6
@@ -78,6 +78,11 @@ pub struct AuthenticatedWebUser {
|
|||||||
/// Mirrors [`AuthenticatedUser::window_hours`] so the same policy
|
/// Mirrors [`AuthenticatedUser::window_hours`] so the same policy
|
||||||
/// is available to web handlers without a second config lookup.
|
/// is available to web handlers without a second config lookup.
|
||||||
pub window_hours: u32,
|
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
|
/// Session's CSRF token. Rendered into every state-changing form
|
||||||
/// as a hidden field so the `CsrfForm` extractor can validate it
|
/// as a hidden field so the `CsrfForm` extractor can validate it
|
||||||
/// on the matching POST. Cloned out of the session record here so
|
/// 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
|
// does not carry. A missing record can only happen if users.toml
|
||||||
// was edited while a session was still live; fall back to the
|
// was edited while a session was still live; fall back to the
|
||||||
// serde default so the request stays functional.
|
// serde default so the request stays functional.
|
||||||
let window_hours = config
|
let user_record = config.users.iter().find(|u| u.slug == session.slug);
|
||||||
.users
|
let window_hours = user_record.map(|u| u.window_hours).unwrap_or(72);
|
||||||
.iter()
|
let display_name = user_record
|
||||||
.find(|u| u.slug == session.slug)
|
.map(|u| u.display_name().to_owned())
|
||||||
.map(|u| u.window_hours)
|
.unwrap_or_else(|| session.slug.clone());
|
||||||
.unwrap_or(72);
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
slug: session.slug.clone(),
|
slug: session.slug.clone(),
|
||||||
role: session.role.clone(),
|
role: session.role.clone(),
|
||||||
data_dir,
|
data_dir,
|
||||||
window_hours,
|
window_hours,
|
||||||
|
display_name,
|
||||||
csrf_token: session.csrf_token.clone(),
|
csrf_token: session.csrf_token.clone(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
+66
-2
@@ -39,6 +39,11 @@ pub struct User {
|
|||||||
pub api_key: String,
|
pub api_key: String,
|
||||||
pub web_password: String,
|
pub web_password: String,
|
||||||
pub role: 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
|
/// ASR input language code (e.g. "de", "en"). Forwarded as the
|
||||||
/// `language` form/query field to the active ASR backend
|
/// `language` form/query field to the active ASR backend
|
||||||
/// (Whisper or Canary). Missing field → backend default ("de").
|
/// (Whisper or Canary). Missing field → backend default ("de").
|
||||||
@@ -63,6 +68,16 @@ pub struct User {
|
|||||||
pub preview_lines: u32,
|
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.
|
/// Wrapper for deserializing the [[user]] array from TOML.
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
struct UsersFile {
|
struct UsersFile {
|
||||||
@@ -206,13 +221,18 @@ fn load_users(path: &str) -> (Vec<User>, HashMap<String, String>) {
|
|||||||
(parsed.user, api_keys)
|
(parsed.user, api_keys)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build the api_key → slug lookup map. Rejects duplicate slugs (collide on
|
/// Build the api_key → slug lookup map. Rejects malformed slugs (would
|
||||||
/// data directories) and duplicate api_keys (ambiguous authentication).
|
/// 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> {
|
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 slugs: std::collections::HashSet<&str> = std::collections::HashSet::new();
|
||||||
let mut api_keys: HashMap<String, String> = HashMap::new();
|
let mut api_keys: HashMap<String, String> = HashMap::new();
|
||||||
|
|
||||||
for u in users {
|
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()) {
|
if !slugs.insert(u.slug.as_str()) {
|
||||||
return Err(format!("duplicate slug: {}", u.slug));
|
return Err(format!("duplicate slug: {}", u.slug));
|
||||||
}
|
}
|
||||||
@@ -270,6 +290,7 @@ mod tests {
|
|||||||
api_key: api_key.into(),
|
api_key: api_key.into(),
|
||||||
web_password: String::new(),
|
web_password: String::new(),
|
||||||
role: "doctor".into(),
|
role: "doctor".into(),
|
||||||
|
display_name: None,
|
||||||
language: None,
|
language: None,
|
||||||
retention: RetentionUserSettings::default(),
|
retention: RetentionUserSettings::default(),
|
||||||
window_hours: default_window_hours(),
|
window_hours: default_window_hours(),
|
||||||
@@ -421,6 +442,49 @@ mod tests {
|
|||||||
assert!(err.contains("duplicate slug"), "got: {err}");
|
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]
|
#[test]
|
||||||
fn parses_user_without_window_hours_defaults_to_72() {
|
fn parses_user_without_window_hours_defaults_to_72() {
|
||||||
let toml_str = r#"
|
let toml_str = r#"
|
||||||
|
|||||||
@@ -270,7 +270,10 @@ struct DateGroup {
|
|||||||
#[derive(Template)]
|
#[derive(Template)]
|
||||||
#[template(path = "my_cases.html")]
|
#[template(path = "my_cases.html")]
|
||||||
struct MyCasesTemplate {
|
struct MyCasesTemplate {
|
||||||
slug: String,
|
/// Human-readable label rendered in the `<h1>`. Resolved upstream
|
||||||
|
/// from `users.toml` (`display_name` field, falling back to `slug`)
|
||||||
|
/// so the template stays presentation-only.
|
||||||
|
display_name: String,
|
||||||
groups: Vec<DateGroup>,
|
groups: Vec<DateGroup>,
|
||||||
total: usize,
|
total: usize,
|
||||||
/// True iff the session user is an admin — toggles full case_id display.
|
/// 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, ...).
|
// flip (new transcripts in, stale document, ...).
|
||||||
auto_trigger::try_enqueue_all_for_user(&user_root, &pipeline.analyze_tx, &events_tx).await;
|
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,
|
// Lazy retention sweep: at most one disk scan per /web/cases visit,
|
||||||
// reusing the user's retention policy from users.toml. Runs before
|
// reusing the user's retention policy from users.toml. Runs before
|
||||||
// the listing scan so any auto-close/auto-purge actions are
|
// the listing scan so any auto-close/auto-purge actions are
|
||||||
// reflected in the same response.
|
// 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;
|
crate::retention::sweep_user_retention(&user_root, &user.slug, &retention, &events_tx).await;
|
||||||
|
|
||||||
let show_closed = query.include_closed();
|
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 groups = group_by_utc_date(cases, &closed_extra);
|
||||||
let is_admin = user.is_admin();
|
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 {
|
MyCasesTemplate {
|
||||||
csrf_token: user.csrf_token,
|
csrf_token: user.csrf_token,
|
||||||
slug: user.slug,
|
display_name: user.display_name,
|
||||||
groups,
|
groups,
|
||||||
total,
|
total,
|
||||||
is_admin,
|
is_admin,
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<html lang="de" style="--preview-lines: {{ preview_lines }}">
|
<html lang="de" style="--preview-lines: {{ preview_lines }}">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<title>Doctate — Meine Fälle</title>
|
<title>Doctate — Fälle</title>
|
||||||
<style>
|
<style>
|
||||||
body { font-family: sans-serif; max-width: 900px; margin: 2em auto; padding: 0 1em; }
|
body { font-family: sans-serif; max-width: 900px; margin: 2em auto; padding: 0 1em; }
|
||||||
header { display: flex; justify-content: space-between; align-items: center; }
|
header { display: flex; justify-content: space-between; align-items: center; }
|
||||||
@@ -85,7 +85,7 @@ try {
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header>
|
<header>
|
||||||
<h1>{{ slug }} — Meine Fälle</h1>
|
<h1>{{ display_name }}</h1>
|
||||||
<div class="header-right">
|
<div class="header-right">
|
||||||
{% if is_admin %}<label class="admin-toggle"><input type="checkbox" id="admin-view-toggle"> Admin view</label>{% endif %}
|
{% if is_admin %}<label class="admin-toggle"><input type="checkbox" id="admin-view-toggle"> Admin view</label>{% endif %}
|
||||||
<form method="post" action="/web/logout">{% call csrf::field(csrf_token) %}<button type="submit">Logout</button></form>
|
<form method="post" action="/web/logout">{% call csrf::field(csrf_token) %}<button type="submit">Logout</button></form>
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ pub fn test_user(slug: &str) -> User {
|
|||||||
api_key: format!("key-{slug}"),
|
api_key: format!("key-{slug}"),
|
||||||
web_password: hash_password(TEST_PASSWORD),
|
web_password: hash_password(TEST_PASSWORD),
|
||||||
role: "doctor".into(),
|
role: "doctor".into(),
|
||||||
|
display_name: None,
|
||||||
language: None,
|
language: None,
|
||||||
retention: Default::default(),
|
retention: Default::default(),
|
||||||
window_hours: 72,
|
window_hours: 72,
|
||||||
|
|||||||
@@ -8,6 +8,11 @@ slug = "dr_mueller"
|
|||||||
api_key = "change-me-to-a-secure-key"
|
api_key = "change-me-to-a-secure-key"
|
||||||
web_password = "$2b$12$..."
|
web_password = "$2b$12$..."
|
||||||
role = "doctor"
|
role = "doctor"
|
||||||
|
# Optional: human-readable name shown in the web UI page header
|
||||||
|
# in place of the slug (the slug stays the on-disk directory and
|
||||||
|
# login identifier). Free-form UTF-8 — accents and spaces are fine.
|
||||||
|
# Defaults to the slug when omitted.
|
||||||
|
display_name = "Dr. Müller"
|
||||||
# Optional: how many visual lines of the LLM analysis preview the case
|
# Optional: how many visual lines of the LLM analysis preview the case
|
||||||
# list renders under each row. The preview itself is always the first
|
# list renders under each row. The preview itself is always the first
|
||||||
# paragraph of document.md; this value only caps the visible lines
|
# paragraph of document.md; this value only caps the visible lines
|
||||||
|
|||||||
Reference in New Issue
Block a user