Add Admin User Content Management Features
All checks were successful
Deploy Development / deploy (push) Successful in 42s
Test Suite / pytest-backend (push) Successful in 44s
Test Suite / lint-backend (push) Successful in 1s
Test Suite / build-frontend (push) Successful in 15s
Test Suite / k6 /health Baseline (push) Successful in 35s
Test Suite / playwright-tests (push) Successful in 1m18s
Test Suite / pytest-backend (pull_request) Successful in 38s
Test Suite / lint-backend (pull_request) Successful in 0s
Test Suite / build-frontend (pull_request) Successful in 13s
Test Suite / k6 /health Baseline (pull_request) Successful in 38s
Test Suite / playwright-tests (pull_request) Successful in 1m12s

- Introduced a new admin user content management endpoint for superadmins, allowing for moderation of user-generated content.
- Updated the backend to include new API functions for retrieving, patching, and deleting user content items.
- Enhanced the frontend with a new Admin User Content page and navigation link for easy access to user content management.
- Updated access layer documentation to reflect the new endpoint and its exempt status.
- Incremented version to 0.8.191 and updated changelog to document these additions in admin functionality.
This commit is contained in:
Lars 2026-06-06 17:53:25 +02:00
parent 3450a9296a
commit bd5a409fa7
8 changed files with 1254 additions and 3 deletions

View File

@ -38,12 +38,13 @@ Fortlaufend gemäß `ACCESS_LAYER_AND_GOVERNANCE_PLAN.md` Stufe AC.
| ai_skill_retrieval_admin | `/api/admin/ai-skill-retrieval-profiles*` (CRUD) | Plattform | `require_auth` | nur `superadmin`; JSON `config` | EXEMPT wie `admin_users`; kein Vereinsbezug |
| ai_prompts_admin | `/api/admin/ai-prompts*` (Liste, Detail, PUT, Preview, Reset) | Plattform | `require_auth` | nur `superadmin` | EXEMPT; globale `ai_prompts` ohne Mandantenkontext |
| exercise_enrichment_admin | `/api/admin/exercise-enrichment/*` (Kandidaten, Preview, Apply) | Plattform | `require_auth` | nur `superadmin` | EXEMPT; plattformweite Übungsliste + Skill-Schreibung; kein TenantContext |
| admin_user_content | `/api/admin/user-content/*` (Meta, Nutzer-Summary, Items, PATCH, DELETE) | Plattform | `require_auth` | nur `superadmin` | EXEMPT; Moderation nutzerangelegter Inhalte inkl. privat; kein TenantContext |
**Legende:** Router auf der EXEMPT-Liste des Scripts sind globale oder Auth-only-Pfade; sobald ein Router Vereinsdaten oder Bibliotheks-Sichtbarkeit erhält, EXEMPT entfernen und `get_tenant_context` einführen.
**Pflege / Drift:** Änderungen an Mandanten, Governance (`visibility`/`club_id`) oder neuen inhaltsbezogenen Endpoints → eine Zeile in dieser Tabelle anpassen und `PRODUCTION_READINESS_AUDIT_2026-05.md` prüfen.
Letzte Änderung: 2026-05-23 — Superadmin `/api/admin/exercise-enrichment/*` (Batch-KI Skills, Status in_review).
Letzte Änderung: 2026-06-06 — Superadmin `/api/admin/user-content/*` (Nutzer-Inhalte Moderation).
---

View File

@ -193,7 +193,7 @@ def read_root():
return out
# Register routers
from routers import auth, profiles, exercises, exercise_progression_graphs, clubs, club_memberships, club_join_requests, admin_users, platform_media_storage, media_assets, skills, skill_profiles, training_planning, planning_exercise_suggest, dashboard, training_modules, training_framework_programs, catalogs, maturity_models, matrix_stack_bundle, matrix_editor, import_wiki, import_wiki_admin, legal_documents, content_reports, ai_prompts_admin, ai_skill_retrieval_admin, exercise_enrichment_admin
from routers import auth, profiles, exercises, exercise_progression_graphs, clubs, club_memberships, club_join_requests, admin_users, admin_user_content, platform_media_storage, media_assets, skills, skill_profiles, training_planning, planning_exercise_suggest, dashboard, training_modules, training_framework_programs, catalogs, maturity_models, matrix_stack_bundle, matrix_editor, import_wiki, import_wiki_admin, legal_documents, content_reports, ai_prompts_admin, ai_skill_retrieval_admin, exercise_enrichment_admin
app.include_router(auth.router)
app.include_router(profiles.router)
@ -203,6 +203,7 @@ app.include_router(clubs.router)
app.include_router(club_memberships.router)
app.include_router(club_join_requests.router)
app.include_router(admin_users.router)
app.include_router(admin_user_content.router)
app.include_router(platform_media_storage.router)
app.include_router(media_assets.router)
app.include_router(media_assets.admin_rights_router)

View File

