feat: Add admin view toggle to web UI
Introduces a toggle in the web UI that allows administrators to switch between a standard view and an "admin view". This admin view exposes additional administrative elements that are hidden in the standard view. The toggle state is persisted in local storage, allowing the user's preference to be remembered across sessions. Non-administrator users will not see the toggle. feat: Add admin view toggle to web UI Introduce a client-side toggle for an "admin view" in the web UI. This allows administrators to selectively hide or show elements intended only for administrative purposes. The toggle state is persisted in local storage for a persistent user experience. The implementation involves: - Adding a checkbox element to the header of the case pages and my cases list. - Using CSS to conditionally hide elements with the `admin-only` class when the admin view is off. - Implementing JavaScript to manage the toggle's state, update local storage, and apply the `admin-view-off` class to the `<html>` element. - Updating relevant templates (`case_page.html`, `case_recordings.html`, `my_cases.html`) to include the toggle and the `admin-only` class where appropriate. - Adding unit tests to verify the visibility of the toggle for admins and non-admins.
This commit is contained in:
@@ -19,6 +19,8 @@ h1 { display: flex; align-items: center; gap: 0.6em; flex-wrap: wrap; }
|
||||
.actions .analyzing { color: #888; font-style: italic; }
|
||||
.actions .llm-missing { color: #a66; font-style: italic; }
|
||||
.actions .delete-form { margin-left: auto; }
|
||||
.actions .delete-btn { padding: 0.4em; background: transparent; border: none; color: #888; cursor: pointer; display: inline-flex; align-items: center; line-height: 0; border-radius: 4px; transition: color 0.15s, background 0.15s; }
|
||||
.actions .delete-btn:hover { color: #e24a4a; background: #fff0f0; }
|
||||
.placeholder { color: #888; font-style: italic; margin: 1em 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; }
|
||||
@@ -34,12 +36,26 @@ h1 { display: flex; align-items: center; gap: 0.6em; flex-wrap: wrap; }
|
||||
.copy-btn.copied { color: #2d8c2d; opacity: 1; }
|
||||
.copy-btn.copied .icon-copy { display: none; }
|
||||
.copy-btn.copied .icon-check { display: block; }
|
||||
|
||||
.header-right { display: flex; align-items: center; gap: 0.8em; }
|
||||
.admin-toggle { font-size: 0.85em; color: #666; display: inline-flex; align-items: center; gap: 0.3em; cursor: pointer; user-select: none; }
|
||||
html.admin-view-off .admin-only { display: none !important; }
|
||||
</style>
|
||||
<script>
|
||||
try {
|
||||
if (localStorage.getItem('admin-view') === 'off') {
|
||||
document.documentElement.classList.add('admin-view-off');
|
||||
}
|
||||
} catch (_) {}
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div><a class="back" href="/web/cases">← Fälle</a></div>
|
||||
<div class="header-right">
|
||||
{% if is_admin %}<label class="admin-toggle"><input type="checkbox" id="admin-view-toggle"> Admin view</label>{% endif %}
|
||||
<form method="post" action="/web/logout"><button type="submit">Logout</button></form>
|
||||
</div>
|
||||
</header>
|
||||
<h1>
|
||||
{% match oneliner %}
|
||||
@@ -48,7 +64,7 @@ h1 { display: flex; align-items: center; gap: 0.6em; flex-wrap: wrap; }
|
||||
{% endmatch %}
|
||||
{% 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 %}
|
||||
{% if is_admin %}<div class="meta admin-only">{{ case_id }}</div>{% endif %}
|
||||
|
||||
<div class="actions">
|
||||
{% if analyzing %}
|
||||
@@ -59,9 +75,9 @@ h1 { display: flex; align-items: center; gap: 0.6em; flex-wrap: wrap; }
|
||||
<span class="llm-missing">LLM-Analyse nicht konfiguriert</span>
|
||||
{% endif %}
|
||||
{% if is_admin %}
|
||||
<form method="post" action="/web/cases/{{ case_id }}/reset"><button type="submit">Reset</button></form>
|
||||
<form class="admin-only" method="post" action="/web/cases/{{ case_id }}/reset"><button type="submit">Reset</button></form>
|
||||
{% endif %}
|
||||
<form class="delete-form" method="post" action="/web/cases/{{ case_id }}/delete"><button type="submit">Entfernen</button></form>
|
||||
<form class="delete-form" method="post" action="/web/cases/{{ case_id }}/delete"><button type="submit" class="delete-btn" title="Fall entfernen" aria-label="Fall entfernen"><svg 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="3 6 5 6 21 6"></polyline><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"></path><path d="M10 11v6"></path><path d="M14 11v6"></path><path d="M9 6V4a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2v2"></path></svg></button></form>
|
||||
</div>
|
||||
|
||||
{% match document_html %}
|
||||
@@ -120,5 +136,17 @@ h1 { display: flex; align-items: center; gap: 0.6em; flex-wrap: wrap; }
|
||||
<div>
|
||||
<a class="recordings-link" href="/web/cases/{{ case_id }}/recordings">→ Aufnahmen anzeigen ({{ recordings_count }})</a>
|
||||
</div>
|
||||
<script>
|
||||
(() => {
|
||||
const cb = document.getElementById('admin-view-toggle');
|
||||
if (!cb) return;
|
||||
cb.checked = !document.documentElement.classList.contains('admin-view-off');
|
||||
cb.addEventListener('change', () => {
|
||||
const on = cb.checked;
|
||||
try { localStorage.setItem('admin-view', on ? 'on' : 'off'); } catch (_) {}
|
||||
document.documentElement.classList.toggle('admin-view-off', !on);
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -18,7 +18,18 @@ header form { margin: 0; }
|
||||
.transcript { margin: 0.6em 0 0; padding: 0.6em; background: #f6f6f6; border-radius: 4px; white-space: pre-wrap; font-family: serif; }
|
||||
.pending { color: #888; font-style: italic; margin-top: 0.5em; }
|
||||
.failed { color: #b00; font-weight: bold; margin-top: 0.5em; }
|
||||
|
||||
.header-right { display: flex; align-items: center; gap: 0.8em; }
|
||||
.admin-toggle { font-size: 0.85em; color: #666; display: inline-flex; align-items: center; gap: 0.3em; cursor: pointer; user-select: none; }
|
||||
html.admin-view-off .admin-only { display: none !important; }
|
||||
</style>
|
||||
<script>
|
||||
try {
|
||||
if (localStorage.getItem('admin-view') === 'off') {
|
||||
document.documentElement.classList.add('admin-view-off');
|
||||
}
|
||||
} catch (_) {}
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
@@ -31,10 +42,13 @@ header form { margin: 0; }
|
||||
<a href="/web/cases/{{ case_id }}">← Fall</a>
|
||||
{% endmatch %}
|
||||
</div>
|
||||
<div class="header-right">
|
||||
{% if is_admin %}<label class="admin-toggle"><input type="checkbox" id="admin-view-toggle"> Admin view</label>{% endif %}
|
||||
<form method="post" action="/web/logout"><button type="submit">Logout</button></form>
|
||||
</div>
|
||||
</header>
|
||||
<h1>Aufnahmen</h1>
|
||||
{% if is_admin %}<div class="meta">{{ case_id }}</div>{% endif %}
|
||||
{% if is_admin %}<div class="meta admin-only">{{ case_id }}</div>{% endif %}
|
||||
|
||||
<h2>Aufnahmen ({{ recordings.len() }})</h2>
|
||||
{% for rec in recordings %}
|
||||
@@ -65,5 +79,17 @@ header form { margin: 0; }
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
<script>
|
||||
(() => {
|
||||
const cb = document.getElementById('admin-view-toggle');
|
||||
if (!cb) return;
|
||||
cb.checked = !document.documentElement.classList.contains('admin-view-off');
|
||||
cb.addEventListener('change', () => {
|
||||
const on = cb.checked;
|
||||
try { localStorage.setItem('admin-view', on ? 'on' : 'off'); } catch (_) {}
|
||||
document.documentElement.classList.toggle('admin-view-off', !on);
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -29,8 +29,10 @@ section h2 { font-size: 1.1em; color: #555; border-bottom: 1px solid #ddd; paddi
|
||||
.status-badge { display: inline-block; padding: 0.05em 0.5em; border-radius: 999px; font-size: 0.75em; font-weight: bold; color: white; }
|
||||
.status-badge.open { background: #e24a4a; }
|
||||
.status-badge.done { background: #7ed321; }
|
||||
.case-row .actions { display: flex; gap: 0.4em; }
|
||||
.case-row .actions { display: flex; gap: 0.4em; align-items: center; }
|
||||
.case-row .actions button { font-size: 0.85em; padding: 0.35em 0.8em; }
|
||||
.case-row .actions .delete-btn { padding: 0.3em; background: transparent; border: none; color: #888; cursor: pointer; display: inline-flex; align-items: center; line-height: 0; border-radius: 4px; transition: color 0.15s, background 0.15s; }
|
||||
.case-row .actions .delete-btn:hover { color: #e24a4a; background: #fff0f0; }
|
||||
|
||||
.label { display: inline-block; margin-left: 0.6em; padding: 0.05em 0.5em; border-radius: 999px; font-size: 0.75em; font-weight: bold; vertical-align: middle; }
|
||||
.label.analyzing { background: #eee; color: #555; }
|
||||
@@ -40,12 +42,26 @@ section h2 { font-size: 1.1em; color: #555; border-bottom: 1px solid #ddd; paddi
|
||||
.bulk-bar button { font-size: 0.9em; padding: 0.5em 1em; }
|
||||
.bulk-bar .undo { margin-left: auto; }
|
||||
.bulk-bar .undo button { background: #fff3cd; border: 1px solid #d39e00; color: #5a4500; }
|
||||
|
||||
.header-right { display: flex; align-items: center; gap: 0.8em; }
|
||||
.admin-toggle { font-size: 0.85em; color: #666; display: inline-flex; align-items: center; gap: 0.3em; cursor: pointer; user-select: none; }
|
||||
html.admin-view-off .admin-only { display: none !important; }
|
||||
</style>
|
||||
<script>
|
||||
try {
|
||||
if (localStorage.getItem('admin-view') === 'off') {
|
||||
document.documentElement.classList.add('admin-view-off');
|
||||
}
|
||||
} catch (_) {}
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>{{ slug }} — Meine Fälle</h1>
|
||||
<div class="header-right">
|
||||
{% if is_admin %}<label class="admin-toggle"><input type="checkbox" id="admin-view-toggle"> Admin view</label>{% endif %}
|
||||
<form method="post" action="/web/logout"><button type="submit">Logout</button></form>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{% if undo_count > 0 %}
|
||||
@@ -70,13 +86,13 @@ section h2 { font-size: 1.1em; color: #555; border-bottom: 1px solid #ddd; paddi
|
||||
<a href="/web/cases/{{ case.case_id }}">
|
||||
<div class="line1"><span class="time"><time datetime="{{ case.recorded_at_iso }}">{{ case.time_hms_utc }}</time></span>{% match case.oneliner %}{% when Some with (t) %} — {{ t }}{% when None %} — <span class="empty">kein Oneliner</span>{% endmatch %}</div>
|
||||
<div class="line2">{{ case.recordings_count }} Aufnahmen{% if case.has_document %} — <span class="status-badge done">ausgewertet</span>{% else %} — <span class="status-badge open">offen</span>{% endif %}{% if case.analyzing %} <span class="label analyzing">wird analysiert</span>{% endif %}</div>
|
||||
{% if is_admin %}<div class="uuid">{{ case.case_id }}</div>{% endif %}
|
||||
{% if is_admin %}<div class="uuid admin-only">{{ case.case_id }}</div>{% endif %}
|
||||
</a>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button type="submit" formaction="/web/cases/{{ case.case_id }}/analyze"{% if !case.can_analyze %} disabled{% endif %}>{% if case.has_document %}Neu analysieren{% else %}Analysieren{% endif %}</button>
|
||||
{% if is_admin %}<button type="submit" formaction="/web/cases/{{ case.case_id }}/reset">Reset</button>{% endif %}
|
||||
<button type="submit" formaction="/web/cases/{{ case.case_id }}/delete">Entfernen</button>
|
||||
{% if is_admin %}<button type="submit" formaction="/web/cases/{{ case.case_id }}/reset" class="admin-only">Reset</button>{% endif %}
|
||||
<button type="submit" formaction="/web/cases/{{ case.case_id }}/delete" class="delete-btn" title="Fall entfernen" aria-label="Fall entfernen"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"></path><path d="M10 11v6"></path><path d="M14 11v6"></path><path d="M9 6V4a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2v2"></path></svg></button>
|
||||
</div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
@@ -87,7 +103,7 @@ section h2 { font-size: 1.1em; color: #555; border-bottom: 1px solid #ddd; paddi
|
||||
<span>Auswahl:</span>
|
||||
<button type="button" onclick="const b=document.querySelectorAll('input[name=case_id]');const all=Array.from(b).every(c=>c.checked);b.forEach(c=>c.checked=!all);this.textContent=all?'Alles auswählen':'Keine auswählen'">Alles auswählen</button>
|
||||
<button type="submit" name="action" value="analyze">Analysieren</button>
|
||||
{% if is_admin %}<button type="submit" name="action" value="reset">Reset</button>{% endif %}
|
||||
{% if is_admin %}<button type="submit" name="action" value="reset" class="admin-only">Reset</button>{% endif %}
|
||||
<button type="submit" name="action" value="delete">Entfernen</button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -124,6 +140,17 @@ section h2 { font-size: 1.1em; color: #555; border-bottom: 1px solid #ddd; paddi
|
||||
h.textContent = label + ' ' + suffix;
|
||||
});
|
||||
})();
|
||||
|
||||
(() => {
|
||||
const cb = document.getElementById('admin-view-toggle');
|
||||
if (!cb) return;
|
||||
cb.checked = !document.documentElement.classList.contains('admin-view-off');
|
||||
cb.addEventListener('change', () => {
|
||||
const on = cb.checked;
|
||||
try { localStorage.setItem('admin-view', on ? 'on' : 'off'); } catch (_) {}
|
||||
document.documentElement.classList.toggle('admin-view-off', !on);
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -326,6 +326,44 @@ async fn case_page_uses_oneliner_as_h1_when_present() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn case_page_admin_sees_admin_view_toggle() {
|
||||
let data_path = unique_tmp("cp-toggle");
|
||||
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(r#"id="admin-view-toggle""#),
|
||||
"admin must see the admin-view toggle checkbox"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn case_page_non_admin_has_no_admin_view_toggle() {
|
||||
let config = config_with_llm(unique_tmp("cp-no-toggle"));
|
||||
let case_id = "11111111-1111-1111-1111-111111111111";
|
||||
seed_case(&config.data_path, "dr_a", case_id);
|
||||
|
||||
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;
|
||||
|
||||
assert!(
|
||||
!body.contains(r#"id="admin-view-toggle""#),
|
||||
"non-admin must not see the admin-view toggle"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn case_page_admin_sees_full_case_id_meta() {
|
||||
let data_path = unique_tmp("cp-admin");
|
||||
|
||||
Reference in New Issue
Block a user