feat(admin): restrict admin access and enhance navigation for superadmins #29
|
|
@ -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={
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 & Vereinsrollen</h1>
|
||||
<h1 style={{ marginTop: 0 }}>Globale Nutzer & 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 …) 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. B. bestimmte Medien-/Governance-Aktionen).
|
||||
</li>
|
||||
<li>
|
||||
<strong>Super-Administrator</strong> — volle Plattform-Governance (u. 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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 & Organisation</Link>
|
||||
) : null}
|
||||
{isSuperadmin ? <Link to="/admin">Globale Administration</Link> : null}
|
||||
{hasClubOrgAdmin ? <Link to="/clubs">Vereine & Mitglieder</Link> : null}
|
||||
</div>
|
||||
</div>
|
||||
<p className="media-library__intro">
|
||||
|
|
|
|||
5
frontend/src/utils/portalRoles.js
Normal file
5
frontend/src/utils/portalRoles.js
Normal 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'
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user