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,213 @@
package web
import (
"net/http"
)
type kanzleiListItem struct {
Name string
}
type kanzleiListData struct {
Title string
Kanzleien []kanzleiListItem
}
// handlePublicKanzleiList zeigt das öffentliche, kostenlose Kanzlei-
// Verzeichnis — keine Anmeldung nötig. Bewusst nur Name, kein Kontakt-
// Button/Routing (siehe CLAUDE.md, § 49b Abs. 3 BRAO): Nutzer wählen
// selbst, es gibt keine Vermittlung.
func (s *Server) handlePublicKanzleiList(w http.ResponseWriter, r *http.Request) {
accounts, err := s.store.ListVerifiedKanzleien(r.Context())
if err != nil {
http.Error(w, "Verzeichnis konnte nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
return
}
data := kanzleiListData{Title: "Kanzlei-Verzeichnis"}
for _, a := range accounts {
data.Kanzleien = append(data.Kanzleien, kanzleiListItem{Name: a.Name})
}
if err := s.templates.ExecuteTemplate(w, "kanzleien", data); err != nil {
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
}
}
type adminDashboardData struct {
Title string
AccountCount int
UnverifiedCount int
RecentAuditCount int
}
// handleAdminDashboard zeigt eine kurze Übersicht als Einstieg in den
// Admin-Bereich.
func (s *Server) handleAdminDashboard(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
accounts, err := s.store.ListAccounts(ctx)
if err != nil {
http.Error(w, "Accounts konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
return
}
unverified := 0
for _, a := range accounts {
if !a.Verified {
unverified++
}
}
auditLog, err := s.store.ListAuditLog(ctx, 5)
if err != nil {
http.Error(w, "Audit-Log konnte nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
return
}
data := adminDashboardData{
Title: "Admin", AccountCount: len(accounts), UnverifiedCount: unverified, RecentAuditCount: len(auditLog),
}
if err := s.templates.ExecuteTemplate(w, "admin-dashboard", data); err != nil {
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
}
}
type adminAccountListItem struct {
ID string
Name string
Verified bool
CreatedAt string
}
type adminAccountListData struct {
Title string
Accounts []adminAccountListItem
}
// handleAdminAccountList listet alle Mandanten der Plattform.
func (s *Server) handleAdminAccountList(w http.ResponseWriter, r *http.Request) {
accounts, err := s.store.ListAccounts(r.Context())
if err != nil {
http.Error(w, "Accounts konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
return
}
data := adminAccountListData{Title: "Accounts"}
for _, a := range accounts {
data.Accounts = append(data.Accounts, adminAccountListItem{
ID: a.ID, Name: a.Name, Verified: a.Verified, CreatedAt: a.CreatedAt.Format("02.01.2006 15:04"),
})
}
if err := s.templates.ExecuteTemplate(w, "admin-accounts", data); err != nil {
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
}
}
type adminUserView struct {
Email string
Role string
}
type adminAccountDetailData struct {
Title string
AccountID string
Name string
Verified bool
HasKanzlei bool
Users []adminUserView
CreatedAt string
}
// handleAdminAccountDetail zeigt einen Mandanten mit seinen Logins und —
// falls mindestens ein Login die Rolle "kanzlei" hat — der Möglichkeit,
// die Freigabe fürs öffentliche Kanzlei-Verzeichnis zu setzen.
func (s *Server) handleAdminAccountDetail(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
acc, err := s.store.GetAccount(ctx, r.PathValue("id"))
if err != nil {
http.Error(w, "Account nicht gefunden", http.StatusNotFound)
return
}
users, err := s.store.ListUsersForAccount(ctx, acc.ID)
if err != nil {
http.Error(w, "Nutzer konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
return
}
data := adminAccountDetailData{
Title: "Account", AccountID: acc.ID, Name: acc.Name, Verified: acc.Verified,
CreatedAt: acc.CreatedAt.Format("02.01.2006 15:04"),
}
for _, u := range users {
data.Users = append(data.Users, adminUserView{Email: u.Email, Role: u.Role})
if u.Role == "kanzlei" {
data.HasKanzlei = true
}
}
if err := s.templates.ExecuteTemplate(w, "admin-account-detail", data); err != nil {
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
}
}
// handleAdminSetVerified schaltet die Freigabe fürs Kanzlei-Verzeichnis
// um und protokolliert die Aktion im Audit-Log.
func (s *Server) handleAdminSetVerified(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, "ungültiges Formular", http.StatusBadRequest)
return
}
accountID := r.PathValue("id")
ctx := r.Context()
if _, err := s.store.GetAccount(ctx, accountID); err != nil {
http.Error(w, "Account nicht gefunden", http.StatusNotFound)
return
}
verified := r.FormValue("verified") == "true"
updated, err := s.store.SetAccountVerified(ctx, accountID, verified)
if err != nil {
http.Error(w, "Freigabe konnte nicht gesetzt werden: "+err.Error(), http.StatusInternalServerError)
return
}
action := "account.unverified"
details := "Kanzlei-Verzeichnis: Freigabe entzogen"
if updated.Verified {
action = "account.verified"
details = "Kanzlei-Verzeichnis: Freigabe erteilt"
}
if _, err := s.store.CreateAuditEntry(ctx, currentUser(r).ID, action, "account", accountID, details); err != nil {
http.Error(w, "Audit-Log konnte nicht geschrieben werden: "+err.Error(), http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/admin/accounts/"+accountID, http.StatusSeeOther)
}
type adminAuditEntryView struct {
CreatedAt string
Action string
TargetType string
TargetID string
Details string
}
type adminAuditLogData struct {
Title string
Entries []adminAuditEntryView
}
// handleAdminAuditLog zeigt das Protokoll der Admin-Aktionen.
func (s *Server) handleAdminAuditLog(w http.ResponseWriter, r *http.Request) {
entries, err := s.store.ListAuditLog(r.Context(), 200)
if err != nil {
http.Error(w, "Audit-Log konnte nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
return
}
data := adminAuditLogData{Title: "Audit-Log"}
for _, e := range entries {
data.Entries = append(data.Entries, adminAuditEntryView{
CreatedAt: e.CreatedAt.Format("02.01.2006 15:04:05"), Action: e.Action,
TargetType: e.TargetType, TargetID: e.TargetID, Details: e.Details,
})
}
if err := s.templates.ExecuteTemplate(w, "admin-audit-log", data); err != nil {
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
}
}

View File

@@ -0,0 +1,176 @@
package web_test
import (
"net/http"
"net/url"
"strings"
"testing"
"github.com/netcell-it/deklarix/internal/rules"
)
func TestAdminRoutesRejectNonAdminWith404(t *testing.T) {
s, _, cookie := newAuthedTestServer(t, fakeExtractor{})
for _, path := range []string{"/admin", "/admin/accounts", "/admin/audit-log"} {
resp := getWithCookie(t, s, cookie, path)
if resp.Code != http.StatusNotFound {
t.Errorf("GET %s status = %d, want 404 for a non-admin user", path, resp.Code)
}
}
}
func TestAdminRoutesRedirectToLoginWithoutSession(t *testing.T) {
s, _, _ := newAuthedTestServer(t, fakeExtractor{})
resp := getWithCookie(t, s, nil, "/admin")
if resp.Code != http.StatusSeeOther {
t.Fatalf("status = %d, want 303 redirect to /login", resp.Code)
}
}
func TestAdminDashboardAccessibleForAdmin(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fakeExtractor{}, fs)
adminCookie := seedAccountWithRole(t, fs, "Deklarix Admin", "admin@example.com", "admin")
resp := getWithCookie(t, s, adminCookie, "/admin")
if resp.Code != http.StatusOK {
t.Fatalf("status = %d, want 200, body: %s", resp.Code, resp.Body.String())
}
}
func TestAdminAccountListShowsAllAccountsAcrossTenants(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fakeExtractor{}, fs)
adminCookie := seedAccountWithRole(t, fs, "Deklarix Admin", "admin@example.com", "admin")
seedAccount(t, fs, "Mandant A", "a@example.com")
seedAccount(t, fs, "Mandant B", "b@example.com")
resp := getWithCookie(t, s, adminCookie, "/admin/accounts")
if resp.Code != http.StatusOK {
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
}
body := resp.Body.String()
for _, want := range []string{"Mandant A", "Mandant B", "Deklarix Admin"} {
if !strings.Contains(body, want) {
t.Errorf("expected %q in the admin account list, got: %s", want, body)
}
}
}
func TestAdminAccountDetailShowsUsersAndVerifyToggleOnlyForKanzlei(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fakeExtractor{}, fs)
adminCookie := seedAccountWithRole(t, fs, "Deklarix Admin", "admin@example.com", "admin")
creatorCookie := seedAccount(t, fs, "Nur Creator", "creator@example.com")
_ = creatorCookie
var creatorAccID string
for id, acc := range fs.accounts {
if acc.Name == "Nur Creator" {
creatorAccID = id
}
}
kanzleiCookie := seedAccountWithRole(t, fs, "Kanzlei Musterfrau", "kanzlei@example.com", "kanzlei")
_ = kanzleiCookie
var kanzleiAccID string
for id, acc := range fs.accounts {
if acc.Name == "Kanzlei Musterfrau" {
kanzleiAccID = id
}
}
creatorResp := getWithCookie(t, s, adminCookie, "/admin/accounts/"+creatorAccID)
if creatorResp.Code != http.StatusOK {
t.Fatalf("status = %d", creatorResp.Code)
}
if strings.Contains(creatorResp.Body.String(), "verifizieren") {
t.Errorf("expected no verify action for a non-kanzlei account, got: %s", creatorResp.Body.String())
}
kanzleiResp := getWithCookie(t, s, adminCookie, "/admin/accounts/"+kanzleiAccID)
if kanzleiResp.Code != http.StatusOK {
t.Fatalf("status = %d", kanzleiResp.Code)
}
if !strings.Contains(kanzleiResp.Body.String(), "kanzlei@example.com") {
t.Errorf("expected the kanzlei user's email on the account detail page, got: %s", kanzleiResp.Body.String())
}
if !strings.Contains(kanzleiResp.Body.String(), "/admin/accounts/"+kanzleiAccID+"/verifizieren") {
t.Errorf("expected a verify action for a kanzlei account, got: %s", kanzleiResp.Body.String())
}
}
func TestAdminVerifyAddsAccountToPublicDirectoryAndAuditLog(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fakeExtractor{}, fs)
adminCookie := seedAccountWithRole(t, fs, "Deklarix Admin", "admin@example.com", "admin")
seedAccountWithRole(t, fs, "Kanzlei Musterfrau", "kanzlei@example.com", "kanzlei")
var kanzleiAccID string
for id, acc := range fs.accounts {
if acc.Name == "Kanzlei Musterfrau" {
kanzleiAccID = id
}
}
// Vor der Freigabe taucht die Kanzlei nicht im oeffentlichen
// Verzeichnis auf.
before := getWithCookie(t, s, nil, "/kanzleien")
if strings.Contains(before.Body.String(), "Kanzlei Musterfrau") {
t.Fatalf("kanzlei should not be public before verification, got: %s", before.Body.String())
}
verifyResp := postForm(t, s, adminCookie, "/admin/accounts/"+kanzleiAccID+"/verifizieren", url.Values{"verified": {"true"}})
if verifyResp.Code != http.StatusSeeOther {
t.Fatalf("verify status = %d, want 303, body: %s", verifyResp.Code, verifyResp.Body.String())
}
after := getWithCookie(t, s, nil, "/kanzleien")
if !strings.Contains(after.Body.String(), "Kanzlei Musterfrau") {
t.Fatalf("expected the kanzlei to be listed publicly after verification, got: %s", after.Body.String())
}
if len(fs.auditLog) != 1 {
t.Fatalf("expected exactly one audit entry, got %d", len(fs.auditLog))
}
if fs.auditLog[0].Action != "account.verified" || fs.auditLog[0].TargetID != kanzleiAccID {
t.Errorf("unexpected audit entry: %+v", fs.auditLog[0])
}
auditPageResp := getWithCookie(t, s, adminCookie, "/admin/audit-log")
if !strings.Contains(auditPageResp.Body.String(), "account.verified") {
t.Errorf("expected the audit entry on the audit log page, got: %s", auditPageResp.Body.String())
}
}
func TestAdminVerifyRejectsNonAdmin(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fakeExtractor{}, fs)
tenantCookie := seedAccountWithRole(t, fs, "Kanzlei Musterfrau", "kanzlei@example.com", "kanzlei")
var kanzleiAccID string
for id, acc := range fs.accounts {
if acc.Name == "Kanzlei Musterfrau" {
kanzleiAccID = id
}
}
resp := postForm(t, s, tenantCookie, "/admin/accounts/"+kanzleiAccID+"/verifizieren", url.Values{"verified": {"true"}})
if resp.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404 for a non-admin actor", resp.Code)
}
if fs.accounts[kanzleiAccID].Verified {
t.Fatal("account should not have been verified by a non-admin request")
}
}
func TestPublicKanzleiDirectoryRequiresNoLogin(t *testing.T) {
s, _, _ := newAuthedTestServer(t, fakeExtractor{facts: rules.Facts{Platform: "instagram"}})
req := getWithCookie(t, s, nil, "/kanzleien")
if req.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 without any session", req.Code)
}
}

