shinkan-jinkendo/frontend/src/pages/AdminUserContentPage.jsx
Lars bd5a409fa7
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
Add Admin User Content Management Features
- 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.
2026-06-06 17:53:25 +02:00

682 lines
23 KiB
JavaScript

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>
)
}