feat(compliance): P-01c Admin-konfigurierbare Rechtstexte (0.8.71)
Some checks failed
Deploy Development / deploy (push) Successful in 46s
Test Suite / pytest-backend (push) Failing after 2s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 7s
Test Suite / playwright-tests (push) Successful in 50s

DB 047: legal_documents (versioniert, draft/published/archived) +
legal_document_audit (Änderungslog); Partial-Unique-Index garantiert
max. ein published-Dokument pro document_type.

Backend: GET /api/legal-documents/{type}/published (kein Auth);
Superadmin-CRUD + Publish/Archive + Audit unter /api/admin/legal-documents.

Frontend: LegalPage lädt aus API mit Platzhalter-Fallback;
AdminLegalDocumentsPage (/admin/legal-documents) mit Tab-Navigation,
Versionsliste, Entwurf-Editor, Publish/Archive-Workflow, Änderungslog.
AdminPageNav: Link „Rechtstexte" ergänzt.

version: 0.8.71 (backend + frontend)
module:  legal_documents 1.0.0

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Lars 2026-05-10 11:53:07 +02:00
parent 8bed0199b6
commit 80936b226d
14 changed files with 1184 additions and 47 deletions

View File

@ -193,7 +193,7 @@ def read_root():
return out
# Register routers
from routers import auth, profiles, exercises, exercise_progression_graphs, clubs, club_memberships, club_join_requests, admin_users, platform_media_storage, media_assets, skills, training_planning, training_framework_programs, catalogs, maturity_models, matrix_stack_bundle, import_wiki, import_wiki_admin
from routers import auth, profiles, exercises, exercise_progression_graphs, clubs, club_memberships, club_join_requests, admin_users, platform_media_storage, media_assets, skills, training_planning, training_framework_programs, catalogs, maturity_models, matrix_stack_bundle, import_wiki, import_wiki_admin, legal_documents
app.include_router(auth.router)
app.include_router(profiles.router)
@ -213,6 +213,7 @@ app.include_router(maturity_models.router)
app.include_router(matrix_stack_bundle.router)
app.include_router(import_wiki.router)
app.include_router(import_wiki_admin.router)
app.include_router(legal_documents.router)
# Lokale Übungs-Medien: standardmäßig nur über geschützten API-Pfad
# GET /api/exercises/{id}/media/{mid}/file (?ssetoken für <img>/<video>).

View File

@ -0,0 +1,37 @@
-- Migration 047: Admin-konfigurierbare Rechtstexte
-- Tabellen: legal_documents (versioniert), legal_document_audit (Änderungslog)
-- document_type: impressum | privacy_policy | terms_of_use | media_policy
-- status: draft | published | archived
-- Partial unique index: nur genau ein published-Dokument pro document_type erlaubt.
CREATE TABLE IF NOT EXISTS legal_documents (
id SERIAL PRIMARY KEY,
document_type VARCHAR(50) NOT NULL
CHECK (document_type IN ('impressum', 'privacy_policy', 'terms_of_use', 'media_policy')),
version INT NOT NULL DEFAULT 1,
title VARCHAR(255) NOT NULL,
content_sections JSONB NOT NULL DEFAULT '[]',
status VARCHAR(20) NOT NULL DEFAULT 'draft'
CHECK (status IN ('draft', 'published', 'archived')),
change_note TEXT,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW(),
created_by_profile_id INT REFERENCES profiles(id) ON DELETE SET NULL,
published_by_profile_id INT REFERENCES profiles(id) ON DELETE SET NULL,
published_at TIMESTAMP
);
-- Sicherstellt: pro document_type maximal ein published-Datensatz
CREATE UNIQUE INDEX IF NOT EXISTS legal_documents_unique_published
ON legal_documents (document_type)
WHERE status = 'published';
CREATE TABLE IF NOT EXISTS legal_document_audit (
id SERIAL PRIMARY KEY,
legal_document_id INT NOT NULL REFERENCES legal_documents(id) ON DELETE CASCADE,
action VARCHAR(50) NOT NULL,
changed_by_profile_id INT REFERENCES profiles(id) ON DELETE SET NULL,
change_note TEXT,
previous_status VARCHAR(20),
created_at TIMESTAMP DEFAULT NOW()
);

View File

