diff --git a/CLAUDE.md b/CLAUDE.md index 15c90e2..f983840 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -139,12 +139,33 @@ Plus drei Tabellen für Auth/Mandantentrennung (`account`, `app_user`, `session`), mehr braucht der MVP nicht: - `account` — ein Mandant (Creator, Agentur, Marke oder Kanzlei als - eigene Organisation); jede Submission gehört genau einem Account + eigene Organisation); jede Submission gehört genau einem Account. + `verified` markiert einen Kanzlei-Account als für das öffentliche, + kostenlose Kanzlei-Verzeichnis (`GET /kanzleien`) freigegeben — nur + vom Admin-Bereich aus setzbar, nie vom Mandanten selbst - `app_user` — ein Login innerhalb eines Accounts (E-Mail, Passwort- - Hash, Rolle) + Hash, Rolle). Rolle ist eine von `creator`, `agentur`, `marke`, + `kanzlei` **oder `admin`**. `admin` ist Betreiber-Personal + (Netcell-IT), nicht an einen Mandanten-Geschäftszweck gebunden, + zuständig für den Admin-Bereich (`/admin/...`: Accounts-Übersicht, + Kanzlei-Verzeichnis-Freigabe, Audit-Log). Es gibt **keine** + Selbstregistrierung für `admin` über `POST /register` (das Formular + bietet die Rolle nicht an) — der erste Admin wird einmalig per SQL + angelegt: + ```sql + INSERT INTO account (name) VALUES ('Deklarix Admin') RETURNING id; + INSERT INTO app_user (account_id, email, password_hash, role) + VALUES ('', '', '', 'admin'); + ``` + (bcrypt-Hash z. B. über `internal/auth.HashPassword` in einem + Wegwerf-`cmd/`-Programm erzeugen, da `internal/` von außerhalb des + Moduls nicht importierbar ist) - `session` — eine angemeldete Sitzung (Token, Ablaufzeit); bewusst eine echte Tabelle statt zustandsloser signierter Tokens, damit Logout eine Sitzung wirklich beendet +- `audit_log` — Protokoll der Admin-Aktionen (wer hat wann welchen + Account wie verändert); append-only aus demselben Grund wie + `finding`/`extraction`/`evidence_package` - `submission` — ein eingereichter Beitrag, Status, Zeitpunkte - `asset` — Bild oder Datei, Pfad, SHA-256 - `extraction` — das JSON aus Stufe 1, Modellversion, Prompt-Version @@ -157,9 +178,10 @@ Plus drei Tabellen für Auth/Mandantentrennung (`account`, `app_user`, (`creator`, `agentur`, `marke`, `kanzlei`) und Beitrag zur Verantwortungsmatrix (wer hat vorgegeben, wer freigegeben) -**Append-only.** Kein UPDATE auf `finding`, `extraction` oder -`evidence_package`. Korrekturen sind neue Zeilen mit Verweis auf die alte. -Ein Beweisarchiv, in dem man Zeilen ändern kann, ist kein Beweisarchiv. +**Append-only.** Kein UPDATE auf `finding`, `extraction`, +`evidence_package` oder `audit_log`. Korrekturen sind neue Zeilen mit +Verweis auf die alte. Ein Beweisarchiv (bzw. Protokoll), in dem man +Zeilen ändern kann, ist keines mehr. **Beweiskette:** SHA-256 über jedes Asset und über die kanonisierte JSON-Repräsentation der Metadaten, RFC-3161-Zeitstempel über diesen Hash. diff --git a/internal/store/account.go b/internal/store/account.go index 48f8120..b2cc29b 100644 --- a/internal/store/account.go +++ b/internal/store/account.go @@ -2,25 +2,32 @@ package store import ( "context" + "errors" "fmt" "time" + + "github.com/jackc/pgx/v5" ) // Account ist ein Mandant (Creator, Agentur, Marke oder Kanzlei als // eigene Organisation). Jeder Beitrag gehört genau einem Account. +// Verified ist nur für Kanzlei-Accounts relevant: ob sie im öffentlichen +// Kanzlei-Verzeichnis gelistet werden (siehe Migration 0004). type Account struct { ID string Name string + Verified bool CreatedAt time.Time } -// CreateAccount legt einen neuen Mandanten an. +// CreateAccount legt einen neuen Mandanten an (verified startet false — +// jede Freigabe fürs Kanzlei-Verzeichnis ist eine bewusste Admin-Aktion). func (s *Store) CreateAccount(ctx context.Context, name string) (Account, error) { var a Account err := s.Pool.QueryRow(ctx, ` INSERT INTO account (name) VALUES ($1) - RETURNING id, name, created_at - `, name).Scan(&a.ID, &a.Name, &a.CreatedAt) + RETURNING id, name, verified, created_at + `, name).Scan(&a.ID, &a.Name, &a.Verified, &a.CreatedAt) if err != nil { return Account{}, fmt.Errorf("store: create account: %w", err) } @@ -31,10 +38,85 @@ func (s *Store) CreateAccount(ctx context.Context, name string) (Account, error) func (s *Store) GetAccount(ctx context.Context, id string) (Account, error) { var a Account err := s.Pool.QueryRow(ctx, ` - SELECT id, name, created_at FROM account WHERE id = $1 - `, id).Scan(&a.ID, &a.Name, &a.CreatedAt) + SELECT id, name, verified, created_at FROM account WHERE id = $1 + `, id).Scan(&a.ID, &a.Name, &a.Verified, &a.CreatedAt) + if errors.Is(err, pgx.ErrNoRows) { + return Account{}, ErrNotFound + } if err != nil { return Account{}, fmt.Errorf("store: get account: %w", err) } return a, nil } + +// ListAccounts liefert alle Mandanten, neueste zuerst — für den +// Admin-Bereich (Accounts-Verwaltung). +func (s *Store) ListAccounts(ctx context.Context) ([]Account, error) { + rows, err := s.Pool.Query(ctx, ` + SELECT id, name, verified, created_at FROM account ORDER BY created_at DESC + `) + if err != nil { + return nil, fmt.Errorf("store: list accounts: %w", err) + } + defer rows.Close() + + var out []Account + for rows.Next() { + var a Account + if err := rows.Scan(&a.ID, &a.Name, &a.Verified, &a.CreatedAt); err != nil { + return nil, fmt.Errorf("store: scan account: %w", err) + } + out = append(out, a) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("store: list accounts: %w", err) + } + return out, nil +} + +// ListVerifiedKanzleien liefert alle Accounts, die fürs öffentliche +// Kanzlei-Verzeichnis freigegeben sind UND mindestens einen Nutzer der +// Rolle "kanzlei" haben — verified allein reicht nicht, falls ein Admin +// versehentlich einen Nicht-Kanzlei-Account markiert. +func (s *Store) ListVerifiedKanzleien(ctx context.Context) ([]Account, error) { + rows, err := s.Pool.Query(ctx, ` + SELECT DISTINCT a.id, a.name, a.verified, a.created_at + FROM account a + JOIN app_user u ON u.account_id = a.id + WHERE a.verified = true AND u.role = 'kanzlei' + ORDER BY a.name + `) + if err != nil { + return nil, fmt.Errorf("store: list verified kanzleien: %w", err) + } + defer rows.Close() + + var out []Account + for rows.Next() { + var a Account + if err := rows.Scan(&a.ID, &a.Name, &a.Verified, &a.CreatedAt); err != nil { + return nil, fmt.Errorf("store: scan account: %w", err) + } + out = append(out, a) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("store: list verified kanzleien: %w", err) + } + return out, nil +} + +// SetAccountVerified setzt die Freigabe fürs Kanzlei-Verzeichnis. +func (s *Store) SetAccountVerified(ctx context.Context, id string, verified bool) (Account, error) { + var a Account + err := s.Pool.QueryRow(ctx, ` + UPDATE account SET verified = $2 WHERE id = $1 + RETURNING id, name, verified, created_at + `, id, verified).Scan(&a.ID, &a.Name, &a.Verified, &a.CreatedAt) + if errors.Is(err, pgx.ErrNoRows) { + return Account{}, ErrNotFound + } + if err != nil { + return Account{}, fmt.Errorf("store: set account verified: %w", err) + } + return a, nil +} diff --git a/internal/store/admin_test.go b/internal/store/admin_test.go new file mode 100644 index 0000000..e37a532 --- /dev/null +++ b/internal/store/admin_test.go @@ -0,0 +1,221 @@ +package store_test + +import ( + "context" + "testing" +) + +func TestAppUserRoleAllowsAdmin(t *testing.T) { + s := openTestStore(t) + ctx := context.Background() + accID := testAccountID(t, s) + + u, err := s.CreateUser(ctx, accID, "admin@example.com", "hash", "admin") + if err != nil { + t.Fatalf("CreateUser mit role=admin: %v", err) + } + if u.Role != "admin" { + t.Fatalf("Role = %q, want admin", u.Role) + } +} + +func TestAccountVerifiedDefaultsFalseAndCanBeSet(t *testing.T) { + s := openTestStore(t) + ctx := context.Background() + + acc, err := s.CreateAccount(ctx, "Kanzlei Musterfrau") + if err != nil { + t.Fatalf("CreateAccount: %v", err) + } + if acc.Verified { + t.Fatal("expected a new account to be unverified by default") + } + + updated, err := s.SetAccountVerified(ctx, acc.ID, true) + if err != nil { + t.Fatalf("SetAccountVerified: %v", err) + } + if !updated.Verified { + t.Fatal("expected the account to be verified after SetAccountVerified(true)") + } + + got, err := s.GetAccount(ctx, acc.ID) + if err != nil { + t.Fatalf("GetAccount: %v", err) + } + if !got.Verified { + t.Fatal("expected verified=true to persist") + } +} + +func TestSetAccountVerifiedNotFound(t *testing.T) { + s := openTestStore(t) + _, err := s.SetAccountVerified(context.Background(), "00000000-0000-0000-0000-000000000000", true) + if err == nil { + t.Fatal("expected an error for an unknown account") + } +} + +func TestListAccounts(t *testing.T) { + s := openTestStore(t) + ctx := context.Background() + + before, err := s.ListAccounts(ctx) + if err != nil { + t.Fatalf("ListAccounts: %v", err) + } + acc, err := s.CreateAccount(ctx, "Neuer Mandant fuer ListAccounts") + if err != nil { + t.Fatalf("CreateAccount: %v", err) + } + + after, err := s.ListAccounts(ctx) + if err != nil { + t.Fatalf("ListAccounts: %v", err) + } + if len(after) != len(before)+1 { + t.Fatalf("expected exactly one more account, got %d -> %d", len(before), len(after)) + } + found := false + for _, a := range after { + if a.ID == acc.ID { + found = true + } + } + if !found { + t.Fatal("expected the newly created account in ListAccounts") + } +} + +func TestListVerifiedKanzleienRequiresBothVerifiedAndKanzleiRole(t *testing.T) { + s := openTestStore(t) + ctx := context.Background() + + // Verifiziert, aber kein Kanzlei-Nutzer -> darf nicht auftauchen. + verifiedNonKanzlei, err := s.CreateAccount(ctx, "Verifizierte Marke") + if err != nil { + t.Fatalf("CreateAccount: %v", err) + } + if _, err := s.CreateUser(ctx, verifiedNonKanzlei.ID, "marke@example.com", "hash", "marke"); err != nil { + t.Fatalf("CreateUser: %v", err) + } + if _, err := s.SetAccountVerified(ctx, verifiedNonKanzlei.ID, true); err != nil { + t.Fatalf("SetAccountVerified: %v", err) + } + + // Kanzlei-Nutzer, aber nicht verifiziert -> darf nicht auftauchen. + unverifiedKanzlei, err := s.CreateAccount(ctx, "Unverifizierte Kanzlei") + if err != nil { + t.Fatalf("CreateAccount: %v", err) + } + if _, err := s.CreateUser(ctx, unverifiedKanzlei.ID, "unverifiziert@example.com", "hash", "kanzlei"); err != nil { + t.Fatalf("CreateUser: %v", err) + } + + // Beides erfuellt -> muss auftauchen. + verifiedKanzlei, err := s.CreateAccount(ctx, "Verifizierte Kanzlei") + if err != nil { + t.Fatalf("CreateAccount: %v", err) + } + if _, err := s.CreateUser(ctx, verifiedKanzlei.ID, "verifiziert@example.com", "hash", "kanzlei"); err != nil { + t.Fatalf("CreateUser: %v", err) + } + if _, err := s.SetAccountVerified(ctx, verifiedKanzlei.ID, true); err != nil { + t.Fatalf("SetAccountVerified: %v", err) + } + + list, err := s.ListVerifiedKanzleien(ctx) + if err != nil { + t.Fatalf("ListVerifiedKanzleien: %v", err) + } + byID := map[string]bool{} + for _, a := range list { + byID[a.ID] = true + } + if byID[verifiedNonKanzlei.ID] { + t.Error("verified non-kanzlei account should not appear in the directory") + } + if byID[unverifiedKanzlei.ID] { + t.Error("unverified kanzlei account should not appear in the directory") + } + if !byID[verifiedKanzlei.ID] { + t.Error("expected the verified kanzlei account in the directory") + } +} + +func TestListUsersForAccount(t *testing.T) { + s := openTestStore(t) + ctx := context.Background() + accID := testAccountID(t, s) + otherAccID := testAccountID(t, s) + + if _, err := s.CreateUser(ctx, accID, "eins@example.com", "hash", "creator"); err != nil { + t.Fatalf("CreateUser: %v", err) + } + if _, err := s.CreateUser(ctx, accID, "zwei@example.com", "hash", "agentur"); err != nil { + t.Fatalf("CreateUser: %v", err) + } + if _, err := s.CreateUser(ctx, otherAccID, "fremd@example.com", "hash", "marke"); err != nil { + t.Fatalf("CreateUser: %v", err) + } + + list, err := s.ListUsersForAccount(ctx, accID) + if err != nil { + t.Fatalf("ListUsersForAccount: %v", err) + } + if len(list) != 2 { + t.Fatalf("expected exactly 2 users for this account, got %d: %+v", len(list), list) + } +} + +func TestAuditLogCreateAndList(t *testing.T) { + s := openTestStore(t) + ctx := context.Background() + accID := testAccountID(t, s) + admin, err := s.CreateUser(ctx, accID, "admin-audit@example.com", "hash", "admin") + if err != nil { + t.Fatalf("CreateUser: %v", err) + } + + entry, err := s.CreateAuditEntry(ctx, admin.ID, "account.verified", "account", accID, "manuell freigegeben") + if err != nil { + t.Fatalf("CreateAuditEntry: %v", err) + } + if entry.ActorUserID != admin.ID { + t.Fatalf("ActorUserID = %q, want %q", entry.ActorUserID, admin.ID) + } + + list, err := s.ListAuditLog(ctx, 10) + if err != nil { + t.Fatalf("ListAuditLog: %v", err) + } + if len(list) == 0 { + t.Fatal("expected at least one audit entry") + } + if list[0].ID != entry.ID { + t.Fatalf("expected the newest entry first, got %+v", list[0]) + } +} + +func TestAuditLogIsAppendOnly(t *testing.T) { + s := openTestStore(t) + ctx := context.Background() + accID := testAccountID(t, s) + admin, err := s.CreateUser(ctx, accID, "admin-appendonly@example.com", "hash", "admin") + if err != nil { + t.Fatalf("CreateUser: %v", err) + } + entry, err := s.CreateAuditEntry(ctx, admin.ID, "account.verified", "account", accID, "") + if err != nil { + t.Fatalf("CreateAuditEntry: %v", err) + } + + _, err = s.Pool.Exec(ctx, `UPDATE audit_log SET action = 'geaendert' WHERE id = $1`, entry.ID) + if err == nil { + t.Fatal("expected UPDATE on audit_log to be rejected by the append-only trigger") + } + _, err = s.Pool.Exec(ctx, `DELETE FROM audit_log WHERE id = $1`, entry.ID) + if err == nil { + t.Fatal("expected DELETE on audit_log to be rejected by the append-only trigger") + } +} diff --git a/internal/store/auditlog.go b/internal/store/auditlog.go new file mode 100644 index 0000000..1b285a5 --- /dev/null +++ b/internal/store/auditlog.go @@ -0,0 +1,61 @@ +package store + +import ( + "context" + "fmt" + "time" +) + +// AuditEntry ist ein Protokolleintrag einer Admin-Aktion. Append-only: +// siehe Migration 0004 — ein Protokoll, das man ändern kann, ist kein +// Nachweis mehr, aus demselben Grund wie bei finding/extraction. +type AuditEntry struct { + ID string + ActorUserID string + Action string + TargetType string + TargetID string + Details string + CreatedAt time.Time +} + +// CreateAuditEntry protokolliert eine Admin-Aktion. +func (s *Store) CreateAuditEntry(ctx context.Context, actorUserID, action, targetType, targetID, details string) (AuditEntry, error) { + var e AuditEntry + err := s.Pool.QueryRow(ctx, ` + INSERT INTO audit_log (actor_user_id, action, target_type, target_id, details) + VALUES ($1, $2, $3, $4, $5) + RETURNING id, actor_user_id, action, target_type, target_id, details, created_at + `, actorUserID, action, targetType, targetID, details).Scan( + &e.ID, &e.ActorUserID, &e.Action, &e.TargetType, &e.TargetID, &e.Details, &e.CreatedAt, + ) + if err != nil { + return AuditEntry{}, fmt.Errorf("store: create audit entry: %w", err) + } + return e, nil +} + +// ListAuditLog liefert die letzten Protokolleinträge, neueste zuerst. +func (s *Store) ListAuditLog(ctx context.Context, limit int) ([]AuditEntry, error) { + rows, err := s.Pool.Query(ctx, ` + SELECT id, actor_user_id, action, target_type, target_id, details, created_at + FROM audit_log ORDER BY created_at DESC LIMIT $1 + `, limit) + if err != nil { + return nil, fmt.Errorf("store: list audit log: %w", err) + } + defer rows.Close() + + var out []AuditEntry + for rows.Next() { + var e AuditEntry + if err := rows.Scan(&e.ID, &e.ActorUserID, &e.Action, &e.TargetType, &e.TargetID, &e.Details, &e.CreatedAt); err != nil { + return nil, fmt.Errorf("store: scan audit entry: %w", err) + } + out = append(out, e) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("store: list audit log: %w", err) + } + return out, nil +} diff --git a/internal/store/migrations/0004_admin.down.sql b/internal/store/migrations/0004_admin.down.sql new file mode 100644 index 0000000..6845dff --- /dev/null +++ b/internal/store/migrations/0004_admin.down.sql @@ -0,0 +1,6 @@ +DROP TRIGGER audit_log_append_only ON audit_log; +DROP TABLE audit_log; +ALTER TABLE account DROP COLUMN verified; +ALTER TABLE app_user DROP CONSTRAINT app_user_role_check; +ALTER TABLE app_user ADD CONSTRAINT app_user_role_check + CHECK (role IN ('creator', 'agentur', 'marke', 'kanzlei')); diff --git a/internal/store/migrations/0004_admin.up.sql b/internal/store/migrations/0004_admin.up.sql new file mode 100644 index 0000000..b1fe158 --- /dev/null +++ b/internal/store/migrations/0004_admin.up.sql @@ -0,0 +1,36 @@ +-- "admin" ist eine fünfte app_user-Rolle: Betreiber-Personal (Netcell-IT), +-- nicht an einen Mandanten-Geschäftszweck (creator/agentur/marke/kanzlei) +-- gebunden, sondern zuständig für die Plattform selbst (Accounts, +-- Kanzlei-Verzeichnis, Audit-Log). Bewusst KEIN eigenes account_role-Feld +-- getrennt von app_user.role — ein Admin-Login ist genauso ein app_user +-- wie jeder andere, nur mit einer anderen Rolle. Es gibt bewusst keine +-- Selbstregistrierung für "admin" über /register (siehe internal/web) — +-- der erste Admin wird per SQL angelegt (siehe CLAUDE.md). +ALTER TABLE app_user DROP CONSTRAINT app_user_role_check; +ALTER TABLE app_user ADD CONSTRAINT app_user_role_check + CHECK (role IN ('creator', 'agentur', 'marke', 'kanzlei', 'admin')); + +-- verified markiert eine Kanzlei-Account als für das öffentliche, +-- kostenlose Kanzlei-Verzeichnis freigegeben (siehe CLAUDE.md, § 49b +-- Abs. 3 BRAO: keine Sachvorteile, kein Routing — das Verzeichnis ist +-- eine reine Auflistung, keine Vermittlung). Nur für Accounts mit +-- mindestens einem Nutzer der Rolle "kanzlei" sinnvoll; das erzwingt die +-- Anwendungsschicht, nicht die Datenbank. +ALTER TABLE account ADD COLUMN verified BOOLEAN NOT NULL DEFAULT false; + +-- Audit-Log für Admin-Aktionen: append-only aus demselben Grund wie +-- finding/extraction/evidence_package — ein Protokoll, das man ändern +-- kann, ist kein Nachweis mehr. +CREATE TABLE audit_log ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + actor_user_id UUID NOT NULL REFERENCES app_user (id), + action TEXT NOT NULL, + target_type TEXT NOT NULL, + target_id TEXT NOT NULL, + details TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TRIGGER audit_log_append_only + BEFORE UPDATE OR DELETE ON audit_log + FOR EACH ROW EXECUTE FUNCTION forbid_update_delete(); diff --git a/internal/store/user.go b/internal/store/user.go index 9dfe27b..bb56fd9 100644 --- a/internal/store/user.go +++ b/internal/store/user.go @@ -75,3 +75,29 @@ func (s *Store) GetUser(ctx context.Context, id string) (User, error) { } return u, nil } + +// ListUsersForAccount liefert alle Logins eines Mandanten — für den +// Admin-Bereich (Account-Detailansicht). +func (s *Store) ListUsersForAccount(ctx context.Context, accountID string) ([]User, error) { + rows, err := s.Pool.Query(ctx, ` + SELECT id, account_id, email, password_hash, role, created_at + FROM app_user WHERE account_id = $1 ORDER BY created_at + `, accountID) + if err != nil { + return nil, fmt.Errorf("store: list users for account: %w", err) + } + defer rows.Close() + + var out []User + for rows.Next() { + var u User + if err := rows.Scan(&u.ID, &u.AccountID, &u.Email, &u.PasswordHash, &u.Role, &u.CreatedAt); err != nil { + return nil, fmt.Errorf("store: scan user: %w", err) + } + out = append(out, u) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("store: list users for account: %w", err) + } + return out, nil +} diff --git a/internal/web/admin_handlers.go b/internal/web/admin_handlers.go new file mode 100644 index 0000000..42afda1 --- /dev/null +++ b/internal/web/admin_handlers.go @@ -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) + } +} diff --git a/internal/web/admin_handlers_test.go b/internal/web/admin_handlers_test.go new file mode 100644 index 0000000..4d3e577 --- /dev/null +++ b/internal/web/admin_handlers_test.go @@ -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) + } +} diff --git a/internal/web/middleware.go b/internal/web/middleware.go index ee9b2e9..07a0545 100644 --- a/internal/web/middleware.go +++ b/internal/web/middleware.go @@ -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, diff --git a/internal/web/server.go b/internal/web/server.go index 03442bc..c886754 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -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 diff --git a/internal/web/server_test.go b/internal/web/server_test.go index c08916a..d4bb33e 100644 --- a/internal/web/server_test.go +++ b/internal/web/server_test.go @@ -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) } diff --git a/internal/web/static/app.css b/internal/web/static/app.css index 8bc32e6..35bb398 100644 --- a/internal/web/static/app.css +++ b/internal/web/static/app.css @@ -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) { diff --git a/internal/web/templates/admin_account_detail.html b/internal/web/templates/admin_account_detail.html new file mode 100644 index 0000000..e193115 --- /dev/null +++ b/internal/web/templates/admin_account_detail.html @@ -0,0 +1,39 @@ +{{define "admin-account-detail"}} + +{{template "head" .}} + +{{template "nav" .}} +
+

← Alle Accounts

+

{{.Name}}

+

Angelegt am {{.CreatedAt}}

+ +

Nutzer

+
    + {{range .Users}} +
  • +
    {{.Email}} ({{.Role}})
    +
  • + {{end}} +
+ +{{if .HasKanzlei}} +

Kanzlei-Verzeichnis

+{{if .Verified}} +

Status: verifiziert — im öffentlichen Verzeichnis gelistet

+
+ + +
+{{else}} +

Status: nicht verifiziert — noch nicht gelistet

+
+ + +
+{{end}} +{{end}} +
+ + +{{end}} diff --git a/internal/web/templates/admin_accounts.html b/internal/web/templates/admin_accounts.html new file mode 100644 index 0000000..f119d9b --- /dev/null +++ b/internal/web/templates/admin_accounts.html @@ -0,0 +1,30 @@ +{{define "admin-accounts"}} + +{{template "head" .}} + +{{template "nav" .}} +
+

← Admin

+

Accounts

+{{if not .Accounts}} +

Noch keine Accounts.

+{{else}} + +{{end}} +
+ + +{{end}} diff --git a/internal/web/templates/admin_audit_log.html b/internal/web/templates/admin_audit_log.html new file mode 100644 index 0000000..1e3d9d9 --- /dev/null +++ b/internal/web/templates/admin_audit_log.html @@ -0,0 +1,24 @@ +{{define "admin-audit-log"}} + +{{template "head" .}} + +{{template "nav" .}} +
+

← Admin

+

Audit-Log

+{{if not .Entries}} +

Noch keine Einträge.

+{{else}} +
    + {{range .Entries}} +
  • +
    {{.CreatedAt}} — {{.Action}} ({{.TargetType}} {{.TargetID}})
    + {{if .Details}}

    {{.Details}}

    {{end}} +
  • + {{end}} +
+{{end}} +
+ + +{{end}} diff --git a/internal/web/templates/admin_dashboard.html b/internal/web/templates/admin_dashboard.html new file mode 100644 index 0000000..42bf93d --- /dev/null +++ b/internal/web/templates/admin_dashboard.html @@ -0,0 +1,17 @@ +{{define "admin-dashboard"}} + +{{template "head" .}} + +{{template "nav" .}} + + + +{{end}} diff --git a/internal/web/templates/kanzleien.html b/internal/web/templates/kanzleien.html new file mode 100644 index 0000000..7340174 --- /dev/null +++ b/internal/web/templates/kanzleien.html @@ -0,0 +1,23 @@ +{{define "kanzleien"}} + +{{template "head" .}} + +
+

Kanzlei-Verzeichnis

+

+ Kostenloses Verzeichnis auf Deklarix verifizierter Kanzleien. Keine + Vermittlung, keine Empfehlung — die Auswahl trifft allein der Nutzer. +

+{{if not .Kanzleien}} +

Noch keine Kanzlei gelistet.

+{{else}} +
    + {{range .Kanzleien}} +
  • {{.Name}}
  • + {{end}} +
+{{end}} +
+ + +{{end}}