shinkan-jinkendo/frontend/src/pages/AdminUsersPage.jsx
Lars 01be9ffcd4
All checks were successful
Deploy Development / deploy (push) Successful in 37s
Test Suite / pytest-backend (push) Successful in 25s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 8s
Test Suite / playwright-tests (push) Successful in 23s
Test Suite / pytest-backend (pull_request) Successful in 24s
Test Suite / lint-backend (pull_request) Successful in 0s
Test Suite / build-frontend (pull_request) Successful in 7s
Test Suite / playwright-tests (pull_request) Successful in 24s
feat(admin): restrict admin access and enhance navigation for superadmins
- Updated access control to ensure only superadmins can view admin routes and manage users.
- Refactored navigation components to reflect the new role-based access, removing platform admin references.
- Enhanced the admin user management page to streamline functionality for superadmins, including password reset options.
- Improved overall user experience by clarifying navigation paths and access permissions for different user roles.
2026-05-09 13:26:22 +02:00

632 lines
24 KiB
JavaScript

import { useCallback, useEffect, 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 CLUB_ROLE_OPTIONS = [
{ code: 'club_admin', label: 'Vereinsadmin' },
{ code: 'trainer', label: 'Trainer' },
{ code: 'division_lead', label: 'Spartenleitung' },
{ code: 'content_editor', label: 'Inhalte bearbeiten' },
]
const PORTAL_ROLE_LABEL = {
user: 'Nutzer',
trainer: 'Portal-Trainer',
admin: 'Portal-Administrator',
superadmin: 'Super-Administrator',
}
function portalRoleSelectOptions(viewerIsSuperadmin, currentRole) {
const base = [
{ value: 'user', label: PORTAL_ROLE_LABEL.user },
{ value: 'trainer', label: `${PORTAL_ROLE_LABEL.trainer} (Legacy)` },
{ value: 'admin', label: PORTAL_ROLE_LABEL.admin },
]
const cur = (currentRole || 'user').toLowerCase()
if (viewerIsSuperadmin) base.push({ value: 'superadmin', label: PORTAL_ROLE_LABEL.superadmin })
const values = new Set(base.map((x) => x.value))
if (cur && !values.has(cur)) {
base.unshift({ value: cur, label: cur })
}
return base
}
/**
* Nur Super-Admins — globale Nutzer- und Portal-Rollen plus Vereinszuordnung pro Profil.
* Vereinsorganisation (Mitglieder, Anträge, Rollen vor Ort): unter Vereine → Mitglieder.
*/
export default function AdminUsersPage() {
const { user } = useAuth()
const isSuperadminViewer = user?.role === 'superadmin'
const [platformUsers, setPlatformUsers] = useState([])
const [clubs, setClubs] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [portalDraft, setPortalDraft] = useState({})
const [assignModal, setAssignModal] = useState(null)
const [assignRoles, setAssignRoles] = useState(['trainer'])
const [clubEditModal, setClubEditModal] = useState(null)
const [pwdModal, setPwdModal] = useState(null)
const [pwdNew, setPwdNew] = useState('')
const [pwdNew2, setPwdNew2] = useState('')
const loadPlatform = useCallback(async () => {
const [u, c] = await Promise.all([api.listAdminUsers(), api.listClubs()])
setPlatformUsers(Array.isArray(u) ? u : [])
setClubs(Array.isArray(c) ? c : [])
const d = {}
for (const row of u || []) {
d[row.id] = { role: (row.role || 'user').toLowerCase() }
}
setPortalDraft(d)
}, [])
useEffect(() => {
if (!isSuperadminViewer) return
let cancelled = false
;(async () => {
setError('')
setLoading(true)
try {
await loadPlatform()
} catch (e) {
if (!cancelled) setError(e.message || String(e))
} finally {
if (!cancelled) setLoading(false)
}
})()
return () => {
cancelled = true
}
}, [isSuperadminViewer, loadPlatform])
if (!isSuperadminViewer) return <Navigate to="/" replace />
const savePortal = async (profileId) => {
const dr = portalDraft[profileId]
if (!dr) return
try {
await api.updateProfile(profileId, { role: dr.role })
await loadPlatform()
} catch (e) {
alert(e.message || String(e))
}
}
const submitAssignClub = async () => {
if (!assignModal) return
const clubId = assignModal.clubId
const profileId = assignModal.profileId
if (!clubId || !assignRoles.length) {
alert('Verein und mindestens eine Rolle wählen.')
return
}
try {
await api.addClubMember(clubId, { profile_id: profileId, roles: assignRoles })
setAssignModal(null)
setAssignRoles(['trainer'])
await loadPlatform()
} catch (e) {
alert(e.message || String(e))
}
}
const saveClubMembership = async () => {
if (!clubEditModal) return
const { clubId, profileId, roles, status } = clubEditModal
try {
await api.updateClubMember(clubId, profileId, { roles, status })
setClubEditModal(null)
await loadPlatform()
} catch (e) {
alert(e.message || String(e))
}
}
const removeClubMembership = async () => {
if (!clubEditModal) return
if (!confirm('Mitgliedschaft in diesem Verein wirklich entfernen?')) return
try {
await api.removeClubMember(clubEditModal.clubId, clubEditModal.profileId)
setClubEditModal(null)
await loadPlatform()
} catch (e) {
alert(e.message || String(e))
}
}
const submitPasswordEmail = async () => {
if (!pwdModal) return
try {
const res = await api.managementPasswordReset(pwdModal.profileId, null)
setPwdModal(null)
setPwdNew('')
setPwdNew2('')
let msg =
'Sofern eine E-Mail-Adresse hinterlegt ist, wurde ein Link zum Setzen eines neuen Passworts versendet. Das bisherige Passwort bleibt bis zur Bestätigung im Link aktiv.'
if (res?.email_sent === false) {
msg += ' Hinweis: Der E-Mail-Versand ist fehlgeschlagen (SMTP prüfen).'
}
alert(msg)
} catch (e) {
alert(e.message || String(e))
}
}
const submitPasswordDirect = async () => {
if (!pwdModal) return
if (pwdNew.length < 8) {
alert('Mindestens 8 Zeichen.')
return
}
if (pwdNew !== pwdNew2) {
alert('Die beiden Passwörter stimmen nicht überein.')
return
}
try {
await api.managementPasswordReset(pwdModal.profileId, pwdNew)
setPwdModal(null)
setPwdNew('')
setPwdNew2('')
alert('Neues Passwort wurde direkt gesetzt.')
} catch (e) {
alert(e.message || String(e))
}
}
return (
<div className="app-page">
<AdminPageNav />
<h1 style={{ marginTop: 0 }}>Globale Nutzer &amp; Portal-Rollen</h1>
<>
<p style={{ color: 'var(--text2)', maxWidth: '52rem', lineHeight: 1.55, marginBottom: '0.75rem' }}>
Plattformweite Konten und Vereinszuordnungen im Überblick. <strong>Vereinsmitgliedschaft vor Ort</strong>{' '}
(Mitglieder, Beitrittsanträge, Vereinszugriffe) liegt unter <strong>Vereine Mitglieder</strong>. Die
geschützten <strong>/admin</strong>-Menüpunkte (Hierarchie, Kataloge, ) gibt es nur noch hier für Super-Admins.
</p>
<div
className="card"
style={{
marginBottom: '1.25rem',
padding: '0.85rem 1rem',
maxWidth: '52rem',
fontSize: '0.92rem',
lineHeight: 1.5,
color: 'var(--text2)',
}}
>
<strong style={{ color: 'var(--text1)' }}>Portal-Rollen auf Profil-Ebene:</strong>
<ul style={{ margin: '0.5rem 0 0', paddingLeft: '1.2rem' }}>
<li>
<strong>Nutzer</strong> Standard ohne globale Administrationsbereiche.
</li>
<li>
<strong>Portal-Trainer (Legacy)</strong> historische Kennzeichnung; organisatorisch ist Trainer meist eine{' '}
<strong>Vereinsrolle</strong>.
</li>
<li>
<strong>Portal-Administrator</strong> erhöhte API-/Operativ-Rechte nach Backend-Policies; die{' '}
<strong>Admin-Navigation dieser App</strong> sieht jedoch nur noch der Super-Admin.
</li>
<li>
<strong>Super-Administrator</strong> volle Plattform-Governance (diese Oberfläche, offizielle Inhalte, ).
</li>
</ul>
</div>
</>
{loading ? (
<p style={{ color: 'var(--text2)' }}>Laden</p>
) : error ? (
<div className="card" style={{ borderColor: 'var(--danger)', color: 'var(--danger)' }}>
{error}
</div>
) : (
<div style={{ display: 'grid', gap: '1rem' }}>
{platformUsers.map((row) => {
const portalRoleChoices = portalRoleSelectOptions(isSuperadminViewer, row.role)
return (
<div key={row.id} className="card">
<div style={{ display: 'flex', justifyContent: 'space-between', flexWrap: 'wrap', gap: '0.75rem' }}>
<div>
<strong style={{ fontSize: '1.05rem' }}>
{row.name || '—'} <span style={{ color: 'var(--text2)', fontWeight: 400 }}>#{row.id}</span>
</strong>
<div style={{ fontSize: '0.875rem', color: 'var(--text2)' }}>{row.email || '—'}</div>
<div style={{ fontSize: '0.78rem', color: 'var(--text3)', marginTop: '0.35rem' }}>
Verifiziert: {row.email_verified ? 'ja' : 'nein'}
</div>
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem', alignItems: 'flex-end' }}>
<div>
<label className="form-label" style={{ fontSize: '0.75rem' }}>
Portal-Zugriff
</label>
<select
className="form-input"
style={{ minWidth: '200px' }}
value={(portalDraft[row.id]?.role || row.role || 'user').toLowerCase()}
onChange={(e) =>
setPortalDraft((prev) => ({
...prev,
[row.id]: { role: e.target.value },
}))
}
>
{portalRoleChoices.map((r) => (
<option key={r.value} value={r.value}>
{r.label}
</option>
))}
</select>
</div>
<button
type="button"
className="btn btn-secondary"
disabled={row.id === user?.id}
title={row.id === user?.id ? 'Eigenes Passwort unter Einstellungen' : undefined}
onClick={() =>
setPwdModal({
profileId: row.id,
label: row.name || row.email || `#${row.id}`,
})
}
>
Passwort / Link
</button>
<button type="button" className="btn btn-secondary" onClick={() => savePortal(row.id)}>
Portal speichern
</button>
<button
type="button"
className="btn btn-primary"
disabled={!clubs.length}
title={!clubs.length ? 'Zuerst einen Verein anlegen' : undefined}
onClick={() => {
if (!clubs.length) return
setAssignRoles(['trainer'])
setAssignModal({
profileId: row.id,
profileLabel: row.name || row.email || `#${row.id}`,
clubId: clubs[0]?.id ?? '',
})
}}
>
Verein zuweisen
</button>
</div>
</div>
<div style={{ marginTop: '1rem', paddingTop: '0.75rem', borderTop: '1px solid var(--border)' }}>
<strong style={{ fontSize: '0.85rem' }}>Vereinsmitgliedschaften</strong>
{!row.clubs?.length ? (
<p style={{ color: 'var(--text2)', fontSize: '0.875rem', margin: '0.35rem 0 0' }}>
Keine Zuordnung.
</p>
) : (
<ul style={{ margin: '0.5rem 0 0', paddingLeft: '1.2rem', fontSize: '0.9rem' }}>
{(row.clubs || []).map((c) => (
<li key={c.id} style={{ marginBottom: '0.35rem' }}>
<strong>{c.name}</strong>
{c.abbreviation ? ` (${c.abbreviation})` : ''} {' '}
{(c.roles || []).join(', ') || '—'}
{c.membership_status === 'inactive' ? (
<span style={{ color: 'var(--warning, #d4a012)', fontSize: '0.8rem', fontWeight: 600 }}>
{' '}
(Vereinszugang deaktiviert)
</span>
) : null}{' '}
<button
type="button"
style={{
marginLeft: '0.35rem',
fontSize: '0.75rem',
padding: '0.12rem 0.45rem',
borderRadius: '6px',
border: '1px solid var(--border)',
background: 'var(--surface2)',
cursor: 'pointer',
}}
onClick={() =>
setClubEditModal({
clubId: c.id,
clubName: c.name,
profileId: row.id,
profileLabel: row.name || row.email,
roles: [...(c.roles || [])],
status: (c.membership_status || 'active').toLowerCase(),
})
}
>
bearbeiten
</button>
{c.membership_status === 'inactive' ? (
<button
type="button"
style={{
marginLeft: '0.35rem',
fontSize: '0.75rem',
padding: '0.12rem 0.45rem',
borderRadius: '6px',
border: '1px solid var(--accent, #0366d6)',
background: 'var(--surface)',
cursor: 'pointer',
}}
onClick={async () => {
try {
await api.updateClubMember(c.id, row.id, {
roles: [...(c.roles || [])],
status: 'active',
})
await loadPlatform()
} catch (e) {
alert(e.message || String(e))
}
}}
>
Zugang aktivieren
</button>
) : null}
</li>
))}
</ul>
)}
</div>
</div>
)
})}
</div>
)}
{assignModal && (
<div
style={{
position: 'fixed',
inset: 0,
background: 'rgba(0,0,0,0.5)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1200,
padding: '1rem',
}}
>
<div
style={{
background: 'var(--surface)',
borderRadius: '12px',
padding: '1.5rem',
maxWidth: '440px',
width: '100%',
}}
>
<h2 style={{ marginTop: 0 }}>Verein zuweisen</h2>
<p style={{ color: 'var(--text2)', fontSize: '0.9rem' }}>{assignModal.profileLabel}</p>
<div className="form-row">
<label className="form-label">Verein</label>
<select
className="form-input"
value={assignModal.clubId === '' ? '' : String(assignModal.clubId)}
onChange={(e) =>
setAssignModal((prev) =>
prev ? { ...prev, clubId: e.target.value ? parseInt(e.target.value, 10) : '' } : prev
)
}
>
{clubs.map((c) => (
<option key={c.id} value={String(c.id)}>
{c.name}
</option>
))}
</select>
</div>
<div className="form-row">
<span className="form-label">Rollen im Verein</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={assignRoles.includes(opt.code)}
onChange={() => {
setAssignRoles((prev) => {
const s = new Set(prev)
if (s.has(opt.code)) s.delete(opt.code)
else s.add(opt.code)
const out = Array.from(s)
return out.length ? out : ['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={submitAssignClub}>
Zuweisen
</button>
<button type="button" className="btn btn-secondary" onClick={() => setAssignModal(null)}>
Abbrechen
</button>
</div>
</div>
</div>
)}
{clubEditModal && (
<div
style={{
position: 'fixed',
inset: 0,
background: 'rgba(0,0,0,0.5)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1200,
padding: '1rem',
}}
>
<div
style={{
background: 'var(--surface)',
borderRadius: '12px',
padding: '1.5rem',
maxWidth: '440px',
width: '100%',
}}
>
<h2 style={{ marginTop: 0 }}>Vereinsmitgliedschaft</h2>
<p style={{ color: 'var(--text2)', fontSize: '0.9rem' }}>
{clubEditModal.profileLabel} {clubEditModal.clubName}
</p>
<p className="muted" style={{ fontSize: '0.82rem', lineHeight: 1.45 }}>
Deaktiviert betrifft nur den Zugriff auf Inhalte dieses Vereins; Login und andere Vereine bleiben unberührt.
</p>
<div className="form-row">
<label className="form-label">Vereinszugang</label>
<select
className="form-input"
value={clubEditModal.status}
onChange={(e) =>
setClubEditModal((prev) => (prev ? { ...prev, status: e.target.value } : prev))
}
>
<option value="active">aktiv sieht Vereinsinhalte</option>
<option value="inactive">deaktiviert kein Zugriff auf Vereinsinhalte</option>
</select>
</div>
<div className="form-row">
<span className="form-label">Rollen</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={clubEditModal.roles.includes(opt.code)}
onChange={() => {
setClubEditModal((prev) => {
if (!prev) return prev
const s = new Set(prev.roles)
if (s.has(opt.code)) s.delete(opt.code)
else s.add(opt.code)
let roles = Array.from(s)
if (!roles.length) roles = ['trainer']
return { ...prev, roles }
})
}}
/>
{opt.label}
</label>
))}
</div>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem', marginTop: '1rem' }}>
<button type="button" className="btn btn-primary" onClick={saveClubMembership}>
Speichern
</button>
<button
type="button"
className="btn"
style={{ background: 'var(--danger)', color: '#fff', border: 'none' }}
onClick={removeClubMembership}
>
Aus Verein entfernen
</button>
<button type="button" className="btn btn-secondary" onClick={() => setClubEditModal(null)}>
Schließen
</button>
</div>
</div>
</div>
)}
{pwdModal ? (
<div
style={{
position: 'fixed',
inset: 0,
background: 'rgba(0,0,0,0.5)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1250,
padding: '1rem',
}}
>
<div
style={{
background: 'var(--surface)',
borderRadius: '12px',
padding: '1.5rem',
maxWidth: '440px',
width: '100%',
}}
>
<h2 style={{ marginTop: 0 }}>Passwort zurücksetzen</h2>
<p style={{ color: 'var(--text2)', fontSize: '0.9rem' }}>{pwdModal.label}</p>
<p className="muted" style={{ fontSize: '0.82rem', marginBottom: '0.75rem' }}>
Standard: Es wird ein sicherer Link per E-Mail verschickt (wie Passwort vergessen). Das bisherige Passwort
bleibt gültig, bis die Person den Link nutzt und ein neues Passwort wählt.
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
<button type="button" className="btn btn-primary" onClick={submitPasswordEmail}>
Reset-Link per E-Mail senden
</button>
</div>
<>
<hr style={{ margin: '1rem 0', borderColor: 'var(--border, #333)' }} />
<p className="muted" style={{ fontSize: '0.82rem', marginBottom: '0.5rem' }}>
Ausnahme: Passwort direkt setzen (nur bei Bedarf). Das bisherige Passwort ist danach ungültig.
</p>
<div className="form-row">
<label className="form-label">Neues Passwort</label>
<input
type="password"
className="form-input"
autoComplete="new-password"
value={pwdNew}
onChange={(e) => setPwdNew(e.target.value)}
/>
</div>
<div className="form-row">
<label className="form-label">Wiederholen</label>
<input
type="password"
className="form-input"
autoComplete="new-password"
value={pwdNew2}
onChange={(e) => setPwdNew2(e.target.value)}
/>
</div>
<button type="button" className="btn btn-secondary" style={{ width: '100%' }} onClick={submitPasswordDirect}>
Passwort direkt setzen
</button>
</>
<div style={{ marginTop: '1rem' }}>
<button
type="button"
className="btn btn-secondary"
style={{ width: '100%' }}
onClick={() => {
setPwdModal(null)
setPwdNew('')
setPwdNew2('')
}}
>
Schließen
</button>
</div>
</div>
</div>
) : null}
</div>
)
}