Feat: Implement basic web authentication and UI

This commit introduces the foundation for web-based authentication and
user interface. It includes:

- **Session Management**: Securely handling user sessions using
  cryptographically generated tokens, cookies with appropriate security
  flags, and server-side storage.
- **Login/Logout Functionality**: Endpoints for users to log in with
  their credentials and log out, clearing their session.
- **User Interface**: Basic templates for the login page, a list of
  cases, and a detailed view of a single case, allowing users to
  navigate and view their data.
- **Authorization**: An `AuthenticatedWebUser` extractor to ensure only
  logged-in users can access protected web routes.
- **Configuration**: Added a `cookie_secure` option to the configuration
  to control the `Secure` flag on session cookies, useful for local
  development.
- **Dependencies**: Added necessary dependencies for password hashing,
  time handling, and TOML file manipulation.
- **Helper Binary**: Included a `hash-password` binary to simplify
  generating bcrypt hashes for user passwords.
This commit is contained in:
2026-04-14 15:09:23 +02:00
parent 61b5c8e125
commit d489716c9b
24 changed files with 930 additions and 16 deletions
+61
View File
@@ -0,0 +1,61 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<title>Doctate — Fall {{ case_id_short }}</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; }
.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; }
.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; }
.recording-head span { font-family: monospace; font-size: 0.9em; min-width: 20em; }
.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; }
.status-badge { display: inline-block; padding: 0.1em 0.6em; border-radius: 999px; font-size: 0.8em; font-weight: bold; color: white; }
.status-badge.open { background: #4a90e2; }
.status-badge.done { background: #7ed321; }
</style>
</head>
<body>
<header>
<div><a class="back" href="/web/cases">&larr; Zurück</a></div>
<form method="post" action="/web/logout"><button type="submit">Logout</button></form>
</header>
<h1>Fall {{ case_id_short }} <span class="status-badge {{ status }}">{{ status }}</span></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>
{% endmatch %}
<div class="meta">{{ case_id }}</div>
<h2>Aufnahmen ({{ recordings.len() }})</h2>
{% for rec in recordings %}
<div class="recording{% if rec.failed %} failed-row{% endif %}">
<div class="recording-head">
<span>{{ rec.filename }}</span>
<audio controls preload="none">
<source src="/web/audio/{{ slug }}/{{ case_id }}/{{ rec.filename }}" type="audio/mp4">
</audio>
</div>
{% if rec.failed %}
<div class="failed">Transkription fehlgeschlagen — .failed-Suffix entfernen zum Erneut-Versuchen.</div>
{% else %}
{% match rec.transcript %}
{% when Some with (t) %}
<div class="transcript">{{ t }}</div>
{% when None %}
<div class="pending">Transkription läuft…</div>
{% endmatch %}
{% endif %}
</div>
{% endfor %}
</body>
</html>
+28
View File
@@ -0,0 +1,28 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<title>Doctate — Login</title>
<style>
body { font-family: sans-serif; max-width: 400px; margin: 4em auto; padding: 0 1em; }
h1 { font-size: 1.3em; }
form { display: flex; flex-direction: column; gap: 0.7em; }
input { padding: 0.5em; font-size: 1em; }
button { padding: 0.6em; font-size: 1em; cursor: pointer; }
.error { background: #fee; border: 1px solid #e99; padding: 0.5em; border-radius: 4px; color: #900; }
</style>
</head>
<body>
<h1>Doctate Login</h1>
{% match error %}
{% when Some with (msg) %}
<div class="error">{{ msg }}</div>
{% when None %}
{% endmatch %}
<form method="post" action="/web/login">
<label>Benutzer (slug)<input type="text" name="slug" autofocus required></label>
<label>Passwort<input type="password" name="password" required></label>
<button type="submit">Anmelden</button>
</form>
</body>
</html>
+66
View File
@@ -0,0 +1,66 @@
<!DOCTYPE html>
<html lang="de">
<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; }
.case { border: 1px solid #ccc; margin: 0.8em 0; padding: 0.8em 1em; border-radius: 4px; display: block; text-decoration: none; color: inherit; }
.case:hover { background: #f6f6f6; }
.case h2 { margin: 0 0 0.3em 0; font-size: 1em; }
.case small { color: #666; font-family: monospace; }
.status-open { border-left: 4px solid #4a90e2; }
.status-done { border-left: 4px solid #7ed321; }
.oneliner { font-size: 1em; color: #333; margin: 0.2em 0; }
section { margin: 1.5em 0; }
section h2 { font-size: 1.1em; color: #555; border-bottom: 1px solid #ddd; padding-bottom: 0.2em; }
.empty { color: #888; font-style: italic; }
</style>
</head>
<body>
<header>
<h1>{{ slug }} — Meine Fälle</h1>
<form method="post" action="/web/logout"><button type="submit">Logout</button></form>
</header>
<section>
<h2>Offen ({{ open.len() }})</h2>
{% if open.is_empty() %}
<p class="empty">Keine offenen Fälle.</p>
{% else %}
{% for case in open %}
<a class="case status-{{ case.status }}" href="/web/cases/{{ case.case_id }}">
<h2>{{ case.case_id_short }} — {{ case.recordings.len() }} Aufnahme(n)</h2>
{% match case.oneliner %}
{% when Some with (t) %}
<div class="oneliner">{{ t }}</div>
{% when None %}
{% endmatch %}
<small>{{ case.most_recent }}</small>
</a>
{% endfor %}
{% endif %}
</section>
<section>
<h2>Abgeschlossen ({{ done.len() }})</h2>
{% if done.is_empty() %}
<p class="empty">Keine abgeschlossenen Fälle.</p>
{% else %}
{% for case in done %}
<a class="case status-{{ case.status }}" href="/web/cases/{{ case.case_id }}">
<h2>{{ case.case_id_short }} — {{ case.recordings.len() }} Aufnahme(n)</h2>
{% match case.oneliner %}
{% when Some with (t) %}
<div class="oneliner">{{ t }}</div>
{% when None %}
{% endmatch %}
<small>{{ case.most_recent }}</small>
</a>
{% endfor %}
{% endif %}
</section>
</body>
</html>