@ -0,0 +1,408 @@
"""
Rechtstexte (Impressum, Datenschutz, Nutzungsbedingungen, Medienrichtlinie).
Öffentlich (kein Auth):
GET /api/legal-documents/{document_type}/published
Superadmin only:
GET /api/admin/legal-documents Liste aller Versionen
POST /api/admin/legal-documents Neuen Entwurf anlegen
GET /api/admin/legal-documents/{id} Einzelnes Dokument
PUT /api/admin/legal-documents/{id} Entwurf bearbeiten
POST /api/admin/legal-documents/{id}/publish Veröffentlichen
POST /api/admin/legal-documents/{id}/archive Archivieren
GET /api/admin/legal-documents/{id}/audit Änderungslog
"""
from typing import Any, Dict, List, Optional
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from auth import require_auth
from club_tenancy import is_superadmin
from db import get_db, get_cursor, r2d
router = APIRouter(tags=["legal_documents"])
VALID_TYPES = {"impressum", "privacy_policy", "terms_of_use", "media_policy"}
def _require_superadmin(session: dict):
role = (session.get("role") or "").lower()
if not is_superadmin(role):
raise HTTPException(status_code=403, detail="Nur Superadmins")
return session
class LegalDocumentCreate(BaseModel):
document_type: str
title: str
content_sections: List[Dict[str, Any]] = []
change_note: Optional[str] = None
class LegalDocumentUpdate(BaseModel):
title: Optional[str] = None
content_sections: Optional[List[Dict[str, Any]]] = None
change_note: Optional[str] = None
class PublishRequest(BaseModel):
change_note: Optional[str] = None
# ─── Public endpoint ────────────────────────────────────────────────────────
@router.get("/api/legal-documents/{document_type}/published")
def get_published_legal_document(document_type: str):
"""Liefert das aktuell veröffentlichte Dokument oder null. Kein Auth erforderlich."""
if document_type not in VALID_TYPES:
raise HTTPException(status_code=404, detail="Unbekannter Dokumententyp")
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"""
SELECT id, document_type, version, title, content_sections,
status, change_note, published_at, updated_at
FROM legal_documents
WHERE document_type = %s AND status = 'published'
LIMIT 1
""",
(document_type,),
)
row = cur.fetchone()
if not row:
return None
doc = r2d(row)
return doc
# ─── Superadmin endpoints ────────────────────────────────────────────────────
@router.get("/api/admin/legal-documents")
def list_legal_documents(
document_type: Optional[str] = None,
session: dict = Depends(require_auth),
):
"""Alle Versionen aller Rechtstexte (oder gefiltert nach document_type)."""
_require_superadmin(session)
with get_db() as conn:
cur = get_cursor(conn)
if document_type:
cur.execute(
"""
SELECT d.id, d.document_type, d.version, d.title, d.status,
d.change_note, d.created_at, d.updated_at, d.published_at,
p.name AS created_by_name
FROM legal_documents d
LEFT JOIN profiles p ON p.id = d.created_by_profile_id
WHERE d.document_type = %s
ORDER BY d.document_type, d.version DESC
""",
(document_type,),
)
else:
cur.execute(
"""
SELECT d.id, d.document_type, d.version, d.title, d.status,
d.change_note, d.created_at, d.updated_at, d.published_at,
p.name AS created_by_name
FROM legal_documents d
LEFT JOIN profiles p ON p.id = d.created_by_profile_id
ORDER BY d.document_type, d.version DESC
"""
)
rows = cur.fetchall()
return [r2d(r) for r in rows]
@router.post("/api/admin/legal-documents", status_code=201)
def create_legal_document(
body: LegalDocumentCreate,
session: dict = Depends(require_auth),
):
"""Neuen Entwurf anlegen. Versionsnummer = max(vorherige) + 1."""
_require_superadmin(session)
if body.document_type not in VALID_TYPES:
raise HTTPException(status_code=422, detail="Ungültiger document_type")
profile_id = session["profile_id"]
import json as _json
sections_json = _json.dumps(body.content_sections, ensure_ascii=False)
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"SELECT COALESCE(MAX(version), 0) FROM legal_documents WHERE document_type = %s",
(body.document_type,),
)
row = cur.fetchone()
next_version = list(row.values())[0] + 1
cur.execute(
"""
INSERT INTO legal_documents
(document_type, version, title, content_sections, status, change_note, created_by_profile_id)
VALUES (%s, %s, %s, %s::jsonb, 'draft', %s, %s)
RETURNING id, document_type, version, title, content_sections,
status, change_note, created_at, updated_at
""",
(body.document_type, next_version, body.title, sections_json, body.change_note, profile_id),
)
new_row = cur.fetchone()
new_id = list(new_row.values())[0]
cur.execute(
"""
INSERT INTO legal_document_audit (legal_document_id, action, changed_by_profile_id, change_note)
VALUES (%s, 'created', %s, %s)
""",
(new_id, profile_id, body.change_note),
)
conn.commit()
return r2d(new_row)
@router.get("/api/admin/legal-documents/{doc_id}")
def get_legal_document(doc_id: int, session: dict = Depends(require_auth)):
"""Einzelnes Dokument (alle Felder inkl. content_sections)."""
_require_superadmin(session)
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"""
SELECT d.id, d.document_type, d.version, d.title, d.content_sections,
d.status, d.change_note, d.created_at, d.updated_at,
d.published_at, d.created_by_profile_id,
p.name AS created_by_name,
pb.name AS published_by_name
FROM legal_documents d
LEFT JOIN profiles p ON p.id = d.created_by_profile_id
LEFT JOIN profiles pb ON pb.id = d.published_by_profile_id
WHERE d.id = %s
""",
(doc_id,),
)
row = cur.fetchone()
if not row:
raise HTTPException(status_code=404, detail="Dokument nicht gefunden")
return r2d(row)
@router.put("/api/admin/legal-documents/{doc_id}")
def update_legal_document(
doc_id: int,
body: LegalDocumentUpdate,
session: dict = Depends(require_auth),
):
"""Entwurf bearbeiten. Nur status='draft' kann bearbeitet werden."""
_require_superadmin(session)
profile_id = session["profile_id"]
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"SELECT id, status, title, content_sections, change_note FROM legal_documents WHERE id = %s",
(doc_id,),
)
row = cur.fetchone()
if not row:
raise HTTPException(status_code=404, detail="Dokument nicht gefunden")
doc = r2d(row)
if doc["status"] != "draft":
raise HTTPException(status_code=409, detail="Nur Entwürfe können bearbeitet werden")
import json as _json
new_title = body.title if body.title is not None else doc["title"]
new_sections = body.content_sections if body.content_sections is not None else doc["content_sections"]
new_note = body.change_note if body.change_note is not None else doc["change_note"]
sections_json = _json.dumps(new_sections, ensure_ascii=False)
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"""
UPDATE legal_documents
SET title = %s, content_sections = %s::jsonb, change_note = %s, updated_at = NOW()
WHERE id = %s
RETURNING id, document_type, version, title, content_sections,
status, change_note, created_at, updated_at
""",
(new_title, sections_json, new_note, doc_id),
)
updated = cur.fetchone()
cur.execute(
"""
INSERT INTO legal_document_audit (legal_document_id, action, changed_by_profile_id, change_note)
VALUES (%s, 'updated', %s, %s)
""",
(doc_id, profile_id, new_note),
)
conn.commit()
return r2d(updated)
@router.post("/api/admin/legal-documents/{doc_id}/publish")
def publish_legal_document(
doc_id: int,
body: PublishRequest = PublishRequest(),
session: dict = Depends(require_auth),
):
"""
Veröffentlicht dieses Dokument. Ein bisher veröffentlichtes Dokument desselben
Typs wird automatisch auf 'archived' gesetzt (partial unique index erzwingt dies).
"""
_require_superadmin(session)
profile_id = session["profile_id"]
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"SELECT id, document_type, status FROM legal_documents WHERE id = %s",
(doc_id,),
)
row = cur.fetchone()
if not row:
raise HTTPException(status_code=404, detail="Dokument nicht gefunden")
doc = r2d(row)
if doc["status"] == "published":
raise HTTPException(status_code=409, detail="Dokument ist bereits veröffentlicht")
if doc["status"] == "archived":
raise HTTPException(status_code=409, detail="Archivierte Dokumente können nicht veröffentlicht werden")
with get_db() as conn:
cur = get_cursor(conn)
# Vorheriges published-Dokument desselben Typs archivieren
cur.execute(
"""
UPDATE legal_documents
SET status = 'archived', updated_at = NOW()
WHERE document_type = %s AND status = 'published' AND id != %s
""",
(doc["document_type"], doc_id),
)
# Dieses Dokument veröffentlichen
cur.execute(
"""
UPDATE legal_documents
SET status = 'published', published_at = NOW(),
published_by_profile_id = %s, updated_at = NOW()
WHERE id = %s
RETURNING id, document_type, version, title, content_sections,
status, change_note, published_at, updated_at
""",
(profile_id, doc_id),
)
updated = cur.fetchone()
cur.execute(
"""
INSERT INTO legal_document_audit
(legal_document_id, action, changed_by_profile_id, change_note, previous_status)
VALUES (%s, 'published', %s, %s, %s)
""",
(doc_id, profile_id, body.change_note, doc["status"]),
)
conn.commit()
return r2d(updated)
@router.post("/api/admin/legal-documents/{doc_id}/archive")
def archive_legal_document(
doc_id: int,
session: dict = Depends(require_auth),
):
"""Archiviert ein Dokument. Veröffentlichte Dokumente dürfen nur archiviert werden wenn kein anderes published ist."""
_require_superadmin(session)
profile_id = session["profile_id"]
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"SELECT id, status FROM legal_documents WHERE id = %s",
(doc_id,),
)
row = cur.fetchone()
if not row:
raise HTTPException(status_code=404, detail="Dokument nicht gefunden")
doc = r2d(row)
if doc["status"] == "archived":
raise HTTPException(status_code=409, detail="Dokument ist bereits archiviert")
prev_status = doc["status"]
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"""
UPDATE legal_documents
SET status = 'archived', updated_at = NOW()
WHERE id = %s
RETURNING id, document_type, version, title, status, updated_at
""",
(doc_id,),
)
updated = cur.fetchone()
cur.execute(
"""
INSERT INTO legal_document_audit
(legal_document_id, action, changed_by_profile_id, previous_status)
VALUES (%s, 'archived', %s, %s)
""",
(doc_id, profile_id, prev_status),
)
conn.commit()
return r2d(updated)
@router.get("/api/admin/legal-documents/{doc_id}/audit")
def get_legal_document_audit(doc_id: int, session: dict = Depends(require_auth)):
"""Änderungslog für ein Dokument."""
_require_superadmin(session)
with get_db() as conn:
cur = get_cursor(conn)
cur.execute("SELECT id FROM legal_documents WHERE id = %s", (doc_id,))
if not cur.fetchone():
raise HTTPException(status_code=404, detail="Dokument nicht gefunden")
cur.execute(
"""
SELECT a.id, a.action, a.change_note, a.previous_status, a.created_at,
p.name AS changed_by_name
FROM legal_document_audit a
LEFT JOIN profiles p ON p.id = a.changed_by_profile_id
WHERE a.legal_document_id = %s
ORDER BY a.created_at DESC
""",
(doc_id,),
)
rows = cur.fetchall()
return [r2d(r) for r in rows]

