feat: Add admin flag to user and templates

Passes an `is_admin` flag to the `CaseRecordingsTemplate` and
`case_page.html`.
This flag determines whether the full case ID is displayed in the meta
section
for administrative users. It also influences the header display on the
case page
to prioritize the oneliner when available.
This commit is contained in:
2026-04-19 17:33:29 +02:00
parent b7e54db54c
commit 6369ff1680
4 changed files with 132 additions and 30 deletions
+3
View File
@@ -153,6 +153,7 @@ struct CaseRecordingsTemplate {
oneliner: Option<String>,
recordings: Vec<RecordingView>,
transcribe_busy: bool,
is_admin: bool,
}
/// Flags derived from filesystem state + config.
@@ -349,6 +350,7 @@ pub async fn handle_case_recordings(
let t_busy = pipeline.transcribe_busy.0.load(Ordering::Acquire);
let case_id_short = case_id_str.chars().take(8).collect();
let is_admin = user.is_admin();
CaseRecordingsTemplate {
slug: user.slug,
@@ -357,6 +359,7 @@ pub async fn handle_case_recordings(
oneliner,
recordings,
transcribe_busy: t_busy,
is_admin,
}
.render()
.map(Html)
+27 -20
View File
@@ -8,9 +8,9 @@ body { font-family: sans-serif; max-width: 900px; margin: 2em auto; padding: 0 1
header { display: flex; justify-content: space-between; align-items: center; }
header form { margin: 0; }
.back { color: #4a90e2; text-decoration: none; }
.oneliner { font-size: 1.1em; color: #222; margin: 0.6em 0 1em; }
.meta { color: #666; font-family: monospace; font-size: 0.9em; }
.status-badge { display: inline-block; padding: 0.1em 0.6em; border-radius: 999px; font-size: 0.8em; font-weight: bold; color: white; }
h1 { display: flex; align-items: center; gap: 0.6em; flex-wrap: wrap; }
.meta { color: #999; font-family: monospace; font-size: 0.8em; margin: 0 0 1em; }
.status-badge { display: inline-block; padding: 0.1em 0.6em; border-radius: 999px; font-size: 0.6em; font-weight: bold; color: white; vertical-align: middle; }
.status-badge.open { background: #4a90e2; }
.status-badge.done { background: #7ed321; }
.actions { margin: 1em 0 1.5em; display: flex; align-items: center; gap: 0.4em; }
@@ -23,14 +23,17 @@ header form { margin: 0; }
.status-panel { margin: 1em 0; padding: 0.8em 1em; background: #f6f6f6; border-radius: 4px; }
.recordings-link { display: inline-block; margin: 1em 0; color: #4a90e2; text-decoration: none; font-size: 0.95em; }
.recordings-link:hover { text-decoration: underline; }
.doc-content { font-family: serif; font-size: 1.05em; line-height: 1.55; background: #f6f6f6; padding: 1em 1.2em; border-radius: 4px; }
.doc-content { position: relative; font-family: serif; font-size: 1.05em; line-height: 1.55; background: #f6f6f6; padding: 1em 1.2em; border-radius: 4px; }
.doc-content p { margin: 0.8em 0; }
.doc-content p:first-child { margin-top: 0; }
.doc-content p:last-child { margin-bottom: 0; }
.doc-content mark { background: #fff3a8; padding: 0 0.2em; border-radius: 2px; }
.copy-btn { padding: 0.5em 1em; font-size: 1em; border: 1px solid #4a90e2; background: white; color: #4a90e2; border-radius: 4px; cursor: pointer; font-weight: bold; }
.copy-btn:hover { background: #eaf3fc; }
.toolbar { display: flex; gap: 0.6em; margin: 1em 0; }
.copy-btn { position: absolute; bottom: 0.5em; right: 0.5em; padding: 0.35em; background: transparent; border: none; border-radius: 4px; cursor: pointer; color: #888; display: inline-flex; align-items: center; justify-content: center; line-height: 0; opacity: 0.45; transition: opacity 0.15s, color 0.15s; }
.copy-btn:hover { color: #4a90e2; opacity: 1; }
.copy-btn .icon-check { display: none; }
.copy-btn.copied { color: #2d8c2d; opacity: 1; }
.copy-btn.copied .icon-copy { display: none; }
.copy-btn.copied .icon-check { display: block; }
</style>
</head>
<body>
@@ -38,14 +41,14 @@ header form { margin: 0; }
<div><a class="back" href="/web/cases">&larr; Fälle</a></div>
<form method="post" action="/web/logout"><button type="submit">Logout</button></form>
</header>
<h1>Fall {{ case_id_short }} {% if has_document %}<span class="status-badge done">ausgewertet</span>{% else %}<span class="status-badge open">offen</span>{% endif %}</h1>
<h1>
{% match oneliner %}
{% when Some with (t) %}
<div class="oneliner">{{ t }}</div>
{% when None %}
<div class="oneliner" style="color:#888;font-style:italic">Noch kein Oneliner.</div>
{% when Some with (t) %}{{ t }}
{% when None %}Fall
{% endmatch %}
<div class="meta">{{ case_id }}</div>
{% if has_document %}<span class="status-badge done">ausgewertet</span>{% else %}<span class="status-badge open">offen</span>{% endif %}
</h1>
{% if is_admin %}<div class="meta">{{ case_id }}</div>{% endif %}
<div class="actions">
{% if analyzing %}
@@ -63,18 +66,20 @@ header form { margin: 0; }
{% match document_html %}
{% when Some with (html) %}
<div class="toolbar">
<button id="copy-btn" type="button" class="copy-btn" hidden>In Zwischenablage</button>
<div id="doc-content" class="doc-content">
<button id="copy-btn" type="button" class="copy-btn" title="In Zwischenablage kopieren" hidden>
<svg class="icon-copy" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg>
<svg class="icon-check" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg>
</button>
<div id="doc-body">{{ html|safe }}</div>
</div>
<div id="doc-content" class="doc-content">{{ html|safe }}</div>
<script>
(() => {
const btn = document.getElementById('copy-btn');
if (!btn) return;
btn.hidden = false;
const label = btn.textContent;
btn.addEventListener('click', async () => {
const text = document.getElementById('doc-content').innerText;
const text = document.getElementById('doc-body').innerText;
let ok = false;
try {
if (navigator.clipboard && window.isSecureContext) {
@@ -93,8 +98,10 @@ header form { margin: 0; }
try { ok = document.execCommand('copy'); } catch (_) {}
ta.remove();
}
btn.textContent = ok ? 'Kopiert ✓' : 'Fehlgeschlagen';
setTimeout(() => { btn.textContent = label; }, 1500);
if (ok) {
btn.classList.add('copied');
setTimeout(() => btn.classList.remove('copied'), 1500);
}
});
})();
</script>
+14 -10
View File
@@ -7,9 +7,10 @@
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 form { margin: 0; }
.back { color: #4a90e2; text-decoration: none; }
.oneliner { font-size: 1.1em; color: #222; margin: 0.6em 0 1em; }
.meta { color: #666; font-family: monospace; font-size: 0.9em; }
.breadcrumb { display: flex; gap: 0.8em; flex-wrap: wrap; }
.breadcrumb a { color: #4a90e2; text-decoration: none; }
.breadcrumb a:hover { text-decoration: underline; }
.meta { color: #999; font-family: monospace; font-size: 0.8em; margin: 0 0 1em; }
.recording { margin: 1em 0; padding: 0.7em; border: 1px solid #ddd; border-radius: 4px; }
.recording.failed-row { background: #fff7f7; border-left: 3px solid #e24a4a; }
.recording-head { display: flex; align-items: center; gap: 1em; flex-wrap: wrap; }
@@ -21,16 +22,19 @@ header form { margin: 0; }
</head>
<body>
<header>
<div><a class="back" href="/web/cases/{{ case_id }}">&larr; Fall {{ case_id_short }}</a></div>
<div class="breadcrumb">
<a href="/web/cases">&larr; Fälle</a>
{% match oneliner %}
{% when Some with (t) %}
<a href="/web/cases/{{ case_id }}">&larr; {{ t }}</a>
{% when None %}
<a href="/web/cases/{{ case_id }}">&larr; Fall</a>
{% endmatch %}
</div>
<form method="post" action="/web/logout"><button type="submit">Logout</button></form>
</header>
<h1>Aufnahmen</h1>
{% match oneliner %}
{% when Some with (t) %}
<div class="oneliner">{{ t }}</div>
{% when None %}
{% endmatch %}
<div class="meta">{{ case_id }}</div>
{% if is_admin %}<div class="meta">{{ case_id }}</div>{% endif %}
<h2>Aufnahmen ({{ recordings.len() }})</h2>
{% for rec in recordings %}
+88
View File
@@ -25,6 +25,16 @@ fn make_user(slug: &str) -> User {
}
}
fn make_admin(slug: &str) -> User {
let mut u = make_user(slug);
u.role = "admin".into();
u
}
fn seed_oneliner(case_dir: &Path, text: &str) {
std::fs::write(case_dir.join("oneliner.txt"), text).unwrap();
}
fn config_with_llm_users(data_path: PathBuf, llm_url: String, users: Vec<User>) -> Arc<Config> {
let api_keys: HashMap<String, String> = users
.iter()
@@ -290,6 +300,84 @@ async fn case_recordings_back_link_targets_case_page() {
);
}
#[tokio::test]
async fn case_page_uses_oneliner_as_h1_when_present() {
let config = config_with_llm(unique_tmp("cp-h1"));
let case_id = "11111111-1111-1111-1111-111111111111";
let case_dir = seed_case(&config.data_path, "dr_a", case_id);
seed_oneliner(&case_dir, "Herzkatheter unauffällig");
std::fs::write(case_dir.join("document.md"), "Inhalt").unwrap();
let app = doctate_server::create_router(config);
let cookie = login(app.clone(), "dr_a").await;
let resp = app.oneshot(get_page(case_id, &cookie, "")).await.unwrap();
let body = body_string(resp).await;
// H1 wraps the oneliner; the short case_id fallback must be gone.
assert!(
body.contains("<h1>") && body.contains("Herzkatheter unauffällig"),
"h1 should show the oneliner"
);
// The prominent meta block with the full UUID is admin-only.
assert!(
!body.contains(r#"<div class="meta">"#),
"non-admin must not see the full case_id meta block"
);
}
#[tokio::test]
async fn case_page_admin_sees_full_case_id_meta() {
let data_path = unique_tmp("cp-admin");
let users = vec![make_admin("dr_admin")];
let config = config_with_llm_users(data_path, "http://unused".into(), users);
let case_id = "11111111-1111-1111-1111-111111111111";
seed_case(&config.data_path, "dr_admin", case_id);
let app = doctate_server::create_router(config);
let cookie = login(app.clone(), "dr_admin").await;
let resp = app.oneshot(get_page(case_id, &cookie, "")).await.unwrap();
let body = body_string(resp).await;
assert!(
body.contains(case_id),
"admin meta block should expose the full case_id"
);
}
#[tokio::test]
async fn case_recordings_breadcrumb_includes_oneliner() {
let config = config_with_llm(unique_tmp("rec-breadcrumb"));
let case_id = "11111111-1111-1111-1111-111111111111";
let case_dir = seed_case(&config.data_path, "dr_a", case_id);
seed_oneliner(&case_dir, "Kurzer Befund");
seed_recording(&case_dir, "10-00-00", None);
let app = doctate_server::create_router(config);
let cookie = login(app.clone(), "dr_a").await;
let resp = app
.oneshot(get_page(case_id, &cookie, "/recordings"))
.await
.unwrap();
let body = body_string(resp).await;
// Two separate breadcrumb links: back to the case list and back to the case page.
assert!(
body.contains(r#"href="/web/cases""#),
"breadcrumb must link back to the case list"
);
assert!(
body.contains(&format!(r#"href="/web/cases/{case_id}""#)),
"breadcrumb must link back to the case page"
);
assert!(
body.contains("Kurzer Befund"),
"breadcrumb must show the oneliner text"
);
}
#[tokio::test]
async fn case_recordings_has_no_action_buttons() {
let config = config_with_llm(unique_tmp("rec-ro"));