@ -0,0 +1,519 @@
"""
Superadmin API: Übersicht und Moderation nutzerangelegter Inhalte (inkl. private).
# ACCESS_LAYER exempt: Plattform-weites Superadmin-Werkzeug ohne TenantContext.
Siehe ACCESS_LAYER_ENDPOINT_AUDIT.md.
"""
from __future__ import annotations
from typing import Any, Dict, List, Literal, Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, Field, model_validator
from auth import require_auth
from club_tenancy import is_superadmin
from db import get_db, get_cursor, r2d
from media_lifecycle import superadmin_hard_delete_media_asset
router = APIRouter(prefix="/api/admin/user-content", tags=["admin_user_content"])
_VALID_VISIBILITY = frozenset({"private", "club", "official"})
_VALID_EXERCISE_STATUS = frozenset({"draft", "in_review", "approved", "archived"})
_VALID_MATURITY_STATUS = frozenset({"draft", "active", "archived"})
_VALID_MEDIA_RIGHTS = frozenset({"legacy_unreviewed", "declared", "blocked"})
_VALID_MEDIA_LIFECYCLE = frozenset({"active", "trash_soft", "trash_hidden"})
_MAX_ITEMS_LIMIT = 100
ContentType = Literal[
"exercise",
"training_module",
"framework_program",
"progression_graph",
"plan_template",
"maturity_model",
"media_asset",
]
_CONTENT_SPECS: Dict[str, Dict[str, Any]] = {
"exercise": {
"label": "Übung",
"table": "exercises",
"creator_col": "created_by",
"title_col": "title",
"status_col": "status",
"visibility_col": "visibility",
"club_col": "club_id",
"has_status": True,
"has_visibility": True,
"status_values": sorted(_VALID_EXERCISE_STATUS),
},
"training_module": {
"label": "Trainingsmodul",
"table": "training_modules",
"creator_col": "created_by",
"title_col": "title",
"status_col": None,
"visibility_col": "visibility",
"club_col": "club_id",
"has_status": False,
"has_visibility": True,
"status_values": [],
},
"framework_program": {
"label": "Rahmenprogramm",
"table": "training_framework_programs",
"creator_col": "created_by",
"title_col": "title",
"status_col": None,
"visibility_col": "visibility",
"club_col": "club_id",
"has_status": False,
"has_visibility": True,
"status_values": [],
},
"progression_graph": {
"label": "Progressionspfad",
"table": "exercise_progression_graphs",
"creator_col": "created_by",
"title_col": "name",
"status_col": None,
"visibility_col": "visibility",
"club_col": "club_id",
"has_status": False,
"has_visibility": True,
"status_values": [],
},
"plan_template": {
"label": "Trainingsvorlage",
"table": "training_plan_templates",
"creator_col": "created_by",
"title_col": "name",
"status_col": None,
"visibility_col": "visibility",
"club_col": "club_id",
"has_status": False,
"has_visibility": True,
"status_values": [],
},
"maturity_model": {
"label": "Reifegradmodell",
"table": "maturity_models",
"creator_col": "created_by",
"title_col": "name",
"status_col": "status",
"visibility_col": None,
"club_col": "club_id",
"has_status": True,
"has_visibility": False,
"status_values": sorted(_VALID_MATURITY_STATUS),
},
"media_asset": {
"label": "Medium",
"table": "media_assets",
"creator_col": "uploaded_by_profile_id",
"title_col": "original_filename",
"status_col": "rights_status",
"visibility_col": "visibility",
"club_col": "club_id",
"extra_col": "lifecycle_state",
"has_status": True,
"has_visibility": True,
"status_values": sorted(_VALID_MEDIA_RIGHTS),
},
}
def _require_superadmin(session: dict) -> dict:
role = (session.get("role") or "").strip().lower()
if not is_superadmin(role):
raise HTTPException(status_code=403, detail="Nur Superadmins")
return session
def _spec(content_type: str) -> Dict[str, Any]:
key = (content_type or "").strip().lower()
spec = _CONTENT_SPECS.get(key)
if not spec:
raise HTTPException(status_code=400, detail="Ungültiger Inhaltstyp")
return spec
def _types_for_filters(
content_type: Optional[str],
status: Optional[str],
) -> List[str]:
if content_type and content_type != "all":
return [content_type]
types = list(_CONTENT_SPECS.keys())
if status:
types = [t for t in types if _CONTENT_SPECS[t].get("has_status")]
return types
def _build_type_select(spec: Dict[str, Any], content_type: str) -> str:
title = spec["title_col"]
creator = spec["creator_col"]
status = spec.get("status_col")
visibility = spec.get("visibility_col")
club = spec.get("club_col")
extra = spec.get("extra_col")
status_sql = f"t.{status}" if status else "NULL"
vis_sql = f"t.{visibility}" if visibility else "NULL"
club_sql = f"t.{club}" if club else "NULL"
extra_sql = f"t.{extra}" if extra else "NULL"
return f"""
SELECT
'{content_type}' AS content_type,
t.id,
t.{title} AS title,
t.{creator} AS profile_id,
{status_sql} AS status,
{vis_sql} AS visibility,
{club_sql} AS club_id,
{extra_sql} AS extra_status,
t.created_at,
t.updated_at
FROM {spec['table']} t
"""
def _append_filters(
where: List[str],
params: List[Any],
*,
spec: Dict[str, Any],
profile_id: Optional[int],
visibility: Optional[str],
status: Optional[str],
search: Optional[str],
) -> None:
creator = spec["creator_col"]
if profile_id is not None:
where.append(f"t.{creator} = %s")
params.append(profile_id)
if visibility and visibility != "all":
vis_col = spec.get("visibility_col")
if vis_col:
where.append(f"t.{vis_col} = %s")
params.append(visibility)
if status:
st_col = spec.get("status_col")
if st_col:
where.append(f"t.{st_col} = %s")
params.append(status)
if search:
title_col = spec["title_col"]
where.append(f"t.{title_col} ILIKE %s")
params.append(f"%{search}%")
def _exercise_delete_usage_message(cur, exercise_id: int) -> str:
cur.execute(
"""
SELECT
(SELECT COUNT(*)::int FROM exercise_block_items WHERE exercise_id = %s) AS block_items,
(SELECT COUNT(*)::int FROM training_unit_section_items WHERE exercise_id = %s) AS section_items,
(SELECT COUNT(*)::int FROM exercise_progression_edges
WHERE from_exercise_id = %s OR to_exercise_id = %s) AS prog_edges
""",
(exercise_id, exercise_id, exercise_id, exercise_id),
)
row = r2d(cur.fetchone() or {})
parts = []
if int(row.get("block_items") or 0):
parts.append(f"{row['block_items']}× in Übungsblöcken")
if int(row.get("section_items") or 0):
parts.append(f"{row['section_items']}× in Trainingsplänen oder Rahmenabläufen")
if int(row.get("prog_edges") or 0):
parts.append(f"{row['prog_edges']}× in Progressionsgraphen (Kanten)")
if not parts:
return ""
return (
"Die Übung wird noch verwendet und kann nicht gelöscht werden. "
"Bitte auf „archiviert“ setzen. Verwendung: " + ", ".join(parts) + "."
)
class UserContentPatchBody(BaseModel):
status: Optional[str] = None
visibility: Optional[str] = None
lifecycle_state: Optional[str] = None
@model_validator(mode="after")
def at_least_one_field(self):
if self.status is None and self.visibility is None and self.lifecycle_state is None:
raise ValueError("Mindestens eines der Felder status, visibility oder lifecycle_state angeben")
return self
@router.get("/meta")
def get_user_content_meta(session: dict = Depends(require_auth)):
"""Metadaten zu unterstützten Inhaltstypen."""
_require_superadmin(session)
types = []
for key, spec in _CONTENT_SPECS.items():
types.append(
{
"id": key,
"label": spec["label"],
"has_status": spec["has_status"],
"has_visibility": spec["has_visibility"],
"status_values": spec.get("status_values") or [],
}
)
return {"content_types": types}
@router.get("/users-summary")
def list_users_content_summary(session: dict = Depends(require_auth)):
"""Anzahl angelegter Inhalte je Nutzer (alle Sichtbarkeiten)."""
_require_superadmin(session)
count_exprs: List[str] = []
for key, spec in _CONTENT_SPECS.items():
creator = spec["creator_col"]
count_exprs.append(
f"(SELECT COUNT(*)::int FROM {spec['table']} WHERE {creator} = p.id) AS {key}_count"
)
counts_sql = ",\n ".join(count_exprs)
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
f"""
SELECT
p.id,
p.name,
p.email,
p.role,
p.created_at,
{counts_sql},
(
{" + ".join(f"COALESCE((SELECT COUNT(*)::int FROM {spec['table']} WHERE {spec['creator_col']} = p.id), 0)" for spec in _CONTENT_SPECS.values())}
) AS total_count
FROM profiles p
WHERE EXISTS (
SELECT 1 FROM exercises e WHERE e.created_by = p.id
UNION ALL SELECT 1 FROM training_modules tm WHERE tm.created_by = p.id
UNION ALL SELECT 1 FROM training_framework_programs fp WHERE fp.created_by = p.id
UNION ALL SELECT 1 FROM exercise_progression_graphs pg WHERE pg.created_by = p.id
UNION ALL SELECT 1 FROM training_plan_templates pt WHERE pt.created_by = p.id
UNION ALL SELECT 1 FROM maturity_models mm WHERE mm.created_by = p.id
UNION ALL SELECT 1 FROM media_assets ma WHERE ma.uploaded_by_profile_id = p.id
)
ORDER BY total_count DESC, COALESCE(lower(trim(p.email)), ''), p.id
"""
)
rows = []
for r in cur.fetchall():
d = r2d(r)
counts = {k: int(d.pop(f"{k}_count") or 0) for k in _CONTENT_SPECS}
d["counts_by_type"] = counts
d["total_count"] = int(d.get("total_count") or 0)
rows.append(d)
return rows
@router.get("/items")
def list_user_content_items(
session: dict = Depends(require_auth),
profile_id: Optional[int] = Query(default=None, ge=1),
content_type: str = Query(default="all"),
visibility: Optional[str] = Query(default="all"),
status: Optional[str] = Query(default=None),
search: Optional[str] = Query(default=None, max_length=200),
limit: int = Query(default=50, ge=1, le=_MAX_ITEMS_LIMIT),
offset: int = Query(default=0, ge=0),
):
"""Paginierte Inhaltsliste — Superadmin sieht auch private Inhalte."""
_require_superadmin(session)
ct_raw = (content_type or "all").strip().lower()
if ct_raw != "all" and ct_raw not in _CONTENT_SPECS:
raise HTTPException(status_code=400, detail="Ungültiger Inhaltstyp")
vis_raw = (visibility or "all").strip().lower()
if vis_raw not in ("all", *_VALID_VISIBILITY):
raise HTTPException(status_code=400, detail="Ungültiger Sichtbarkeits-Filter")
types = _types_for_filters(ct_raw if ct_raw != "all" else None, status)
if not types:
return {"items": [], "total": 0, "limit": limit, "offset": offset}
unions: List[str] = []
all_params: List[Any] = []
for tkey in types:
spec = _CONTENT_SPECS[tkey]
where: List[str] = ["TRUE"]
params: List[Any] = []
_append_filters(
where,
params,
spec=spec,
profile_id=profile_id,
visibility=vis_raw,
status=(status or "").strip().lower() or None,
search=(search or "").strip() or None,
)
unions.append(_build_type_select(spec, tkey) + " WHERE " + " AND ".join(where))
all_params.extend(params)
union_sql = " UNION ALL ".join(unions)
count_sql = f"SELECT COUNT(*)::int AS c FROM ({union_sql}) sub"
list_sql = f"""
SELECT sub.*,
p.name AS profile_name,
p.email AS profile_email,
c.name AS club_name
FROM ({union_sql}) sub
LEFT JOIN profiles p ON p.id = sub.profile_id
LEFT JOIN clubs c ON c.id = sub.club_id
ORDER BY sub.updated_at DESC NULLS LAST, sub.id DESC
LIMIT %s OFFSET %s
"""
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(count_sql, tuple(all_params))
count_row = cur.fetchone()
total = int(r2d(count_row).get("c") or 0)
cur.execute(list_sql, tuple(all_params + [limit, offset]))
items = []
for r in cur.fetchall():
d = r2d(r)
d["type_label"] = _CONTENT_SPECS[d["content_type"]]["label"]
items.append(d)
return {"items": items, "total": total, "limit": limit, "offset": offset}
@router.patch("/items/{content_type}/{item_id}")
def patch_user_content_item(
content_type: ContentType,
item_id: int,
body: UserContentPatchBody,
session: dict = Depends(require_auth),
):
"""Status und/oder Sichtbarkeit setzen (Superadmin)."""
_require_superadmin(session)
spec = _spec(content_type)
table = spec["table"]
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(f"SELECT * FROM {table} WHERE id = %s", (item_id,))
row = cur.fetchone()
if not row:
raise HTTPException(status_code=404, detail="Inhalt nicht gefunden")
current = r2d(row)
fields: List[str] = []
params: List[Any] = []
if body.status is not None:
st_col = spec.get("status_col")
if not st_col:
raise HTTPException(status_code=400, detail="Dieser Inhaltstyp hat keinen Status")
st = body.status.strip().lower()
if content_type == "exercise" and st not in _VALID_EXERCISE_STATUS:
raise HTTPException(status_code=400, detail="Ungültiger Übungs-Status")
if content_type == "maturity_model" and st not in _VALID_MATURITY_STATUS:
raise HTTPException(status_code=400, detail="Ungültiger Modell-Status")
if content_type == "media_asset" and st not in _VALID_MEDIA_RIGHTS:
raise HTTPException(status_code=400, detail="Ungültiger Medien-Rechte-Status")
fields.append(f"{st_col} = %s")
params.append(st)
if body.visibility is not None:
vis_col = spec.get("visibility_col")
if not vis_col:
raise HTTPException(status_code=400, detail="Dieser Inhaltstyp hat keine Sichtbarkeit")
vis = body.visibility.strip().lower()
if vis not in _VALID_VISIBILITY:
raise HTTPException(status_code=400, detail="Ungültige Sichtbarkeit")
if vis == "club" and not current.get(spec.get("club_col") or "club_id"):
raise HTTPException(
status_code=400,
detail="Vereins-Sichtbarkeit erfordert eine Vereinszuordnung (club_id).",
)
fields.append(f"{vis_col} = %s")
params.append(vis)
if body.lifecycle_state is not None:
if content_type != "media_asset":
raise HTTPException(status_code=400, detail="Lifecycle nur für Medien")
lc = body.lifecycle_state.strip().lower()
if lc not in _VALID_MEDIA_LIFECYCLE:
raise HTTPException(status_code=400, detail="Ungültiger Lifecycle-Status")
fields.append("lifecycle_state = %s")
params.append(lc)
if lc == "active":
fields.extend(
[
"trash_soft_at = NULL",
"trash_hidden_at = NULL",
"purge_after_at = NULL",
]
)
if not fields:
raise HTTPException(status_code=400, detail="Keine gültigen Änderungen")
fields.append("updated_at = NOW()")
params.append(item_id)
cur.execute(
f"UPDATE {table} SET {', '.join(fields)} WHERE id = %s RETURNING id",
tuple(params),
)
if not cur.fetchone():
raise HTTPException(status_code=404, detail="Inhalt nicht gefunden")
conn.commit()
return {"ok": True, "content_type": content_type, "id": item_id}
@router.delete("/items/{content_type}/{item_id}")
def delete_user_content_item(
content_type: ContentType,
item_id: int,
session: dict = Depends(require_auth),
):
"""Inhalt endgültig löschen (Superadmin)."""
_require_superadmin(session)
spec = _spec(content_type)
table = spec["table"]
with get_db() as conn:
cur = get_cursor(conn)
if content_type == "exercise":
cur.execute("SELECT id FROM exercises WHERE id = %s", (item_id,))
if not cur.fetchone():
raise HTTPException(status_code=404, detail="Übung nicht gefunden")
usage_msg = _exercise_delete_usage_message(cur, item_id)
if usage_msg:
raise HTTPException(status_code=409, detail=usage_msg)
cur.execute("DELETE FROM exercises WHERE id = %s", (item_id,))
conn.commit()
return {"ok": True}
if content_type == "media_asset":
cur.execute("SELECT id FROM media_assets WHERE id = %s", (item_id,))
if not cur.fetchone():
raise HTTPException(status_code=404, detail="Medium nicht gefunden")
ok = superadmin_hard_delete_media_asset(cur, conn, item_id)
if not ok:
raise HTTPException(status_code=404, detail="Medium nicht gefunden")
return {"ok": True}
cur.execute(f"DELETE FROM {table} WHERE id = %s RETURNING id", (item_id,))
if not cur.fetchone():
raise HTTPException(status_code=404, detail="Inhalt nicht gefunden")
conn.commit()
return {"ok": True, "content_type": content_type, "id": item_id}

