feat(admin): restrict admin access and enhance navigation for superadmins
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

- 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.
This commit is contained in:
Lars 2026-05-09 13:26:22 +02:00
parent 30c1c259d2
commit 01be9ffcd4
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 ActiveClubSwitcher from './components/ActiveClubSwitcher'
import InactiveMembershipBanner from './components/InactiveMembershipBanner'
import { activeClubMemberships } from './utils/activeClub'
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) {
const plat = currentUser?.role === 'admin' || currentUser?.role === 'superadmin'
if (plat) return true
return activeClubMemberships(currentUser?.clubs).some((c) => (c.roles || []).includes('club_admin'))
return currentUser?.role === 'superadmin'
}
// Bottom Navigation (Mobile)
@ -196,7 +193,14 @@ function AppRoutes() {
<Route path="planning/run/:unitId" element={<TrainingUnitRunPage />} />
<Route path="planning" element={<TrainingPlanningPage />} />
<Route path="admin" element={<AdminHomeRedirect />} />
<Route path="admin/users" element={<AdminUsersPage />} />
<Route
path="admin/users"
element={
<PlatformAdminRoute>
<AdminUsersPage />
</PlatformAdminRoute>
}
/>
<Route
path="admin/hierarchy"
element={

View File

@ -3,6 +3,6 @@ import { useAuth } from '../context/AuthContext'
export default function AdminHomeRedirect() {
const { user } = useAuth()
const isPlat = user?.role === 'admin' || user?.role === 'superadmin'
return <Navigate to={isPlat ? '/admin/hierarchy' : '/admin/users'} replace />
const isSuper = user?.role === 'superadmin'
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'
/**
* Admin-Seiten-Navigation (horizontal)
* Nutzer-Verwaltung: eingeschränkte Tabs für Vereinsorga ohne Plattform-Admin.
* Admin-Seiten-Navigation (horizontal) nur für Super-Admins (globaler Portal-Mandant).
*/
export default function AdminPageNav({ clubOrgOnly = false }) {
const pages = clubOrgOnly
? [{ to: '/admin/users', label: 'Nutzer', icon: Users }]
: [
{ to: '/admin/hierarchy', label: 'Hierarchie', icon: TreePine },
{ to: '/admin/users', label: 'Nutzer', icon: Users },
{ to: '/admin/maturity-models', label: 'Fähigkeitsmatrix', icon: Grid3x3 },
{ to: '/admin/catalogs', label: 'Kataloge', icon: FolderTree },
{ to: '/admin/mediawiki-import', label: 'Wiki-Import', icon: Download },
]
export default function AdminPageNav() {
const pages = [
{ to: '/admin/hierarchy', label: 'Hierarchie', icon: TreePine },
{ to: '/admin/users', label: 'Nutzer', icon: Users },
{ to: '/admin/maturity-models', label: 'Fähigkeitsmatrix', icon: Grid3x3 },
{ to: '/admin/catalogs', label: 'Kataloge', icon: FolderTree },
{ to: '/admin/mediawiki-import', label: 'Wiki-Import', icon: Download },
]
return (
<nav className="admin-top-nav" aria-label="Administration">

View File

@ -1,10 +1,10 @@
import { Navigate } from 'react-router-dom'
import { useAuth } from '../context/AuthContext'
/** Nur Plattform-Admins (admin/superadmin); Vereinsorga → /admin/users */
/** Nur Super-Admins; andere Nutzer → Startseite. */
export default function PlatformAdminRoute({ children }) {
const { user } = useAuth()
const ok = user?.role === 'admin' || user?.role === 'superadmin'
if (!ok) return <Navigate to="/admin/users" replace />
const ok = user?.role === 'superadmin'
if (!ok) return <Navigate to="/" replace />
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 { useAuth } from '../context/AuthContext'
import api from '../utils/api'
import { activeClubMemberships } from '../utils/activeClub'
import AdminPageNav from '../components/AdminPageNav'
const CLUB_ROLE_OPTIONS = [
@ -19,26 +18,6 @@ const PORTAL_ROLE_LABEL = {
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) {
const base = [
{ value: 'user', label: PORTAL_ROLE_LABEL.user },
@ -54,45 +33,26 @@ function portalRoleSelectOptions(viewerIsSuperadmin, currentRole) {
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 isPlatformAdmin = user?.role === 'admin' || 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 [clubs, setClubs] = useState([])
const [clubMembers, setClubMembers] = useState([])
const [selectedClubId, setSelectedClubId] = useState(
() => managedClubIds[0] ?? null
)
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 [addMemberOpen, setAddMemberOpen] = useState(false)
const [newMemberProfileId, setNewMemberProfileId] = useState('')
const [newMemberRoles, setNewMemberRoles] = useState(['trainer'])
const [pwdModal, setPwdModal] = useState(null)
const [pwdNew, setPwdNew] = 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 [u, c] = await Promise.all([api.listAdminUsers(), api.listClubs()])
setPlatformUsers(Array.isArray(u) ? u : [])
@ -105,8 +65,7 @@ export default function AdminUsersPage() {
}, [])
useEffect(() => {
if (!canAccess) return
if (clubOrgMode) return
if (!isSuperadminViewer) return
let cancelled = false
;(async () => {
setError('')
@ -122,48 +81,9 @@ export default function AdminUsersPage() {
return () => {
cancelled = true
}
}, [canAccess, clubOrgMode, loadPlatform])
}, [isSuperadminViewer, loadPlatform])
useEffect(() => {
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'
if (!isSuperadminViewer) return <Navigate to="/" replace />
const savePortal = async (profileId) => {
const dr = portalDraft[profileId]
@ -200,31 +120,7 @@ export default function AdminUsersPage() {
try {
await api.updateClubMember(clubId, profileId, { roles, status })
setClubEditModal(null)
if (clubOrgMode) await reloadClubMembers()
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()
await loadPlatform()
} catch (e) {
alert(e.message || String(e))
}
@ -236,30 +132,7 @@ export default function AdminUsersPage() {
try {
await api.removeClubMember(clubEditModal.clubId, clubEditModal.profileId)
setClubEditModal(null)
if (clubOrgMode) await reloadClubMembers()
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()
await loadPlatform()
} catch (e) {
alert(e.message || String(e))
}
@ -284,7 +157,7 @@ export default function AdminUsersPage() {
}
const submitPasswordDirect = async () => {
if (!pwdModal || !isSuperadminViewer) return
if (!pwdModal) return
if (pwdNew.length < 8) {
alert('Mindestens 8 Zeichen.')
return
@ -306,73 +179,46 @@ export default function AdminUsersPage() {
return (
<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' }}>
Du verwaltest Mitglieder des ausgewählten Vereins. <strong>Vereinszugang deaktivieren</strong> sperrt nur die
Sicht auf Vereinsinhalte der Login des Nutzers bleibt möglich. Wiederherstellen über aktivieren oder
Bearbeiten.
<>
<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>
) : (
<>
<p style={{ color: 'var(--text2)', maxWidth: '52rem', lineHeight: 1.55, marginBottom: '0.75rem' }}>
Gesamtübersicht aller Konten und Vereinszuordnungen. <strong>Portal</strong>-Einstellungen steuern nur den
Zugang zur <strong>plattformweiten Administration</strong> (Kataloge, Hierarchie, globale Nutzerliste usw.);
Vereinsarbeit (Trainer, Vereinsadmin&nbsp;) bleiben <strong>Vereinsrollen</strong>. Abonnement/Tier ist
derzeit nicht freigeschaltet.
</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)' }}>Die vier Portal-Zugriffsstufen:</strong>
<ul style={{ margin: '0.5rem 0 0', paddingLeft: '1.2rem' }}>
<li>
<strong>Nutzer</strong> Standardnutzer ohne Plattform-Admin-Bereiche.
</li>
<li>
<strong>Portal-Trainer (Legacy)</strong> ältere Kennzeichnung auf Profil-Ebene; organisatorisch ist
Trainer in der Regel eine <strong>Vereinsrolle</strong>.
</li>
<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
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>
) : null}
</>
{loading ? (
<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)' }}>
{error}
</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' }}>
{platformUsers.map((row) => {
@ -591,7 +345,7 @@ export default function AdminUsersPage() {
>
bearbeiten
</button>
{isSuperadminViewer && c.membership_status === 'inactive' ? (
{c.membership_status === 'inactive' ? (
<button
type="button"
style={{
@ -706,77 +460,6 @@ export default function AdminUsersPage() {
</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 && (
<div
style={{
@ -804,8 +487,7 @@ export default function AdminUsersPage() {
{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.
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>
@ -890,45 +572,43 @@ export default function AdminUsersPage() {
<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.
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>
{isSuperadminViewer ? (
<>
<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>
</>
) : null}
<>
<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"

View File

@ -3,6 +3,7 @@ import api from '../utils/api'
import { notifyOrgInboxChanged } from '../context/OrgInboxContext'
import { useAuth } from '../context/AuthContext'
import { activeClubMemberships } from '../utils/activeClub'
import { isEscalatedPortalRole } from '../utils/portalRoles'
import PageSectionNav from '../components/PageSectionNav'
const CLUB_ROLE_OPTIONS = [
@ -35,6 +36,9 @@ function ClubsPage() {
const [editMemberModal, setEditMemberModal] = useState(null)
const [acceptJoinModal, setAcceptJoinModal] = useState(null)
const [pwdModal, setPwdModal] = useState(null)
const [pwdNew, setPwdNew] = useState('')
const [pwdNew2, setPwdNew2] = useState('')
// Form state
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) => {
setEditing(item)
setModalType(type)
@ -753,6 +794,34 @@ function ClubsPage() {
<button type="button" className="btn btn-secondary" onClick={() => setEditMemberModal(m)}>
Mitglied bearbeiten
</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 ? (
inactiveRow ? (
<button
@ -1475,6 +1544,93 @@ function ClubsPage() {
</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>
)
}

View File

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