diff --git a/backend/main.py b/backend/main.py index 6dc94e4..72395c9 100644 --- a/backend/main.py +++ b/backend/main.py @@ -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 /). diff --git a/backend/migrations/047_legal_documents.sql b/backend/migrations/047_legal_documents.sql new file mode 100644 index 0000000..04f4b1d --- /dev/null +++ b/backend/migrations/047_legal_documents.sql @@ -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() +); diff --git a/backend/routers/legal_documents.py b/backend/routers/legal_documents.py new file mode 100644 index 0000000..2d1ecad --- /dev/null +++ b/backend/routers/legal_documents.py @@ -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] diff --git a/backend/version.py b/backend/version.py index 168a3ce..608b78a 100644 --- a/backend/version.py +++ b/backend/version.py @@ -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", diff --git a/docs/compliance-implementation.md b/docs/compliance-implementation.md index 1f29640..c1bdfd0 100644 --- a/docs/compliance-implementation.md +++ b/docs/compliance-implementation.md @@ -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 diff --git a/docs/compliance-package-register.md b/docs/compliance-package-register.md index ca08be6..7cdfdfd 100644 --- a/docs/compliance-package-register.md +++ b/docs/compliance-package-register.md @@ -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. | --- diff --git a/docs/compliance-roadmap.md b/docs/compliance-roadmap.md index 864570e..db6487b 100644 --- a/docs/compliance-roadmap.md +++ b/docs/compliance-roadmap.md @@ -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 | diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 348833d..ad2cc96 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -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() { } /> + + + + } + /> } /> diff --git a/frontend/src/components/AdminPageNav.jsx b/frontend/src/components/AdminPageNav.jsx index 11a9955..452dbe8 100644 --- a/frontend/src/components/AdminPageNav.jsx +++ b/frontend/src/components/AdminPageNav.jsx @@ -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 ( diff --git a/frontend/src/pages/AdminLegalDocumentsPage.jsx b/frontend/src/pages/AdminLegalDocumentsPage.jsx new file mode 100644 index 0000000..10ba9b8 --- /dev/null +++ b/frontend/src/pages/AdminLegalDocumentsPage.jsx @@ -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 ( + + {s.label} + + ) +} + +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 ( + + {sections.map((sec, i) => ( + + + Abschnitt {i + 1} + removeSection(i)} + > + Entfernen + + + + Überschrift + update(i, 'heading', e.target.value)} + placeholder="Abschnittsüberschrift" + /> + + + Inhalt + update(i, 'content', e.target.value)} + placeholder="Textinhalt des Abschnitts" + /> + + + ))} + + + Abschnitt hinzufügen + + + ) +} + +function DocTypeTab({ docType, active, onClick }) { + return ( + + {docType.label} + + ) +} + +function DocumentRow({ doc, onPublish, onArchive, onEdit, onViewAudit }) { + return ( + + + + {doc.title} v{doc.version} + + + + {doc.published_at && ( + + veröffentlicht {new Date(doc.published_at).toLocaleDateString('de-DE')} + + )} + {doc.change_note && ( + + {doc.change_note} + + )} + + + + {doc.status === 'draft' && ( + <> + onEdit(doc)} + title="Bearbeiten" + > + + + onPublish(doc)} + title="Veröffentlichen" + > + + + > + )} + {doc.status === 'published' && ( + onArchive(doc)} + title="Archivieren" + > + + + )} + onViewAudit(doc)} + title="Änderungslog" + > + + + + + ) +} + +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 + + return ( + + + {editDoc ? 'Entwurf bearbeiten' : 'Neuen Entwurf erstellen'} + + + {error && ( + + {error} + + )} + + + Titel * + setTitle(e.target.value)} + required + /> + + + + Abschnitte + + + + + Änderungsnotiz (optional) + setChangeNote(e.target.value)} + placeholder="z. B. Erste Version nach juristischer Prüfung" + /> + + + + + {saving ? 'Speichern…' : 'Entwurf speichern'} + + + Abbrechen + + + + ) +} + +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 ( + + + Änderungslog + + Schließen + + + {loading ? ( + + ) : entries.length === 0 ? ( + Keine Einträge. + ) : ( + entries.map(e => ( + + + {ACTION_LABELS[e.action] || e.action} + {e.previous_status && ( + von {e.previous_status} + )} + + {new Date(e.created_at).toLocaleString('de-DE')} + + {e.changed_by_name && ( + von {e.changed_by_name} + )} + + {e.change_note && ( + + {e.change_note} + + )} + + )) + )} + + ) +} + +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 ( + + + + + + Rechtstexte verwalten + + + + + Hier können Superadmins versionierte Rechtstexte erstellen, bearbeiten und veröffentlichen. + Pro Dokumententyp kann immer nur ein Text veröffentlicht sein. + + + + {/* Tab-Leiste */} + + {DOC_TYPES.map(dt => ( + setActiveType(dt.key)} + /> + ))} + + + {/* Aktuell veröffentlicht */} + {publishedDoc && ( + + + + Aktuell live: {publishedDoc.title} (v{publishedDoc.version} + {publishedDoc.published_at && `, ${new Date(publishedDoc.published_at).toLocaleDateString('de-DE')}`}) + + + )} + + {/* Aktionen */} + + + Neuer Entwurf + + + + {/* Fehler */} + {error && ( + + {error} + + )} + + {/* Bestätigung Veröffentlichen */} + {confirmPublish && ( + + + „{confirmPublish.title}" veröffentlichen? + {publishedDoc && publishedDoc.id !== confirmPublish.id && ( + <> Die aktuelle Version ({publishedDoc.title} v{publishedDoc.version}) wird dabei archiviert.> + )} + + + Ja, veröffentlichen + setConfirmPublish(null)}>Abbrechen + + + )} + + {/* Dokumentenliste */} + {loading ? ( + + ) : documents.length === 0 ? ( + + Noch keine Versionen für {activeDocType?.label}. Erstelle einen neuen Entwurf. + + ) : ( + documents.map(doc => ( + + )) + )} + + {/* Formular */} + {showForm && ( + { setShowForm(false); setEditDoc(null) }} + /> + )} + + {/* Audit-Log */} + {auditDocId && ( + setAuditDocId(null)} /> + )} + + + ) +} diff --git a/frontend/src/pages/LegalPage.jsx b/frontend/src/pages/LegalPage.jsx index 349af9e..3408cd4 100644 --- a/frontend/src/pages/LegalPage.jsx +++ b/frontend/src/pages/LegalPage.jsx @@ -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 ( @@ -156,33 +189,41 @@ function LegalPage({ type }) { - - ⚠ MUSTER / PLATZHALTER - - Inhalt wird vor Produktivbetrieb juristisch geprüft und durch den Betreiber ergänzt. - Diese Seite hat keinen rechtlich verbindlichen Charakter. - - + {loading ? ( + + ) : ( + <> + {isPlaceholder && ( + + ⚠ MUSTER / PLATZHALTER + + Inhalt wird vor Produktivbetrieb juristisch geprüft und durch den Betreiber ergänzt. + Diese Seite hat keinen rechtlich verbindlichen Charakter. + + + )} - {page.title} + {title} - {page.sections.map((section) => ( - - - {section.heading} - - - {section.placeholder} - - - ))} + {sections.map((section, i) => ( + + + {section.heading} + + + {section.content} + + + ))} + > + )} - 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 diff --git a/frontend/src/version.js b/frontend/src/version.js index 9ccbd2f..4fb0482 100644 --- a/frontend/src/version.js +++ b/frontend/src/version.js @@ -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 diff --git a/tests/dev-smoke-test.spec.js b/tests/dev-smoke-test.spec.js index 57afd73..f10a44c 100644 --- a/tests/dev-smoke-test.spec.js +++ b/tests/dev-smoke-test.spec.js @@ -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 => {
Keine Einträge.
+ {e.change_note} +
+ Hier können Superadmins versionierte Rechtstexte erstellen, bearbeiten und veröffentlichen. + Pro Dokumententyp kann immer nur ein Text veröffentlicht sein. +
+ „{confirmPublish.title}" veröffentlichen? + {publishedDoc && publishedDoc.id !== confirmPublish.id && ( + <> Die aktuelle Version ({publishedDoc.title} v{publishedDoc.version}) wird dabei archiviert.> + )} +
- Inhalt wird vor Produktivbetrieb juristisch geprüft und durch den Betreiber ergänzt. - Diese Seite hat keinen rechtlich verbindlichen Charakter. -
+ Inhalt wird vor Produktivbetrieb juristisch geprüft und durch den Betreiber ergänzt. + Diese Seite hat keinen rechtlich verbindlichen Charakter. +
- {section.placeholder} -
+ {section.content} +