Refactor Org Inbox Context and Enhance Club Creation Management
Some checks failed
Deploy Development / deploy (push) Successful in 47s
Test Suite / pytest-backend (push) Successful in 42s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 13s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Failing after 1m21s

- Updated the OrgInboxContext to include handling for club creation requests, allowing for better management of inbox items.
- Refactored components to utilize the new `canShowInboxNav` and `canAccessClubCreationInbox` flags for improved access control.
- Enhanced the InboxPage to display club creation requests with appropriate actions for approval and rejection.
- Updated the DashboardOrgInboxWidget to show both club creation and join requests, improving the user interface for managing inbox items.
This commit is contained in:
Lars 2026-06-07 07:18:43 +02:00
parent 8ee8f52e0f
commit 37785135b1
5 changed files with 206 additions and 28 deletions

View File

@ -88,9 +88,9 @@ function AppRouteFallback() {
// Bottom Navigation (Mobile) // Bottom Navigation (Mobile)
function Nav({ showAdminNav, onboardingOnly }) { function Nav({ showAdminNav, onboardingOnly }) {
const { canAccessOrgInbox, inboxCount } = useOrgInbox() const { canShowInboxNav, inboxCount } = useOrgInbox()
const items = getMainNavItems(showAdminNav, { const items = getMainNavItems(showAdminNav, {
showInbox: canAccessOrgInbox, showInbox: canShowInboxNav,
onboardingOnly, onboardingOnly,
}) })
const loc = useLocation() const loc = useLocation()

View File

@ -6,11 +6,30 @@ import { useOrgInbox } from '../context/OrgInboxContext'
* Desktop-Dashboard: Hinweis auf offene Beitrittsanträge (nur ab 1024px sichtbar via CSS). * Desktop-Dashboard: Hinweis auf offene Beitrittsanträge (nur ab 1024px sichtbar via CSS).
*/ */
export default function DashboardOrgInboxWidget() { export default function DashboardOrgInboxWidget() {
const { canAccessOrgInbox, inboxJoinRequests, inboxCount } = useOrgInbox() const {
canShowInboxNav,
inboxJoinRequests,
inboxClubCreationRequests,
clubCreationRequestCount,
inboxCount,
} = useOrgInbox()
if (!canAccessOrgInbox) return null if (!canShowInboxNav) return null
const preview = (inboxJoinRequests || []).slice(0, 5) const preview = [
...(inboxClubCreationRequests || []).map((req) => ({
key: `creation-${req.id}`,
club: req.proposed_name || 'Neuer Verein',
applicant: req.applicant_name || req.applicant_email || 'Antragsteller/in',
kind: 'creation',
})),
...(inboxJoinRequests || []).map((req) => ({
key: `${req.club_id}-${req.id}`,
club: req.club_name || 'Verein',
applicant: req.applicant_name || req.applicant_email || 'Bewerber/in',
kind: 'join',
})),
].slice(0, 5)
return ( return (
<section <section
@ -31,17 +50,27 @@ export default function DashboardOrgInboxWidget() {
</div> </div>
<p className="muted dashboard-org-inbox-widget__lead"> <p className="muted dashboard-org-inbox-widget__lead">
{inboxCount === 0 {inboxCount === 0
? 'Keine offenen Beitrittsanträge.' ? 'Keine offenen Anträge.'
: `${inboxCount} offene Beitrittsantrag${inboxCount === 1 ? '' : 'e'}.`} : [
clubCreationRequestCount > 0
? `${clubCreationRequestCount} Gründungsantrag${clubCreationRequestCount === 1 ? '' : 'e'}`
: null,
(inboxJoinRequests || []).length > 0
? `${(inboxJoinRequests || []).length} Beitrittsantrag${(inboxJoinRequests || []).length === 1 ? '' : 'e'}`
: null,
]
.filter(Boolean)
.join(' · ')}
</p> </p>
{preview.length > 0 ? ( {preview.length > 0 ? (
<ul className="dashboard-org-inbox-widget__list"> <ul className="dashboard-org-inbox-widget__list">
{preview.map((req) => ( {preview.map((req) => (
<li key={`${req.club_id}-${req.id}`} className="dashboard-org-inbox-widget__item"> <li key={req.key} className="dashboard-org-inbox-widget__item">
<span className="dashboard-org-inbox-widget__club">{req.club_name || 'Verein'}</span> <span className="dashboard-org-inbox-widget__club">
<span className="dashboard-org-inbox-widget__applicant"> {req.kind === 'creation' ? 'Gründung: ' : ''}
{req.applicant_name || req.applicant_email || 'Bewerber/in'} {req.club}
</span> </span>
<span className="dashboard-org-inbox-widget__applicant">{req.applicant}</span>
</li> </li>
))} ))}
</ul> </ul>

View File

@ -19,9 +19,9 @@ export default function DesktopSidebar({
onLogout onLogout
}) { }) {
const loc = useLocation() const loc = useLocation()
const { canAccessOrgInbox, inboxCount } = useOrgInbox() const { canShowInboxNav, inboxCount } = useOrgInbox()
const items = getMainNavItems(showAdminNav, { const items = getMainNavItems(showAdminNav, {
showInbox: canAccessOrgInbox, showInbox: canShowInboxNav,
onboardingOnly, onboardingOnly,
}) })
const tier = user?.tier || '' const tier = user?.tier || ''

View File

@ -17,6 +17,11 @@ export function canAccessOrgInbox(user) {
return activeClubMemberships(user.clubs).some((c) => (c.roles || []).includes('club_admin')) return activeClubMemberships(user.clubs).some((c) => (c.roles || []).includes('club_admin'))
} }
/** Gründungsanträge freigeben — aktuell nur Superadmin (platform.club_creation.approve). */
export function canAccessClubCreationInbox(user) {
return user?.role === 'superadmin'
}
function canSeeContentReports(user) { function canSeeContentReports(user) {
if (user?.role === 'admin' || user?.role === 'superadmin') return true if (user?.role === 'admin' || user?.role === 'superadmin') return true
return activeClubMemberships(user?.clubs || []).some((c) => (c.roles || []).includes('club_admin')) return activeClubMemberships(user?.clubs || []).some((c) => (c.roles || []).includes('club_admin'))
@ -28,8 +33,13 @@ export function notifyOrgInboxChanged() {
} }
/** Eine konsistente Ladepfad-Logik für Join-Requests + Content-Reports (ein Codepfad für Mount + refresh). */ /** Eine konsistente Ladepfad-Logik für Join-Requests + Content-Reports (ein Codepfad für Mount + refresh). */
async function fetchOrgInboxSnapshot(canAccess, canAccessReports) { async function fetchOrgInboxSnapshot(canAccess, canAccessReports, canAccessClubCreation) {
const out = { items: [], contentReports: [], contentReportsError: null } const out = {
items: [],
clubCreationRequests: [],
contentReports: [],
contentReportsError: null,
}
if (canAccess) { if (canAccess) {
try { try {
const data = await api.getInboxJoinRequests() const data = await api.getInboxJoinRequests()
@ -38,6 +48,14 @@ async function fetchOrgInboxSnapshot(canAccess, canAccessReports) {
out.items = [] out.items = []
} }
} }
if (canAccessClubCreation) {
try {
const data = await api.listAdminClubCreationRequests()
out.clubCreationRequests = Array.isArray(data) ? data : []
} catch {
out.clubCreationRequests = []
}
}
if (canAccessReports) { if (canAccessReports) {
try { try {
const data = await api.getInboxContentReports() const data = await api.getInboxContentReports()
@ -52,27 +70,33 @@ async function fetchOrgInboxSnapshot(canAccess, canAccessReports) {
export function OrgInboxProvider({ user, children }) { export function OrgInboxProvider({ user, children }) {
const [items, setItems] = useState([]) const [items, setItems] = useState([])
const [clubCreationRequests, setClubCreationRequests] = useState([])
const [contentReports, setContentReports] = useState([]) const [contentReports, setContentReports] = useState([])
const [contentReportsError, setContentReportsError] = useState(null) const [contentReportsError, setContentReportsError] = useState(null)
const canAccess = useMemo(() => canAccessOrgInbox(user), [user]) const canAccess = useMemo(() => canAccessOrgInbox(user), [user])
const canAccessReports = useMemo(() => canSeeContentReports(user), [user]) const canAccessReports = useMemo(() => canSeeContentReports(user), [user])
const canAccessClubCreation = useMemo(() => canAccessClubCreationInbox(user), [user])
const hasInboxAccess = canAccess || canAccessReports || canAccessClubCreation
const refresh = useCallback(async () => { const refresh = useCallback(async () => {
if (!canAccess && !canAccessReports) { if (!hasInboxAccess) {
setItems([]) setItems([])
setClubCreationRequests([])
setContentReports([]) setContentReports([])
setContentReportsError(null) setContentReportsError(null)
return return
} }
const snap = await fetchOrgInboxSnapshot(canAccess, canAccessReports) const snap = await fetchOrgInboxSnapshot(canAccess, canAccessReports, canAccessClubCreation)
setItems(snap.items) setItems(snap.items)
setClubCreationRequests(snap.clubCreationRequests)
setContentReports(snap.contentReports) setContentReports(snap.contentReports)
setContentReportsError(canAccessReports ? snap.contentReportsError : null) setContentReportsError(canAccessReports ? snap.contentReportsError : null)
}, [canAccess, canAccessReports]) }, [hasInboxAccess, canAccess, canAccessReports, canAccessClubCreation])
useEffect(() => { useEffect(() => {
if (!canAccess && !canAccessReports) { if (!hasInboxAccess) {
setItems([]) setItems([])
setClubCreationRequests([])
setContentReports([]) setContentReports([])
setContentReportsError(null) setContentReportsError(null)
return undefined return undefined
@ -82,9 +106,10 @@ export function OrgInboxProvider({ user, children }) {
let timeoutId = null let timeoutId = null
const load = async () => { const load = async () => {
const snap = await fetchOrgInboxSnapshot(canAccess, canAccessReports) const snap = await fetchOrgInboxSnapshot(canAccess, canAccessReports, canAccessClubCreation)
if (cancelled) return if (cancelled) return
setItems(snap.items) setItems(snap.items)
setClubCreationRequests(snap.clubCreationRequests)
setContentReports(snap.contentReports) setContentReports(snap.contentReports)
setContentReportsError(canAccessReports ? snap.contentReportsError : null) setContentReportsError(canAccessReports ? snap.contentReportsError : null)
} }
@ -116,7 +141,7 @@ export function OrgInboxProvider({ user, children }) {
} }
if (timeoutId != null) window.clearTimeout(timeoutId) if (timeoutId != null) window.clearTimeout(timeoutId)
} }
}, [canAccess, canAccessReports, user?.id]) }, [hasInboxAccess, canAccess, canAccessReports, canAccessClubCreation, user?.id])
useEffect(() => { useEffect(() => {
const onChange = () => { refresh() } const onChange = () => { refresh() }
@ -124,21 +149,42 @@ export function OrgInboxProvider({ user, children }) {
return () => window.removeEventListener('shinkan:inbox-changed', onChange) return () => window.removeEventListener('shinkan:inbox-changed', onChange)
}, [refresh]) }, [refresh])
const clubCreationCount = clubCreationRequests.length
const joinCount = items.length
const value = useMemo( const value = useMemo(
() => ({ () => ({
inboxJoinRequests: items, inboxJoinRequests: items,
inboxCount: items.length, inboxClubCreationRequests: clubCreationRequests,
clubCreationRequestCount: clubCreationCount,
inboxCount: joinCount + clubCreationCount,
contentReports, contentReports,
contentReportCount: contentReports.filter((r) => r.status === 'submitted').length, contentReportCount: contentReports.filter((r) => r.status === 'submitted').length,
contentReportsError, contentReportsError,
refreshOrgInbox: refresh, refreshOrgInbox: refresh,
canAccessOrgInbox: canAccess, canAccessOrgInbox: canAccess,
canAccessContentReports: canAccessReports, canAccessContentReports: canAccessReports,
canAccessClubCreationInbox: canAccessClubCreation,
canShowInboxNav: hasInboxAccess,
isSuperadmin: user?.role === 'superadmin', isSuperadmin: user?.role === 'superadmin',
isPlatformAdmin: user?.role === 'admin' || user?.role === 'superadmin', isPlatformAdmin: user?.role === 'admin' || user?.role === 'superadmin',
isClubAdmin: activeClubMemberships(user?.clubs || []).some((c) => (c.roles || []).includes('club_admin')), isClubAdmin: activeClubMemberships(user?.clubs || []).some((c) => (c.roles || []).includes('club_admin')),
}), }),
[items, contentReports, contentReportsError, refresh, canAccess, canAccessReports, user?.role, user?.clubs] [
items,
clubCreationRequests,
clubCreationCount,
joinCount,
contentReports,
contentReportsError,
refresh,
canAccess,
canAccessReports,
canAccessClubCreation,
hasInboxAccess,
user?.role,
user?.clubs,
]
) )
return <OrgInboxContext.Provider value={value}>{children}</OrgInboxContext.Provider> return <OrgInboxContext.Provider value={value}>{children}</OrgInboxContext.Provider>

View File

@ -324,11 +324,14 @@ export default function InboxPage() {
const { const {
canAccessOrgInbox, canAccessOrgInbox,
canAccessContentReports, canAccessContentReports,
canAccessClubCreationInbox,
canShowInboxNav,
isSuperadmin, isSuperadmin,
isPlatformAdmin, isPlatformAdmin,
isClubAdmin, isClubAdmin,
refreshOrgInbox, refreshOrgInbox,
inboxJoinRequests, inboxJoinRequests,
inboxClubCreationRequests,
contentReports, contentReports,
contentReportCount, contentReportCount,
contentReportsError, contentReportsError,
@ -339,7 +342,7 @@ export default function InboxPage() {
const [showArchive, setShowArchive] = useState(false) const [showArchive, setShowArchive] = useState(false)
const load = useCallback(async () => { const load = useCallback(async () => {
if (!canAccessOrgInbox && !canAccessContentReports) { if (!canShowInboxNav) {
setLoading(false) setLoading(false)
return return
} }
@ -349,13 +352,13 @@ export default function InboxPage() {
} finally { } finally {
setLoading(false) setLoading(false)
} }
}, [canAccessOrgInbox, canAccessContentReports, refreshOrgInbox]) }, [canShowInboxNav, refreshOrgInbox])
useEffect(() => { useEffect(() => {
load() load()
}, [load]) }, [load])
if (!canAccessOrgInbox && !canAccessContentReports) { if (!canShowInboxNav) {
return ( return (
<div className="app-page"> <div className="app-page">
<h1 className="page-title">Posteingang</h1> <h1 className="page-title">Posteingang</h1>
@ -375,7 +378,7 @@ export default function InboxPage() {
Posteingang Posteingang
</h1> </h1>
<p className="muted" style={{ marginTop: 0 }}> <p className="muted" style={{ marginTop: 0 }}>
Beitrittsanträge und Inhaltsmeldungen für deine Zuständigkeitsbereiche. Beitrittsanträge, Vereinsgründungen und Inhaltsmeldungen für deine Zuständigkeitsbereiche.
</p> </p>
</div> </div>
<button type="button" className="btn btn-secondary" onClick={() => load()} disabled={loading}> <button type="button" className="btn btn-secondary" onClick={() => load()} disabled={loading}>
@ -389,7 +392,107 @@ export default function InboxPage() {
</div> </div>
) : ( ) : (
<> <>
{/* Abschnitt 1: Beitrittsanträge */} {/* Abschnitt: Vereinsgründungen (nur Superadmin) */}
{canAccessClubCreationInbox && (
<section style={{ marginBottom: '2rem' }}>
<h2 style={{ fontSize: '1rem', fontWeight: 600, marginBottom: '0.75rem', color: 'var(--text2)' }}>
Vereinsgründungen
{inboxClubCreationRequests.length > 0 && (
<span
style={{
background: 'var(--accent)',
color: '#fff',
borderRadius: '12px',
padding: '1px 8px',
fontSize: '0.75rem',
marginLeft: '0.5rem',
}}
>
{inboxClubCreationRequests.length}
</span>
)}
</h2>
{inboxClubCreationRequests.length === 0 ? (
<div className="card" style={{ padding: '1.25rem' }}>
<p style={{ margin: 0 }} className="muted">Keine offenen Gründungsanträge.</p>
</div>
) : (
<div className="inbox-page__list">
{inboxClubCreationRequests.map((req) => (
<div key={`creation-${req.id}`} className="card inbox-request-card">
<div className="inbox-request-card__main">
<div className="inbox-request-card__club">
{req.proposed_name}
{req.proposed_abbreviation ? (
<span className="muted" style={{ marginLeft: '0.35rem' }}>
({req.proposed_abbreviation})
</span>
) : null}
</div>
<strong className="inbox-request-card__applicant">
{req.applicant_name || req.applicant_email || 'Antragsteller/in'}
</strong>
<div className="muted inbox-request-card__meta">
{req.applicant_email} · Profil #{req.profile_id} · {formatWhen(req.created_at)}
</div>
{req.proposed_description ? (
<p className="inbox-request-card__message">{req.proposed_description}</p>
) : null}
{req.message ? (
<p className="inbox-request-card__message" style={{ fontStyle: 'italic' }}>
Nachricht: {req.message}
</p>
) : null}
</div>
<div className="inbox-request-card__actions">
<button
type="button"
className="btn btn-primary"
onClick={async () => {
if (
!confirm(
'Verein anlegen und Antragsteller als Hauptverwalter eintragen?'
)
) {
return
}
try {
await api.approveClubCreationRequest(req.id)
notifyOrgInboxChanged()
await load()
} catch (err) {
alert(err.message || String(err))
}
}}
>
Freigeben
</button>
<button
type="button"
className="btn btn-secondary"
onClick={async () => {
if (!confirm('Gründungsantrag ablehnen?')) return
try {
await api.rejectClubCreationRequest(req.id)
notifyOrgInboxChanged()
await load()
} catch (err) {
alert(err.message || String(err))
}
}}
>
Ablehnen
</button>
</div>
</div>
))}
</div>
)}
</section>
)}
{/* Abschnitt: Beitrittsanträge */}
{canAccessOrgInbox && ( {canAccessOrgInbox && (
<section style={{ marginBottom: '2rem' }}> <section style={{ marginBottom: '2rem' }}>
<h2 style={{ fontSize: '1rem', fontWeight: 600, marginBottom: '0.75rem', color: 'var(--text2)' }}> <h2 style={{ fontSize: '1rem', fontWeight: 600, marginBottom: '0.75rem', color: 'var(--text2)' }}>