shinkan-jinkendo/frontend/src/pages/InboxPage.jsx
Lars 60709df615
All checks were successful
Deploy Development / deploy (push) Successful in 37s
Test Suite / pytest-backend (push) Successful in 35s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 11s
Test Suite / playwright-tests (push) Successful in 58s
feat: Implement Content Reporting Backend
- Added new API endpoints for content reporting, including submission, retrieval, and status updates.
- Created database migration for `content_reports` table to store report data.
- Integrated content reports into the existing admin inbox for better management.
- Implemented validation for report submissions, including required fields and email format.
- Added tests for content reporting functionality, covering various scenarios and edge cases.
- Updated frontend API utility to include new content report methods.
- Bumped app version to 0.8.87 and updated relevant page versions.
2026-05-11 17:54:53 +02:00

638 lines
22 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useState, useEffect, useCallback } from 'react'
import { Link } from 'react-router-dom'
import api from '../utils/api'
import { notifyOrgInboxChanged, useOrgInbox } from '../context/OrgInboxContext'
const CLUB_ROLE_OPTIONS = [
{ code: 'club_admin', label: 'Vereinsadmin' },
{ code: 'trainer', label: 'Trainer' },
{ code: 'division_lead', label: 'Spartenleitung' },
{ code: 'content_editor', label: 'Inhalte bearbeiten' },
]
const REASON_LABELS = {
copyright: 'Urheberrecht',
image_rights: 'Bildrechte',
privacy: 'Datenschutz / Persönlichkeitsrecht',
minors: 'Minderjährige',
illegal_content: 'Rechtswidriger Inhalt',
youth_protection: 'Jugendschutz',
offensive_content: 'Beleidigender Inhalt',
other: 'Sonstiges',
}
const STATUS_LABELS = {
submitted: 'Eingegangen',
under_review: 'In Bearbeitung',
resolved_no_action: 'Abgeschlossen (kein Handlungsbedarf)',
resolved_legal_hold: 'Abgeschlossen (Legal Hold)',
rejected_invalid: 'Abgewiesen (ungültig)',
}
const STATUS_COLORS = {
submitted: 'var(--accent)',
under_review: '#e8960a',
resolved_no_action: 'var(--text3)',
resolved_legal_hold: 'var(--danger)',
rejected_invalid: 'var(--text3)',
}
function formatWhen(iso) {
if (!iso) return ''
const s = String(iso)
const d = s.includes('T') ? s.split('T')[0] : s.slice(0, 10)
const t = s.includes('T') ? s.split('T')[1] : ''
const time = t ? t.slice(0, 5) : ''
return time ? `${d} · ${time}` : d
}
function PriorityBadge({ priority }) {
if (priority !== 'high') return null
return (
<span
style={{
background: 'var(--danger)',
color: '#fff',
borderRadius: '4px',
padding: '2px 7px',
fontSize: '0.72rem',
fontWeight: 600,
marginLeft: '0.4rem',
verticalAlign: 'middle',
}}
>
DRINGEND
</span>
)
}
function ReportDetailModal({ report, onClose, onRefresh, isSuperadmin }) {
const [resolutionNote, setResolutionNote] = useState('')
const [legalHoldNote, setLegalHoldNote] = useState('')
const [saving, setSaving] = useState(false)
const [error, setError] = useState(null)
const [showLegalHoldForm, setShowLegalHoldForm] = useState(false)
const isOpen = report.status === 'submitted' || report.status === 'under_review'
async function handleStatus(status) {
if ((status === 'resolved_no_action' || status === 'rejected_invalid') && !resolutionNote.trim()) {
setError('Bitte eine Begründung eingeben.')
return
}
setSaving(true)
setError(null)
try {
await api.patchContentReport(report.id, {
status,
resolution_note: resolutionNote.trim() || undefined,
})
notifyOrgInboxChanged()
onRefresh()
onClose()
} catch (err) {
setError(err.message || String(err))
} finally {
setSaving(false)
}
}
async function handleUnderReview() {
setSaving(true)
setError(null)
try {
await api.patchContentReport(report.id, { status: 'under_review' })
notifyOrgInboxChanged()
onRefresh()
onClose()
} catch (err) {
setError(err.message || String(err))
} finally {
setSaving(false)
}
}
async function handleLegalHold() {
if (!legalHoldNote.trim()) {
setError('Begründung für Legal Hold erforderlich.')
return
}
setSaving(true)
setError(null)
try {
await api.setLegalHoldFromReport(report.id, { reason_note: legalHoldNote.trim() })
notifyOrgInboxChanged()
onRefresh()
onClose()
} catch (err) {
setError(err.message || String(err))
} finally {
setSaving(false)
}
}
return (
<div
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
background: 'rgba(0,0,0,0.55)',
display: 'flex',
alignItems: 'flex-start',
justifyContent: 'center',
zIndex: 1100,
padding: '1rem',
overflowY: 'auto',
}}
onClick={(e) => { if (e.target === e.currentTarget) onClose() }}
>
<div
style={{
background: 'var(--surface)',
borderRadius: '12px',
padding: '1.5rem',
maxWidth: '560px',
width: '100%',
marginTop: '2rem',
marginBottom: '2rem',
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '1rem' }}>
<h2 style={{ margin: 0 }}>
Meldung #{report.id}
<PriorityBadge priority={report.priority} />
</h2>
<button type="button" className="btn btn-secondary" style={{ padding: '4px 10px' }} onClick={onClose}>✕</button>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.6rem', marginBottom: '1.2rem' }}>
<div>
<span className="muted" style={{ fontSize: '0.8rem' }}>Status</span>
<div style={{ fontWeight: 600, color: STATUS_COLORS[report.status] }}>
{STATUS_LABELS[report.status] || report.status}
</div>
</div>
<div>
<span className="muted" style={{ fontSize: '0.8rem' }}>Ziel</span>
<div>
{report.target_type === 'media_asset' ? 'Medium' : 'Übung'} #{report.target_id}
{report.target_name ? ` – ${report.target_name}` : ''}
</div>
</div>
<div>
<span className="muted" style={{ fontSize: '0.8rem' }}>Meldegrund</span>
<div>{REASON_LABELS[report.report_reason] || report.report_reason}</div>
</div>
<div>
<span className="muted" style={{ fontSize: '0.8rem' }}>Beschreibung</span>
<div style={{ whiteSpace: 'pre-wrap' }}>{report.report_description}</div>
</div>
<div>
<span className="muted" style={{ fontSize: '0.8rem' }}>Meldende Person</span>
<div>
{report.reporter_name}
{report.reporter_email ? ` · ${report.reporter_email}` : ''}
{report.reporter_profile_id ? ` · Profil #${report.reporter_profile_id}` : ' · anonym'}
</div>
</div>
<div>
<span className="muted" style={{ fontSize: '0.8rem' }}>Eingegangen</span>
<div>{formatWhen(report.submitted_at || report.created_at)}</div>
</div>
{report.resolution_note && (
<div>
<span className="muted" style={{ fontSize: '0.8rem' }}>Begründung</span>
<div style={{ whiteSpace: 'pre-wrap' }}>{report.resolution_note}</div>
</div>
)}
{report.legal_hold_active && (
<div style={{ background: 'rgba(216,90,48,0.1)', borderRadius: '8px', padding: '0.6rem 0.8rem' }}>
<span style={{ color: 'var(--danger)', fontWeight: 600 }}>Legal Hold aktiv</span>
</div>
)}
</div>
{error && (
<div style={{ color: 'var(--danger)', marginBottom: '0.75rem', fontSize: '0.9rem' }}>{error}</div>
)}
{isOpen && (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
{report.status === 'submitted' && (
<button type="button" className="btn btn-primary" onClick={handleUnderReview} disabled={saving}>
In Bearbeitung nehmen
</button>
)}
<div className="form-row">
<label className="form-label">Begründung (erforderlich für Abschluss/Abweisung)</label>
<textarea
className="form-input"
rows={2}
value={resolutionNote}
onChange={(e) => setResolutionNote(e.target.value)}
placeholder="Kurze Begründung der Entscheidung…"
/>
</div>
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
<button
type="button"
className="btn btn-secondary"
onClick={() => handleStatus('resolved_no_action')}
disabled={saving}
>
Kein Handlungsbedarf
</button>
<button
type="button"
className="btn btn-secondary"
onClick={() => handleStatus('rejected_invalid')}
disabled={saving}
>
Meldung abweisen
</button>
</div>
{isSuperadmin && !showLegalHoldForm && (
<button
type="button"
className="btn"
style={{ background: 'rgba(216,90,48,0.12)', color: 'var(--danger)', border: '1px solid var(--danger)' }}
onClick={() => setShowLegalHoldForm(true)}
>
Legal Hold setzen (Superadmin)
</button>
)}
{isSuperadmin && showLegalHoldForm && (
<div style={{ border: '1px solid var(--danger)', borderRadius: '8px', padding: '0.75rem' }}>
<div className="form-row">
<label className="form-label" style={{ color: 'var(--danger)' }}>Begründung Legal Hold *</label>
<textarea
className="form-input"
rows={2}
value={legalHoldNote}
onChange={(e) => setLegalHoldNote(e.target.value)}
placeholder="Rechtliche Begründung für den Legal Hold…"
/>
</div>
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '0.5rem' }}>
<button
type="button"
className="btn"
style={{ background: 'var(--danger)', color: '#fff', flex: 1 }}
onClick={handleLegalHold}
disabled={saving}
>
Legal Hold bestätigen
</button>
<button type="button" className="btn btn-secondary" onClick={() => setShowLegalHoldForm(false)}>
Abbrechen
</button>
</div>
</div>
)}
</div>
)}
{!isOpen && (
<p className="muted" style={{ margin: 0, fontSize: '0.9rem' }}>
Diese Meldung ist bereits abgeschlossen.
</p>
)}
</div>
</div>
)
}
export default function InboxPage() {
const {
canAccessOrgInbox,
canAccessContentReports,
isSuperadmin,
refreshOrgInbox,
inboxJoinRequests,
contentReports,
contentReportCount,
} = useOrgInbox()
const [loading, setLoading] = useState(true)
const [acceptModal, setAcceptModal] = useState(null)
const [reportModal, setReportModal] = useState(null)
const load = useCallback(async () => {
if (!canAccessOrgInbox && !canAccessContentReports) {
setLoading(false)
return
}
setLoading(true)
try {
await refreshOrgInbox()
} finally {
setLoading(false)
}
}, [canAccessOrgInbox, canAccessContentReports, refreshOrgInbox])
useEffect(() => {
load()
}, [load])
if (!canAccessOrgInbox && !canAccessContentReports) {
return (
<div className="app-page">
<h1 className="page-title">Posteingang</h1>
<p className="muted">Kein Zugriff. Nur Plattform-Admins und Vereinsadmins sehen den Posteingang.</p>
<p>
<Link to="/">Zur Übersicht</Link>
</p>
</div>
)
}
return (
<div className="app-page inbox-page">
<div className="inbox-page__header">
<div>
<h1 className="page-title" style={{ marginBottom: '6px' }}>
Posteingang
</h1>
<p className="muted" style={{ marginTop: 0 }}>
Beitrittsanträge und Inhaltsmeldungen für deine Zuständigkeitsbereiche.
</p>
</div>
<button type="button" className="btn btn-secondary" onClick={() => load()} disabled={loading}>
Aktualisieren
</button>
</div>
{loading ? (
<div style={{ padding: '2rem', textAlign: 'center' }}>
<div className="spinner" />
</div>
) : (
<>
{/* Abschnitt 1: Beitrittsanträge */}
{canAccessOrgInbox && (
<section style={{ marginBottom: '2rem' }}>
<h2 style={{ fontSize: '1rem', fontWeight: 600, marginBottom: '0.75rem', color: 'var(--text2)' }}>
Beitrittsanträge
{inboxJoinRequests.length > 0 && (
<span
style={{
background: 'var(--accent)',
color: '#fff',
borderRadius: '12px',
padding: '1px 8px',
fontSize: '0.75rem',
marginLeft: '0.5rem',
}}
>
{inboxJoinRequests.length}
</span>
)}
</h2>
{inboxJoinRequests.length === 0 ? (
<div className="card" style={{ padding: '1.25rem' }}>
<p style={{ margin: 0 }} className="muted">Keine offenen Beitrittsanträge.</p>
</div>
) : (
<div className="inbox-page__list">
{inboxJoinRequests.map((req) => (
<div key={`${req.club_id}-${req.id}`} className="card inbox-request-card">
<div className="inbox-request-card__main">
<div className="inbox-request-card__club">
{req.club_name || 'Verein'}
{req.club_abbreviation ? (
<span className="muted" style={{ marginLeft: '0.35rem' }}>
({req.club_abbreviation})
</span>
) : null}
</div>
<strong className="inbox-request-card__applicant">
{req.applicant_name || req.applicant_email || 'Bewerber/in'}
</strong>
<div className="muted inbox-request-card__meta">
{req.applicant_email} · Profil #{req.profile_id} · {formatWhen(req.created_at)}
</div>
{req.message ? <p className="inbox-request-card__message">{req.message}</p> : null}
</div>
<div className="inbox-request-card__actions">
<button
type="button"
className="btn btn-primary"
onClick={() =>
setAcceptModal({
id: req.id,
club_id: req.club_id,
label: req.applicant_name || req.applicant_email,
roles: ['trainer'],
})
}
>
Annehmen
</button>
<button
type="button"
className="btn btn-secondary"
onClick={async () => {
if (!confirm('Antrag ablehnen?')) return
try {
await api.rejectClubJoinRequest(req.club_id, req.id)
notifyOrgInboxChanged()
await load()
} catch (err) {
alert(err.message || String(err))
}
}}
>
Ablehnen
</button>
</div>
</div>
))}
</div>
)}
</section>
)}
{/* Abschnitt 2: Inhaltsmeldungen (nur Plattform-Admins) */}
{canAccessContentReports && (
<section>
<h2 style={{ fontSize: '1rem', fontWeight: 600, marginBottom: '0.75rem', color: 'var(--text2)' }}>
Inhaltsmeldungen
{contentReportCount > 0 && (
<span
style={{
background: 'var(--danger)',
color: '#fff',
borderRadius: '12px',
padding: '1px 8px',
fontSize: '0.75rem',
marginLeft: '0.5rem',
}}
>
{contentReportCount} neu
</span>
)}
</h2>
{contentReports.length === 0 ? (
<div className="card" style={{ padding: '1.25rem' }}>
<p style={{ margin: 0 }} className="muted">Keine Inhaltsmeldungen.</p>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
{contentReports.map((rep) => (
<div
key={rep.id}
className="card"
style={{
padding: '1rem 1.25rem',
cursor: 'pointer',
borderLeft: rep.priority === 'high' ? '3px solid var(--danger)' : '3px solid transparent',
}}
onClick={() => setReportModal(rep)}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: '0.5rem' }}>
<div>
<span style={{ fontWeight: 600 }}>
Meldung #{rep.id}
<PriorityBadge priority={rep.priority} />
</span>
<span className="muted" style={{ marginLeft: '0.75rem', fontSize: '0.85rem' }}>
{rep.target_type === 'media_asset' ? 'Medium' : 'Übung'} #{rep.target_id}
{rep.target_name ? ` – ${rep.target_name}` : ''}
</span>
</div>
<span
style={{
fontSize: '0.78rem',
fontWeight: 500,
color: STATUS_COLORS[rep.status],
whiteSpace: 'nowrap',
}}
>
{STATUS_LABELS[rep.status] || rep.status}
</span>
</div>
<div className="muted" style={{ fontSize: '0.85rem', marginTop: '0.3rem' }}>
{REASON_LABELS[rep.report_reason] || rep.report_reason}
{' · '}
{rep.reporter_name}
{rep.reporter_profile_id ? ` (Profil #${rep.reporter_profile_id})` : ' (anonym)'}
{' · '}
{formatWhen(rep.submitted_at || rep.created_at)}
</div>
</div>
))}
</div>
)}
</section>
)}
</>
)}
{acceptModal && (
<div
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
background: 'rgba(0,0,0,0.5)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1100,
padding: '1rem',
}}
>
<div
style={{
background: 'var(--surface)',
borderRadius: '12px',
padding: '1.5rem',
maxWidth: '480px',
width: '100%',
}}
>
<h2 style={{ marginTop: 0 }}>Antrag annehmen</h2>
<p style={{ color: 'var(--text2)' }}>{acceptModal.label}</p>
<div className="form-row">
<span className="form-label">Rollen bei Aufnahme</span>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem' }}>
{CLUB_ROLE_OPTIONS.map((opt) => (
<label key={opt.code} style={{ display: 'flex', alignItems: 'center', gap: '0.35rem' }}>
<input
type="checkbox"
checked={acceptModal.roles.includes(opt.code)}
onChange={() => {
setAcceptModal((prev) => {
if (!prev) return prev
const set = new Set(prev.roles)
if (set.has(opt.code)) set.delete(opt.code)
else set.add(opt.code)
const roles = Array.from(set)
return { ...prev, roles: roles.length ? roles : ['trainer'] }
})
}}
/>
{opt.label}
</label>
))}
</div>
</div>
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '1rem' }}>
<button
type="button"
className="btn btn-primary"
style={{ flex: 1 }}
onClick={async () => {
try {
await api.acceptClubJoinRequest(
acceptModal.club_id,
acceptModal.id,
acceptModal.roles.length ? acceptModal.roles : ['trainer']
)
setAcceptModal(null)
notifyOrgInboxChanged()
await load()
} catch (err) {
alert(err.message || String(err))
}
}}
>
Aufnehmen
</button>
<button type="button" className="btn btn-secondary" onClick={() => setAcceptModal(null)}>
Abbrechen
</button>
</div>
</div>
</div>
)}
{reportModal && (
<ReportDetailModal
report={reportModal}
isSuperadmin={isSuperadmin}
onClose={() => setReportModal(null)}
onRefresh={load}
/>
)}
</div>
)
}