View File

@ -1,10 +1,11 @@
# Shinkan Jinkendo Version Information
APP_VERSION = "0.8.70"
APP_VERSION = "0.8.71"
BUILD_DATE = "2026-05-10"
DB_SCHEMA_VERSION = "20260508049"
DB_SCHEMA_VERSION = "20260510047"
MODULE_VERSIONS = {
"legal_documents": "1.0.0", # P-01c: Admin-konfigurierbare Rechtstexte (legal_documents + legal_document_audit)
"auth": "1.2.3", # P-05b: reset-password min_length=8 via Pydantic PasswordResetConfirm
"profiles": "1.7.0", # exercise_list_prefs JSONB (Standard Übungsfilter); Patch via ProfileUpdate + Json()
"tenant_context": "1.0.5", # Plattform-Admin: effective_club ohne Header aus Profil active_club_id wenn Verein existiert
@ -29,6 +30,13 @@ MODULE_VERSIONS = {
}
CHANGELOG = [
{
"version": "0.8.71",
"date": "2026-05-10",
"changes": [
"Compliance P-01c: Admin-konfigurierbare Rechtstexte — DB 047 (legal_documents + legal_document_audit); Superadmin CRUD + Publish/Archive-Workflow; LegalPage laedt aus API mit Platzhalter-Fallback; AdminLegalDocumentsPage unter /admin/legal-documents",
],
},
{
"version": "0.8.70",
"date": "2026-05-10",

View File

@ -3,7 +3,7 @@
**Erstellt:** 2026-05-09
**Zuletzt aktualisiert:** 2026-05-10
**Audit-Basis:** `docs/compliance-audit.md`
**App-Version nach Umsetzung:** 0.8.70
**App-Version nach Umsetzung:** 0.8.71
---
@ -67,6 +67,50 @@ Neue Seite `/settings/legal` im eingeloggten Einstellungsbereich: Hub-Seite mit
---
### P-01c Admin-konfigurierbare Rechtstexte ✅
**Status:** Umgesetzt (2026-05-10, Version 0.8.71) — Nacharbeit zu P-01
**Betroffene Dateien:**
- `backend/migrations/047_legal_documents.sql` (neu) — Tabellen `legal_documents` + `legal_document_audit`
- `backend/routers/legal_documents.py` (neu) — Öffentliche + Superadmin-Endpoints
- `backend/main.py` — Router-Registrierung
- `frontend/src/pages/LegalPage.jsx` — API-Fetch mit Fallback auf statischen Platzhalter
- `frontend/src/pages/AdminLegalDocumentsPage.jsx` (neu) — Superadmin-UI
- `frontend/src/App.jsx` — Route `/admin/legal-documents`
- `frontend/src/components/AdminPageNav.jsx` — Link „Rechtstexte" im Admin-Nav
- `frontend/src/utils/api.js` — 8 neue API-Funktionen
**Technische Änderung:**
**Datenbank (Migration 047):**
Tabelle `legal_documents`: versionierte Rechtstexte mit Workflow `draft → published → archived`. Felder: `document_type` (impressum | privacy_policy | terms_of_use | media_policy), `version` (INT, auto-inkrementiert), `title`, `content_sections` (JSONB: `[{heading, content}]`), `status`, `change_note`, Timestamps, FK auf Ersteller + Publisher. Partial-Unique-Index: nur ein `published`-Datensatz pro `document_type` gleichzeitig. Tabelle `legal_document_audit`: unveränderlicher Änderungslog je Dokument.
**Backend-Endpoints:**
| Endpoint | Auth | Beschreibung |
|----------|------|--------------|
| `GET /api/legal-documents/{type}/published` | Kein | Liefert veröffentlichtes Dokument oder `null` |
| `GET /api/admin/legal-documents` | Superadmin | Alle Versionen aller Typen |
| `POST /api/admin/legal-documents` | Superadmin | Neuen Entwurf anlegen |
| `GET /api/admin/legal-documents/{id}` | Superadmin | Einzeldokument mit `content_sections` |
| `PUT /api/admin/legal-documents/{id}` | Superadmin | Entwurf bearbeiten (nur `status=draft`) |
| `POST /api/admin/legal-documents/{id}/publish` | Superadmin | Veröffentlichen; bisherige Version → `archived` |
| `POST /api/admin/legal-documents/{id}/archive` | Superadmin | Archivieren |
| `GET /api/admin/legal-documents/{id}/audit` | Superadmin | Änderungslog |
**Frontend:**
`LegalPage.jsx` ruft beim Laden `GET /api/legal-documents/{type}/published` ab. Gibt die API `null` zurück (kein veröffentlichtes Dokument vorhanden), zeigt die Seite weiterhin den bisherigen Platzhalter mit MUSTER-Banner. Ist ein Dokument veröffentlicht, wird dessen Inhalt ohne Platzhalter-Banner angezeigt. `AdminLegalDocumentsPage.jsx` unter `/admin/legal-documents` (nur Superadmin) ermöglicht Erstellen, Bearbeiten, Veröffentlichen und Archivieren von Entwürfen mit Tabs pro Dokumententyp und Änderungslog.
**Kein neues npm-Paket notwendig** — JSONB-Struktur mit `{heading, content}` statt Markdown; keine XSS-Gefahr.
**Tests:** 3 Playwright-Tests:
- Rechtstextseiten laden ohne Fehler (API-fetch mit Fallback)
- `/admin/legal-documents` erreichbar für Superadmin mit korrekter Überschrift
- Admin-Nav enthält Link zu Rechtstexten
---
### P-03 Papierkorb-Retention-Job aktivieren ✅
**Status:** Umgesetzt

View File

@ -3,7 +3,7 @@
**Typ:** Kanonisches Referenzdokument
**Erstellt:** 2026-05-10
**Basisdokument:** `docs/compliance-audit.md` (Initial-Audit 2026-05-09, App-Version 0.8.65)
**Letzte Aktualisierung:** 2026-05-10 (App-Version 0.8.70)
**Letzte Aktualisierung:** 2026-05-10 (App-Version 0.8.71)
---
@ -32,9 +32,9 @@
| **Findings** | KRIT-01 |
| **Etappe** | 1 |
| **Status** | ⚠️ partially implemented |
| **Letzter Stand** | Technischer Teil umgesetzt (2026-05-10, Version 0.8.70, inkl. P-01b): Routen `/impressum`, `/datenschutz`, `/nutzungsbedingungen`, `/medienrichtlinie` öffentlich erreichbar ohne Auth. Platzhalterseiten mit strukturierten Pflichtfeldern und sichtbarem Muster-Hinweis. Links in LoginPage, DesktopSidebar und `/settings/legal` (Mobile/PWA-Erreichbarkeit via Einstellungen → Rechtliches, P-01b). **Juristisch geprüfte Inhalte fehlen noch — Betreiber + Rechtsanwalt erforderlich. KRIT-01 bleibt offen.** |
| **Verweise** | `docs/compliance-audit.md` §14.2, §17, §19.1; `docs/compliance-implementation.md` §P-01, §P-01b; `frontend/src/pages/LegalPage.jsx`; `frontend/src/pages/SettingsLegalPage.jsx` |
| **Hinweise** | P-01b (Mobile-Erreichbarkeit) als Suffix-Paket ergänzt und vollständig umgesetzt. Keine Nummerierungsabweichung. Scope Drift ausgeschlossen. |
| **Letzter Stand** | Technischer Teil umgesetzt (2026-05-10, Version 0.8.71, inkl. P-01b + P-01c): Routen `/impressum`, `/datenschutz`, `/nutzungsbedingungen`, `/medienrichtlinie` öffentlich erreichbar ohne Auth. Platzhalterseiten mit strukturierten Pflichtfeldern und sichtbarem Muster-Hinweis. Links in LoginPage, DesktopSidebar und `/settings/legal` (Mobile/PWA, P-01b). Admin-konfigurierbare Rechtstexte mit Versionierung + Publish-Workflow (DB 047, Superadmin-UI `/admin/legal-documents`, P-01c). **Juristisch geprüfte Inhalte fehlen noch — Betreiber + Rechtsanwalt erforderlich. KRIT-01 bleibt offen bis zur Veröffentlichung echter Texte.** |
| **Verweise** | `docs/compliance-audit.md` §14.2, §17, §19.1; `docs/compliance-implementation.md` §P-01, §P-01b, §P-01c; `frontend/src/pages/LegalPage.jsx`; `frontend/src/pages/SettingsLegalPage.jsx`; `frontend/src/pages/AdminLegalDocumentsPage.jsx`; `backend/routers/legal_documents.py`; `backend/migrations/047_legal_documents.sql` |
| **Hinweise** | P-01b (Mobile-Erreichbarkeit) und P-01c (Admin-konfigurierbar) als Suffix-Pakete ergänzt und vollständig umgesetzt. Keine Nummerierungsabweichung. Scope Drift ausgeschlossen. |
---

View File

@ -2,7 +2,7 @@
**Typ:** Lebendes Steuerungsdokument
**Erstellt:** 2026-05-10
**App-Version:** 0.8.70
**App-Version:** 0.8.71
**Zuletzt aktualisiert:** 2026-05-10
---
@ -29,7 +29,7 @@ Diese Roadmap ist nach jedem Re-Audit zu aktualisieren. Abweichungen von der bis
## 2. Aktueller Stand (2026-05-10)
### App-Version: 0.8.70
### App-Version: 0.8.71
### Teilweise umgesetzte Pakete
@ -51,8 +51,9 @@ Diese Roadmap ist nach jedem Re-Audit zu aktualisieren. Abweichungen von der bis
| P-23 | LoginPage: minLength angleichen + Versionsstring entfernen | 0.8.66 |
| P-24 | CORS einschränken (Methoden + Header) | 0.8.66 |
| P-01b | _Nacharbeit:_ Mobile/PWA-Erreichbarkeit Rechtstexte via `/settings/legal` | 0.8.70 |
| P-01c | _Nacharbeit:_ Admin-konfigurierbare Rechtstexte (DB 047, Superadmin-UI, API) | 0.8.71 |
**Vollständig abgeschlossen:** 7 Hauptpakete + 3 Nacharbeiten = 10 Umsetzungseinheiten
**Vollständig abgeschlossen:** 7 Hauptpakete + 4 Nacharbeiten = 11 Umsetzungseinheiten
**Teilweise umgesetzt (technisch):** P-01
### Offene Pakete (16)
@ -82,9 +83,9 @@ Die folgenden Pakete sind vor der Freigabe für allgemeine öffentliche Registri
### Blocker 1 — P-01: Rechtstexte
**Finding:** KRIT-01 (Ordnungswidrigkeit, § 5 DDG)
**Technischer Stand:** ⚠️ Routen und Platzhalterseiten vorhanden (Version 0.8.69); Mobile/PWA-Erreichbarkeit via `/settings/legal` ergänzt (P-01b, Version 0.8.70). Juristische Inhalte fehlen noch.
**Warum noch offen:** Impressum, Datenschutzerklärung und AGB ohne geprüfte Inhalte genügen der gesetzlichen Pflicht nicht. Die Platzhalterseiten machen die Lücke sichtbar, schließen sie aber nicht.
**Nächster Schritt:** Rechtsanwalt beauftragt werden → Inhalte durch Betreiber ergänzen → Platzhaltermarkierung entfernen.
**Technischer Stand:** ⚠️ Routen und Platzhalterseiten vorhanden (Version 0.8.69); Mobile/PWA-Erreichbarkeit via `/settings/legal` ergänzt (P-01b, Version 0.8.70); Admin-konfigurierbare Rechtstexte mit DB-Versionierung und Superadmin-UI implementiert (P-01c, Version 0.8.71). Juristische Inhalte fehlen noch.
**Warum noch offen:** Impressum, Datenschutzerklärung und AGB ohne geprüfte Inhalte genügen der gesetzlichen Pflicht nicht. Technisch kann der Superadmin jetzt Inhalte über `/admin/legal-documents` einpflegen — sobald juristisch geprüfte Texte vorliegen, werden diese veröffentlicht und die Platzhaltermarkierung verschwindet automatisch.
**Nächster Schritt:** Rechtsanwalt beauftragt werden → Inhalte durch Betreiber über Admin-UI einpflegen → Veröffentlichen → Platzhaltermarkierung verschwindet automatisch.
### Blocker 2 — P-06: Upload-Einwilligungsdialog
@ -214,7 +215,7 @@ Erst wenn diese Fragen beantwortet und als Spec dokumentiert sind, wird P-02 in
## 6. Nächste empfohlene Freigabe
**P-01 + P-01b technisch umgesetzt (Version 0.8.70).** Rechtstexte über Login, Desktop-Sidebar und Einstellungen → Rechtliches (Mobile/PWA) erreichbar. Juristische Inhalte bleiben offen.
**P-01 + P-01b + P-01c technisch umgesetzt (Version 0.8.71).** Rechtstexte über Login, Desktop-Sidebar und Einstellungen → Rechtliches (Mobile/PWA) erreichbar. Superadmin kann jetzt über `/admin/legal-documents` versionierte Rechtstexte anlegen und veröffentlichen — sobald Inhalte eingepflegt sind, verschwindet der Platzhalter-Banner automatisch. Juristische Inhalte bleiben offen.
Die nächste technische Freigabe sollte **Etappe B** umfassen — beginnend mit dem kleinsten Paket:
@ -225,7 +226,7 @@ oder als Paket:
> „Freigabe zur Umsetzung Etappe B: P-06, P-11, P-13"
**Parallel dazu Betreiber-Aufgabe:**
Rechtsanwalt für die Ausarbeitung der Inhalte von P-01 beauftragen — dies ist eine Betreiber-Aufgabe, keine weitere technische Freigabe.
Rechtsanwalt für die Ausarbeitung der Inhalte von P-01 beauftragen — Inhalte über `/admin/legal-documents` einpflegen und veröffentlichen. Dies ist eine Betreiber-Aufgabe, keine weitere technische Freigabe.
**Nach P-01 vollständig (Inhalte eingepflegt):** Gate 1 rückt näher — verbleibende Blocker: P-06, P-02 (Spez.), P-11, P-13, P-08 (Betreiber).
@ -343,6 +344,7 @@ Diese Punkte liegen außerhalb des Code-Scopes und erfordern organisatorische Ma
| ~~P-12 allein~~ | ~~„Freigabe zur Umsetzung P-12: sessionStorage bei Logout bereinigen"~~ | ✅ historisch abgeschlossen (Version 0.8.68) |
| ~~P-01 technisch~~ | ~~„Freigabe zur Umsetzung P-01: Rechtstexte technisch anlegen"~~ | ✅ historisch abgeschlossen (Version 0.8.69) |
| ~~P-01b~~ | ~~„Freigabe zur Umsetzung P-01b: Mobile/PWA-Zugriff auf Rechtliches"~~ | ✅ historisch abgeschlossen (Version 0.8.70) |
| ~~P-01c~~ | ~~„Freigabe zur Umsetzung P-01c: Admin-konfigurierbare Rechtstexte"~~ | ✅ historisch abgeschlossen (Version 0.8.71) |
| **P-06 (empfohlen)** | **„Freigabe zur Umsetzung P-06: Upload-Einwilligungsdialog"** | ⬅ nächste empfohlene Freigabe |
| Etappe B komplett | „Freigabe zur Umsetzung Etappe B: P-06, P-11, P-13" | offen |
| P-02 Spezifikation | „Freigabe zur Spezifikation P-02: DSGVO-Self-Service-Prozess" | offen |

View File

@ -38,6 +38,7 @@ import AdminHomeRedirect from './components/AdminHomeRedirect'
import PlatformAdminRoute from './components/PlatformAdminRoute'
import MediaLibraryPage from './pages/MediaLibraryPage'
import LegalPage from './pages/LegalPage'
import AdminLegalDocumentsPage from './pages/AdminLegalDocumentsPage'
import SettingsLegalPage from './pages/SettingsLegalPage'
import ActiveClubSwitcher from './components/ActiveClubSwitcher'
import InactiveMembershipBanner from './components/InactiveMembershipBanner'
@ -242,6 +243,14 @@ function AppRoutes() {
</PlatformAdminRoute>
}
/>
<Route
path="admin/legal-documents"
element={
<PlatformAdminRoute>
<AdminLegalDocumentsPage />
</PlatformAdminRoute>
}
/>
<Route path="trainer-contexts" element={<TrainerContextsPage />} />
</Route>

View File

@ -1,5 +1,5 @@
import { NavLink } from 'react-router-dom'
import { TreePine, FolderTree, Download, Grid3x3, Users } from 'lucide-react'
import { TreePine, FolderTree, Download, Grid3x3, Users, Scale } from 'lucide-react'
/**
* Admin-Seiten-Navigation (horizontal) nur für Super-Admins (globaler Portal-Mandant).
@ -11,6 +11,7 @@ export default function AdminPageNav() {
{ to: '/admin/maturity-models', label: 'Fähigkeitsmatrix', icon: Grid3x3 },
{ to: '/admin/catalogs', label: 'Kataloge', icon: FolderTree },
{ to: '/admin/mediawiki-import', label: 'Wiki-Import', icon: Download },
{ to: '/admin/legal-documents', label: 'Rechtstexte', icon: Scale },
]
return (

View File

@ -0,0 +1,517 @@
import { useState, useEffect, useCallback } from 'react'
import { FileText, Plus, Eye, Edit2, Archive, CheckCircle, Clock, ChevronDown, ChevronUp } from 'lucide-react'
import api from '../utils/api'
const DOC_TYPES = [
{ key: 'impressum', label: 'Impressum', defaultTitle: 'Impressum' },
{ key: 'privacy_policy', label: 'Datenschutz', defaultTitle: 'Datenschutzerklärung' },
{ key: 'terms_of_use', label: 'Nutzungsbedingungen', defaultTitle: 'Nutzungsbedingungen' },
{ key: 'media_policy', label: 'Medienrichtlinie', defaultTitle: 'Medienrichtlinie' },
]
const STATUS_LABELS = {
draft: { label: 'Entwurf', color: 'var(--text3)' },
published: { label: 'Veröffentlicht', color: 'var(--accent)' },
archived: { label: 'Archiviert', color: 'var(--danger)' },
}
function StatusBadge({ status }) {
const s = STATUS_LABELS[status] || { label: status, color: 'var(--text3)' }
return (
<span style={{
fontSize: '0.75rem',
fontWeight: 600,
color: s.color,
border: `1px solid ${s.color}`,
borderRadius: '4px',
padding: '1px 6px',
}}>
{s.label}
</span>
)
}
function SectionEditor({ sections, onChange }) {
const addSection = () => onChange([...sections, { heading: '', content: '' }])
const removeSection = (i) => onChange(sections.filter((_, idx) => idx !== i))
const update = (i, field, val) => {
const next = sections.map((s, idx) => idx === i ? { ...s, [field]: val } : s)
onChange(next)
}
return (
<div>
{sections.map((sec, i) => (
<div key={i} className="card" style={{ marginBottom: '0.75rem', padding: '12px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.5rem' }}>
<span style={{ fontSize: '0.8rem', color: 'var(--text3)' }}>Abschnitt {i + 1}</span>
<button
type="button"
className="btn btn-secondary"
style={{ fontSize: '0.75rem', padding: '2px 8px' }}
onClick={() => removeSection(i)}
>
Entfernen
</button>
</div>
<div className="form-row">
<label className="form-label">Überschrift</label>
<input
type="text"
className="form-input"
value={sec.heading}
onChange={e => update(i, 'heading', e.target.value)}
placeholder="Abschnittsüberschrift"
/>
</div>
<div className="form-row">
<label className="form-label">Inhalt</label>
<textarea
className="form-input"
rows={4}
value={sec.content}
onChange={e => update(i, 'content', e.target.value)}
placeholder="Textinhalt des Abschnitts"
/>
</div>
</div>
))}
<button type="button" className="btn btn-secondary" onClick={addSection} style={{ width: '100%' }}>
+ Abschnitt hinzufügen
</button>
</div>
)
}
function DocTypeTab({ docType, active, onClick }) {
return (
<button
className={`btn ${active ? 'btn-primary' : 'btn-secondary'}`}
onClick={onClick}
style={{ minWidth: '130px' }}
>
{docType.label}
</button>
)
}
function DocumentRow({ doc, onPublish, onArchive, onEdit, onViewAudit }) {
return (
<div
className="card"
style={{
display: 'flex',
alignItems: 'center',
gap: '1rem',
padding: '12px 16px',
marginBottom: '0.5rem',
}}
>
<div style={{ flex: 1 }}>
<div style={{ fontWeight: 600, marginBottom: '2px' }}>
{doc.title} <span style={{ color: 'var(--text3)', fontWeight: 400, fontSize: '0.85rem' }}>v{doc.version}</span>
</div>
<div style={{ display: 'flex', gap: '0.75rem', alignItems: 'center', flexWrap: 'wrap' }}>
<StatusBadge status={doc.status} />
{doc.published_at && (
<span style={{ fontSize: '0.78rem', color: 'var(--text3)' }}>
veröffentlicht {new Date(doc.published_at).toLocaleDateString('de-DE')}
</span>
)}
{doc.change_note && (
<span style={{ fontSize: '0.78rem', color: 'var(--text3)', fontStyle: 'italic' }}>
{doc.change_note}
</span>
)}
</div>
</div>
<div style={{ display: 'flex', gap: '0.4rem', flexShrink: 0 }}>
{doc.status === 'draft' && (
<>
<button
className="btn btn-secondary"
style={{ padding: '4px 10px', fontSize: '0.78rem' }}
onClick={() => onEdit(doc)}
title="Bearbeiten"
>
<Edit2 size={13} />
</button>
<button
className="btn btn-primary"
style={{ padding: '4px 10px', fontSize: '0.78rem' }}
onClick={() => onPublish(doc)}
title="Veröffentlichen"
>
<CheckCircle size={13} />
</button>
</>
)}
{doc.status === 'published' && (
<button
className="btn btn-secondary"
style={{ padding: '4px 10px', fontSize: '0.78rem' }}
onClick={() => onArchive(doc)}
title="Archivieren"
>
<Archive size={13} />
</button>
)}
<button
className="btn btn-secondary"
style={{ padding: '4px 10px', fontSize: '0.78rem' }}
onClick={() => onViewAudit(doc)}
title="Änderungslog"
>
<Clock size={13} />
</button>
</div>
</div>
)
}
function EditForm({ docType, editDoc, onSaved, onCancel }) {
const [title, setTitle] = useState(editDoc ? editDoc.title : docType.defaultTitle)
const [sections, setSections] = useState([])
const [changeNote, setChangeNote] = useState('')
const [saving, setSaving] = useState(false)
const [error, setError] = useState(null)
const [loadingDoc, setLoadingDoc] = useState(!!editDoc)
useEffect(() => {
if (editDoc) {
api.getLegalDocument(editDoc.id)
.then(d => {
setTitle(d.title)
setSections(d.content_sections || [])
setChangeNote(d.change_note || '')
})
.catch(e => setError(e.message))
.finally(() => setLoadingDoc(false))
}
}, [editDoc])
const handleSave = async () => {
setSaving(true)
setError(null)
try {
const payload = { title, content_sections: sections, change_note: changeNote || null }
if (editDoc) {
await api.updateLegalDocument(editDoc.id, payload)
} else {
await api.createLegalDocument({ document_type: docType.key, ...payload })
}
onSaved()
} catch (e) {
setError(e.message)
} finally {
setSaving(false)
}
}
if (loadingDoc) return <div className="spinner" />
return (
<div className="card" style={{ marginTop: '1rem' }}>
<h3 style={{ marginBottom: '1rem' }}>
{editDoc ? 'Entwurf bearbeiten' : 'Neuen Entwurf erstellen'}
</h3>
{error && (
<div className="card" style={{ borderLeft: '4px solid var(--danger)', marginBottom: '1rem' }}>
<span style={{ color: 'var(--danger)' }}>{error}</span>
</div>
)}
<div className="form-row">
<label className="form-label">Titel *</label>
<input
type="text"
className="form-input"
value={title}
onChange={e => setTitle(e.target.value)}
required
/>
</div>
<div className="form-row">
<label className="form-label">Abschnitte</label>
<SectionEditor sections={sections} onChange={setSections} />
</div>
<div className="form-row">
<label className="form-label">Änderungsnotiz (optional)</label>
<input
type="text"
className="form-input"
value={changeNote}
onChange={e => setChangeNote(e.target.value)}
placeholder="z. B. Erste Version nach juristischer Prüfung"
/>
</div>
<div style={{ display: 'flex', gap: '0.75rem', marginTop: '0.5rem' }}>
<button className="btn btn-primary" onClick={handleSave} disabled={saving || !title.trim()}>
{saving ? 'Speichern…' : 'Entwurf speichern'}
</button>
<button className="btn btn-secondary" onClick={onCancel}>
Abbrechen
</button>
</div>
</div>
)
}
function AuditLog({ docId, onClose }) {
const [entries, setEntries] = useState([])
const [loading, setLoading] = useState(true)
useEffect(() => {
api.getLegalDocumentAudit(docId)
.then(setEntries)
.catch(() => setEntries([]))
.finally(() => setLoading(false))
}, [docId])
const ACTION_LABELS = {
created: 'Erstellt',
updated: 'Bearbeitet',
published:'Veröffentlicht',
archived: 'Archiviert',
}
return (
<div className="card" style={{ marginTop: '1rem' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}>
<h3 style={{ margin: 0 }}>Änderungslog</h3>
<button className="btn btn-secondary" style={{ padding: '4px 10px', fontSize: '0.8rem' }} onClick={onClose}>
Schließen
</button>
</div>
{loading ? (
<div className="spinner" />
) : entries.length === 0 ? (
<p style={{ color: 'var(--text3)' }}>Keine Einträge.</p>
) : (
entries.map(e => (
<div key={e.id} style={{ padding: '8px 0', borderBottom: '1px solid var(--border)' }}>
<div style={{ display: 'flex', gap: '0.75rem', alignItems: 'center', flexWrap: 'wrap' }}>
<strong style={{ fontSize: '0.85rem' }}>{ACTION_LABELS[e.action] || e.action}</strong>
{e.previous_status && (
<span style={{ fontSize: '0.78rem', color: 'var(--text3)' }}>von {e.previous_status}</span>
)}
<span style={{ fontSize: '0.78rem', color: 'var(--text3)' }}>
{new Date(e.created_at).toLocaleString('de-DE')}
</span>
{e.changed_by_name && (
<span style={{ fontSize: '0.78rem', color: 'var(--text3)' }}>von {e.changed_by_name}</span>
)}
</div>
{e.change_note && (
<p style={{ margin: '2px 0 0', fontSize: '0.82rem', color: 'var(--text2)', fontStyle: 'italic' }}>
{e.change_note}
</p>
)}
</div>
))
)}
</div>
)
}
export default function AdminLegalDocumentsPage() {
const [activeType, setActiveType] = useState(DOC_TYPES[0].key)
const [documents, setDocuments] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
const [showForm, setShowForm] = useState(false)
const [editDoc, setEditDoc] = useState(null)
const [auditDocId, setAuditDocId] = useState(null)
const [confirmPublish, setConfirmPublish] = useState(null)
const activeDocType = DOC_TYPES.find(d => d.key === activeType)
const load = useCallback(() => {
setLoading(true)
setError(null)
api.listLegalDocuments(activeType)
.then(setDocuments)
.catch(e => setError(e.message))
.finally(() => setLoading(false))
}, [activeType])
useEffect(() => {
load()
setShowForm(false)
setEditDoc(null)
setAuditDocId(null)
setConfirmPublish(null)
}, [load])
const handlePublish = async (doc) => {
setConfirmPublish(doc)
}
const confirmAndPublish = async () => {
if (!confirmPublish) return
try {
await api.publishLegalDocument(confirmPublish.id, null)
setConfirmPublish(null)
load()
} catch (e) {
alert('Fehler: ' + e.message)
}
}
const handleArchive = async (doc) => {
if (!confirm(`"${doc.title}" archivieren?`)) return
try {
await api.archiveLegalDocument(doc.id)
load()
} catch (e) {
alert('Fehler: ' + e.message)
}
}
const handleEdit = (doc) => {
setEditDoc(doc)
setShowForm(true)
setAuditDocId(null)
}
const handleViewAudit = (doc) => {
setAuditDocId(doc.id)
setShowForm(false)
setEditDoc(null)
}
const handleNew = () => {
setEditDoc(null)
setShowForm(true)
setAuditDocId(null)
}
const handleSaved = () => {
setShowForm(false)
setEditDoc(null)
load()
}
const publishedDoc = documents.find(d => d.status === 'published')
return (
<div className="app-main">
<div style={{ maxWidth: '860px', margin: '0 auto' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', marginBottom: '1.5rem' }}>
<FileText size={22} color="var(--accent)" />
<h1 style={{ margin: 0 }}>Rechtstexte verwalten</h1>
</div>
<div className="card" style={{ marginBottom: '1rem', padding: '12px', background: 'var(--surface)' }}>
<p style={{ margin: 0, fontSize: '0.88rem', color: 'var(--text2)' }}>
Hier können Superadmins versionierte Rechtstexte erstellen, bearbeiten und veröffentlichen.
Pro Dokumententyp kann immer nur ein Text veröffentlicht sein.
</p>
</div>
{/* Tab-Leiste */}
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap', marginBottom: '1.25rem' }}>
{DOC_TYPES.map(dt => (
<DocTypeTab
key={dt.key}
docType={dt}
active={activeType === dt.key}
onClick={() => setActiveType(dt.key)}
/>
))}
</div>
{/* Aktuell veröffentlicht */}
{publishedDoc && (
<div
className="card"
style={{
marginBottom: '1rem',
borderLeft: '3px solid var(--accent)',
display: 'flex',
alignItems: 'center',
gap: '0.75rem',
padding: '10px 16px',
}}
>
<CheckCircle size={16} color="var(--accent)" />
<span style={{ fontSize: '0.88rem' }}>
<strong>Aktuell live:</strong> {publishedDoc.title} (v{publishedDoc.version}
{publishedDoc.published_at && `, ${new Date(publishedDoc.published_at).toLocaleDateString('de-DE')}`})
</span>
</div>
)}
{/* Aktionen */}
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: '0.75rem' }}>
<button className="btn btn-primary" onClick={handleNew} style={{ display: 'flex', alignItems: 'center', gap: '0.4rem' }}>
<Plus size={15} /> Neuer Entwurf
</button>
</div>
{/* Fehler */}
{error && (
<div className="card" style={{ borderLeft: '4px solid var(--danger)', marginBottom: '1rem' }}>
<span style={{ color: 'var(--danger)' }}>{error}</span>
</div>
)}
{/* Bestätigung Veröffentlichen */}
{confirmPublish && (
<div className="card" style={{ borderLeft: '4px solid var(--accent)', marginBottom: '1rem' }}>
<p style={{ margin: '0 0 0.75rem' }}>
<strong>{confirmPublish.title}"</strong> veröffentlichen?
{publishedDoc && publishedDoc.id !== confirmPublish.id && (
<> Die aktuelle Version ({publishedDoc.title} v{publishedDoc.version}) wird dabei archiviert.</>
)}
</p>
<div style={{ display: 'flex', gap: '0.5rem' }}>
<button className="btn btn-primary" onClick={confirmAndPublish}>Ja, veröffentlichen</button>
<button className="btn btn-secondary" onClick={() => setConfirmPublish(null)}>Abbrechen</button>
</div>
</div>
)}
{/* Dokumentenliste */}
{loading ? (
<div className="spinner" />
) : documents.length === 0 ? (
<div className="card" style={{ textAlign: 'center', padding: '2rem', color: 'var(--text3)' }}>
Noch keine Versionen für {activeDocType?.label}. Erstelle einen neuen Entwurf.
</div>
) : (
documents.map(doc => (
<DocumentRow
key={doc.id}
doc={doc}
onPublish={handlePublish}
onArchive={handleArchive}
onEdit={handleEdit}
onViewAudit={handleViewAudit}
/>
))
)}
{/* Formular */}
{showForm && (
<EditForm
docType={activeDocType}
editDoc={editDoc}
onSaved={handleSaved}
onCancel={() => { setShowForm(false); setEditDoc(null) }}
/>
)}
{/* Audit-Log */}
{auditDocId && (
<AuditLog docId={auditDocId} onClose={() => setAuditDocId(null)} />
)}
</div>
</div>
)
}

