Compare commits

..

No commits in common. "31341600031e5fb70e99dc7353467576fbdf11a0" and "b19940c99709cd5684792d1b35bbecb794f4c84c" have entirely different histories.

8 changed files with 427 additions and 267 deletions

View File

@ -39,11 +39,14 @@ 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“: nur Super-Admin (global). Vereinsorga: Vereine → Mitglieder. */ /** Shield-„Admin“: Portal-Admins oder Vereinsorganisation (Zugriff mindestens /admin/users). */
function computeShowAdminNav(currentUser) { function computeShowAdminNav(currentUser) {
return currentUser?.role === 'superadmin' const plat = currentUser?.role === 'admin' || currentUser?.role === 'superadmin'
if (plat) return true
return activeClubMemberships(currentUser?.clubs).some((c) => (c.roles || []).includes('club_admin'))
} }
// Bottom Navigation (Mobile) // Bottom Navigation (Mobile)
@ -193,14 +196,7 @@ 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 <Route path="admin/users" element={<AdminUsersPage />} />
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 isSuper = user?.role === 'superadmin' const isPlat = user?.role === 'admin' || user?.role === 'superadmin'
return <Navigate to={isSuper ? '/admin/hierarchy' : '/'} replace /> return <Navigate to={isPlat ? '/admin/hierarchy' : '/admin/users'} replace />
} }

View File

