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:
@@ -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
|
||||
}
|
||||
|
||||
221
internal/store/admin_test.go
Normal file
221
internal/store/admin_test.go
Normal file
@@ -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")
|
||||
}
|
||||
}
|
||||
61
internal/store/auditlog.go
Normal file
61
internal/store/auditlog.go
Normal file
@@ -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
|
||||
}
|
||||
6
internal/store/migrations/0004_admin.down.sql
Normal file
6
internal/store/migrations/0004_admin.down.sql
Normal file
@@ -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'));
|
||||
36
internal/store/migrations/0004_admin.up.sql
Normal file
36
internal/store/migrations/0004_admin.up.sql
Normal file
@@ -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();
|
||||
@@ -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
|
||||
}
|
||||
|
||||
213
internal/web/admin_handlers.go
Normal file
213
internal/web/admin_handlers.go
Normal 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)
|
||||
}
|
||||
}
|
||||
176
internal/web/admin_handlers_test.go
Normal file
176
internal/web/admin_handlers_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
39
internal/web/templates/admin_account_detail.html
Normal file
39
internal/web/templates/admin_account_detail.html
Normal 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">← 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}}
|
||||
30
internal/web/templates/admin_accounts.html
Normal file
30
internal/web/templates/admin_accounts.html
Normal 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">← 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}}
|
||||
24
internal/web/templates/admin_audit_log.html
Normal file
24
internal/web/templates/admin_audit_log.html
Normal 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">← 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}}
|
||||
17
internal/web/templates/admin_dashboard.html
Normal file
17
internal/web/templates/admin_dashboard.html
Normal 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}}
|
||||
23
internal/web/templates/kanzleien.html
Normal file
23
internal/web/templates/kanzleien.html
Normal 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}}
|
||||
Reference in New Issue
Block a user