View File

@ -1,4 +1,14 @@
import { useState, useEffect } from 'react'
import { Link } from 'react-router-dom'
import api from '../utils/api'
// document_type values used in the DB / API
const TYPE_MAP = {
impressum: 'impressum',
datenschutz: 'privacy_policy',
nutzungsbedingungen: 'terms_of_use',
medienrichtlinie: 'media_policy',
}
const PAGES = {
impressum: {
@ -143,8 +153,31 @@ const LEGAL_LINKS = [
]
function LegalPage({ type }) {
const page = PAGES[type]
if (!page) return null
const fallback = PAGES[type]
const [apiDoc, setApiDoc] = useState(undefined) // undefined = loading, null = not found
const [loading, setLoading] = useState(true)
const documentType = TYPE_MAP[type]
useEffect(() => {
if (!documentType) {
setLoading(false)
return
}
api.getPublishedLegalDocument(documentType)
.then(doc => setApiDoc(doc))
.catch(() => setApiDoc(null))
.finally(() => setLoading(false))
}, [documentType])
if (!fallback) return null
const isPlaceholder = !apiDoc
const title = apiDoc ? apiDoc.title : fallback.title
const sections = apiDoc
? (apiDoc.content_sections || [])
: fallback.sections.map(s => ({ heading: s.heading, content: s.placeholder }))
return (
<div style={{ minHeight: '100vh', background: 'var(--bg)', padding: '2rem 1rem' }}>
@ -156,33 +189,41 @@ function LegalPage({ type }) {
</Link>
</div>
<div
className="card"
style={{
marginBottom: '1.5rem',
borderLeft: '4px solid var(--danger)',
background: 'var(--surface)',
}}
>
<strong style={{ color: 'var(--danger)' }}> MUSTER / PLATZHALTER</strong>
<p style={{ margin: '0.5rem 0 0', color: 'var(--text2)', fontSize: '0.9rem' }}>
Inhalt wird vor Produktivbetrieb juristisch geprüft und durch den Betreiber ergänzt.
Diese Seite hat keinen rechtlich verbindlichen Charakter.
</p>
</div>
{loading ? (
<div className="spinner" />
) : (
<>
{isPlaceholder && (
<div
className="card"
style={{
marginBottom: '1.5rem',
borderLeft: '4px solid var(--danger)',
background: 'var(--surface)',
}}
>
<strong style={{ color: 'var(--danger)' }}> MUSTER / PLATZHALTER</strong>
<p style={{ margin: '0.5rem 0 0', color: 'var(--text2)', fontSize: '0.9rem' }}>
Inhalt wird vor Produktivbetrieb juristisch geprüft und durch den Betreiber ergänzt.
Diese Seite hat keinen rechtlich verbindlichen Charakter.
</p>
</div>
)}
<h1 style={{ marginBottom: '2rem', color: 'var(--text1)' }}>{page.title}</h1>
<h1 style={{ marginBottom: '2rem', color: 'var(--text1)' }}>{title}</h1>
{page.sections.map((section) => (
<div key={section.heading} style={{ marginBottom: '1.75rem' }}>
<h2 style={{ fontSize: '1.05rem', marginBottom: '0.4rem', color: 'var(--text1)' }}>
{section.heading}
</h2>
<p style={{ color: 'var(--text3)', fontStyle: 'italic', margin: 0 }}>
{section.placeholder}
</p>
</div>
))}
{sections.map((section, i) => (
<div key={i} style={{ marginBottom: '1.75rem' }}>
<h2 style={{ fontSize: '1.05rem', marginBottom: '0.4rem', color: 'var(--text1)' }}>
{section.heading}
</h2>
<p style={{ color: isPlaceholder ? 'var(--text3)' : 'var(--text1)', fontStyle: isPlaceholder ? 'italic' : 'normal', margin: 0, whiteSpace: 'pre-wrap' }}>
{section.content}
</p>
</div>
))}
</>
)}
<div
style={{

View File

@ -1510,7 +1510,30 @@ export const api = {
request('/api/import/mediawiki/logs'),
deleteMediaWikiImportReference: (refId) =>
request(`/api/import/mediawiki/references/${refId}`, { method: 'DELETE' })
request(`/api/import/mediawiki/references/${refId}`, { method: 'DELETE' }),
// Legal Documents (public)
getPublishedLegalDocument: (documentType) =>
request(`/api/legal-documents/${documentType}/published`),
// Legal Documents (superadmin)
listLegalDocuments: (documentType) =>
request(`/api/admin/legal-documents${documentType ? `?document_type=${documentType}` : ''}`),
createLegalDocument: (data) =>
request('/api/admin/legal-documents', { method: 'POST', body: JSON.stringify(data) }),
getLegalDocument: (id) =>
request(`/api/admin/legal-documents/${id}`),
updateLegalDocument: (id, data) =>
request(`/api/admin/legal-documents/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
publishLegalDocument: (id, changeNote) =>
request(`/api/admin/legal-documents/${id}/publish`, {
method: 'POST',
body: JSON.stringify({ change_note: changeNote }),
}),
archiveLegalDocument: (id) =>
request(`/api/admin/legal-documents/${id}/archive`, { method: 'POST' }),
getLegalDocumentAudit: (id) =>
request(`/api/admin/legal-documents/${id}/audit`),
}
export default api

View File

@ -1,12 +1,13 @@
// Shinkan Jinkendo Frontend Version
export const APP_VERSION = "0.8.70"
export const APP_VERSION = "0.8.71"
export const BUILD_DATE = "2026-05-10"
export const PAGE_VERSIONS = {
LoginPage: "1.0.2",
LegalPage: "1.0.0",
LegalPage: "1.1.0",
SettingsLegalPage: "1.0.0",
AdminLegalDocumentsPage: "1.0.0",
Dashboard: "1.0.0",
AccountSettingsPage: "1.0.1",
ExercisesPage: "1.5.0", // Fokus +/- Regeln, nur ohne Fokusbereich; Filterprefs

View File

@ -287,6 +287,51 @@ test('P-01b: Jeder Rechtstext-Link aus /settings/legal führt zur korrekten Rout
}
});
// P-01c: Admin-konfigurierbare Rechtstexte
test('P-01c: Rechtstextseiten zeigen Platzhalter-Banner wenn kein published-Dokument', async ({ page }) => {
// Keine Auth — public route
await page.goto('/impressum');
await page.waitForLoadState('networkidle');
// Seite erreichbar (kein Redirect zu /login)
expect(page.url()).toContain('/impressum');
// Spinner weg, dann entweder Platzhalter-Banner oder API-Inhalt (beides OK)
await expect(page.locator('.spinner')).toHaveCount(0, { timeout: 10000 });
await expect(page.getByRole('heading', { level: 1 })).toBeVisible({ timeout: 5000 });
console.log('✓ P-01c: /impressum lädt ohne Fehler (API-fetch mit Fallback)');
});
test('P-01c: /admin/legal-documents erreichbar für Superadmin', async ({ page }) => {
await page.setViewportSize({ width: 1280, height: 800 });
await login(page);
await expect(page.locator('.spinner')).toHaveCount(0, { timeout: 10000 });
await gotoAuthenticated(page, '/admin/legal-documents');
// Superadmin sieht die Seite; normaler Admin landet auf 403 (PlatformAdminRoute)
// Test läuft mit Superadmin-Account (TEST_EMAIL), also Seite sichtbar
await expect(page.locator('.app-main').getByRole('heading', { level: 1, name: 'Rechtstexte verwalten' })).toBeVisible({ timeout: 8000 });
await page.screenshot({ path: 'screenshots/p01c-admin-legal-documents.png' });
console.log('✓ P-01c: /admin/legal-documents erreichbar, Überschrift sichtbar');
});
test('P-01c: Admin-Nav enthält Link zu Rechtstexten', async ({ page }) => {
await page.setViewportSize({ width: 1280, height: 800 });
await login(page);
await expect(page.locator('.spinner')).toHaveCount(0, { timeout: 10000 });
await gotoAuthenticated(page, '/admin/hierarchy');
await expect(page.locator('.spinner')).toHaveCount(0, { timeout: 10000 });
const link = page.locator('a[href="/admin/legal-documents"]');
await expect(link).toBeVisible({ timeout: 5000 });
console.log('✓ P-01c: Admin-Nav enthält Link /admin/legal-documents');
});
test('8. Keine kritischen Console-Fehler', async ({ page }) => {
const errors = [];
page.on('console', msg => {