@ -2,10 +2,13 @@ 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) nur für Super-Admins (globaler Portal-Mandant). * Admin-Seiten-Navigation (horizontal)
* Nutzer-Verwaltung: eingeschränkte Tabs für Vereinsorga ohne Plattform-Admin.
*/ */
export default function AdminPageNav() { export default function AdminPageNav({ clubOrgOnly = false }) {
const pages = [ const pages = clubOrgOnly
? [{ to: '/admin/users', label: 'Nutzer', icon: Users }]
: [
{ to: '/admin/hierarchy', label: 'Hierarchie', icon: TreePine }, { to: '/admin/hierarchy', label: 'Hierarchie', icon: TreePine },
{ to: '/admin/users', label: 'Nutzer', icon: Users }, { to: '/admin/users', label: 'Nutzer', icon: Users },
{ to: '/admin/maturity-models', label: 'Fähigkeitsmatrix', icon: Grid3x3 }, { to: '/admin/maturity-models', label: 'Fähigkeitsmatrix', icon: Grid3x3 },

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 Super-Admins; andere Nutzer → Startseite. */ /** Nur Plattform-Admins (admin/superadmin); Vereinsorga → /admin/users */
export default function PlatformAdminRoute({ children }) { export default function PlatformAdminRoute({ children }) {
const { user } = useAuth() const { user } = useAuth()
const ok = user?.role === 'superadmin' const ok = user?.role === 'admin' || user?.role === 'superadmin'
if (!ok) return <Navigate to="/" replace /> if (!ok) return <Navigate to="/admin/users" replace />
return children return children
} }

View File

@ -1,7 +1,8 @@
import { useCallback, useEffect, useState } from 'react' import { useCallback, useEffect, useMemo, 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 = [
@ -18,6 +19,26 @@ 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 },
@ -33,26 +54,45 @@ 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 : [])
@ -65,7 +105,8 @@ export default function AdminUsersPage() {
}, []) }, [])
useEffect(() => { useEffect(() => {
if (!isSuperadminViewer) return if (!canAccess) return
if (clubOrgMode) return
let cancelled = false let cancelled = false
;(async () => { ;(async () => {
setError('') setError('')
@ -81,9 +122,48 @@ export default function AdminUsersPage() {
return () => { return () => {
cancelled = true cancelled = true
} }
}, [isSuperadminViewer, loadPlatform]) }, [canAccess, clubOrgMode, loadPlatform])
if (!isSuperadminViewer) return <Navigate to="/" replace /> 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'
const savePortal = async (profileId) => { const savePortal = async (profileId) => {
const dr = portalDraft[profileId] const dr = portalDraft[profileId]
@ -120,7 +200,31 @@ export default function AdminUsersPage() {
try { try {
await api.updateClubMember(clubId, profileId, { roles, status }) await api.updateClubMember(clubId, profileId, { roles, status })
setClubEditModal(null) setClubEditModal(null)
await loadPlatform() 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()
} catch (e) { } catch (e) {
alert(e.message || String(e)) alert(e.message || String(e))
} }
@ -132,7 +236,30 @@ export default function AdminUsersPage() {
try { try {
await api.removeClubMember(clubEditModal.clubId, clubEditModal.profileId) await api.removeClubMember(clubEditModal.clubId, clubEditModal.profileId)
setClubEditModal(null) setClubEditModal(null)
await loadPlatform() 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()
} catch (e) { } catch (e) {
alert(e.message || String(e)) alert(e.message || String(e))
} }
@ -157,7 +284,7 @@ export default function AdminUsersPage() {
} }
const submitPasswordDirect = async () => { const submitPasswordDirect = async () => {
if (!pwdModal) return if (!pwdModal || !isSuperadminViewer) return
if (pwdNew.length < 8) { if (pwdNew.length < 8) {
alert('Mindestens 8 Zeichen.') alert('Mindestens 8 Zeichen.')
return return
@ -179,15 +306,23 @@ export default function AdminUsersPage() {
return ( return (
<div className="app-page"> <div className="app-page">
<AdminPageNav /> <AdminPageNav clubOrgOnly={clubOrgMode} />
<h1 style={{ marginTop: 0 }}>Globale Nutzer &amp; Portal-Rollen</h1> <h1 style={{ marginTop: 0 }}>Nutzer &amp; Vereinsrollen</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>
) : (
<> <>
<p style={{ color: 'var(--text2)', maxWidth: '52rem', lineHeight: 1.55, marginBottom: '0.75rem' }}> <p style={{ color: 'var(--text2)', maxWidth: '52rem', lineHeight: 1.55, marginBottom: '0.75rem' }}>
Plattformweite Konten und Vereinszuordnungen im Überblick. <strong>Vereinsmitgliedschaft vor Ort</strong>{' '} Gesamtübersicht aller Konten und Vereinszuordnungen. <strong>Portal</strong>-Einstellungen steuern nur den
(Mitglieder, Beitrittsanträge, Vereinszugriffe) liegt unter <strong>Vereine Mitglieder</strong>. Die Zugang zur <strong>plattformweiten Administration</strong> (Kataloge, Hierarchie, globale Nutzerliste usw.);
geschützten <strong>/admin</strong>-Menüpunkte (Hierarchie, Kataloge, ) gibt es nur noch hier für Super-Admins. Vereinsarbeit (Trainer, Vereinsadmin&nbsp;) bleiben <strong>Vereinsrollen</strong>. Abonnement/Tier ist
derzeit nicht freigeschaltet.
</p> </p>
<div <div
className="card" className="card"
@ -200,25 +335,44 @@ export default function AdminUsersPage() {
color: 'var(--text2)', color: 'var(--text2)',
}} }}
> >
<strong style={{ color: 'var(--text1)' }}>Portal-Rollen auf Profil-Ebene:</strong> <strong style={{ color: 'var(--text1)' }}>Die vier Portal-Zugriffsstufen:</strong>
<ul style={{ margin: '0.5rem 0 0', paddingLeft: '1.2rem' }}> <ul style={{ margin: '0.5rem 0 0', paddingLeft: '1.2rem' }}>
<li> <li>
<strong>Nutzer</strong> Standard ohne globale Administrationsbereiche. <strong>Nutzer</strong> Standardnutzer ohne Plattform-Admin-Bereiche.
</li> </li>
<li> <li>
<strong>Portal-Trainer (Legacy)</strong> historische Kennzeichnung; organisatorisch ist Trainer meist eine{' '} <strong>Portal-Trainer (Legacy)</strong> ältere Kennzeichnung auf Profil-Ebene; organisatorisch ist
<strong>Vereinsrolle</strong>. Trainer in der Regel eine <strong>Vereinsrolle</strong>.
</li> </li>
<li> <li>
<strong>Portal-Administrator</strong> erhöhte API-/Operativ-Rechte nach Backend-Policies; die{' '} <strong>Portal-Administrator</strong> Zugang zu allen geschützten <code>/admin</code>-Bereichen
<strong>Admin-Navigation dieser App</strong> sieht jedoch nur noch der Super-Admin. (Außer: einige Funktionen nur Superadmin, z.&nbsp;B. bestimmte Medien-/Governance-Aktionen).
</li> </li>
<li> <li>
<strong>Super-Administrator</strong> volle Plattform-Governance (diese Oberfläche, offizielle Inhalte, ). <strong>Super-Administrator</strong> volle Plattform-Governance (u.&nbsp;a. offizielle Medien,
Superadmin-Rolle vergeben, harte Lifecycle-Aktionen an Medien).
</li> </li>
</ul> </ul>
</div> </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>
) : null}
{loading ? ( {loading ? (
<p style={{ color: 'var(--text2)' }}>Laden</p> <p style={{ color: 'var(--text2)' }}>Laden</p>
@ -226,6 +380,98 @@ 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) => {
@ -345,7 +591,7 @@ export default function AdminUsersPage() {
> >
bearbeiten bearbeiten
</button> </button>
{c.membership_status === 'inactive' ? ( {isSuperadminViewer && c.membership_status === 'inactive' ? (
<button <button
type="button" type="button"
style={{ style={{
@ -460,6 +706,77 @@ 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={{
@ -487,7 +804,8 @@ 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 unberührt. Deaktiviert betrifft nur den Zugriff auf Inhalte dieses Vereins; Login und andere Vereine bleiben
unberührt.
</p> </p>
<div className="form-row"> <div className="form-row">
<label className="form-label">Vereinszugang</label> <label className="form-label">Vereinszugang</label>
@ -572,14 +890,15 @@ 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 Passwort Standard: Es wird ein sicherer Link per E-Mail verschickt (wie Passwort vergessen). Das bisherige
bleibt gültig, bis die Person den Link nutzt und ein neues Passwort wählt. Passwort 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' }}>
@ -609,6 +928,7 @@ export default function AdminUsersPage() {
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,7 +3,6 @@ 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 = [
@ -36,9 +35,6 @@ 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({})
@ -222,43 +218,6 @@ 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)
@ -794,34 +753,6 @@ 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
@ -1544,93 +1475,6 @@ 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,8 +546,10 @@ 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>
{isSuperadmin ? <Link to="/admin">Globale Administration</Link> : null} {isPlatformAdmin ? <Link to="/admin/hierarchy">Plattform-Admin</Link> : null}
{hasClubOrgAdmin ? <Link to="/clubs">Vereine &amp; Mitglieder</Link> : null} {hasClubOrgAdmin || isPlatformAdmin ? (
<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

@ -1,5 +0,0 @@
/** 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'
}