Files
doctate/server/templates/my_cases.html
T
Brummel 1182f4817e Enforce admin-only for bulk and purge actions
Move admin checks from individual bulk actions to the main handler for
`bulk.rs` and `case_actions.rs`. This consolidates the authorization
logic for these sensitive operations.

Additionally, refine the HTML template to conditionally render bulk
action UI elements and the purge form only when the user is an admin.
This ensures that non-admin users do not see or have access to these
administrative functions.

Update tests to reflect these changes, ensuring that non-admin users are
correctly rejected for these actions and that the UI is properly hidden.
2026-04-21 19:48:24 +02:00

241 lines
14 KiB
HTML

{%- import "partials/oneliner.html" as ol -%}
<!DOCTYPE html>
<html lang="de" style="--preview-lines: {{ preview_lines }}">
<head>
<meta charset="utf-8">
<title>Doctate — Meine Fälle</title>
<style>
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; }
section { margin: 1.5em 0; }
section h2 { font-size: 1.1em; color: #555; border-bottom: 1px solid #ddd; padding-bottom: 0.2em; display: flex; justify-content: space-between; align-items: baseline; gap: 1em; }
section h2 .count { font-weight: normal; font-size: 0.85em; color: #888; }
.empty { color: #888; font-style: italic; }
.pending { color: #888; font-style: italic; }
.case-list { list-style: none; padding: 0; margin: 0; }
.case-row { display: grid; grid-template-columns: 1fr auto; gap: 0.6em 1em; align-items: center; padding: 0.7em 1em; border: 1px solid #ccc; border-radius: 4px; margin: 0.5em 0; }
.case-list.has-bulk .case-row { grid-template-columns: auto 1fr auto; }
html.admin-view-off .case-list.has-bulk .case-row { grid-template-columns: 1fr auto; }
.case-row.done { border-left: 4px solid #7ed321; }
.case-row.open { border-left: 4px solid #4a90e2; }
.case-row .check { align-self: start; padding-top: 0.25em; }
.case-row .body { min-width: 0; }
.case-row .body h3 { margin: 0 0 0.2em 0; font-size: 1em; font-weight: 600; }
.case-row .body small { color: #666; font-family: monospace; font-size: 0.85em; }
.case-row .oneliner { color: #333; margin: 0.2em 0 0; }
.case-row .line1 { font-size: 1em; margin: 0; display: flex; flex-wrap: wrap; align-items: baseline; justify-content: space-between; gap: 0.25em 0.6em; }
.case-row .line1 .time { font-family: monospace; font-weight: bold; color: #444; }
.case-row .case-link { color: inherit; text-decoration: none; min-width: 0; }
.case-row .case-link:hover { text-decoration: underline; }
.case-row .recordings-link { color: #4a90e2; text-decoration: none; font-size: 0.9em; white-space: nowrap; }
.case-row .recordings-link:hover { text-decoration: underline; }
.case-row .analysis { margin-top: 0.55em; padding-top: 0.45em; border-top: 1px solid #e5e5e5; display: flex; align-items: flex-start; gap: 0.4em; cursor: pointer; }
.case-row .analysis .arrow { flex-shrink: 0; color: #888; line-height: 1.2; transition: transform 0.15s ease; }
.case-row .analysis.open .arrow { transform: rotate(90deg); }
.case-row .analysis-content { color: #555; font-size: 0.9em; min-width: 0; flex: 1 1 auto; display: -webkit-box; -webkit-line-clamp: var(--preview-lines); line-clamp: var(--preview-lines); -webkit-box-orient: vertical; overflow: hidden; }
.case-row .analysis-content > :first-child { margin-top: 0; }
.case-row .analysis-content > :last-child { margin-bottom: 0; }
.case-row .analysis-content p { display: inline; margin: 0; }
.case-row .analysis-content mark { background: #fff3a0; padding: 0 0.15em; border-radius: 2px; }
.case-row .analysis.open .analysis-content { display: block; overflow: visible; }
.case-row .analysis.open .analysis-content p { display: block; margin: 0 0 0.5em; }
.case-row .uuid { color: #999; font-family: monospace; font-size: 0.7em; margin-top: 0.2em; }
.status-badge { display: inline-block; padding: 0.05em 0.5em; border-radius: 999px; font-size: 0.75em; font-weight: bold; color: white; margin-left: 0.7em; }
.status-badge.open { background: #4a90e2; }
.status-badge.done { background: #7ed321; }
.status-badge.fehler { background: #e24a4a; }
.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; }
.case-row .actions .reopen-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 .reopen-btn:hover { color: #4a90e2; background: #eef4fb; }
.case-row.closed { opacity: 0.65; background: #fafafa; }
.case-row.closed .case-link { color: #777; }
.case-row .closed-badge { display: inline-block; margin-left: 0.5em; padding: 0.05em 0.5em; border-radius: 999px; font-size: 0.75em; font-weight: bold; background: #ddd; color: #444; }
.closed-toggle { display: block; margin: 0.5em 0 1em; font-size: 0.9em; color: #4a90e2; text-decoration: none; }
.closed-toggle:hover { text-decoration: underline; }
.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; }
.label.done-doc { background: #7ed321; color: white; }
.bulk-bar { margin: 1em 0; padding: 0.8em 1em; background: #f6f6f6; border-radius: 4px; display: flex; gap: 0.6em; flex-wrap: wrap; align-items: center; }
.bulk-bar button { font-size: 0.9em; padding: 0.5em 1em; }
.bulk-bar.purge { background: #fff0f0; border: 1px solid #e24a4a; }
.bulk-bar.purge button { background: #e24a4a; color: white; border: none; }
.bulk-bar.purge button:hover { background: #c43030; }
.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 show_closed %}
<a class="closed-toggle" href="/web/cases">← Nur offene Fälle anzeigen</a>
{% else %}
<a class="closed-toggle" href="/web/cases?show_closed=1">Geschlossene Fälle einblenden</a>
{% endif %}
{% if total == 0 %}
<section><p class="empty">{% if show_closed %}Keine offenen oder geschlossenen Fälle.{% else %}Keine Fälle.{% endif %}</p></section>
{% else %}
<form method="post" action="/web/cases/bulk">
{% for g in groups %}
<section>
<h2 class="date-group" data-iso="{{ g.label }}"><span class="date-label">{{ g.label }}</span><span class="count">{{ g.open_count }}/{{ g.total_count }} Fälle</span></h2>
<ul class="case-list{% if is_admin %} has-bulk{% endif %}">
{% for case in g.cases %}
<li class="case-row {% if case.is_closed %}closed{% else if case.has_document %}done{% else %}open{% endif %}">
{% if is_admin %}<div class="check admin-only"><input type="checkbox" name="case_id" value="{{ case.case_id }}"{% if case.is_closed %} disabled{% endif %}></div>{% endif %}
<div class="body">
<div class="line1">
<a class="case-link" href="/web/cases/{{ case.case_id }}{% if case.is_closed %}?show_closed=1{% endif %}"><span class="time"><time datetime="{{ case.recorded_at_iso }}">{{ case.time_hms_utc }}</time></span> — {% call ol::render(case.oneliner) %}{% if case.has_failed_recording %} <span class="status-badge fehler">Fehler</span>{% else if case.has_document %} <span class="status-badge done">ausgewertet</span>{% else if !case.is_closed %} <span class="status-badge open">offen</span>{% endif %}{% if case.analyzing %} <span class="label analyzing">wird analysiert</span>{% endif %}{% if case.is_closed %} <span class="closed-badge">geschlossen{% match case.days_until_purge %}{% when Some with (d) %} — wird in {{ d }} Tagen entfernt{% when None %}{% endmatch %}</span>{% endif %}</a>
<a class="recordings-link" href="/web/cases/{{ case.case_id }}/recordings{% if case.is_closed %}?show_closed=1{% endif %}">{{ case.recordings_count }} Aufnahmen</a>
</div>
{% match case.analysis_html %}{% when Some with (html) %}<div class="analysis" data-expand><span class="arrow"></span><div class="analysis-content">{{ html|safe }}</div></div>{% when None %}{% endmatch %}
{% if is_admin %}<div class="uuid admin-only">{{ case.case_id }}</div>{% endif %}
</div>
<div class="actions">
{% if is_admin %}<button type="submit" formaction="/web/cases/{{ case.case_id }}/analyze" class="admin-only"{% if !case.can_analyze || case.is_closed %} disabled{% endif %}>{% if case.has_document %}Neu analysieren{% else %}Analysieren{% endif %}</button>
<button type="submit" formaction="/web/cases/{{ case.case_id }}/reset" class="admin-only"{% if case.is_closed %} disabled{% endif %}>Reset</button>{% endif %}
{% if case.is_closed %}
<button type="submit" formaction="/web/cases/{{ case.case_id }}/reopen" class="reopen-btn" title="Fall wiedereröffnen" aria-label="Fall wiedereröffnen"><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"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"></path><path d="M3 3v5h5"></path></svg></button>
{% else %}
<button type="submit" formaction="/web/cases/{{ case.case_id }}/close" class="delete-btn" title="Fall schließen" aria-label="Fall schließen"><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"><path d="M3 6h18"></path><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"></path><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path><line x1="10" x2="10" y1="11" y2="17"></line><line x1="14" x2="14" y1="11" y2="17"></line></svg></button>
{% endif %}
</div>
</li>
{% endfor %}
</ul>
</section>
{% endfor %}
{% if is_admin %}<div class="bulk-bar admin-only">
<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>
<button type="submit" name="action" value="reset">Reset</button>
<button type="submit" name="action" value="close">Schließen</button>
</div>{% endif %}
</form>
{% if is_admin && show_closed && any_closed %}
<form class="bulk-bar purge admin-only" method="post" action="/web/cases/purge-closed" onsubmit="return confirm('Alle geschlossenen Fälle UNWIDERRUFLICH löschen? Diese Aktion kann nicht rückgängig gemacht werden.')">
<input type="hidden" name="confirm" value="yes">
<span>Geschlossene Fälle:</span>
<button type="submit">Alle geschlossenen unwiderruflich löschen</button>
</form>
{% endif %}
{% endif %}
<script>
{% include "partials/time_format.js" %}
</script>
<script>
// Render all UTC timestamps in the browser's local timezone, and relabel
// date-group headings to Heute/Gestern/DD.MM.YYYY.
(() => {
document.querySelectorAll('time[datetime]').forEach((el) => {
const d = new Date(el.dateTime);
if (!isNaN(d.getTime())) el.textContent = TimeFmt.timeLabel(d);
});
document.querySelectorAll('h2.date-group').forEach((h) => {
const iso = h.dataset.iso;
if (!iso) return;
const labelEl = h.querySelector('.date-label');
if (labelEl) labelEl.textContent = TimeFmt.dateLabelFromISO(iso);
});
})();
(() => {
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);
});
})();
// Preserve scroll position across POST-redirects (close, reopen,
// bulk-*). Browsers keep scroll on location.reload() but NOT on a
// redirect-driven navigation, even when the target URL matches the
// referer. Without this, clicking close on row 30 of a long list
// dumps the user back at scrollY=0 with the action-row off-screen.
//
// Mechanism: capture scrollY in sessionStorage before every form
// submit on this page, restore it on the very next load, then delete.
// Using the classic two-arg scrollTo(0, y) form — the options-object
// variant is silently ignored in some older browsers.
(() => {
const STASH = 'doctate:scrollY';
try {
document.querySelectorAll('form').forEach((f) => {
f.addEventListener('submit', () => {
sessionStorage.setItem(STASH, String(window.scrollY));
});
});
const stashed = sessionStorage.getItem(STASH);
if (stashed !== null) {
sessionStorage.removeItem(STASH);
const y = parseInt(stashed, 10);
if (!isNaN(y)) {
window.scrollTo(0, y);
}
}
} catch (_) {
// sessionStorage may be blocked (strict privacy mode) — silent fail,
// scroll returns to 0 like it did before this feature.
}
})();
// Live-update bus: any case event for this user triggers a debounced
// page reload. Debounce collapses event bursts (e.g. bulk-delete) into
// one reload. EventSource auto-reconnects on network drops.
(() => {
let timer = null;
const scheduleReload = () => {
clearTimeout(timer);
timer = setTimeout(() => location.reload(), 300);
};
const es = new EventSource('/web/events');
// Release the socket-pool slot on navigation. Without this, Chrome
// keeps the SSE connection "draining" after unload; six rapid nav
// cycles exhaust the 6-per-origin pool and stall further requests.
window.addEventListener('pagehide', () => es.close());
es.addEventListener('case', () => scheduleReload());
})();
// Expandable analysis: click anywhere on .analysis toggles the `open`
// class. Links/buttons inside the analysis are preserved — a click on
// a <mark>-wrapped term shouldn't accidentally expand (no such case
// today, but the guard is free). Delegation keeps one listener across
// all case rows, no per-row wiring.
document.addEventListener('click', (e) => {
const exp = e.target.closest('[data-expand]');
if (!exp) return;
if (e.target.closest('a, button, input')) return;
exp.classList.toggle('open');
});
</script>
</body>
</html>