View File

@@ -68,6 +68,26 @@ func (s *Server) requireAPI(next http.HandlerFunc) http.HandlerFunc {
}
}
// requireAdmin schützt den Admin-Bereich. Ohne Sitzung geht es wie bei
// requirePage zu /login; mit einer Sitzung, aber ohne Admin-Rolle, gibt
// es 404 statt 403 — sonst würde eine 403 einem angemeldeten, aber
// unprivilegierten Nutzer verraten, dass unter dieser URL überhaupt
// etwas existiert.
func (s *Server) requireAdmin(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
user, ok := s.authenticate(r)
if !ok {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
if user.Role != "admin" {
http.Error(w, "nicht gefunden", http.StatusNotFound)
return
}
next(w, r.WithContext(context.WithValue(r.Context(), userContextKey, user)))
}
}
// currentUser liest den Nutzer, den requirePage/requireAPI in den
// Kontext gelegt haben. Panics, wenn es aufgerufen wird, ohne dass eine
// dieser Middlewares vorgeschaltet war — das ist ein Programmierfehler,

View File

@@ -57,12 +57,19 @@ type Store interface {
DeleteParticipant(ctx context.Context, id string) error
CreateAccount(ctx context.Context, name string) (store.Account, error)
GetAccount(ctx context.Context, id string) (store.Account, error)
ListAccounts(ctx context.Context) ([]store.Account, error)
ListVerifiedKanzleien(ctx context.Context) ([]store.Account, error)
SetAccountVerified(ctx context.Context, id string, verified bool) (store.Account, error)
CreateUser(ctx context.Context, accountID, email, passwordHash, role string) (store.User, error)
GetUserByEmail(ctx context.Context, email string) (store.User, error)
GetUser(ctx context.Context, id string) (store.User, error)
ListUsersForAccount(ctx context.Context, accountID string) ([]store.User, error)
CreateSession(ctx context.Context, token, userID string, expiresAt time.Time) (store.Session, error)
GetSession(ctx context.Context, token string) (store.Session, error)
DeleteSession(ctx context.Context, token string) error
CreateAuditEntry(ctx context.Context, actorUserID, action, targetType, targetID, details string) (store.AuditEntry, error)
ListAuditLog(ctx context.Context, limit int) ([]store.AuditEntry, error)
}
// Server bündelt Routing und Abhängigkeiten der Web-Schicht.
@@ -110,6 +117,12 @@ func NewServer(extractor Extractor, ruleSet []rules.Rule, st Store, timestamper
mux.HandleFunc("POST /beitraege/{id}/beteiligte", s.requireAPI(s.handleAddParticipant))
mux.HandleFunc("POST /beitraege/{id}/beteiligte/{pid}/aktualisieren", s.requireAPI(s.handleUpdateParticipant))
mux.HandleFunc("POST /beitraege/{id}/beteiligte/{pid}/loeschen", s.requireAPI(s.handleDeleteParticipant))
mux.HandleFunc("GET /kanzleien", s.handlePublicKanzleiList)
mux.HandleFunc("GET /admin", s.requireAdmin(s.handleAdminDashboard))
mux.HandleFunc("GET /admin/accounts", s.requireAdmin(s.handleAdminAccountList))
mux.HandleFunc("GET /admin/accounts/{id}", s.requireAdmin(s.handleAdminAccountDetail))
mux.HandleFunc("POST /admin/accounts/{id}/verifizieren", s.requireAdmin(s.handleAdminSetVerified))
mux.HandleFunc("GET /admin/audit-log", s.requireAdmin(s.handleAdminAuditLog))
mux.Handle("GET /static/", http.FileServerFS(staticFS))
s.mux = mux

View File

@@ -67,6 +67,7 @@ type fakeStore struct {
findings map[string][]store.Finding
evidencePkgs map[string]store.EvidencePackage
participants map[string]store.Participant
auditLog []store.AuditEntry
}
func newFakeStore() *fakeStore {
@@ -96,6 +97,98 @@ func (f *fakeStore) CreateAccount(ctx context.Context, name string) (store.Accou
return acc, nil
}
func (f *fakeStore) GetAccount(ctx context.Context, id string) (store.Account, error) {
f.mu.Lock()
defer f.mu.Unlock()
acc, ok := f.accounts[id]
if !ok {
return store.Account{}, store.ErrNotFound
}
return acc, nil
}
func (f *fakeStore) ListAccounts(ctx context.Context) ([]store.Account, error) {
f.mu.Lock()
defer f.mu.Unlock()
var out []store.Account
for _, acc := range f.accounts {
out = append(out, acc)
}
return out, nil
}
func (f *fakeStore) ListVerifiedKanzleien(ctx context.Context) ([]store.Account, error) {
f.mu.Lock()
defer f.mu.Unlock()
var out []store.Account
for _, acc := range f.accounts {
if !acc.Verified {
continue
}
hasKanzlei := false
for _, u := range f.users {
if u.AccountID == acc.ID && u.Role == "kanzlei" {
hasKanzlei = true
break
}
}
if hasKanzlei {
out = append(out, acc)
}
}
return out, nil
}
func (f *fakeStore) SetAccountVerified(ctx context.Context, id string, verified bool) (store.Account, error) {
f.mu.Lock()
defer f.mu.Unlock()
acc, ok := f.accounts[id]
if !ok {
return store.Account{}, store.ErrNotFound
}
acc.Verified = verified
f.accounts[id] = acc
return acc, nil
}
func (f *fakeStore) ListUsersForAccount(ctx context.Context, accountID string) ([]store.User, error) {
f.mu.Lock()
defer f.mu.Unlock()
var out []store.User
for _, u := range f.users {
if u.AccountID == accountID {
out = append(out, u)
}
}
return out, nil
}
func (f *fakeStore) CreateAuditEntry(ctx context.Context, actorUserID, action, targetType, targetID, details string) (store.AuditEntry, error) {
f.mu.Lock()
defer f.mu.Unlock()
e := store.AuditEntry{
ID: f.newID(), ActorUserID: actorUserID, Action: action, TargetType: targetType,
TargetID: targetID, Details: details, CreatedAt: time.Now(),
}
f.auditLog = append(f.auditLog, e)
return e, nil
}
func (f *fakeStore) ListAuditLog(ctx context.Context, limit int) ([]store.AuditEntry, error) {
f.mu.Lock()
defer f.mu.Unlock()
// Neueste zuerst, wie die echte Store-Implementierung (ORDER BY
// created_at DESC) — hier reicht eine Umkehrung der Einfuegereihenfolge.
out := make([]store.AuditEntry, len(f.auditLog))
for i, e := range f.auditLog {
out[len(f.auditLog)-1-i] = e
}
if len(out) > limit {
out = out[:limit]
}
return out, nil
}
func (f *fakeStore) CreateUser(ctx context.Context, accountID, email, passwordHash, role string) (store.User, error) {
f.mu.Lock()
defer f.mu.Unlock()
@@ -417,6 +510,11 @@ func newServer(t *testing.T, ex web.Extractor, fs *fakeStore) *web.Server {
// seedAccount legt direkt im fakeStore (ohne HTTP) einen Account, einen
// Nutzer und eine gültige Sitzung an und liefert das Session-Cookie.
func seedAccount(t *testing.T, fs *fakeStore, accountName, email string) *http.Cookie {
t.Helper()
return seedAccountWithRole(t, fs, accountName, email, "creator")
}
func seedAccountWithRole(t *testing.T, fs *fakeStore, accountName, email, role string) *http.Cookie {
t.Helper()
ctx := context.Background()
acc, err := fs.CreateAccount(ctx, accountName)
@@ -427,7 +525,7 @@ func seedAccount(t *testing.T, fs *fakeStore, accountName, email string) *http.C
if err != nil {
t.Fatalf("HashPassword: %v", err)
}
user, err := fs.CreateUser(ctx, acc.ID, email, hash, "creator")
user, err := fs.CreateUser(ctx, acc.ID, email, hash, role)
if err != nil {
t.Fatalf("CreateUser: %v", err)
}

View File

@@ -260,7 +260,8 @@ nav button {
color: var(--color-niedrig);
}
.status-checked {
.status-checked,
.status-mittel {
background: var(--color-mittel-bg);
color: var(--color-mittel);
}
@@ -352,6 +353,37 @@ button.entfernen {
font-size: 0.875rem;
}
.admin-kacheln {
list-style: none;
margin: 0 0 16px;
padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.admin-kacheln a {
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
padding: 14px 16px;
border-radius: var(--radius-md);
box-shadow: var(--shadow);
background: #fff;
color: var(--color-text);
text-decoration: none;
font-weight: 500;
}
.kanzlei-eintrag {
display: block;
padding: 12px 14px;
border-radius: var(--radius-md);
box-shadow: var(--shadow);
background: #fff;
}
/* Ab hier mehr Platz (Tablet/Desktop) — der Container bekommt spürbaren
Rand statt volle Breite, sonst bleibt alles identisch. */
@media (min-width: 640px) {

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}}