View File

@ -26,6 +26,7 @@ EXEMPT_ROUTERS: frozenset[str] = frozenset(
"ai_skill_retrieval_admin.py", # Superadmin-Plattform-Konfiguration Skill-KI-Retrieval; require_auth + is_superadmin — kein Vereinsmandant
"ai_prompts_admin.py", # Superadmin ai_prompts; require_auth + is_superadmin — kein Vereinsmandant
"exercise_enrichment_admin.py", # Superadmin Batch-Übungs-Anreicherung KI; require_auth + is_superadmin — kein Vereinsmandant
"admin_user_content.py", # Superadmin Moderation nutzerangelegter Inhalte; require_auth + is_superadmin — kein Vereinsmandant
"catalogs.py",
"skills.py",
"maturity_models.py",

View File

@ -57,6 +57,7 @@ const AdminLegalDocumentsPage = lazy(() => import('./pages/AdminLegalDocumentsPa
const AdminAiSkillRetrievalPage = lazy(() => import('./pages/AdminAiSkillRetrievalPage'))
const AdminAiPromptsPage = lazy(() => import('./pages/AdminAiPromptsPage'))
const AdminExerciseEnrichmentPage = lazy(() => import('./pages/AdminExerciseEnrichmentPage'))
const AdminUserContentPage = lazy(() => import('./pages/AdminUserContentPage'))
const SettingsLegalPage = lazy(() => import('./pages/SettingsLegalPage'))
/** Shield „Admin“: nur Super-Admin (global). Vereinsorga: Vereine → Mitglieder. */
@ -327,6 +328,14 @@ const appRouter = createBrowserRouter([
</PlatformAdminRoute>
),
},
{
path: 'admin/user-content',
element: (
<PlatformAdminRoute>
<AdminUserContentPage />
</PlatformAdminRoute>
),
},
{ path: 'trainer-contexts', element: <TrainerContextsPage /> },
],
},

View File

@ -1,5 +1,5 @@
import { NavLink } from 'react-router-dom'
import { TreePine, FolderTree, Download, Grid3x3, Users, Scale, Brain, Sparkles, Wand2 } from 'lucide-react'
import { TreePine, FolderTree, Download, Grid3x3, Users, Scale, Sparkles, Wand2, Activity } from 'lucide-react'
/**
* Admin-Seiten-Navigation (horizontal) nur für Super-Admins (globaler Portal-Mandant).
@ -8,6 +8,7 @@ export default function AdminPageNav() {
const pages = [
{ to: '/admin/hierarchy', label: 'Hierarchie', icon: TreePine },
{ to: '/admin/users', label: 'Nutzer', icon: Users },
{ to: '/admin/user-content', label: 'Nutzer-Inhalte', icon: Activity },
{ 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 },

View File

@ -0,0 +1,681 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { Link, Navigate } from 'react-router-dom'
import { useAuth } from '../context/AuthContext'
import api from '../utils/api'
import AdminPageNav from '../components/AdminPageNav'
const VISIBILITY_OPTIONS = [
{ value: 'all', label: 'Alle Sichtbarkeiten' },
{ value: 'private', label: 'Privat' },
{ value: 'club', label: 'Verein' },
{ value: 'official', label: 'Offiziell' },
]
const VISIBILITY_LABEL = {
private: 'Privat',
club: 'Verein',
official: 'Offiziell',
}
const STATUS_LABELS = {
draft: 'Entwurf',
in_review: 'In Prüfung',
approved: 'Freigegeben',
archived: 'Archiviert',
active: 'Aktiv',
legacy_unreviewed: 'Rechte ungeprüft',
declared: 'Rechte erklärt',
blocked: 'Gesperrt',
}
const LIFECYCLE_LABELS = {
active: 'Aktiv',
trash_soft: 'Papierkorb (soft)',
trash_hidden: 'Papierkorb (hidden)',
}
function formatDate(value) {
if (!value) return '—'
try {
return new Date(value).toLocaleString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
} catch {
return String(value)
}
}
function contentLink(item) {
const id = item.id
switch (item.content_type) {
case 'exercise':
return `/exercises/${id}`
case 'training_module':
return `/planning/training-modules/${id}`
case 'framework_program':
return `/planning/framework-programs/${id}`
case 'plan_template':
return `/planning/plan-templates/${id}`
case 'maturity_model':
return '/admin/maturity-models'
case 'media_asset':
return '/media'
default:
return null
}
}
function statusOptionsForType(meta, contentType) {
const t = meta?.content_types?.find((x) => x.id === contentType)
return (t?.status_values || []).map((v) => ({
value: v,
label: STATUS_LABELS[v] || v,
}))
}
function EditModal({ open, item, meta, onClose, onSaved }) {
const [status, setStatus] = useState('')
const [visibility, setVisibility] = useState('')
const [lifecycle, setLifecycle] = useState('')
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
useEffect(() => {
if (!item) return
setStatus(item.status || '')
setVisibility(item.visibility || '')
setLifecycle(item.extra_status || 'active')
setError('')
}, [item])
if (!open || !item) return null
const typeMeta = meta?.content_types?.find((x) => x.id === item.content_type)
const statusOpts = statusOptionsForType(meta, item.content_type)
const submit = async () => {
setSaving(true)
setError('')
try {
const body = {}
if (typeMeta?.has_status && status && status !== item.status) body.status = status
if (typeMeta?.has_visibility && visibility && visibility !== item.visibility) {
body.visibility = visibility
}
if (item.content_type === 'media_asset' && lifecycle && lifecycle !== item.extra_status) {
body.lifecycle_state = lifecycle
}
if (!Object.keys(body).length) {
onClose()
return
}
await api.patchAdminUserContentItem(item.content_type, item.id, body)
await onSaved()
onClose()
} catch (e) {
setError(e.message || String(e))
} finally {
setSaving(false)
}
}
return (
<div className="modal-overlay" role="dialog" aria-modal="true">
<div className="card modal-card" style={{ maxWidth: 480, width: '100%' }}>
<h2 style={{ marginTop: 0 }}>Inhalt bearbeiten</h2>
<p className="text-muted" style={{ marginTop: 0 }}>
{item.type_label} · #{item.id}
</p>
<p style={{ fontWeight: 600 }}>{item.title || '—'}</p>
{typeMeta?.has_status ? (
<div className="form-row" style={{ marginTop: 16 }}>
<label className="form-label" htmlFor="uc-status">
Status
</label>
<select
id="uc-status"
className="form-input"
value={status}
onChange={(e) => setStatus(e.target.value)}
>
{statusOpts.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
</div>
) : null}
{typeMeta?.has_visibility ? (
<div className="form-row">
<label className="form-label" htmlFor="uc-vis">
Sichtbarkeit
</label>
<select
id="uc-vis"
className="form-input"
value={visibility}
onChange={(e) => setVisibility(e.target.value)}
>
{VISIBILITY_OPTIONS.filter((o) => o.value !== 'all').map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
</div>
) : null}
{item.content_type === 'media_asset' ? (
<div className="form-row">
<label className="form-label" htmlFor="uc-lc">
Lifecycle
</label>
<select
id="uc-lc"
className="form-input"
value={lifecycle}
onChange={(e) => setLifecycle(e.target.value)}
>
{Object.entries(LIFECYCLE_LABELS).map(([v, label]) => (
<option key={v} value={v}>
{label}
</option>
))}
</select>
</div>
) : null}
{error ? (
<p style={{ color: 'var(--danger)', marginTop: 12 }} role="alert">
{error}
</p>
) : null}
<div style={{ display: 'flex', gap: 8, marginTop: 20, justifyContent: 'flex-end' }}>
<button type="button" className="btn btn-secondary" onClick={onClose} disabled={saving}>
Abbrechen
</button>
<button type="button" className="btn btn-primary" onClick={submit} disabled={saving}>
{saving ? 'Speichern…' : 'Speichern'}
</button>
</div>
</div>
</div>
)
}
export default function AdminUserContentPage() {
const { user } = useAuth()
const isSuperadmin = user?.role === 'superadmin'
const [meta, setMeta] = useState(null)
const [userSummary, setUserSummary] = useState([])
const [items, setItems] = useState([])
const [total, setTotal] = useState(0)
const [loading, setLoading] = useState(true)
const [itemsLoading, setItemsLoading] = useState(false)
const [error, setError] = useState('')
const [profileId, setProfileId] = useState('')
const [contentType, setContentType] = useState('all')
const [visibility, setVisibility] = useState('all')
const [status, setStatus] = useState('')
const [search, setSearch] = useState('')
const [offset, setOffset] = useState(0)
const limit = 50
const [editItem, setEditItem] = useState(null)
const contentTypeOptions = useMemo(() => {
const base = [{ value: 'all', label: 'Alle Typen' }]
for (const t of meta?.content_types || []) {
base.push({ value: t.id, label: t.label })
}
return base
}, [meta])
const statusFilterOptions = useMemo(() => {
if (contentType === 'all') {
return [
{ value: '', label: 'Beliebiger Status' },
{ value: 'draft', label: STATUS_LABELS.draft },
{ value: 'in_review', label: STATUS_LABELS.in_review },
{ value: 'approved', label: STATUS_LABELS.approved },
{ value: 'archived', label: STATUS_LABELS.archived },
{ value: 'active', label: STATUS_LABELS.active },
{ value: 'legacy_unreviewed', label: STATUS_LABELS.legacy_unreviewed },
]
}
return [
{ value: '', label: 'Beliebiger Status' },
...statusOptionsForType(meta, contentType),
]
}, [contentType, meta])
const loadSummary = useCallback(async () => {
const [m, s] = await Promise.all([api.getAdminUserContentMeta(), api.listAdminUserContentSummary()])
setMeta(m)
setUserSummary(Array.isArray(s) ? s : [])
}, [])
const loadItems = useCallback(async (forcedOffset) => {
setItemsLoading(true)
try {
const params = {
content_type: contentType,
visibility,
limit,
offset: forcedOffset ?? offset,
}
if (profileId) params.profile_id = Number(profileId)
if (status) params.status = status
if (search.trim()) params.search = search.trim()
const res = await api.listAdminUserContentItems(params)
setItems(Array.isArray(res?.items) ? res.items : [])
setTotal(Number(res?.total) || 0)
} finally {
setItemsLoading(false)
}
}, [contentType, visibility, status, search, profileId, offset])
useEffect(() => {
if (!isSuperadmin) return
let cancelled = false
;(async () => {
setLoading(true)
setError('')
try {
await loadSummary()
} catch (e) {
if (!cancelled) setError(e.message || String(e))
} finally {
if (!cancelled) setLoading(false)
}
})()
return () => {
cancelled = true
}
}, [isSuperadmin, loadSummary])
useEffect(() => {
if (!isSuperadmin) return
let cancelled = false
;(async () => {
try {
await loadItems()
} catch (e) {
if (!cancelled) setError(e.message || String(e))
}
})()
return () => {
cancelled = true
}
}, [isSuperadmin, loadItems])
const applyFilters = () => {
setOffset(0)
loadItems(0)
}
const handleDelete = async (item) => {
const label = item.title || `${item.type_label} #${item.id}`
if (
!confirm(
`${label}" wirklich endgültig löschen?\n\nDiese Aktion kann nicht rückgängig gemacht werden.`,
)
) {
return
}
try {
await api.deleteAdminUserContentItem(item.content_type, item.id)
await Promise.all([loadItems(), loadSummary()])
} catch (e) {
alert(e.message || String(e))
}
}
if (!isSuperadmin) return <Navigate to="/" replace />
return (
<div className="page" style={{ paddingBottom: 96 }}>
<AdminPageNav />
<header style={{ marginBottom: 20 }}>
<h1 style={{ margin: '0 0 8px' }}>Nutzer-Inhalte</h1>
<p className="text-muted" style={{ margin: 0 }}>
Aktivitäten aller Nutzer einsehen inklusive privater Inhalte. Status setzen oder Inhalte
löschen (nur Superadmin).
</p>
</header>
{error ? (
<div className="card" style={{ borderColor: 'var(--danger)', marginBottom: 16 }}>
<p style={{ margin: 0, color: 'var(--danger)' }}>{error}</p>
</div>
) : null}
{loading ? (
<div className="spinner" aria-label="Laden" />
) : (
<>
<section className="card" style={{ marginBottom: 16 }}>
<h2 style={{ marginTop: 0, fontSize: '1.05rem' }}>Aktivität je Nutzer</h2>
{userSummary.length === 0 ? (
<p className="text-muted" style={{ margin: 0 }}>
Noch keine nutzerangelegten Inhalte.
</p>
) : (
<div style={{ overflowX: 'auto' }}>
<table className="data-table" style={{ width: '100%', minWidth: 640 }}>
<thead>
<tr>
<th>Nutzer</th>
<th>Gesamt</th>
{(meta?.content_types || []).map((t) => (
<th key={t.id}>{t.label}</th>
))}
<th></th>
</tr>
</thead>
<tbody>
{userSummary.map((u) => (
<tr key={u.id}>
<td>
<div style={{ fontWeight: 600 }}>{u.name || `Profil #${u.id}`}</div>
<div className="text-muted" style={{ fontSize: '0.85rem' }}>
{u.email || '—'}
</div>
</td>
<td>
<strong>{u.total_count}</strong>
</td>
{(meta?.content_types || []).map((t) => (
<td key={t.id}>{u.counts_by_type?.[t.id] ?? 0}</td>
))}
<td>
<button
type="button"
className="btn btn-secondary"
style={{ fontSize: '0.85rem', padding: '4px 10px' }}
onClick={() => {
setProfileId(String(u.id))
setOffset(0)
}}
>
Filtern
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
<section className="card">
<h2 style={{ marginTop: 0, fontSize: '1.05rem' }}>Inhalte</h2>
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(160px, 1fr))',
gap: 12,
marginBottom: 16,
}}
>
<div>
<label className="form-label" htmlFor="uc-user">
Nutzer
</label>
<select
id="uc-user"
className="form-input"
value={profileId}
onChange={(e) => {
setProfileId(e.target.value)
setOffset(0)
}}
>
<option value="">Alle Nutzer</option>
{userSummary.map((u) => (
<option key={u.id} value={u.id}>
{u.name || u.email || `#${u.id}`}
</option>
))}
</select>
</div>
<div>
<label className="form-label" htmlFor="uc-type">
Typ
</label>
<select
id="uc-type"
className="form-input"
value={contentType}
onChange={(e) => {
setContentType(e.target.value)
setStatus('')
setOffset(0)
}}
>
{contentTypeOptions.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
</div>
<div>
<label className="form-label" htmlFor="uc-vis-filter">
Sichtbarkeit
</label>
<select
id="uc-vis-filter"
className="form-input"
value={visibility}
onChange={(e) => {
setVisibility(e.target.value)
setOffset(0)
}}
>
{VISIBILITY_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
</div>
<div>
<label className="form-label" htmlFor="uc-status-filter">
Status
</label>
<select
id="uc-status-filter"
className="form-input"
value={status}
onChange={(e) => {
setStatus(e.target.value)
setOffset(0)
}}
>
{statusFilterOptions.map((o) => (
<option key={o.value || '_any'} value={o.value}>
{o.label}
</option>
))}
</select>
</div>
<div style={{ gridColumn: 'span 2' }}>
<label className="form-label" htmlFor="uc-search">
Suche (Titel)
</label>
<div style={{ display: 'flex', gap: 8 }}>
<input
id="uc-search"
className="form-input"
value={search}
onChange={(e) => setSearch(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && applyFilters()}
placeholder="Titel oder Dateiname…"
/>
<button type="button" className="btn btn-primary" onClick={applyFilters}>
Suchen
</button>
</div>
</div>
</div>
{itemsLoading ? (
<div className="spinner" aria-label="Inhalte laden" />
) : items.length === 0 ? (
<p className="text-muted">Keine Inhalte für die aktuellen Filter.</p>
) : (
<div style={{ overflowX: 'auto' }}>
<table className="data-table" style={{ width: '100%', minWidth: 900 }}>
<thead>
<tr>
<th>Typ</th>
<th>Titel</th>
<th>Nutzer</th>
<th>Sichtbarkeit</th>
<th>Status</th>
<th>Aktualisiert</th>
<th></th>
</tr>
</thead>
<tbody>
{items.map((item) => {
const href = contentLink(item)
return (
<tr key={`${item.content_type}-${item.id}`}>
<td>{item.type_label}</td>
<td>
<div style={{ fontWeight: 600, maxWidth: 280 }}>
{href ? (
<Link to={href}>{item.title || '—'}</Link>
) : (
item.title || '—'
)}
</div>
<div className="text-muted" style={{ fontSize: '0.8rem' }}>
#{item.id}
{item.club_name ? ` · ${item.club_name}` : ''}
</div>
</td>
<td>
<div>{item.profile_name || '—'}</div>
<div className="text-muted" style={{ fontSize: '0.8rem' }}>
{item.profile_email || (item.profile_id ? `#${item.profile_id}` : '—')}
</div>
</td>
<td>
{item.visibility ? (
<span
style={{
color:
item.visibility === 'private' ? 'var(--text2)' : 'var(--accent)',
}}
>
{VISIBILITY_LABEL[item.visibility] || item.visibility}
</span>
) : (
'—'
)}
</td>
<td>
{item.status ? STATUS_LABELS[item.status] || item.status : '—'}
{item.extra_status && item.extra_status !== 'active' ? (
<div className="text-muted" style={{ fontSize: '0.8rem' }}>
{LIFECYCLE_LABELS[item.extra_status] || item.extra_status}
</div>
) : null}
</td>
<td style={{ whiteSpace: 'nowrap' }}>{formatDate(item.updated_at)}</td>
<td>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
<button
type="button"
className="btn btn-secondary"
style={{ fontSize: '0.8rem', padding: '4px 8px' }}
onClick={() => setEditItem(item)}
>
Bearbeiten
</button>
<button
type="button"
className="btn btn-secondary"
style={{
fontSize: '0.8rem',
padding: '4px 8px',
color: 'var(--danger)',
}}
onClick={() => handleDelete(item)}
>
Löschen
</button>
</div>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)}
{total > limit ? (
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginTop: 16,
}}
>
<span className="text-muted" style={{ fontSize: '0.9rem' }}>
{total} Einträge · Seite {Math.floor(offset / limit) + 1} von{' '}
{Math.ceil(total / limit)}
</span>
<div style={{ display: 'flex', gap: 8 }}>
<button
type="button"
className="btn btn-secondary"
disabled={offset <= 0}
onClick={() => setOffset(Math.max(0, offset - limit))}
>
Zurück
</button>
<button
type="button"
className="btn btn-secondary"
disabled={offset + limit >= total}
onClick={() => setOffset(offset + limit)}
>
Weiter
</button>
</div>
</div>
) : null}
</section>
</>
)}
<EditModal
open={!!editItem}
item={editItem}
meta={meta}
onClose={() => setEditItem(null)}
onSaved={async () => {
await Promise.all([loadItems(), loadSummary()])
}}
/>
</div>
)
}

View File

@ -50,6 +50,39 @@ export async function listAdminUsers() {
return request('/api/admin/users')
}
/** Superadmin: Metadaten zu nutzerangelegten Inhaltstypen. */
export async function getAdminUserContentMeta() {
return request('/api/admin/user-content/meta')
}
/** Superadmin: Aktivitätsübersicht je Nutzer (Anzahl Inhalte). */
export async function listAdminUserContentSummary() {
return request('/api/admin/user-content/users-summary')
}
/** Superadmin: Inhalte aller Nutzer (inkl. privat) — filterbar. */
export async function listAdminUserContentItems(params = {}) {
const qs = new URLSearchParams()
for (const [k, v] of Object.entries(params)) {
if (v !== undefined && v !== null && v !== '') qs.set(k, String(v))
}
const q = qs.toString()
return request(`/api/admin/user-content/items${q ? `?${q}` : ''}`)
}
/** Superadmin: Status/Sichtbarkeit eines Inhalts setzen. */
export async function patchAdminUserContentItem(contentType, itemId, body) {
return request(`/api/admin/user-content/items/${contentType}/${itemId}`, {
method: 'PATCH',
body: JSON.stringify(body),
})
}
/** Superadmin: Inhalt löschen. */
export async function deleteAdminUserContentItem(contentType, itemId) {
return request(`/api/admin/user-content/items/${contentType}/${itemId}`, { method: 'DELETE' })
}
/** Medien-Speicher (MEDIA_ROOT + relativer Unterordner) — GET: admin/superadmin, PUT: nur superadmin. */
export async function getPlatformMediaStorage() {
return request('/api/admin/platform-media-storage')
@ -819,6 +852,11 @@ export const api = {
getCurrentProfile,
listProfiles,
listAdminUsers,
getAdminUserContentMeta,
listAdminUserContentSummary,
listAdminUserContentItems,
patchAdminUserContentItem,
deleteAdminUserContentItem,
updateProfile,
managementPasswordReset,
changePassword,