feat: Admin-Bereich (Accounts, Kanzlei-Verzeichnis-Freigabe, Audit-Log)

Bislang gab es keine vom Nutzer-Rollenmodell (creator/agentur/marke/
kanzlei) getrennte Betreiber-Rolle — jede Verwaltungsaufgabe (welche
Kanzlei darf im öffentlichen Verzeichnis stehen, wer sind unsere
Accounts) wäre nur per Hand in der Datenbank möglich gewesen. Admin
ist von Anfang an als fünfte app_user-Rolle im Datenmodell verankert,
nicht nachträglich aufgesetzt.

Migration 0004:
- app_user.role erlaubt zusätzlich 'admin' (kein Self-Service-Weg
  dorthin — /register bietet die Rolle nicht an, erster Admin wird
  einmalig per SQL angelegt, siehe CLAUDE.md).
- account.verified: Freigabe fürs kostenlose Kanzlei-Verzeichnis
  (§ 49b Abs. 3 BRAO: reine Auflistung, kein Routing/keine Vermittlung).
- audit_log: append-only-Protokoll jeder Admin-Aktion (gleicher Trigger
  wie finding/extraction/evidence_package).

Neue Routen:
- GET /admin, /admin/accounts, /admin/accounts/{id}: Accounts-Übersicht
  und -Detail (Logins je Account), requireAdmin (404 statt 403 für
  angemeldete Nicht-Admins, wie beim bestehenden Mandanten-404-Muster).
- POST /admin/accounts/{id}/verifizieren: Kanzlei-Freigabe umschalten,
  schreibt einen Audit-Log-Eintrag.
- GET /admin/audit-log: Protokoll ansehen.
- GET /kanzleien: öffentliches Verzeichnis (kein Login), zeigt nur
  Accounts, die sowohl verified sind als auch einen Nutzer der Rolle
  "kanzlei" haben.

Volle Testsuite inkl. echter Postgres-Tests grün; End-to-End manuell
gegen einen laufenden Server verifiziert (Admin-Login, Verify-Toggle,
Erscheinen im öffentlichen Verzeichnis, Audit-Log-Eintrag, 404 für
Nicht-Admin-Zugriff).
This commit is contained in:
noroot
2026-08-27 18:17:45 +02:00
parent c9d71b0d54
commit 835ad9f0a7
18 changed files with 1151 additions and 12 deletions

View File

@@ -0,0 +1,39 @@
{{define "admin-account-detail"}}<!doctype html>
<html lang="de">
<head>{{template "head" .}}</head>
<body>
{{template "nav" .}}
<div class="page">
<p><a href="/admin/accounts">&larr; Alle Accounts</a></p>
<h1>{{.Name}}</h1>
<p class="hinweis">Angelegt am {{.CreatedAt}}</p>
<h2>Nutzer</h2>
<ul class="beteiligte">
{{range .Users}}
<li class="beteiligter">
<div class="beteiligter-kopf">{{.Email}} <span class="rolle">({{.Role}})</span></div>
</li>
{{end}}
</ul>
{{if .HasKanzlei}}
<h2>Kanzlei-Verzeichnis</h2>
{{if .Verified}}
<p>Status: <span class="status status-published">verifiziert — im öffentlichen Verzeichnis gelistet</span></p>
<form method="post" action="/admin/accounts/{{.AccountID}}/verifizieren">
<input type="hidden" name="verified" value="false">
<button type="submit">Freigabe entziehen</button>
</form>
{{else}}
<p>Status: <span class="status status-mittel">nicht verifiziert — noch nicht gelistet</span></p>
<form method="post" action="/admin/accounts/{{.AccountID}}/verifizieren">
<input type="hidden" name="verified" value="true">
<button type="submit">Fürs Verzeichnis freigeben</button>
</form>
{{end}}
{{end}}
</div>
</body>
</html>
{{end}}

View File

@@ -0,0 +1,30 @@
{{define "admin-accounts"}}<!doctype html>
<html lang="de">
<head>{{template "head" .}}</head>
<body>
{{template "nav" .}}
<div class="page">
<p><a href="/admin">&larr; Admin</a></p>
<h1>Accounts</h1>
{{if not .Accounts}}
<p class="hinweis">Noch keine Accounts.</p>
{{else}}
<ul class="beitraege-liste">
{{range .Accounts}}
<li>
<a href="/admin/accounts/{{.ID}}">
<strong>{{.Name}}</strong> · {{.CreatedAt}}
{{if .Verified}}
<span class="status status-published">verifiziert</span>
{{else}}
<span class="status status-mittel">nicht verifiziert</span>
{{end}}
</a>
</li>
{{end}}
</ul>
{{end}}
</div>
</body>
</html>
{{end}}

View File

@@ -0,0 +1,24 @@
{{define "admin-audit-log"}}<!doctype html>
<html lang="de">
<head>{{template "head" .}}</head>
<body>
{{template "nav" .}}
<div class="page">
<p><a href="/admin">&larr; Admin</a></p>
<h1>Audit-Log</h1>
{{if not .Entries}}
<p class="hinweis">Noch keine Einträge.</p>
{{else}}
<ul class="beteiligte">
{{range .Entries}}
<li class="beteiligter">
<div class="beteiligter-kopf">{{.CreatedAt}} — <strong>{{.Action}}</strong> ({{.TargetType}} {{.TargetID}})</div>
{{if .Details}}<p>{{.Details}}</p>{{end}}
</li>
{{end}}
</ul>
{{end}}
</div>
</body>
</html>
{{end}}

View File

@@ -0,0 +1,17 @@
{{define "admin-dashboard"}}<!doctype html>
<html lang="de">
<head>{{template "head" .}}</head>
<body>
{{template "nav" .}}
<div class="page">
<h1>Admin</h1>
<ul class="admin-kacheln">
<li><a href="/admin/accounts">Accounts <span class="status">{{.AccountCount}}</span></a></li>
<li><a href="/admin/accounts">Unverifizierte Kanzleien <span class="status status-mittel">{{.UnverifiedCount}}</span></a></li>
<li><a href="/admin/audit-log">Audit-Log <span class="status">{{.RecentAuditCount}} neueste</span></a></li>
<li><a href="/kanzleien">Öffentliches Kanzlei-Verzeichnis ansehen</a></li>
</ul>
</div>
</body>
</html>
{{end}}

View File

@@ -0,0 +1,23 @@
{{define "kanzleien"}}<!doctype html>
<html lang="de">
<head>{{template "head" .}}</head>
<body>
<div class="page">
<h1>Kanzlei-Verzeichnis</h1>
<p class="hinweis">
Kostenloses Verzeichnis auf Deklarix verifizierter Kanzleien. Keine
Vermittlung, keine Empfehlung — die Auswahl trifft allein der Nutzer.
</p>
{{if not .Kanzleien}}
<p class="hinweis">Noch keine Kanzlei gelistet.</p>
{{else}}
<ul class="beitraege-liste">
{{range .Kanzleien}}
<li><span class="kanzlei-eintrag">{{.Name}}</span></li>
{{end}}
</ul>
{{end}}
</div>
</body>
</html>
{{end}}