2026-06-07 09:21:59 +02:00
|
|
|
|
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 ''
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-07 15:27:37 +02:00
|
|
|
|
function EnforcementBadge({ enforcement, featureConsume }) {
|
|
|
|
|
|
if (!enforcement) return null
|
|
|
|
|
|
const tone =
|
|
|
|
|
|
enforcement.implemented
|
|
|
|
|
|
? 'var(--accent-dark)'
|
|
|
|
|
|
: enforcement.level === 'legacy'
|
|
|
|
|
|
? 'var(--danger)'
|
|
|
|
|
|
: 'var(--text3)'
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div style={{ marginTop: '4px', fontSize: '0.68rem', lineHeight: 1.35 }}>
|
|
|
|
|
|
<span style={{ color: tone }} title={enforcement.detail}>
|
|
|
|
|
|
{enforcement.implemented ? '● ' : '○ '}
|
|
|
|
|
|
{enforcement.label}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
{featureConsume ? (
|
|
|
|
|
|
<div style={{ color: featureConsume.implemented ? 'var(--accent-dark)' : 'var(--text3)' }}>
|
|
|
|
|
|
Kontingent: {featureConsume.label}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function CapabilityNameCell({ cap }) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<td style={{ padding: '6px', verticalAlign: 'top' }}>
|
|
|
|
|
|
<div style={{ fontWeight: 500, color: 'var(--text1)' }}>{cap.name || cap.id}</div>
|
|
|
|
|
|
<code style={{ fontSize: '0.68rem', color: 'var(--text3)' }}>{cap.id}</code>
|
|
|
|
|
|
{cap.linked_feature_id ? (
|
|
|
|
|
|
<div style={{ color: 'var(--text3)', fontSize: '0.68rem' }}>
|
|
|
|
|
|
Kontingent-ID: {cap.linked_feature_id}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
<EnforcementBadge enforcement={cap.enforcement} featureConsume={cap.feature_consume} />
|
|
|
|
|
|
</td>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function clubGrantsForCapability(capMatrix, capabilityId) {
|
|
|
|
|
|
return (capMatrix?.club_role_grants || []).filter((g) => g.capability_id === capabilityId)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-07 09:21:59 +02:00
|
|
|
|
/**
|
|
|
|
|
|
* 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 <Navigate to="/" replace />
|
|
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-07 15:27:37 +02:00
|
|
|
|
const openClubCapabilityForAllMembers = async (capabilityId) => {
|
|
|
|
|
|
setBusy(true)
|
|
|
|
|
|
setError('')
|
|
|
|
|
|
try {
|
|
|
|
|
|
await api.clearAdminRightsClubCapabilityGrants(capabilityId)
|
|
|
|
|
|
await loadCapMatrix()
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
setError(e.message || String(e))
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setBusy(false)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-07 09:21:59 +02:00
|
|
|
|
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 (
|
|
|
|
|
|
<div className="page-padding app-page">
|
|
|
|
|
|
<AdminPageNav />
|
|
|
|
|
|
<h1 style={{ marginTop: '1rem', fontSize: '1.35rem' }}>Rollen & Rechte</h1>
|
2026-06-07 15:27:37 +02:00
|
|
|
|
<p style={{ color: 'var(--text2)', maxWidth: '52rem', lineHeight: 1.55 }}>
|
|
|
|
|
|
<strong>Rechte:</strong> Wer darf welche Funktion nutzen? Haken = Grant für diese Rolle.
|
2026-06-07 09:21:59 +02:00
|
|
|
|
<br />
|
2026-06-07 15:27:37 +02:00
|
|
|
|
<strong>Kontingente:</strong> Wie viel darf ein Verein verbrauchen (an Rechten gekoppelt).
|
2026-06-07 09:21:59 +02:00
|
|
|
|
<br />
|
2026-06-07 15:27:37 +02:00
|
|
|
|
<span style={{ fontSize: '0.85rem' }}>
|
|
|
|
|
|
● = an API angebunden · ○ = nur Legacy oder noch nicht durchgesetzt. Roadmap:{' '}
|
|
|
|
|
|
<code>docs/working/RBAC_ENFORCEMENT_ROADMAP.md</code>
|
|
|
|
|
|
</span>
|
2026-06-07 09:21:59 +02:00
|
|
|
|
</p>
|
|
|
|
|
|
|
|
|
|
|
|
<div
|
|
|
|
|
|
style={{ display: 'flex', flexWrap: 'wrap', gap: '0.35rem', marginTop: '1rem' }}
|
|
|
|
|
|
role="tablist"
|
|
|
|
|
|
>
|
|
|
|
|
|
{TABS.map((t) => (
|
|
|
|
|
|
<button
|
|
|
|
|
|
key={t.id}
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
role="tab"
|
|
|
|
|
|
aria-selected={tab === t.id}
|
|
|
|
|
|
className={`btn ${tab === t.id ? 'btn-primary' : 'btn-secondary'}`}
|
|
|
|
|
|
onClick={() => setTab(t.id)}
|
|
|
|
|
|
>
|
|
|
|
|
|
{t.label}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{error ? (
|
|
|
|
|
|
<p role="alert" style={{ color: 'var(--danger)', marginTop: '0.75rem' }}>
|
|
|
|
|
|
{error}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
|
|
|
|
|
|
{loading ? (
|
|
|
|
|
|
<p className="spinner" style={{ marginTop: '1rem' }}>
|
|
|
|
|
|
Laden…
|
|
|
|
|
|
</p>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
|
|
|
|
|
|
{!loading && tab === 'portal' && capMatrix ? (
|
|
|
|
|
|
<div className="card" style={{ marginTop: '1rem', overflowX: 'auto' }}>
|
|
|
|
|
|
<p style={{ fontSize: '0.875rem', color: 'var(--text2)', margin: '0 0 12px' }}>
|
2026-06-07 15:27:37 +02:00
|
|
|
|
Plattform-Funktionen — Anzeige primär nach Klartext. Technische ID und
|
|
|
|
|
|
Umsetzungsstand darunter.
|
2026-06-07 09:21:59 +02:00
|
|
|
|
</p>
|
|
|
|
|
|
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.8rem' }}>
|
|
|
|
|
|
<thead>
|
|
|
|
|
|
<tr>
|
2026-06-07 15:27:37 +02:00
|
|
|
|
<th style={{ textAlign: 'left', padding: '6px', minWidth: '220px' }}>Recht</th>
|
2026-06-07 09:21:59 +02:00
|
|
|
|
{(capMatrix.portal_roles || []).map((r) => (
|
|
|
|
|
|
<th key={r} style={{ textAlign: 'center', padding: '6px' }}>
|
|
|
|
|
|
{PORTAL_ROLE_LABEL[r] || r}
|
|
|
|
|
|
</th>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
</thead>
|
|
|
|
|
|
<tbody>
|
|
|
|
|
|
{portalCapabilities.map((cap) => (
|
|
|
|
|
|
<tr key={cap.id} style={{ borderTop: '1px solid var(--border)' }}>
|
2026-06-07 15:27:37 +02:00
|
|
|
|
<CapabilityNameCell cap={cap} />
|
2026-06-07 09:21:59 +02:00
|
|
|
|
{(capMatrix.portal_roles || []).map((role) => {
|
|
|
|
|
|
const on = portalGrantSet.has(`${role}::${cap.id}`)
|
|
|
|
|
|
return (
|
|
|
|
|
|
<td key={role} style={{ textAlign: 'center', padding: '6px' }}>
|
|
|
|
|
|
<input
|
|
|
|
|
|
type="checkbox"
|
|
|
|
|
|
checked={on}
|
|
|
|
|
|
disabled={busy}
|
|
|
|
|
|
aria-label={`${cap.id} für ${role}`}
|
|
|
|
|
|
onChange={() => togglePortalGrant(role, cap.id, on)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</td>
|
|
|
|
|
|
)
|
|
|
|
|
|
})}
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</tbody>
|
|
|
|
|
|
</table>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
|
|
|
|
|
|
{!loading && tab === 'club_roles' && capMatrix ? (
|
|
|
|
|
|
<div style={{ marginTop: '1rem', display: 'flex', flexDirection: 'column', gap: '1rem' }}>
|
|
|
|
|
|
<div className="card" style={{ overflowX: 'auto' }}>
|
|
|
|
|
|
<p style={{ fontSize: '0.875rem', color: 'var(--text2)', margin: '0 0 12px' }}>
|
2026-06-07 15:27:37 +02:00
|
|
|
|
Vereinsrollen: Alle Rechte in der Matrix. Haken = diese Rolle hat das Recht.
|
|
|
|
|
|
Zeile mit <em>alle</em> = noch nicht rollenbeschränkt (gilt für jedes aktive Mitglied).
|
|
|
|
|
|
Erster Klick auf <em>alle</em> schränkt auf die gewählte Rolle ein; „Alle Mitglieder“
|
|
|
|
|
|
hebt die Einschränkung wieder auf.
|
2026-06-07 09:21:59 +02:00
|
|
|
|
</p>
|
|
|
|
|
|
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.75rem' }}>
|
|
|
|
|
|
<thead>
|
|
|
|
|
|
<tr>
|
2026-06-07 15:27:37 +02:00
|
|
|
|
<th style={{ textAlign: 'left', padding: '6px', minWidth: '220px' }}>Recht</th>
|
2026-06-07 09:21:59 +02:00
|
|
|
|
{(capMatrix.club_roles || []).map((r) => (
|
|
|
|
|
|
<th key={r} style={{ textAlign: 'center', padding: '6px' }}>
|
|
|
|
|
|
{CLUB_ROLE_LABEL[r] || r}
|
|
|
|
|
|
</th>
|
|
|
|
|
|
))}
|
2026-06-07 15:27:37 +02:00
|
|
|
|
<th style={{ textAlign: 'left', padding: '6px', minWidth: '100px' }}>Freigabe</th>
|
2026-06-07 09:21:59 +02:00
|
|
|
|
</tr>
|
|
|
|
|
|
</thead>
|
|
|
|
|
|
<tbody>
|
2026-06-07 15:27:37 +02:00
|
|
|
|
{clubScopedCapabilities.map((cap) => {
|
|
|
|
|
|
const restricted = clubGrantsForCapability(capMatrix, cap.id).length > 0
|
|
|
|
|
|
return (
|
2026-06-07 09:21:59 +02:00
|
|
|
|
<tr key={cap.id} style={{ borderTop: '1px solid var(--border)' }}>
|
2026-06-07 15:27:37 +02:00
|
|
|
|
<CapabilityNameCell cap={cap} />
|
2026-06-07 09:21:59 +02:00
|
|
|
|
{(capMatrix.club_roles || []).map((role) => {
|
|
|
|
|
|
const on = clubGrantSet.has(`${role}::${cap.id}`)
|
|
|
|
|
|
return (
|
|
|
|
|
|
<td key={role} style={{ textAlign: 'center', padding: '6px' }}>
|
2026-06-07 15:27:37 +02:00
|
|
|
|
{restricted ? (
|
|
|
|
|
|
<input
|
|
|
|
|
|
type="checkbox"
|
|
|
|
|
|
checked={on}
|
|
|
|
|
|
disabled={busy}
|
|
|
|
|
|
aria-label={`${cap.name} für ${CLUB_ROLE_LABEL[role] || role}`}
|
|
|
|
|
|
onChange={() => toggleClubGrant(role, cap.id, on)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className="btn btn-secondary"
|
|
|
|
|
|
disabled={busy}
|
|
|
|
|
|
title={`Standard: alle Mitglieder. Klick = nur ${CLUB_ROLE_LABEL[role] || role}`}
|
|
|
|
|
|
style={{ fontSize: '0.7rem', padding: '2px 6px' }}
|
|
|
|
|
|
onClick={() => toggleClubGrant(role, cap.id, false)}
|
|
|
|
|
|
>
|
|
|
|
|
|
alle
|
|
|
|
|
|
</button>
|
|
|
|
|
|
)}
|
2026-06-07 09:21:59 +02:00
|
|
|
|
</td>
|
|
|
|
|
|
)
|
|
|
|
|
|
})}
|
2026-06-07 15:27:37 +02:00
|
|
|
|
<td style={{ padding: '6px', whiteSpace: 'nowrap' }}>
|
|
|
|
|
|
{restricted ? (
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className="btn btn-secondary"
|
|
|
|
|
|
disabled={busy}
|
|
|
|
|
|
style={{ fontSize: '0.68rem', padding: '2px 6px' }}
|
|
|
|
|
|
onClick={() => openClubCapabilityForAllMembers(cap.id)}
|
|
|
|
|
|
>
|
|
|
|
|
|
Alle Mitglieder
|
|
|
|
|
|
</button>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
</td>
|
2026-06-07 09:21:59 +02:00
|
|
|
|
</tr>
|
2026-06-07 15:27:37 +02:00
|
|
|
|
)
|
|
|
|
|
|
})}
|
2026-06-07 09:21:59 +02:00
|
|
|
|
</tbody>
|
|
|
|
|
|
</table>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
|
|
|
|
|
|
{!loading && tab === 'bypass' && bypassData ? (
|
|
|
|
|
|
<div style={{ marginTop: '1rem', display: 'flex', flexDirection: 'column', gap: '1rem' }}>
|
|
|
|
|
|
<p style={{ fontSize: '0.875rem', color: 'var(--text2)', margin: 0 }}>
|
|
|
|
|
|
Capability <code>platform.club_quota.bypass</code> — umgeht Vereins-Kontingente (z. B.
|
|
|
|
|
|
Superadmin, Helpdesk). Kein separates Rechtemodell.
|
|
|
|
|
|
</p>
|
|
|
|
|
|
<div className="card">
|
|
|
|
|
|
<h2 style={{ fontSize: '1rem', margin: '0 0 0.75rem' }}>Portal-Rollen</h2>
|
|
|
|
|
|
<ul style={{ margin: 0, paddingLeft: '1.25rem', fontSize: '0.875rem' }}>
|
|
|
|
|
|
{(bypassData.portal_role_grants || []).map((g) => (
|
|
|
|
|
|
<li key={`${g.portal_role}-${g.capability_id}`} style={{ marginBottom: '6px' }}>
|
|
|
|
|
|
<strong>{g.portal_role}</strong> → {g.capability_id}
|
|
|
|
|
|
{g.linked_feature_id ? ` (${g.linked_feature_id})` : ' (alle Features)'}
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className="btn btn-secondary"
|
|
|
|
|
|
style={{ marginLeft: '8px', fontSize: '0.75rem', padding: '2px 8px' }}
|
|
|
|
|
|
disabled={busy}
|
|
|
|
|
|
onClick={async () => {
|
|
|
|
|
|
setBusy(true)
|
|
|
|
|
|
try {
|
|
|
|
|
|
await api.deleteAdminRightsQuotaBypassPortal(
|
|
|
|
|
|
g.portal_role,
|
|
|
|
|
|
g.linked_feature_id || null,
|
|
|
|
|
|
)
|
|
|
|
|
|
await loadBypass()
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
setError(err.message || String(err))
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setBusy(false)
|
|
|
|
|
|
}
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
Entfernen
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</li>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</ul>
|
|
|
|
|
|
<form onSubmit={submitBypassPortal} style={{ marginTop: '12px' }}>
|
|
|
|
|
|
<div className="form-row" style={{ display: 'flex', flexWrap: 'wrap', gap: '0.75rem' }}>
|
|
|
|
|
|
<label style={{ flex: '1 1 120px' }}>
|
|
|
|
|
|
<span className="form-label">Portal-Rolle</span>
|
|
|
|
|
|
<input
|
|
|
|
|
|
className="form-input"
|
|
|
|
|
|
value={newBypassPortal.portal_role}
|
|
|
|
|
|
onChange={(e) =>
|
|
|
|
|
|
setNewBypassPortal((p) => ({ ...p, portal_role: e.target.value }))
|
|
|
|
|
|
}
|
|
|
|
|
|
placeholder="helpdesk"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</label>
|
|
|
|
|
|
<label style={{ flex: '1 1 160px' }}>
|
|
|
|
|
|
<span className="form-label">Feature (leer = alle)</span>
|
|
|
|
|
|
<input
|
|
|
|
|
|
className="form-input"
|
|
|
|
|
|
value={newBypassPortal.feature_id}
|
|
|
|
|
|
onChange={(e) =>
|
|
|
|
|
|
setNewBypassPortal((p) => ({ ...p, feature_id: e.target.value }))
|
|
|
|
|
|
}
|
|
|
|
|
|
placeholder="ai_calls"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</label>
|
|
|
|
|
|
<div style={{ alignSelf: 'flex-end' }}>
|
|
|
|
|
|
<button type="submit" className="btn btn-primary" disabled={busy}>
|
|
|
|
|
|
Grant anlegen
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</form>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="card">
|
|
|
|
|
|
<h2 style={{ fontSize: '1rem', margin: '0 0 0.75rem' }}>Einzelprofile</h2>
|
|
|
|
|
|
<ul style={{ margin: 0, paddingLeft: '1.25rem', fontSize: '0.875rem' }}>
|
|
|
|
|
|
{(bypassData.profile_grants || []).map((g) => (
|
|
|
|
|
|
<li key={`${g.profile_id}-${g.capability_id}`} style={{ marginBottom: '6px' }}>
|
|
|
|
|
|
Profil #{g.profile_id} {g.profile_name ? `(${g.profile_name})` : ''} →{' '}
|
|
|
|
|
|
{g.capability_id}
|
|
|
|
|
|
{g.reason ? ` — ${g.reason}` : ''}
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className="btn btn-secondary"
|
|
|
|
|
|
style={{ marginLeft: '8px', fontSize: '0.75rem', padding: '2px 8px' }}
|
|
|
|
|
|
disabled={busy}
|
|
|
|
|
|
onClick={async () => {
|
|
|
|
|
|
setBusy(true)
|
|
|
|
|
|
try {
|
|
|
|
|
|
await api.deleteAdminRightsQuotaBypassProfile(
|
|
|
|
|
|
g.profile_id,
|
|
|
|
|
|
g.linked_feature_id || null,
|
|
|
|
|
|
)
|
|
|
|
|
|
await loadBypass()
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
setError(err.message || String(err))
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setBusy(false)
|
|
|
|
|
|
}
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
Entfernen
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</li>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</ul>
|
|
|
|
|
|
<form onSubmit={submitBypassProfile} style={{ marginTop: '12px' }}>
|
|
|
|
|
|
<div className="form-row" style={{ display: 'flex', flexWrap: 'wrap', gap: '0.75rem' }}>
|
|
|
|
|
|
<label style={{ flex: '0 1 100px' }}>
|
|
|
|
|
|
<span className="form-label">Profil-ID</span>
|
|
|
|
|
|
<input
|
|
|
|
|
|
className="form-input"
|
|
|
|
|
|
value={newBypassProfile.profile_id}
|
|
|
|
|
|
onChange={(e) =>
|
|
|
|
|
|
setNewBypassProfile((p) => ({ ...p, profile_id: e.target.value }))
|
|
|
|
|
|
}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</label>
|
|
|
|
|
|
<label style={{ flex: '1 1 120px' }}>
|
|
|
|
|
|
<span className="form-label">Feature (leer = alle)</span>
|
|
|
|
|
|
<input
|
|
|
|
|
|
className="form-input"
|
|
|
|
|
|
value={newBypassProfile.feature_id}
|
|
|
|
|
|
onChange={(e) =>
|
|
|
|
|
|
setNewBypassProfile((p) => ({ ...p, feature_id: e.target.value }))
|
|
|
|
|
|
}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</label>
|
|
|
|
|
|
<label style={{ flex: '2 1 200px' }}>
|
|
|
|
|
|
<span className="form-label">Grund (optional)</span>
|
|
|
|
|
|
<input
|
|
|
|
|
|
className="form-input"
|
|
|
|
|
|
value={newBypassProfile.reason}
|
|
|
|
|
|
onChange={(e) =>
|
|
|
|
|
|
setNewBypassProfile((p) => ({ ...p, reason: e.target.value }))
|
|
|
|
|
|
}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</label>
|
|
|
|
|
|
<div style={{ alignSelf: 'flex-end' }}>
|
|
|
|
|
|
<button type="submit" className="btn btn-primary" disabled={busy}>
|
|
|
|
|
|
Grant anlegen
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</form>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
|
|
|
|
|
|
{!loading && tab === 'quotas' ? (
|
|
|
|
|
|
<div style={{ marginTop: '1rem', display: 'flex', flexDirection: 'column', gap: '1rem' }}>
|
|
|
|
|
|
<div className="card" style={{ overflowX: 'auto' }}>
|
|
|
|
|
|
<h2 style={{ fontSize: '1rem', margin: '0 0 0.75rem' }}>Plan-Limits</h2>
|
|
|
|
|
|
<p style={{ fontSize: '0.875rem', color: 'var(--text2)', margin: '0 0 12px' }}>
|
|
|
|
|
|
Kontingent-Bündel pro Plan. Leeres Feld = unbegrenzt. Ersetzt keine Rollen-Grants.
|
|
|
|
|
|
</p>
|
|
|
|
|
|
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.875rem' }}>
|
|
|
|
|
|
<thead>
|
|
|
|
|
|
<tr>
|
|
|
|
|
|
<th style={{ textAlign: 'left', padding: '8px', minWidth: '140px' }}>Feature</th>
|
|
|
|
|
|
{(plansData.plans || []).map((p) => (
|
|
|
|
|
|
<th key={p.id} style={{ textAlign: 'center', padding: '8px', minWidth: '100px' }}>
|
|
|
|
|
|
<div>{p.name}</div>
|
|
|
|
|
|
<div style={{ fontWeight: 400, color: 'var(--text3)', fontSize: '0.75rem' }}>
|
|
|
|
|
|
{p.id}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className="btn btn-secondary"
|
|
|
|
|
|
style={{ marginTop: '4px', fontSize: '0.75rem', padding: '4px 8px' }}
|
|
|
|
|
|
disabled={busy}
|
|
|
|
|
|
onClick={() => savePlanLimits(p.id)}
|
|
|
|
|
|
>
|
|
|
|
|
|
Speichern
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</th>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
</thead>
|
|
|
|
|
|
<tbody>
|
|
|
|
|
|
{(plansData.features || []).map((f) => (
|
|
|
|
|
|
<tr key={f.id} style={{ borderTop: '1px solid var(--border)' }}>
|
|
|
|
|
|
<td style={{ padding: '8px' }}>
|
|
|
|
|
|
<strong>{f.name}</strong>
|
|
|
|
|
|
<div style={{ color: 'var(--text3)', fontSize: '0.75rem' }}>
|
|
|
|
|
|
{f.id} · {formatLimitHint(f)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</td>
|
|
|
|
|
|
{(plansData.plans || []).map((p) => (
|
|
|
|
|
|
<td key={p.id} style={{ padding: '8px', textAlign: 'center' }}>
|
|
|
|
|
|
<input
|
|
|
|
|
|
className="form-input"
|
|
|
|
|
|
style={{ width: '72px', textAlign: 'center' }}
|
|
|
|
|
|
placeholder="∞"
|
|
|
|
|
|
value={limitDraft[p.id]?.[f.id] ?? ''}
|
|
|
|
|
|
disabled={busy}
|
|
|
|
|
|
onChange={(e) =>
|
|
|
|
|
|
setLimitDraft((prev) => ({
|
|
|
|
|
|
...prev,
|
|
|
|
|
|
[p.id]: { ...prev[p.id], [f.id]: e.target.value },
|
|
|
|
|
|
}))
|
|
|
|
|
|
}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</td>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</tbody>
|
|
|
|
|
|
</table>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="card" style={{ overflowX: 'auto' }}>
|
|
|
|
|
|
<h2 style={{ fontSize: '1rem', margin: '0 0 0.75rem' }}>Verein → Plan</h2>
|
|
|
|
|
|
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.875rem' }}>
|
|
|
|
|
|
<thead>
|
|
|
|
|
|
<tr>
|
|
|
|
|
|
<th style={{ textAlign: 'left', padding: '8px' }}>Verein</th>
|
|
|
|
|
|
<th style={{ textAlign: 'left', padding: '8px' }}>Plan</th>
|
|
|
|
|
|
<th style={{ textAlign: 'left', padding: '8px' }}>Status</th>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
</thead>
|
|
|
|
|
|
<tbody>
|
|
|
|
|
|
{clubSubs.map((row) => (
|
|
|
|
|
|
<tr key={row.club_id} style={{ borderTop: '1px solid var(--border)' }}>
|
|
|
|
|
|
<td style={{ padding: '8px' }}>
|
|
|
|
|
|
{row.club_name || `Verein #${row.club_id}`}
|
|
|
|
|
|
</td>
|
|
|
|
|
|
<td style={{ padding: '8px' }}>
|
|
|
|
|
|
<select
|
|
|
|
|
|
className="form-input"
|
|
|
|
|
|
value={row.plan_id || 'free'}
|
|
|
|
|
|
disabled={busy}
|
|
|
|
|
|
onChange={(e) =>
|
|
|
|
|
|
saveClubPlan(row.club_id, e.target.value, row.status || 'active')
|
|
|
|
|
|
}
|
|
|
|
|
|
>
|
|
|
|
|
|
{(plansData.plans || []).map((p) => (
|
|
|
|
|
|
<option key={p.id} value={p.id}>
|
|
|
|
|
|
{p.name} ({p.id})
|
|
|
|
|
|
</option>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</select>
|
|
|
|
|
|
</td>
|
|
|
|
|
|
<td style={{ padding: '8px' }}>
|
|
|
|
|
|
<select
|
|
|
|
|
|
className="form-input"
|
|
|
|
|
|
value={row.status || 'active'}
|
|
|
|
|
|
disabled={busy}
|
|
|
|
|
|
onChange={(e) =>
|
|
|
|
|
|
saveClubPlan(row.club_id, row.plan_id || 'free', e.target.value)
|
|
|
|
|
|
}
|
|
|
|
|
|
>
|
|
|
|
|
|
<option value="active">aktiv</option>
|
|
|
|
|
|
<option value="trial">Test</option>
|
|
|
|
|
|
<option value="past_due">überfällig</option>
|
|
|
|
|
|
<option value="cancelled">gekündigt</option>
|
|
|
|
|
|
</select>
|
|
|
|
|
|
</td>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</tbody>
|
|
|
|
|
|
</table>
|
|
|
|
|
|
{clubSubs.length === 0 ? (
|
|
|
|
|
|
<p style={{ padding: '12px', color: 'var(--text2)', margin: 0 }}>Keine Vereine.</p>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|