shinkan-jinkendo/frontend/src/pages/AdminRightsPage.jsx
Lars e4cb491d46
Some checks failed
Deploy Development / deploy (push) Successful in 44s
Test Suite / pytest-backend (push) Failing after 1s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 13s
Test Suite / k6 /health Baseline (push) Successful in 34s
Test Suite / playwright-tests (push) Has been cancelled
Refactor Admin Rights Management and Update Versioning
- Replaced the admin club feature exemptions router with a new admin rights router to streamline capability management.
- Added new API endpoints for managing admin rights, including capability grants and quota bypass for portal roles and profiles.
- Updated the frontend to include navigation and lazy loading for the new Admin Rights page.
- Incremented application version to 0.8.197 to reflect these changes and enhancements.
2026-06-07 09:21:59 +02:00

748 lines
28 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 ''
}
/**
* 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 [newClubGrant, setNewClubGrant] = useState({ role_code: 'trainer', capability_id: '' })
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)
}
}
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 &amp; Rechte</h1>
<p style={{ color: 'var(--text2)', maxWidth: '48rem', lineHeight: 1.55 }}>
<strong>Rollen Fähigkeiten (Capabilities):</strong> Wer darf welche Funktion nutzen?
<br />
<strong>Kontingente:</strong> Wie viel darf ein Verein verbrauchen (an Fähigkeiten gekoppelt
über <code>linked_feature_id</code>)?
<br />
Vereinspläne bündeln nur Kontingent-Werte sie ersetzen keine Berechtigungen.
</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' }}>
Plattform-Funktionen (<code>domain=platform</code>). Jede Funktion im Produkt soll sich
hier anmelden und bei Anzeige und Ausführung prüfen.
</p>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.8rem' }}>
<thead>
<tr>
<th style={{ textAlign: 'left', padding: '6px', minWidth: '200px' }}>Fähigkeit</th>
{(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)' }}>
<td style={{ padding: '6px' }}>
<code style={{ fontSize: '0.75rem' }}>{cap.id}</code>
<div style={{ color: 'var(--text3)' }}>{cap.name}</div>
{cap.linked_feature_id ? (
<div style={{ color: 'var(--text3)', fontSize: '0.7rem' }}>
Kontingent: {cap.linked_feature_id}
</div>
) : null}
</td>
{(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' }}>
Vereinsrollen Fähigkeiten. Ohne Grant-Eintrag gilt die Fähigkeit für alle aktiven
Vereinsmitglieder; gesetzte Grants schränken auf die angehakten Rollen ein.
</p>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.75rem' }}>
<thead>
<tr>
<th style={{ textAlign: 'left', padding: '6px', minWidth: '180px' }}>Fähigkeit</th>
{(capMatrix.club_roles || []).map((r) => (
<th key={r} style={{ textAlign: 'center', padding: '6px' }}>
{CLUB_ROLE_LABEL[r] || r}
</th>
))}
</tr>
</thead>
<tbody>
{clubScopedCapabilities
.filter((cap) =>
(capMatrix.club_role_grants || []).some((g) => g.capability_id === cap.id),
)
.map((cap) => (
<tr key={cap.id} style={{ borderTop: '1px solid var(--border)' }}>
<td style={{ padding: '6px' }}>
<code>{cap.id}</code>
<div style={{ color: 'var(--text3)' }}>{cap.name}</div>
{cap.linked_feature_id ? (
<div style={{ color: 'var(--text3)', fontSize: '0.7rem' }}>
Kontingent: {cap.linked_feature_id}
</div>
) : null}
</td>
{(capMatrix.club_roles || []).map((role) => {
const on = clubGrantSet.has(`${role}::${cap.id}`)
return (
<td key={role} style={{ textAlign: 'center', padding: '6px' }}>
<input
type="checkbox"
checked={on}
disabled={busy}
onChange={() => toggleClubGrant(role, cap.id, on)}
/>
</td>
)
})}
</tr>
))}
</tbody>
</table>
</div>
<form
className="card"
onSubmit={async (e) => {
e.preventDefault()
if (!newClubGrant.capability_id) return
setBusy(true)
setError('')
try {
await api.addAdminRightsClubRoleGrant(
newClubGrant.role_code,
newClubGrant.capability_id,
)
setNewClubGrant((p) => ({ ...p, capability_id: '' }))
await loadCapMatrix()
} catch (err) {
setError(err.message || String(err))
} finally {
setBusy(false)
}
}}
>
<h2 style={{ fontSize: '1rem', margin: '0 0 0.75rem' }}>Vereinsrollen-Grant hinzufügen</h2>
<div className="form-row" style={{ display: 'flex', flexWrap: 'wrap', gap: '0.75rem' }}>
<label style={{ flex: '1 1 140px' }}>
<span className="form-label">Rolle</span>
<select
className="form-input"
value={newClubGrant.role_code}
onChange={(e) =>
setNewClubGrant((p) => ({ ...p, role_code: e.target.value }))
}
>
{(capMatrix.club_roles || []).map((r) => (
<option key={r} value={r}>
{CLUB_ROLE_LABEL[r] || r}
</option>
))}
</select>
</label>
<label style={{ flex: '2 1 240px' }}>
<span className="form-label">Fähigkeit</span>
<select
className="form-input"
value={newClubGrant.capability_id}
onChange={(e) =>
setNewClubGrant((p) => ({ ...p, capability_id: e.target.value }))
}
>
<option value=""> wählen </option>
{clubScopedCapabilities.map((c) => (
<option key={c.id} value={c.id}>
{c.id} {c.name}
</option>
))}
</select>
</label>
<div style={{ alignSelf: 'flex-end' }}>
<button type="submit" className="btn btn-primary" disabled={busy}>
Hinzufügen
</button>
</div>
</div>
</form>
</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>
)
}