Merge pull request 'feat(admin): restrict admin access and enhance navigation for superadmins' (#29) from develop into main
All checks were successful
Deploy Production / 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 26s

Reviewed-on: #29
This commit is contained in:
Lars 2026-05-09 13:30:32 +02:00
commit 3134160003
8 changed files with 267 additions and 427 deletions

View File

@ -39,14 +39,11 @@ import PlatformAdminRoute from './components/PlatformAdminRoute'
import MediaLibraryPage from './pages/MediaLibraryPage' import MediaLibraryPage from './pages/MediaLibraryPage'
import ActiveClubSwitcher from './components/ActiveClubSwitcher' import ActiveClubSwitcher from './components/ActiveClubSwitcher'
import InactiveMembershipBanner from './components/InactiveMembershipBanner' import InactiveMembershipBanner from './components/InactiveMembershipBanner'
import { activeClubMemberships } from './utils/activeClub'
import './app.css' import './app.css'
/** Shield-„Admin“: Portal-Admins oder Vereinsorganisation (Zugriff mindestens /admin/users). */ /** Shield „Admin“: nur Super-Admin (global). Vereinsorga: Vereine → Mitglieder. */
function computeShowAdminNav(currentUser) { function computeShowAdminNav(currentUser) {
const plat = currentUser?.role === 'admin' || currentUser?.role === 'superadmin' return currentUser?.role === 'superadmin'
if (plat) return true
return activeClubMemberships(currentUser?.clubs).some((c) => (c.roles || []).includes('club_admin'))
} }
// Bottom Navigation (Mobile) // Bottom Navigation (Mobile)
@ -196,7 +193,14 @@ function AppRoutes() {
<Route path="planning/run/:unitId" element={<TrainingUnitRunPage />} /> <Route path="planning/run/:unitId" element={<TrainingUnitRunPage />} />
<Route path="planning" element={<TrainingPlanningPage />} /> <Route path="planning" element={<TrainingPlanningPage />} />
<Route path="admin" element={<AdminHomeRedirect />} /> <Route path="admin" element={<AdminHomeRedirect />} />
<Route path="admin/users" element={<AdminUsersPage />} /> <Route
path="admin/users"
element={
<PlatformAdminRoute>
<AdminUsersPage />
</PlatformAdminRoute>
}
/>
<Route <Route
path="admin/hierarchy" path="admin/hierarchy"
element={ element={

View File

@ -3,6 +3,6 @@ import { useAuth } from '../context/AuthContext'
export default function AdminHomeRedirect() { export default function AdminHomeRedirect() {
const { user } = useAuth() const { user } = useAuth()
const isPlat = user?.role === 'admin' || user?.role === 'superadmin' const isSuper = user?.role === 'superadmin'
return <Navigate to={isPlat ? '/admin/hierarchy' : '/admin/users'} replace /> return <Navigate to={isSuper ? '/admin/hierarchy' : '/'} replace />
} }

View File

@ -2,19 +2,16 @@ import { NavLink } from 'react-router-dom'
import { TreePine, FolderTree, Download, Grid3x3, Users } from 'lucide-react' import { TreePine, FolderTree, Download, Grid3x3, Users } from 'lucide-react'
/** /**
* Admin-Seiten-Navigation (horizontal) * Admin-Seiten-Navigation (horizontal) nur für Super-Admins (globaler Portal-Mandant).
* Nutzer-Verwaltung: eingeschränkte Tabs für Vereinsorga ohne Plattform-Admin.
*/ */
export default function AdminPageNav({ clubOrgOnly = false }) { export default function AdminPageNav() {
const pages = clubOrgOnly const pages = [
? [{ to: '/admin/users', label: 'Nutzer', icon: Users }] { to: '/admin/hierarchy', label: 'Hierarchie', icon: TreePine },
: [ { to: '/admin/users', label: 'Nutzer', icon: Users },
{ to: '/admin/hierarchy', label: 'Hierarchie', icon: TreePine }, { to: '/admin/maturity-models', label: 'Fähigkeitsmatrix', icon: Grid3x3 },
{ to: '/admin/users', label: 'Nutzer', icon: Users }, { to: '/admin/catalogs', label: 'Kataloge', icon: FolderTree },
{ to: '/admin/maturity-models', label: 'Fähigkeitsmatrix', icon: Grid3x3 }, { to: '/admin/mediawiki-import', label: 'Wiki-Import', icon: Download },
{ to: '/admin/catalogs', label: 'Kataloge', icon: FolderTree }, ]
{ to: '/admin/mediawiki-import', label: 'Wiki-Import', icon: Download },
]
return ( return (
<nav className="admin-top-nav" aria-label="Administration"> <nav className="admin-top-nav" aria-label="Administration">

View File

@ -1,10 +1,10 @@
import { Navigate } from 'react-router-dom' import { Navigate } from 'react-router-dom'
import { useAuth } from '../context/AuthContext' import { useAuth } from '../context/AuthContext'
/** Nur Plattform-Admins (admin/superadmin); Vereinsorga → /admin/users */ /** Nur Super-Admins; andere Nutzer → Startseite. */
export default function PlatformAdminRoute({ children }) { export default function PlatformAdminRoute({ children }) {
const { user } = useAuth() const { user } = useAuth()
const ok = user?.role === 'admin' || user?.role === 'superadmin' const ok = user?.role === 'superadmin'
if (!ok) return <Navigate to="/admin/users" replace /> if (!ok) return <Navigate to="/" replace />
return children return children
} }

View File

@ -1,8 +1,7 @@
import { useCallback, useEffect, useMemo, useState } from 'react' import { useCallback, useEffect, useState } from 'react'
import { Navigate } from 'react-router-dom' import { Navigate } from 'react-router-dom'
import { useAuth } from '../context/AuthContext' import { useAuth } from '../context/AuthContext'
import api from '../utils/api' import api from '../utils/api'
import { activeClubMemberships } from '../utils/activeClub'
import AdminPageNav from '../components/AdminPageNav' import AdminPageNav from '../components/AdminPageNav'
const CLUB_ROLE_OPTIONS = [ const CLUB_ROLE_OPTIONS = [
@ -19,26 +18,6 @@ const PORTAL_ROLE_LABEL = {
superadmin: 'Super-Administrator', superadmin: 'Super-Administrator',
} }
function clubAdminClubIds(user) {
return activeClubMemberships(user?.clubs)
.filter((c) => Array.isArray(c.roles) && c.roles.includes('club_admin'))
.map((c) => c.id)
}
function clubSelectOptions(user, allClubs, isPlatformAdmin) {
if (!isPlatformAdmin) {
const ids = new Set(clubAdminClubIds(user))
return (allClubs || []).filter((c) => ids.has(c.id))
}
return allClubs || []
}
/** Plattform-Rollen im UI (Tier/Abo entfällt bis auf Weiteres). */
function isEscalatedPortalRole(role) {
const r = (role || 'user').toLowerCase()
return r === 'admin' || r === 'superadmin'
}
function portalRoleSelectOptions(viewerIsSuperadmin, currentRole) { function portalRoleSelectOptions(viewerIsSuperadmin, currentRole) {
const base = [ const base = [
{ value: 'user', label: PORTAL_ROLE_LABEL.user }, { value: 'user', label: PORTAL_ROLE_LABEL.user },
@ -54,45 +33,26 @@ function portalRoleSelectOptions(viewerIsSuperadmin, currentRole) {
return base 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() { export default function AdminUsersPage() {
const { user } = useAuth() const { user } = useAuth()
const isPlatformAdmin = user?.role === 'admin' || user?.role === 'superadmin'
const isSuperadminViewer = user?.role === 'superadmin' const isSuperadminViewer = user?.role === 'superadmin'
const managedClubIds = useMemo(() => clubAdminClubIds(user), [user])
const clubOrgMode = !isPlatformAdmin && managedClubIds.length > 0
const canAccess = isPlatformAdmin || clubOrgMode
const [platformUsers, setPlatformUsers] = useState([]) const [platformUsers, setPlatformUsers] = useState([])
const [clubs, setClubs] = useState([]) const [clubs, setClubs] = useState([])
const [clubMembers, setClubMembers] = useState([])
const [selectedClubId, setSelectedClubId] = useState(
() => managedClubIds[0] ?? null
)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState('') const [error, setError] = useState('')
const [portalDraft, setPortalDraft] = useState({}) const [portalDraft, setPortalDraft] = useState({})
const [assignModal, setAssignModal] = useState(null) const [assignModal, setAssignModal] = useState(null)
const [assignRoles, setAssignRoles] = useState(['trainer']) const [assignRoles, setAssignRoles] = useState(['trainer'])
const [clubEditModal, setClubEditModal] = useState(null) const [clubEditModal, setClubEditModal] = useState(null)
const [addMemberOpen, setAddMemberOpen] = useState(false)
const [newMemberProfileId, setNewMemberProfileId] = useState('')
const [newMemberRoles, setNewMemberRoles] = useState(['trainer'])
const [pwdModal, setPwdModal] = useState(null) const [pwdModal, setPwdModal] = useState(null)
const [pwdNew, setPwdNew] = useState('') const [pwdNew, setPwdNew] = useState('')
const [pwdNew2, setPwdNew2] = useState('') const [pwdNew2, setPwdNew2] = useState('')
const selectableClubs = useMemo(
() => clubSelectOptions(user, clubs, isPlatformAdmin),
[user, clubs, isPlatformAdmin]
)
useEffect(() => {
if (!clubOrgMode) return
if (selectedClubId == null || !managedClubIds.includes(selectedClubId)) {
setSelectedClubId(managedClubIds[0] ?? null)
}
}, [clubOrgMode, managedClubIds, selectedClubId])
const loadPlatform = useCallback(async () => { const loadPlatform = useCallback(async () => {
const [u, c] = await Promise.all([api.listAdminUsers(), api.listClubs()]) const [u, c] = await Promise.all([api.listAdminUsers(), api.listClubs()])
setPlatformUsers(Array.isArray(u) ? u : []) setPlatformUsers(Array.isArray(u) ? u : [])
@ -105,8 +65,7 @@ export default function AdminUsersPage() {
}, []) }, [])
useEffect(() => { useEffect(() => {
if (!canAccess) return if (!isSuperadminViewer) return
if (clubOrgMode) return
let cancelled = false let cancelled = false
;(async () => { ;(async () => {
setError('') setError('')
@ -122,48 +81,9 @@ export default function AdminUsersPage() {
return () => { return () => {
cancelled = true cancelled = true
} }
}, [canAccess, clubOrgMode, loadPlatform]) }, [isSuperadminViewer, loadPlatform])
useEffect(() => { if (!isSuperadminViewer) return <Navigate to="/" replace />
if (!canAccess || !clubOrgMode || !selectedClubId) return
let cancelled = false
;(async () => {
setError('')
setLoading(true)
try {
const [c, m] = await Promise.all([
api.listClubs(),
api.listClubMembers(selectedClubId, { includeInactive: true }),
])
if (!cancelled) {
setClubs(Array.isArray(c) ? c : [])
setClubMembers(Array.isArray(m) ? m : [])
}
} catch (e) {
if (!cancelled) setError(e.message || String(e))
} finally {
if (!cancelled) setLoading(false)
}
})()
return () => {
cancelled = true
}
}, [canAccess, clubOrgMode, selectedClubId])
const reloadClubMembers = useCallback(async () => {
if (!selectedClubId) return
try {
const m = await api.listClubMembers(selectedClubId, { includeInactive: true })
setClubMembers(Array.isArray(m) ? m : [])
} catch {
setClubMembers([])
}
}, [selectedClubId])
if (!canAccess) return <Navigate to="/" replace />
const selectedClubLabel =
selectableClubs.find((c) => c.id === selectedClubId)?.name || 'Verein'
const savePortal = async (profileId) => { const savePortal = async (profileId) => {
const dr = portalDraft[profileId] const dr = portalDraft[profileId]
@ -200,31 +120,7 @@ export default function AdminUsersPage() {
try { try {
await api.updateClubMember(clubId, profileId, { roles, status }) await api.updateClubMember(clubId, profileId, { roles, status })
setClubEditModal(null) setClubEditModal(null)
if (clubOrgMode) await reloadClubMembers() await loadPlatform()
else await loadPlatform()
} catch (e) {
alert(e.message || String(e))
}
}
const toggleMemberClubAccess = async (m, activate) => {
if (!selectedClubId) return
const st = activate ? 'active' : 'inactive'
if (
!activate &&
!confirm(
`Vereinszugang für „${m.name || m.email || '#' + m.profile_id}“ in ${selectedClubLabel} deaktivieren? ` +
'Die Person bleibt anmeldbar, sieht aber keine Inhalte dieses Vereins mehr (Login bleibt unverändert).'
)
) {
return
}
try {
await api.updateClubMember(selectedClubId, m.profile_id, {
roles: [...(m.roles || [])],
status: st,
})
await reloadClubMembers()
} catch (e) { } catch (e) {
alert(e.message || String(e)) alert(e.message || String(e))
} }
@ -236,30 +132,7 @@ export default function AdminUsersPage() {
try { try {
await api.removeClubMember(clubEditModal.clubId, clubEditModal.profileId) await api.removeClubMember(clubEditModal.clubId, clubEditModal.profileId)
setClubEditModal(null) setClubEditModal(null)
if (clubOrgMode) await reloadClubMembers() await loadPlatform()
else await loadPlatform()
} catch (e) {
alert(e.message || String(e))
}
}
const submitAddClubMember = async () => {
const raw = parseInt(String(newMemberProfileId).trim(), 10)
if (!Number.isFinite(raw) || raw < 1) {
alert('Gültige Profil-ID eingeben.')
return
}
if (!selectedClubId) return
if (!newMemberRoles.length) {
alert('Mindestens eine Vereinsrolle.')
return
}
try {
await api.addClubMember(selectedClubId, { profile_id: raw, roles: newMemberRoles })
setAddMemberOpen(false)
setNewMemberProfileId('')
setNewMemberRoles(['trainer'])
await reloadClubMembers()
} catch (e) { } catch (e) {
alert(e.message || String(e)) alert(e.message || String(e))
} }
@ -284,7 +157,7 @@ export default function AdminUsersPage() {
} }
const submitPasswordDirect = async () => { const submitPasswordDirect = async () => {
if (!pwdModal || !isSuperadminViewer) return if (!pwdModal) return
if (pwdNew.length < 8) { if (pwdNew.length < 8) {
alert('Mindestens 8 Zeichen.') alert('Mindestens 8 Zeichen.')
return return
@ -306,73 +179,46 @@ export default function AdminUsersPage() {
return ( return (
<div className="app-page"> <div className="app-page">
<AdminPageNav clubOrgOnly={clubOrgMode} /> <AdminPageNav />
<h1 style={{ marginTop: 0 }}>Nutzer &amp; Vereinsrollen</h1> <h1 style={{ marginTop: 0 }}>Globale Nutzer &amp; Portal-Rollen</h1>
{clubOrgMode ? ( <>
<p style={{ color: 'var(--text2)', maxWidth: '52rem', lineHeight: 1.55, marginBottom: '1.25rem' }}> <p style={{ color: 'var(--text2)', maxWidth: '52rem', lineHeight: 1.55, marginBottom: '0.75rem' }}>
Du verwaltest Mitglieder des ausgewählten Vereins. <strong>Vereinszugang deaktivieren</strong> sperrt nur die Plattformweite Konten und Vereinszuordnungen im Überblick. <strong>Vereinsmitgliedschaft vor Ort</strong>{' '}
Sicht auf Vereinsinhalte der Login des Nutzers bleibt möglich. Wiederherstellen über aktivieren oder (Mitglieder, Beitrittsanträge, Vereinszugriffe) liegt unter <strong>Vereine Mitglieder</strong>. Die
Bearbeiten. geschützten <strong>/admin</strong>-Menüpunkte (Hierarchie, Kataloge, ) gibt es nur noch hier für Super-Admins.
</p> </p>
) : ( <div
<> className="card"
<p style={{ color: 'var(--text2)', maxWidth: '52rem', lineHeight: 1.55, marginBottom: '0.75rem' }}> style={{
Gesamtübersicht aller Konten und Vereinszuordnungen. <strong>Portal</strong>-Einstellungen steuern nur den marginBottom: '1.25rem',
Zugang zur <strong>plattformweiten Administration</strong> (Kataloge, Hierarchie, globale Nutzerliste usw.); padding: '0.85rem 1rem',
Vereinsarbeit (Trainer, Vereinsadmin&nbsp;) bleiben <strong>Vereinsrollen</strong>. Abonnement/Tier ist maxWidth: '52rem',
derzeit nicht freigeschaltet. fontSize: '0.92rem',
</p> lineHeight: 1.5,
<div color: 'var(--text2)',
className="card" }}
style={{ >
marginBottom: '1.25rem', <strong style={{ color: 'var(--text1)' }}>Portal-Rollen auf Profil-Ebene:</strong>
padding: '0.85rem 1rem', <ul style={{ margin: '0.5rem 0 0', paddingLeft: '1.2rem' }}>
maxWidth: '52rem', <li>
fontSize: '0.92rem', <strong>Nutzer</strong> Standard ohne globale Administrationsbereiche.
lineHeight: 1.5, </li>
color: 'var(--text2)', <li>
}} <strong>Portal-Trainer (Legacy)</strong> historische Kennzeichnung; organisatorisch ist Trainer meist eine{' '}
> <strong>Vereinsrolle</strong>.
<strong style={{ color: 'var(--text1)' }}>Die vier Portal-Zugriffsstufen:</strong> </li>
<ul style={{ margin: '0.5rem 0 0', paddingLeft: '1.2rem' }}> <li>
<li> <strong>Portal-Administrator</strong> erhöhte API-/Operativ-Rechte nach Backend-Policies; die{' '}
<strong>Nutzer</strong> Standardnutzer ohne Plattform-Admin-Bereiche. <strong>Admin-Navigation dieser App</strong> sieht jedoch nur noch der Super-Admin.
</li> </li>
<li> <li>
<strong>Portal-Trainer (Legacy)</strong> ältere Kennzeichnung auf Profil-Ebene; organisatorisch ist <strong>Super-Administrator</strong> volle Plattform-Governance (diese Oberfläche, offizielle Inhalte, ).
Trainer in der Regel eine <strong>Vereinsrolle</strong>. </li>
</li> </ul>
<li>
<strong>Portal-Administrator</strong> Zugang zu allen geschützten <code>/admin</code>-Bereichen
(Außer: einige Funktionen nur Superadmin, z.&nbsp;B. bestimmte Medien-/Governance-Aktionen).
</li>
<li>
<strong>Super-Administrator</strong> volle Plattform-Governance (u.&nbsp;a. offizielle Medien,
Superadmin-Rolle vergeben, harte Lifecycle-Aktionen an Medien).
</li>
</ul>
</div>
</>
)}
{clubOrgMode && managedClubIds.length > 1 ? (
<div className="form-row" style={{ maxWidth: '24rem', marginBottom: '1rem' }}>
<label className="form-label">Verein für Verwaltung</label>
<select
className="form-input"
value={selectedClubId ?? ''}
onChange={(e) => setSelectedClubId(parseInt(e.target.value, 10))}
>
{selectableClubs.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
</div> </div>
) : null} </>
{loading ? ( {loading ? (
<p style={{ color: 'var(--text2)' }}>Laden</p> <p style={{ color: 'var(--text2)' }}>Laden</p>
@ -380,98 +226,6 @@ export default function AdminUsersPage() {
<div className="card" style={{ borderColor: 'var(--danger)', color: 'var(--danger)' }}> <div className="card" style={{ borderColor: 'var(--danger)', color: 'var(--danger)' }}>
{error} {error}
</div> </div>
) : clubOrgMode ? (
<div style={{ display: 'grid', gap: '1rem' }}>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem', alignItems: 'center' }}>
<button type="button" className="btn btn-primary" onClick={() => setAddMemberOpen(true)}>
Mitglied hinzufügen (Profil-ID)
</button>
<button type="button" className="btn btn-secondary" onClick={() => reloadClubMembers()}>
Aktualisieren
</button>
</div>
{!clubMembers.length ? (
<p className="muted">Keine Mitglieder in diesem Verein.</p>
) : (
clubMembers.map((m) => {
const memStatus = (m.status || 'active').toLowerCase()
return (
<div key={m.membership_id} className="card">
<div style={{ display: 'flex', justifyContent: 'space-between', flexWrap: 'wrap', gap: '0.75rem' }}>
<div>
<strong style={{ fontSize: '1.05rem' }}>
{m.name || '—'} <span style={{ color: 'var(--text2)', fontWeight: 400 }}>#{m.profile_id}</span>
</strong>
<div style={{ fontSize: '0.875rem', color: 'var(--text2)' }}>{m.email || '—'}</div>
<div style={{ fontSize: '0.78rem', color: 'var(--text3)', marginTop: '0.35rem' }}>
Vereinszugang:{' '}
<strong style={{ color: memStatus === 'active' ? 'var(--text1)' : 'var(--warning, #d4a012)' }}>
{memStatus === 'active' ? 'aktiv' : 'deaktiviert'}
</strong>
{' '}
· Verifiziert: {m.email_verified ? 'ja' : 'nein'}
</div>
<div style={{ fontSize: '0.82rem', marginTop: '0.35rem' }}>
Rollen: {(m.roles || []).join(', ') || '—'}
</div>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem', alignItems: 'stretch' }}>
<button
type="button"
className="btn btn-secondary"
onClick={() =>
setClubEditModal({
clubId: selectedClubId,
clubName: selectedClubLabel,
profileId: m.profile_id,
profileLabel: m.name || m.email,
roles: [...(m.roles || [])],
status: memStatus,
})
}
>
Bearbeiten
</button>
{m.profile_id !== user?.id ? (
memStatus === 'inactive' ? (
<button
type="button"
className="btn btn-primary"
onClick={() => toggleMemberClubAccess(m, true)}
>
Vereinszugang aktivieren
</button>
) : (
<button
type="button"
className="btn btn-secondary"
onClick={() => toggleMemberClubAccess(m, false)}
>
Vereinszugang deaktivieren
</button>
)
) : null}
{m.profile_id !== user?.id && !isEscalatedPortalRole(m.portal_role) ? (
<button
type="button"
className="btn btn-secondary"
onClick={() =>
setPwdModal({
profileId: m.profile_id,
label: m.name || m.email || `Profil #${m.profile_id}`,
})
}
>
Passwort-Link senden
</button>
) : null}
</div>
</div>
</div>
)
})
)}
</div>
) : ( ) : (
<div style={{ display: 'grid', gap: '1rem' }}> <div style={{ display: 'grid', gap: '1rem' }}>
{platformUsers.map((row) => { {platformUsers.map((row) => {
@ -591,7 +345,7 @@ export default function AdminUsersPage() {
> >
bearbeiten bearbeiten
</button> </button>
{isSuperadminViewer && c.membership_status === 'inactive' ? ( {c.membership_status === 'inactive' ? (
<button <button
type="button" type="button"
style={{ style={{
@ -706,77 +460,6 @@ export default function AdminUsersPage() {
</div> </div>
)} )}
{addMemberOpen && clubOrgMode ? (
<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 }}>Mitglied hinzufügen</h2>
<p className="muted" style={{ fontSize: '0.9rem' }}>
Verein: <strong>{selectedClubLabel}</strong>
</p>
<div className="form-row">
<label className="form-label">Profil-ID</label>
<input
className="form-input"
inputMode="numeric"
value={newMemberProfileId}
onChange={(e) => setNewMemberProfileId(e.target.value)}
placeholder="z.B. 42"
/>
</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={newMemberRoles.includes(opt.code)}
onChange={() => {
setNewMemberRoles((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={submitAddClubMember}>
Hinzufügen
</button>
<button type="button" className="btn btn-secondary" onClick={() => setAddMemberOpen(false)}>
Abbrechen
</button>
</div>
</div>
</div>
) : null}
{clubEditModal && ( {clubEditModal && (
<div <div
style={{ style={{
@ -804,8 +487,7 @@ export default function AdminUsersPage() {
{clubEditModal.profileLabel} {clubEditModal.clubName} {clubEditModal.profileLabel} {clubEditModal.clubName}
</p> </p>
<p className="muted" style={{ fontSize: '0.82rem', lineHeight: 1.45 }}> <p className="muted" style={{ fontSize: '0.82rem', lineHeight: 1.45 }}>
Deaktiviert betrifft nur den Zugriff auf Inhalte dieses Vereins; Login und andere Vereine bleiben Deaktiviert betrifft nur den Zugriff auf Inhalte dieses Vereins; Login und andere Vereine bleiben unberührt.
unberührt.
</p> </p>
<div className="form-row"> <div className="form-row">
<label className="form-label">Vereinszugang</label> <label className="form-label">Vereinszugang</label>
@ -890,45 +572,43 @@ export default function AdminUsersPage() {
<h2 style={{ marginTop: 0 }}>Passwort zurücksetzen</h2> <h2 style={{ marginTop: 0 }}>Passwort zurücksetzen</h2>
<p style={{ color: 'var(--text2)', fontSize: '0.9rem' }}>{pwdModal.label}</p> <p style={{ color: 'var(--text2)', fontSize: '0.9rem' }}>{pwdModal.label}</p>
<p className="muted" style={{ fontSize: '0.82rem', marginBottom: '0.75rem' }}> <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 Standard: Es wird ein sicherer Link per E-Mail verschickt (wie Passwort vergessen). Das bisherige Passwort
Passwort bleibt gültig, bis die Person den Link nutzt und ein neues Passwort wählt. bleibt gültig, bis die Person den Link nutzt und ein neues Passwort wählt.
</p> </p>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}> <div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
<button type="button" className="btn btn-primary" onClick={submitPasswordEmail}> <button type="button" className="btn btn-primary" onClick={submitPasswordEmail}>
Reset-Link per E-Mail senden Reset-Link per E-Mail senden
</button> </button>
</div> </div>
{isSuperadminViewer ? ( <>
<> <hr style={{ margin: '1rem 0', borderColor: 'var(--border, #333)' }} />
<hr style={{ margin: '1rem 0', borderColor: 'var(--border, #333)' }} /> <p className="muted" style={{ fontSize: '0.82rem', marginBottom: '0.5rem' }}>
<p className="muted" style={{ fontSize: '0.82rem', marginBottom: '0.5rem' }}> Ausnahme: Passwort direkt setzen (nur bei Bedarf). Das bisherige Passwort ist danach ungültig.
Ausnahme: Passwort direkt setzen (nur bei Bedarf). Das bisherige Passwort ist danach ungültig. </p>
</p> <div className="form-row">
<div className="form-row"> <label className="form-label">Neues Passwort</label>
<label className="form-label">Neues Passwort</label> <input
<input type="password"
type="password" className="form-input"
className="form-input" autoComplete="new-password"
autoComplete="new-password" value={pwdNew}
value={pwdNew} onChange={(e) => setPwdNew(e.target.value)}
onChange={(e) => setPwdNew(e.target.value)} />
/> </div>
</div> <div className="form-row">
<div className="form-row"> <label className="form-label">Wiederholen</label>
<label className="form-label">Wiederholen</label> <input
<input type="password"
type="password" className="form-input"
className="form-input" autoComplete="new-password"
autoComplete="new-password" value={pwdNew2}
value={pwdNew2} onChange={(e) => setPwdNew2(e.target.value)}
onChange={(e) => setPwdNew2(e.target.value)} />
/> </div>
</div> <button type="button" className="btn btn-secondary" style={{ width: '100%' }} onClick={submitPasswordDirect}>
<button type="button" className="btn btn-secondary" style={{ width: '100%' }} onClick={submitPasswordDirect}> Passwort direkt setzen
Passwort direkt setzen </button>
</button> </>
</>
) : null}
<div style={{ marginTop: '1rem' }}> <div style={{ marginTop: '1rem' }}>
<button <button
type="button" type="button"

View File

@ -3,6 +3,7 @@ import api from '../utils/api'
import { notifyOrgInboxChanged } from '../context/OrgInboxContext' import { notifyOrgInboxChanged } from '../context/OrgInboxContext'
import { useAuth } from '../context/AuthContext' import { useAuth } from '../context/AuthContext'
import { activeClubMemberships } from '../utils/activeClub' import { activeClubMemberships } from '../utils/activeClub'
import { isEscalatedPortalRole } from '../utils/portalRoles'
import PageSectionNav from '../components/PageSectionNav' import PageSectionNav from '../components/PageSectionNav'
const CLUB_ROLE_OPTIONS = [ const CLUB_ROLE_OPTIONS = [
@ -35,6 +36,9 @@ function ClubsPage() {
const [editMemberModal, setEditMemberModal] = useState(null) const [editMemberModal, setEditMemberModal] = useState(null)
const [acceptJoinModal, setAcceptJoinModal] = useState(null) const [acceptJoinModal, setAcceptJoinModal] = useState(null)
const [pwdModal, setPwdModal] = useState(null)
const [pwdNew, setPwdNew] = useState('')
const [pwdNew2, setPwdNew2] = useState('')
// Form state // Form state
const [formData, setFormData] = useState({}) const [formData, setFormData] = useState({})
@ -218,6 +222,43 @@ function ClubsPage() {
} }
} }
const submitMembersPasswordEmail = 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: E-Mail-Versand fehlgeschlagen (SMTP).'
alert(msg)
} catch (e) {
alert(e.message || String(e))
}
}
const submitMembersPasswordDirect = async () => {
if (!pwdModal || !isSuperAdmin) 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))
}
}
const handleEdit = (item, type) => { const handleEdit = (item, type) => {
setEditing(item) setEditing(item)
setModalType(type) setModalType(type)
@ -753,6 +794,34 @@ function ClubsPage() {
<button type="button" className="btn btn-secondary" onClick={() => setEditMemberModal(m)}> <button type="button" className="btn btn-secondary" onClick={() => setEditMemberModal(m)}>
Mitglied bearbeiten Mitglied bearbeiten
</button> </button>
{m.profile_id !== user?.id ? (
<button
type="button"
className="btn btn-secondary"
disabled={
!isSuperAdmin &&
!isPlatformAdmin &&
isEscalatedPortalRole(m.portal_role)
}
title={
!isSuperAdmin &&
!isPlatformAdmin &&
isEscalatedPortalRole(m.portal_role)
? 'Für Portal-Administratoren nur mit Super-/Plattform-Administrator möglich'
: undefined
}
onClick={() => {
setPwdNew('')
setPwdNew2('')
setPwdModal({
profileId: m.profile_id,
label: m.name || m.email || `#${m.profile_id}`,
})
}}
>
Passwort / Link
</button>
) : null}
{m.profile_id !== user?.id ? ( {m.profile_id !== user?.id ? (
inactiveRow ? ( inactiveRow ? (
<button <button
@ -1475,6 +1544,93 @@ function ClubsPage() {
</div> </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={submitMembersPasswordEmail}>
Reset-Link per E-Mail senden
</button>
</div>
{isSuperAdmin ? (
<>
<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={submitMembersPasswordDirect}
>
Passwort direkt setzen
</button>
</>
) : null}
<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> </div>
) )
} }

View File

@ -546,10 +546,8 @@ export default function MediaLibraryPage() {
<div className="media-library__hero-links"> <div className="media-library__hero-links">
<Link to="/">Übersicht</Link> <Link to="/">Übersicht</Link>
<Link to="/exercises">Übungen</Link> <Link to="/exercises">Übungen</Link>
{isPlatformAdmin ? <Link to="/admin/hierarchy">Plattform-Admin</Link> : null} {isSuperadmin ? <Link to="/admin">Globale Administration</Link> : null}
{hasClubOrgAdmin || isPlatformAdmin ? ( {hasClubOrgAdmin ? <Link to="/clubs">Vereine &amp; Mitglieder</Link> : null}
<Link to="/admin/users">Nutzer &amp; Organisation</Link>
) : null}
</div> </div>
</div> </div>
<p className="media-library__intro"> <p className="media-library__intro">

View File

@ -0,0 +1,5 @@
/** Portal-/Plattformrollen auf Profil-Ebene mit besonderem Schutz (Passwort-/Rechte-Angriffsfläche). */
export function isEscalatedPortalRole(role) {
const r = (role || 'user').toLowerCase()
return r === 'admin' || r === 'superadmin'
}