import { useCallback, useEffect, useMemo, useState } from 'react' import { Navigate } from 'react-router-dom' import { useAuth } from '../context/AuthContext' import api from '../utils/api' import AdminPageNav from '../components/AdminPageNav' const TABS = [ { id: 'portal', label: 'Portal-Rollen' }, { id: 'club_roles', label: 'Vereinsrollen' }, { id: 'bypass', label: 'Kontingent-Bypass' }, { id: 'quotas', label: 'Vereins-Kontingente' }, ] const PORTAL_ROLE_LABEL = { user: 'Nutzer', trainer: 'Portal-Trainer', admin: 'Portal-Admin', superadmin: 'Superadmin', } const CLUB_ROLE_LABEL = { club_admin: 'Vereinsadmin', trainer: 'Trainer', division_lead: 'Spartenleitung', content_editor: 'Inhalte', } function limitInputValue(v) { if (v === null || v === undefined) return '' return String(v) } function parseLimitInput(raw, limitType) { const s = String(raw ?? '').trim() if (s === '' || s === '∞') return null const n = parseInt(s, 10) if (Number.isNaN(n)) return limitType === 'boolean' ? 0 : null return n } function formatLimitHint(feature) { if (feature.limit_type === 'boolean') return '0 = aus, 1 = an' if (feature.reset_period === 'monthly') return 'pro Monat' if (feature.reset_period === 'never') return 'Bestand' return '' } function EnforcementBadge({ enforcement, featureConsume }) { if (!enforcement) return null const tone = enforcement.implemented ? 'var(--accent-dark)' : enforcement.level === 'legacy' ? 'var(--danger)' : 'var(--text3)' return (
{enforcement.implemented ? '● ' : '○ '} {enforcement.label} {featureConsume ? (
Kontingent: {featureConsume.label}
) : null}
) } function CapabilityNameCell({ cap }) { return (
{cap.name || cap.id}
{cap.id} {cap.linked_feature_id ? (
Kontingent-ID: {cap.linked_feature_id}
) : null} ) } function clubGrantsForCapability(capMatrix, capabilityId) { return (capMatrix?.club_role_grants || []).filter((g) => g.capability_id === capabilityId) } /** * Superadmin: Rollen → Fähigkeiten (Capabilities) und Vereins-Kontingente konfigurieren. */ export default function AdminRightsPage() { const { user } = useAuth() const isSuperadmin = user?.role === 'superadmin' const [tab, setTab] = useState('portal') const [loading, setLoading] = useState(true) const [error, setError] = useState('') const [busy, setBusy] = useState(false) const [plansData, setPlansData] = useState({ plans: [], features: [], limits: {} }) const [limitDraft, setLimitDraft] = useState({}) const [clubSubs, setClubSubs] = useState([]) const [capMatrix, setCapMatrix] = useState(null) const [bypassData, setBypassData] = useState(null) const [newBypassPortal, setNewBypassPortal] = useState({ portal_role: 'helpdesk', feature_id: '' }) const [newBypassProfile, setNewBypassProfile] = useState({ profile_id: '', feature_id: '', reason: '', }) const loadPlans = useCallback(async () => { const data = await api.getAdminRightsClubPlansMatrix() setPlansData(data) const draft = {} for (const plan of data.plans || []) { draft[plan.id] = {} for (const f of data.features || []) { draft[plan.id][f.id] = limitInputValue(data.limits?.[plan.id]?.[f.id]) } } setLimitDraft(draft) }, []) const loadClubs = useCallback(async () => { const rows = await api.listAdminRightsClubSubscriptions() setClubSubs(Array.isArray(rows) ? rows : []) }, []) const loadCapMatrix = useCallback(async () => { setCapMatrix(await api.getAdminRightsCapabilityMatrix()) }, []) const loadBypass = useCallback(async () => { setBypassData(await api.listAdminRightsQuotaBypass()) }, []) const reloadTab = useCallback(async () => { setError('') if (tab === 'portal' || tab === 'club_roles') await loadCapMatrix() else if (tab === 'bypass') await loadBypass() else if (tab === 'quotas') await Promise.all([loadPlans(), loadClubs()]) }, [tab, loadPlans, loadClubs, loadCapMatrix, loadBypass]) useEffect(() => { if (!isSuperadmin) return let cancelled = false ;(async () => { setLoading(true) try { await reloadTab() } catch (e) { if (!cancelled) setError(e.message || String(e)) } finally { if (!cancelled) setLoading(false) } })() return () => { cancelled = true } }, [isSuperadmin, reloadTab]) const portalCapabilities = useMemo(() => { if (!capMatrix?.capabilities) return [] return capMatrix.capabilities.filter( (c) => c.domain === 'platform' || String(c.id).startsWith('platform.'), ) }, [capMatrix]) const clubScopedCapabilities = useMemo(() => { if (!capMatrix?.capabilities) return [] return capMatrix.capabilities.filter( (c) => c.domain !== 'platform' && c.domain !== 'quota_bypass' && c.domain !== 'account' && c.domain !== 'club', ) }, [capMatrix]) const portalGrantSet = useMemo(() => { const s = new Set() for (const g of capMatrix?.portal_grants || []) { s.add(`${g.portal_role}::${g.capability_id}`) } return s }, [capMatrix]) const clubGrantSet = useMemo(() => { const s = new Set() for (const g of capMatrix?.club_role_grants || []) { s.add(`${g.role_code}::${g.capability_id}`) } return s }, [capMatrix]) if (!isSuperadmin) return const savePlanLimits = async (planId) => { setBusy(true) setError('') try { const limits = (plansData.features || []).map((f) => ({ feature_id: f.id, limit_value: parseLimitInput(limitDraft[planId]?.[f.id], f.limit_type), })) await api.updateAdminRightsClubPlanLimits(planId, limits) await loadPlans() } catch (e) { setError(e.message || String(e)) } finally { setBusy(false) } } const saveClubPlan = async (clubId, planId, status) => { setBusy(true) setError('') try { await api.updateAdminRightsClubSubscription(clubId, { plan_id: planId, status }) await loadClubs() } catch (e) { setError(e.message || String(e)) } finally { setBusy(false) } } const togglePortalGrant = async (portalRole, capabilityId, hasGrant) => { setBusy(true) setError('') try { if (hasGrant) { await api.deleteAdminRightsPortalGrant(portalRole, capabilityId) } else { await api.addAdminRightsPortalGrant(portalRole, capabilityId) } await loadCapMatrix() } catch (e) { setError(e.message || String(e)) } finally { setBusy(false) } } const toggleClubGrant = async (roleCode, capabilityId, hasGrant) => { setBusy(true) setError('') try { if (hasGrant) { await api.deleteAdminRightsClubRoleGrant(roleCode, capabilityId) } else { await api.addAdminRightsClubRoleGrant(roleCode, capabilityId) } await loadCapMatrix() } catch (e) { setError(e.message || String(e)) } finally { setBusy(false) } } const openClubCapabilityForAllMembers = async (capabilityId) => { setBusy(true) setError('') try { await api.clearAdminRightsClubCapabilityGrants(capabilityId) await loadCapMatrix() } catch (e) { setError(e.message || String(e)) } finally { setBusy(false) } } const submitBypassPortal = async (e) => { e.preventDefault() setBusy(true) setError('') try { await api.addAdminRightsQuotaBypassPortal( newBypassPortal.portal_role.trim(), newBypassPortal.feature_id.trim() || null, ) setNewBypassPortal({ portal_role: 'helpdesk', feature_id: '' }) await loadBypass() } catch (err) { setError(err.message || String(err)) } finally { setBusy(false) } } const submitBypassProfile = async (e) => { e.preventDefault() const pid = parseInt(newBypassProfile.profile_id, 10) if (!pid) { setError('Profil-ID erforderlich') return } setBusy(true) setError('') try { await api.addAdminRightsQuotaBypassProfile( pid, newBypassProfile.feature_id.trim() || null, newBypassProfile.reason.trim() || null, ) setNewBypassProfile({ profile_id: '', feature_id: '', reason: '' }) await loadBypass() } catch (err) { setError(err.message || String(err)) } finally { setBusy(false) } } return (

Rollen & Rechte

Rechte: Wer darf welche Funktion nutzen? Haken = Grant für diese Rolle.
Kontingente: Wie viel darf ein Verein verbrauchen (an Rechten gekoppelt).
Es erscheinen nur vom Modul registrierte Rechte (nicht der alte Vollkatalog). ● = an API angebunden · ○ = registriert, Endpoint fehlt noch.{' '} docs/working/RIGHTS_AND_FEATURES_REGISTRY.md

{TABS.map((t) => ( ))}
{error ? (

{error}

) : null} {loading ? (

Laden…

) : null} {!loading && tab === 'portal' && capMatrix ? (

Plattform-Funktionen — Anzeige primär nach Klartext. Technische ID und Umsetzungsstand darunter.

{(capMatrix.portal_roles || []).map((r) => ( ))} {portalCapabilities.map((cap) => ( {(capMatrix.portal_roles || []).map((role) => { const on = portalGrantSet.has(`${role}::${cap.id}`) return ( ) })} ))}
Recht {PORTAL_ROLE_LABEL[r] || r}
togglePortalGrant(role, cap.id, on)} />
) : null} {!loading && tab === 'club_roles' && capMatrix ? (

Vereinsrollen: Alle Rechte in der Matrix. Haken = diese Rolle hat das Recht. Zeile mit alle = noch nicht rollenbeschränkt (gilt für jedes aktive Mitglied). Erster Klick auf alle schränkt auf die gewählte Rolle ein; „Alle Mitglieder“ hebt die Einschränkung wieder auf.

{(capMatrix.club_roles || []).map((r) => ( ))} {clubScopedCapabilities.map((cap) => { const restricted = clubGrantsForCapability(capMatrix, cap.id).length > 0 return ( {(capMatrix.club_roles || []).map((role) => { const on = clubGrantSet.has(`${role}::${cap.id}`) return ( ) })} ) })}
Recht {CLUB_ROLE_LABEL[r] || r} Freigabe
{restricted ? ( toggleClubGrant(role, cap.id, on)} /> ) : ( )} {restricted ? ( ) : null}
) : null} {!loading && tab === 'bypass' && bypassData ? (

Capability platform.club_quota.bypass — umgeht Vereins-Kontingente (z. B. Superadmin, Helpdesk). Kein separates Rechtemodell.

Portal-Rollen

    {(bypassData.portal_role_grants || []).map((g) => (
  • {g.portal_role} → {g.capability_id} {g.linked_feature_id ? ` (${g.linked_feature_id})` : ' (alle Features)'}
  • ))}

Einzelprofile

    {(bypassData.profile_grants || []).map((g) => (
  • Profil #{g.profile_id} {g.profile_name ? `(${g.profile_name})` : ''} →{' '} {g.capability_id} {g.reason ? ` — ${g.reason}` : ''}
  • ))}
) : null} {!loading && tab === 'quotas' ? (

Plan-Limits

Kontingent-Bündel pro Plan. Leeres Feld = unbegrenzt. Ersetzt keine Rollen-Grants.

{(plansData.plans || []).map((p) => ( ))} {(plansData.features || []).map((f) => ( {(plansData.plans || []).map((p) => ( ))} ))}
Feature
{p.name}
{p.id}
{f.name}
{f.id} · {formatLimitHint(f)}
setLimitDraft((prev) => ({ ...prev, [p.id]: { ...prev[p.id], [f.id]: e.target.value }, })) } />

Verein → Plan

{clubSubs.map((row) => ( ))}
Verein Plan Status
{row.club_name || `Verein #${row.club_id}`}
{clubSubs.length === 0 ? (

Keine Vereine.

) : null}
) : null}
) }