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
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:
parent
8bed0199b6
commit
80936b226d
|
|
@ -193,7 +193,7 @@ def read_root():
|
||||||
return out
|
return out
|
||||||
|
|
||||||
# Register routers
|
# 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(auth.router)
|
||||||
app.include_router(profiles.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(matrix_stack_bundle.router)
|
||||||
app.include_router(import_wiki.router)
|
app.include_router(import_wiki.router)
|
||||||
app.include_router(import_wiki_admin.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
|
# Lokale Übungs-Medien: standardmäßig nur über geschützten API-Pfad
|
||||||
# GET /api/exercises/{id}/media/{mid}/file (?ssetoken für <img>/<video>).
|
# GET /api/exercises/{id}/media/{mid}/file (?ssetoken für <img>/<video>).
|
||||||
|
|
|
||||||
37
backend/migrations/047_legal_documents.sql
Normal file
37
backend/migrations/047_legal_documents.sql
Normal 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()
|
||||||
|
);
|
||||||
408
backend/routers/legal_documents.py
Normal file
408
backend/routers/legal_documents.py
Normal 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]
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
# Shinkan Jinkendo Version Information
|
# Shinkan Jinkendo Version Information
|
||||||
|
|
||||||
APP_VERSION = "0.8.70"
|
APP_VERSION = "0.8.71"
|
||||||
BUILD_DATE = "2026-05-10"
|
BUILD_DATE = "2026-05-10"
|
||||||
DB_SCHEMA_VERSION = "20260508049"
|
DB_SCHEMA_VERSION = "20260510047"
|
||||||
|
|
||||||
MODULE_VERSIONS = {
|
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
|
"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()
|
"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
|
"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 = [
|
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",
|
"version": "0.8.70",
|
||||||
"date": "2026-05-10",
|
"date": "2026-05-10",
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
**Erstellt:** 2026-05-09
|
**Erstellt:** 2026-05-09
|
||||||
**Zuletzt aktualisiert:** 2026-05-10
|
**Zuletzt aktualisiert:** 2026-05-10
|
||||||
**Audit-Basis:** `docs/compliance-audit.md`
|
**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 ✅
|
### P-03 – Papierkorb-Retention-Job aktivieren ✅
|
||||||
|
|
||||||
**Status:** Umgesetzt
|
**Status:** Umgesetzt
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
**Typ:** Kanonisches Referenzdokument
|
**Typ:** Kanonisches Referenzdokument
|
||||||
**Erstellt:** 2026-05-10
|
**Erstellt:** 2026-05-10
|
||||||
**Basisdokument:** `docs/compliance-audit.md` (Initial-Audit 2026-05-09, App-Version 0.8.65)
|
**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 |
|
| **Findings** | KRIT-01 |
|
||||||
| **Etappe** | 1 |
|
| **Etappe** | 1 |
|
||||||
| **Status** | ⚠️ partially implemented |
|
| **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.** |
|
| **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; `frontend/src/pages/LegalPage.jsx`; `frontend/src/pages/SettingsLegalPage.jsx` |
|
| **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) als Suffix-Paket ergänzt und vollständig umgesetzt. Keine Nummerierungsabweichung. Scope Drift ausgeschlossen. |
|
| **Hinweise** | P-01b (Mobile-Erreichbarkeit) und P-01c (Admin-konfigurierbar) als Suffix-Pakete ergänzt und vollständig umgesetzt. Keine Nummerierungsabweichung. Scope Drift ausgeschlossen. |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
**Typ:** Lebendes Steuerungsdokument
|
**Typ:** Lebendes Steuerungsdokument
|
||||||
**Erstellt:** 2026-05-10
|
**Erstellt:** 2026-05-10
|
||||||
**App-Version:** 0.8.70
|
**App-Version:** 0.8.71
|
||||||
**Zuletzt aktualisiert:** 2026-05-10
|
**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)
|
## 2. Aktueller Stand (2026-05-10)
|
||||||
|
|
||||||
### App-Version: 0.8.70
|
### App-Version: 0.8.71
|
||||||
|
|
||||||
### Teilweise umgesetzte Pakete
|
### 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-23 | LoginPage: minLength angleichen + Versionsstring entfernen | 0.8.66 |
|
||||||
| P-24 | CORS einschränken (Methoden + Header) | 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-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
|
**Teilweise umgesetzt (technisch):** P-01
|
||||||
|
|
||||||
### Offene Pakete (16)
|
### Offene Pakete (16)
|
||||||
|
|
@ -82,9 +83,9 @@ Die folgenden Pakete sind vor der Freigabe für allgemeine öffentliche Registri
|
||||||
### Blocker 1 — P-01: Rechtstexte
|
### Blocker 1 — P-01: Rechtstexte
|
||||||
|
|
||||||
**Finding:** KRIT-01 (Ordnungswidrigkeit, § 5 DDG)
|
**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.
|
**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. Die Platzhalterseiten machen die Lücke sichtbar, schließen sie aber nicht.
|
**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 ergänzen → Platzhaltermarkierung entfernen.
|
**Nächster Schritt:** Rechtsanwalt beauftragt werden → Inhalte durch Betreiber über Admin-UI einpflegen → Veröffentlichen → Platzhaltermarkierung verschwindet automatisch.
|
||||||
|
|
||||||
### Blocker 2 — P-06: Upload-Einwilligungsdialog
|
### 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
|
## 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:
|
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"
|
> „Freigabe zur Umsetzung Etappe B: P-06, P-11, P-13"
|
||||||
|
|
||||||
**Parallel dazu Betreiber-Aufgabe:**
|
**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).
|
**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-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-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-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 |
|
| **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 |
|
| 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 |
|
| P-02 Spezifikation | „Freigabe zur Spezifikation P-02: DSGVO-Self-Service-Prozess" | offen |
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,7 @@ import AdminHomeRedirect from './components/AdminHomeRedirect'
|
||||||
import PlatformAdminRoute from './components/PlatformAdminRoute'
|
import PlatformAdminRoute from './components/PlatformAdminRoute'
|
||||||
import MediaLibraryPage from './pages/MediaLibraryPage'
|
import MediaLibraryPage from './pages/MediaLibraryPage'
|
||||||
import LegalPage from './pages/LegalPage'
|
import LegalPage from './pages/LegalPage'
|
||||||
|
import AdminLegalDocumentsPage from './pages/AdminLegalDocumentsPage'
|
||||||
import SettingsLegalPage from './pages/SettingsLegalPage'
|
import SettingsLegalPage from './pages/SettingsLegalPage'
|
||||||
import ActiveClubSwitcher from './components/ActiveClubSwitcher'
|
import ActiveClubSwitcher from './components/ActiveClubSwitcher'
|
||||||
import InactiveMembershipBanner from './components/InactiveMembershipBanner'
|
import InactiveMembershipBanner from './components/InactiveMembershipBanner'
|
||||||
|
|
@ -242,6 +243,14 @@ function AppRoutes() {
|
||||||
</PlatformAdminRoute>
|
</PlatformAdminRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="admin/legal-documents"
|
||||||
|
element={
|
||||||
|
<PlatformAdminRoute>
|
||||||
|
<AdminLegalDocumentsPage />
|
||||||
|
</PlatformAdminRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route path="trainer-contexts" element={<TrainerContextsPage />} />
|
<Route path="trainer-contexts" element={<TrainerContextsPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { NavLink } from 'react-router-dom'
|
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).
|
* 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/maturity-models', label: 'Fähigkeitsmatrix', icon: Grid3x3 },
|
||||||
{ to: '/admin/catalogs', label: 'Kataloge', icon: FolderTree },
|
{ to: '/admin/catalogs', label: 'Kataloge', icon: FolderTree },
|
||||||
{ to: '/admin/mediawiki-import', label: 'Wiki-Import', icon: Download },
|
{ to: '/admin/mediawiki-import', label: 'Wiki-Import', icon: Download },
|
||||||
|
{ to: '/admin/legal-documents', label: 'Rechtstexte', icon: Scale },
|
||||||
]
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
517
frontend/src/pages/AdminLegalDocumentsPage.jsx
Normal file
517
frontend/src/pages/AdminLegalDocumentsPage.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,14 @@
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
import { Link } from 'react-router-dom'
|
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 = {
|
const PAGES = {
|
||||||
impressum: {
|
impressum: {
|
||||||
|
|
@ -143,8 +153,31 @@ const LEGAL_LINKS = [
|
||||||
]
|
]
|
||||||
|
|
||||||
function LegalPage({ type }) {
|
function LegalPage({ type }) {
|
||||||
const page = PAGES[type]
|
const fallback = PAGES[type]
|
||||||
if (!page) return null
|
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 (
|
return (
|
||||||
<div style={{ minHeight: '100vh', background: 'var(--bg)', padding: '2rem 1rem' }}>
|
<div style={{ minHeight: '100vh', background: 'var(--bg)', padding: '2rem 1rem' }}>
|
||||||
|
|
@ -156,33 +189,41 @@ function LegalPage({ type }) {
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
{loading ? (
|
||||||
className="card"
|
<div className="spinner" />
|
||||||
style={{
|
) : (
|
||||||
marginBottom: '1.5rem',
|
<>
|
||||||
borderLeft: '4px solid var(--danger)',
|
{isPlaceholder && (
|
||||||
background: 'var(--surface)',
|
<div
|
||||||
}}
|
className="card"
|
||||||
>
|
style={{
|
||||||
<strong style={{ color: 'var(--danger)' }}>⚠ MUSTER / PLATZHALTER</strong>
|
marginBottom: '1.5rem',
|
||||||
<p style={{ margin: '0.5rem 0 0', color: 'var(--text2)', fontSize: '0.9rem' }}>
|
borderLeft: '4px solid var(--danger)',
|
||||||
Inhalt wird vor Produktivbetrieb juristisch geprüft und durch den Betreiber ergänzt.
|
background: 'var(--surface)',
|
||||||
Diese Seite hat keinen rechtlich verbindlichen Charakter.
|
}}
|
||||||
</p>
|
>
|
||||||
</div>
|
<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) => (
|
{sections.map((section, i) => (
|
||||||
<div key={section.heading} style={{ marginBottom: '1.75rem' }}>
|
<div key={i} style={{ marginBottom: '1.75rem' }}>
|
||||||
<h2 style={{ fontSize: '1.05rem', marginBottom: '0.4rem', color: 'var(--text1)' }}>
|
<h2 style={{ fontSize: '1.05rem', marginBottom: '0.4rem', color: 'var(--text1)' }}>
|
||||||
{section.heading}
|
{section.heading}
|
||||||
</h2>
|
</h2>
|
||||||
<p style={{ color: 'var(--text3)', fontStyle: 'italic', margin: 0 }}>
|
<p style={{ color: isPlaceholder ? 'var(--text3)' : 'var(--text1)', fontStyle: isPlaceholder ? 'italic' : 'normal', margin: 0, whiteSpace: 'pre-wrap' }}>
|
||||||
{section.placeholder}
|
{section.content}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
|
|
|
||||||
|
|
@ -1510,7 +1510,30 @@ export const api = {
|
||||||
request('/api/import/mediawiki/logs'),
|
request('/api/import/mediawiki/logs'),
|
||||||
|
|
||||||
deleteMediaWikiImportReference: (refId) =>
|
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
|
export default api
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,13 @@
|
||||||
// Shinkan Jinkendo Frontend Version
|
// 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 BUILD_DATE = "2026-05-10"
|
||||||
|
|
||||||
export const PAGE_VERSIONS = {
|
export const PAGE_VERSIONS = {
|
||||||
LoginPage: "1.0.2",
|
LoginPage: "1.0.2",
|
||||||
LegalPage: "1.0.0",
|
LegalPage: "1.1.0",
|
||||||
SettingsLegalPage: "1.0.0",
|
SettingsLegalPage: "1.0.0",
|
||||||
|
AdminLegalDocumentsPage: "1.0.0",
|
||||||
Dashboard: "1.0.0",
|
Dashboard: "1.0.0",
|
||||||
AccountSettingsPage: "1.0.1",
|
AccountSettingsPage: "1.0.1",
|
||||||
ExercisesPage: "1.5.0", // Fokus +/- Regeln, nur ohne Fokusbereich; Filterprefs
|
ExercisesPage: "1.5.0", // Fokus +/- Regeln, nur ohne Fokusbereich; Filterprefs
|
||||||
|
|
|
||||||
|
|
@ -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 }) => {
|
test('8. Keine kritischen Console-Fehler', async ({ page }) => {
|
||||||
const errors = [];
|
const errors = [];
|
||||||
page.on('console', msg => {
|
page.on('console', msg => {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user