feat(admin): enhance admin navigation and user management features
Some checks failed
Deploy Development / deploy (push) Successful in 38s
Test Suite / pytest-backend (push) Failing after 27s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 8s
Test Suite / playwright-tests (push) Successful in 26s

- Updated admin navigation to conditionally display links based on user roles, including new components for platform admin routes.
- Refactored user management page to support club-specific roles and improved access control for platform and club admins.
- Introduced visibility clauses for media assets based on user roles and club memberships.
- Enhanced media library page to reflect user permissions and provide appropriate navigation options.
- Improved overall user experience with better role handling and navigation structure.
This commit is contained in:
Lars 2026-05-09 10:02:56 +02:00
parent 58a38702b9
commit c46f5f99be
10 changed files with 601 additions and 244 deletions

View File

@ -77,7 +77,7 @@ def list_club_members(
cur.execute( cur.execute(
f""" f"""
SELECT cm.id AS membership_id, cm.profile_id, cm.status, cm.created_at, cm.updated_at, SELECT cm.id AS membership_id, cm.profile_id, cm.status, cm.created_at, cm.updated_at,
p.email, p.name, p.email, p.name, COALESCE(p.email_verified, false) AS email_verified,
COALESCE( COALESCE(
ARRAY_AGG(DISTINCT r.role_code) FILTER (WHERE r.role_code IS NOT NULL), ARRAY_AGG(DISTINCT r.role_code) FILTER (WHERE r.role_code IS NOT NULL),
ARRAY[]::varchar[] ARRAY[]::varchar[]
@ -153,7 +153,7 @@ def _one_member(cur, club_id: int, profile_id: int) -> Dict[str, Any]:
cur.execute( cur.execute(
""" """
SELECT cm.id AS membership_id, cm.profile_id, cm.status, cm.created_at, cm.updated_at, SELECT cm.id AS membership_id, cm.profile_id, cm.status, cm.created_at, cm.updated_at,
p.email, p.name, p.email, p.name, COALESCE(p.email_verified, false) AS email_verified,
COALESCE( COALESCE(
ARRAY_AGG(DISTINCT r.role_code) FILTER (WHERE r.role_code IS NOT NULL), ARRAY_AGG(DISTINCT r.role_code) FILTER (WHERE r.role_code IS NOT NULL),
ARRAY[]::varchar[] ARRAY[]::varchar[]

View File

@ -377,17 +377,94 @@ def _relocate_asset_file_if_governance_changed(
return new_key return new_key
def _lifecycle_where_sql(lifecycle: str) -> str:
def _fetch_asset_file_row(cur: Any, asset_id: int) -> Optional[dict]:
"""Sichtbare aktive Einträge: Plattform-Admin alles; sonst official + eigene private + Verein als Mitglied."""
sql = """(
%s
OR lower(trim(ma.visibility)) = 'official'
OR (
lower(trim(ma.visibility)) = 'private'
AND ma.uploaded_by_profile_id = %s
)
OR (
lower(trim(ma.visibility)) = 'club'
AND EXISTS (
SELECT 1 FROM club_members cm
WHERE cm.profile_id = %s
AND cm.club_id = ma.club_id
AND cm.status = 'active'
)
)
)"""
return sql, [is_plat, profile_id, profile_id]
def _list_trash_visibility_clause(
is_plat: bool,
is_sup: bool,
profile_id: int,
admin_club_ids: set[int],
) -> tuple[str, list[Any]]:
"""
Papierkorb nur für eigene private Medien; Vereins-Admins zusätzlich Vereins-Papierkorb ihres Vereins.
Official/Plattform: Superadmin oder Plattform-Admin sieht alles im Papierkorb.
"""
if is_plat or is_sup:
return "(TRUE)", []
parts: list[str] = []
vals: list[Any] = []
parts.append(
"(lower(trim(ma.visibility)) = 'private' AND ma.uploaded_by_profile_id = %s)",
)
vals.append(profile_id)
if admin_club_ids:
parts.append(
"(lower(trim(ma.visibility)) = 'club' AND ma.club_id = ANY(%s))",
)
vals.append(list(admin_club_ids))
return "(" + " OR ".join(parts) + ")", vals
def _list_main_visibility_where(
lifecycle: str,
is_plat: bool,
is_sup: bool,
profile_id: int,
admin_club_ids: set[int],
) -> tuple[str, list[Any]]:
"""
Kombiniert lifecycle mit Leserechten. Papierkorb-Stufen für normale Nutzer stark eingeschränkt.
"""
lc = (lifecycle or "active").strip().lower() lc = (lifecycle or "active").strip().lower()
if lc not in _LIFECYCLE_LIST_FILTERS: if lc not in _LIFECYCLE_LIST_FILTERS:
raise HTTPException(status_code=400, detail="Ungültiger lifecycle-Filter") raise HTTPException(status_code=400, detail="Ungültiger lifecycle-Filter")
active_sql, active_params = _list_active_visibility_clause(is_plat, profile_id)
trash_sql, trash_params = _list_trash_visibility_clause(
is_plat, is_sup, profile_id, admin_club_ids
)
active_block = f"(ma.lifecycle_state = 'active' AND {active_sql})"
trash_block = (
f"(ma.lifecycle_state IN ('trash_soft', 'trash_hidden') AND {trash_sql})"
)
if lc == "active": if lc == "active":
return "ma.lifecycle_state = 'active'" return active_block, active_params
if lc == "trash_soft": if lc == "trash_soft":
return "ma.lifecycle_state = 'trash_soft'" return (
f"(ma.lifecycle_state = 'trash_soft' AND {trash_sql})",
trash_params,
)
if lc == "trash_hidden": if lc == "trash_hidden":
return "ma.lifecycle_state = 'trash_hidden'" return (
return "ma.lifecycle_state IN ('active', 'trash_soft', 'trash_hidden')" f"(ma.lifecycle_state = 'trash_hidden' AND {trash_sql})",
trash_params,
)
# all
combined = f"(({active_block}) OR ({trash_block}))"
return combined, active_params + trash_params
def _fetch_asset_file_row(cur: Any, asset_id: int) -> Optional[dict]: def _fetch_asset_file_row(cur: Any, asset_id: int) -> Optional[dict]:
@ -789,7 +866,6 @@ def list_media_assets(
limit: int = Query(30, ge=1, le=100), limit: int = Query(30, ge=1, le=100),
offset: int = Query(0, ge=0), offset: int = Query(0, ge=0),
): ):
lc_where = _lifecycle_where_sql(lifecycle)
mk = (media_kind or "all").strip().lower() mk = (media_kind or "all").strip().lower()
if mk not in _MEDIA_KIND_FILTERS: if mk not in _MEDIA_KIND_FILTERS:
raise HTTPException(status_code=400, detail="Ungültiger media_kind") raise HTTPException(status_code=400, detail="Ungültiger media_kind")
@ -831,6 +907,9 @@ def list_media_assets(
with get_db() as conn: with get_db() as conn:
cur = get_cursor(conn) cur = get_cursor(conn)
admin_club_ids = club_ids_for_profile_with_roles(cur, profile_id, "club_admin") admin_club_ids = club_ids_for_profile_with_roles(cur, profile_id, "club_admin")
vis_main_sql, vis_params = _list_main_visibility_where(
lifecycle, is_adm, sup, profile_id, admin_club_ids
)
show_uploader = sup or is_adm or bool(admin_club_ids) show_uploader = sup or is_adm or bool(admin_club_ids)
if uploaded_by is not None and not show_uploader: if uploaded_by is not None and not show_uploader:
raise HTTPException(status_code=403, detail="Uploader-Filter nicht erlaubt") raise HTTPException(status_code=403, detail="Uploader-Filter nicht erlaubt")
@ -863,7 +942,7 @@ def list_media_assets(
) )
params: list[Any] = ( params: list[Any] = (
[is_adm, profile_id, profile_id] vis_params
+ club_sql_params + club_sql_params
+ uploaded_params + uploaded_params
+ search_params + search_params
@ -880,24 +959,7 @@ def list_media_assets(
FROM media_assets ma FROM media_assets ma
LEFT JOIN profiles pr ON pr.id = ma.uploaded_by_profile_id LEFT JOIN profiles pr ON pr.id = ma.uploaded_by_profile_id
LEFT JOIN clubs cl ON cl.id = ma.club_id LEFT JOIN clubs cl ON cl.id = ma.club_id
WHERE {lc_where} WHERE {vis_main_sql}
AND (
%s
OR lower(trim(ma.visibility)) = 'official'
OR (
lower(trim(ma.visibility)) = 'private'
AND ma.uploaded_by_profile_id = %s
)
OR (
lower(trim(ma.visibility)) = 'club'
AND EXISTS (
SELECT 1 FROM club_members cm
WHERE cm.profile_id = %s
AND cm.club_id = ma.club_id
AND cm.status = 'active'
)
)
)
{club_sql} {club_sql}
{uploaded_sql} {uploaded_sql}
{media_kind_sql} {media_kind_sql}
@ -907,7 +969,7 @@ def list_media_assets(
params, params,
) )
rows = [r2d(r) for r in cur.fetchall()] rows = [r2d(r) for r in cur.fetchall()]
show_club = sup or is_adm show_club = sup or is_adm or bool(admin_club_ids)
asset_ids = [int(r["id"]) for r in rows] asset_ids = [int(r["id"]) for r in rows]
usage_map = _usage_for_media_assets(cur, asset_ids) usage_map = _usage_for_media_assets(cur, asset_ids)
for r in rows: for r in rows:

View File

@ -34,14 +34,23 @@ import AdminMaturityModelsPage from './pages/AdminMaturityModelsPage'
import TrainerContextsPage from './pages/TrainerContextsPage' import TrainerContextsPage from './pages/TrainerContextsPage'
import MediaWikiImportPage from './pages/MediaWikiImportPage' import MediaWikiImportPage from './pages/MediaWikiImportPage'
import AdminUsersPage from './pages/AdminUsersPage' import AdminUsersPage from './pages/AdminUsersPage'
import AdminHomeRedirect from './components/AdminHomeRedirect'
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 './app.css' import './app.css'
/** Shield-„Admin“: Portal-Admins oder Vereinsorganisation (Zugriff mindestens /admin/users). */
function computeShowAdminNav(currentUser) {
const plat = currentUser?.role === 'admin' || currentUser?.role === 'superadmin'
if (plat) return true
return (currentUser?.clubs || []).some((c) => (c.roles || []).includes('club_admin'))
}
// Bottom Navigation (Mobile) // Bottom Navigation (Mobile)
function Nav({ isAdmin }) { function Nav({ showAdminNav }) {
const { canAccessOrgInbox, inboxCount } = useOrgInbox() const { canAccessOrgInbox, inboxCount } = useOrgInbox()
const items = getMainNavItems(isAdmin, { showInbox: canAccessOrgInbox }) const items = getMainNavItems(showAdminNav, { showInbox: canAccessOrgInbox })
const loc = useLocation() const loc = useLocation()
const navItemActive = (pathname, item, routerIsActive) => { const navItemActive = (pathname, item, routerIsActive) => {
@ -103,11 +112,11 @@ function ProtectedLayout() {
return <Navigate to="/login" replace /> return <Navigate to="/login" replace />
} }
const isAdmin = user?.role === 'admin' || user?.role === 'superadmin' const showAdminNav = computeShowAdminNav(user)
return ( return (
<OrgInboxProvider user={user}> <OrgInboxProvider user={user}>
<DesktopSidebar isAdmin={isAdmin} user={user} onLogout={handleLogout} /> <DesktopSidebar showAdminNav={showAdminNav} user={user} onLogout={handleLogout} />
<div className="app-shell"> <div className="app-shell">
<div className="app-shell__column"> <div className="app-shell__column">
<div className="app-header app-header--mobile app-header--mobile-stack"> <div className="app-header app-header--mobile app-header--mobile-stack">
@ -119,7 +128,7 @@ function ProtectedLayout() {
<div className="app-main"> <div className="app-main">
<Outlet /> <Outlet />
</div> </div>
<Nav isAdmin={isAdmin} /> <Nav showAdminNav={showAdminNav} />
</div> </div>
</div> </div>
</OrgInboxProvider> </OrgInboxProvider>
@ -183,12 +192,40 @@ function AppRoutes() {
<Route path="planning/run/:unitId/coach" element={<TrainingCoachPage />} /> <Route path="planning/run/:unitId/coach" element={<TrainingCoachPage />} />
<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={<Navigate to="/admin/hierarchy" replace />} /> <Route path="admin" element={<AdminHomeRedirect />} />
<Route path="admin/users" element={<AdminUsersPage />} /> <Route path="admin/users" element={<AdminUsersPage />} />
<Route path="admin/hierarchy" element={<AdminHierarchyPage />} /> <Route
<Route path="admin/maturity-models" element={<AdminMaturityModelsPage />} /> path="admin/hierarchy"
<Route path="admin/catalogs" element={<AdminCatalogsPage />} /> element={
<Route path="admin/mediawiki-import" element={<MediaWikiImportPage />} /> <PlatformAdminRoute>
<AdminHierarchyPage />
</PlatformAdminRoute>
}
/>
<Route
path="admin/maturity-models"
element={
<PlatformAdminRoute>
<AdminMaturityModelsPage />
</PlatformAdminRoute>
}
/>
<Route
path="admin/catalogs"
element={
<PlatformAdminRoute>
<AdminCatalogsPage />
</PlatformAdminRoute>
}
/>
<Route
path="admin/mediawiki-import"
element={
<PlatformAdminRoute>
<MediaWikiImportPage />
</PlatformAdminRoute>
}
/>
<Route path="trainer-contexts" element={<TrainerContextsPage />} /> <Route path="trainer-contexts" element={<TrainerContextsPage />} />
</Route> </Route>

View File

@ -0,0 +1,8 @@
import { Navigate } from 'react-router-dom'
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 />
}

View File

@ -1,19 +1,20 @@
import { NavLink } from 'react-router-dom' import { NavLink } from 'react-router-dom'
import { TreePine, FolderTree, Download, Grid3x3, Users, Images } from 'lucide-react' import { TreePine, FolderTree, Download, Grid3x3, Users } from 'lucide-react'
/** /**
* Admin-Seiten-Navigation (horizontal) * Admin-Seiten-Navigation (horizontal)
* Wechselt zwischen verschiedenen Admin-Seiten * 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/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/hierarchy', label: 'Hierarchie', icon: TreePine },
{ to: '/admin/catalogs', label: 'Kataloge', icon: FolderTree }, { to: '/admin/users', label: 'Nutzer', icon: Users },
{ to: '/media', label: 'Medien', icon: Images }, { to: '/admin/maturity-models', label: 'Fähigkeitsmatrix', icon: Grid3x3 },
{ to: '/admin/mediawiki-import', label: 'Wiki-Import', icon: Download } { to: '/admin/catalogs', label: 'Kataloge', icon: FolderTree },
] { to: '/admin/mediawiki-import', label: 'Wiki-Import', icon: Download },
]
return ( return (
<nav className="admin-top-nav" aria-label="Administration"> <nav className="admin-top-nav" aria-label="Administration">

View File

@ -13,13 +13,13 @@ function sidebarLinkActive(pathname, item, routerIsActive) {
* Desktop-Sidebar (1024px) Sichtbarkeit via CSS (.desktop-sidebar). * Desktop-Sidebar (1024px) Sichtbarkeit via CSS (.desktop-sidebar).
*/ */
export default function DesktopSidebar({ export default function DesktopSidebar({
isAdmin, showAdminNav,
user, user,
onLogout onLogout
}) { }) {
const loc = useLocation() const loc = useLocation()
const { canAccessOrgInbox, inboxCount } = useOrgInbox() const { canAccessOrgInbox, inboxCount } = useOrgInbox()
const items = getMainNavItems(isAdmin, { showInbox: canAccessOrgInbox }) const items = getMainNavItems(showAdminNav, { showInbox: canAccessOrgInbox })
const tier = user?.tier || '' const tier = user?.tier || ''
return ( return (

View File

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

View File

@ -2,6 +2,7 @@ import {
LayoutDashboard, LayoutDashboard,
BookOpen, BookOpen,
Calendar, Calendar,
Images,
Building2, Building2,
Settings, Settings,
Shield, Shield,
@ -24,6 +25,7 @@ function baseItems(opts = {}) {
...(showInbox ? [{ to: '/inbox', label: 'Posteingang', shortLabel: 'Post' }] : []), ...(showInbox ? [{ to: '/inbox', label: 'Posteingang', shortLabel: 'Post' }] : []),
{ to: '/exercises', label: 'Übungen', shortLabel: 'Übungen' }, { to: '/exercises', label: 'Übungen', shortLabel: 'Übungen' },
{ to: '/planning', label: 'Planung' }, { to: '/planning', label: 'Planung' },
{ to: '/media', label: 'Medien', shortLabel: 'Medien' },
{ to: '/clubs', label: 'Vereine' }, { to: '/clubs', label: 'Vereine' },
{ to: '/trainer-contexts', label: 'Meine Bereiche', shortLabel: 'Bereiche' }, { to: '/trainer-contexts', label: 'Meine Bereiche', shortLabel: 'Bereiche' },
{ to: '/settings', label: 'Einstellungen', shortLabel: 'Einst.' } { to: '/settings', label: 'Einstellungen', shortLabel: 'Einst.' }
@ -34,7 +36,16 @@ function baseItems(opts = {}) {
/** @param {boolean} isAdmin @param {{ showInbox?: boolean }} opts */ /** @param {boolean} isAdmin @param {{ showInbox?: boolean }} opts */
export function getMainNavItems(isAdmin, opts = {}) { export function getMainNavItems(isAdmin, opts = {}) {
const showInbox = !!opts.showInbox const showInbox = !!opts.showInbox
const icons = [LayoutDashboard, ...(showInbox ? [Inbox] : []), BookOpen, Calendar, Building2, Target, Settings] const icons = [
LayoutDashboard,
...(showInbox ? [Inbox] : []),
BookOpen,
Calendar,
Images,
Building2,
Target,
Settings,
]
const raw = baseItems(opts).map((item, i) => ({ const raw = baseItems(opts).map((item, i) => ({
...item, ...item,

View File

@ -1,4 +1,4 @@
import { 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'
@ -11,68 +11,157 @@ const CLUB_ROLE_OPTIONS = [
{ code: 'content_editor', label: 'Inhalte bearbeiten' }, { code: 'content_editor', label: 'Inhalte bearbeiten' },
] ]
const TIER_OPTIONS = ['free', 'premium', 'pro', 'enterprise'] const PORTAL_ROLE_LABEL = {
const ROLE_LABEL = {
user: 'Nutzer', user: 'Nutzer',
trainer: 'Trainer', trainer: 'Portal-Trainer',
admin: 'Portal-Admin', admin: 'Portal-Administrator',
superadmin: 'Super-Admin', superadmin: 'Super-Administrator',
} }
function AdminUsersPage() { function clubAdminClubIds(user) {
const { user } = useAuth() return (user?.clubs || [])
const isSuper = user?.role === 'superadmin' .filter((c) => Array.isArray(c.roles) && c.roles.includes('club_admin'))
const isPlatformAdmin = user?.role === 'admin' || user?.role === 'superadmin' .map((c) => c.id)
const portalRoleChoices = isSuper }
? ['user', 'trainer', 'admin', 'superadmin']
: ['user', 'trainer', 'admin']
const [users, setUsers] = useState([]) 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 portalRoleSelectOptions(viewerIsSuperadmin, currentRole) {
const base = [
{ value: 'user', label: PORTAL_ROLE_LABEL.user },
{ value: 'trainer', label: `${PORTAL_ROLE_LABEL.trainer} (Legacy)` },
{ value: 'admin', label: PORTAL_ROLE_LABEL.admin },
]
const cur = (currentRole || 'user').toLowerCase()
if (viewerIsSuperadmin) base.push({ value: 'superadmin', label: PORTAL_ROLE_LABEL.superadmin })
const values = new Set(base.map((x) => x.value))
if (cur && !values.has(cur)) {
base.unshift({ value: cur, label: cur })
}
return base
}
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 [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 load = async () => { const selectableClubs = useMemo(
setError('') () => clubSelectOptions(user, clubs, isPlatformAdmin),
try { [user, clubs, isPlatformAdmin]
const [u, c] = await Promise.all([api.listAdminUsers(), api.listClubs()]) )
setUsers(u)
setClubs(c)
const d = {}
for (const row of u) {
d[row.id] = {
role: (row.role || 'user').toLowerCase(),
tier: row.tier || 'free',
}
}
setPortalDraft(d)
} catch (e) {
setError(e.message || String(e))
} finally {
setLoading(false)
}
}
useEffect(() => { useEffect(() => {
if (!isPlatformAdmin) return if (!clubOrgMode) return
load() if (selectedClubId == null || !managedClubIds.includes(selectedClubId)) {
}, [isPlatformAdmin]) setSelectedClubId(managedClubIds[0] ?? null)
}
}, [clubOrgMode, managedClubIds, selectedClubId])
if (!isPlatformAdmin) { const loadPlatform = useCallback(async () => {
return <Navigate to="/" replace /> const [u, c] = await Promise.all([api.listAdminUsers(), api.listClubs()])
} setPlatformUsers(Array.isArray(u) ? u : [])
setClubs(Array.isArray(c) ? c : [])
const d = {}
for (const row of u || []) {
d[row.id] = { role: (row.role || 'user').toLowerCase() }
}
setPortalDraft(d)
}, [])
useEffect(() => {
if (!canAccess) return
if (clubOrgMode) return
let cancelled = false
;(async () => {
setError('')
setLoading(true)
try {
await loadPlatform()
} catch (e) {
if (!cancelled) setError(e.message || String(e))
} finally {
if (!cancelled) setLoading(false)
}
})()
return () => {
cancelled = true
}
}, [canAccess, clubOrgMode, 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'
const savePortal = async (profileId) => { const savePortal = async (profileId) => {
const dr = portalDraft[profileId] const dr = portalDraft[profileId]
if (!dr) return if (!dr) return
try { try {
await api.updateProfile(profileId, { role: dr.role, tier: dr.tier }) await api.updateProfile(profileId, { role: dr.role })
await load() await loadPlatform()
} catch (e) { } catch (e) {
alert(e.message || String(e)) alert(e.message || String(e))
} }
@ -90,7 +179,7 @@ function AdminUsersPage() {
await api.addClubMember(clubId, { profile_id: profileId, roles: assignRoles }) await api.addClubMember(clubId, { profile_id: profileId, roles: assignRoles })
setAssignModal(null) setAssignModal(null)
setAssignRoles(['trainer']) setAssignRoles(['trainer'])
await load() await loadPlatform()
} catch (e) { } catch (e) {
alert(e.message || String(e)) alert(e.message || String(e))
} }
@ -102,7 +191,8 @@ function AdminUsersPage() {
try { try {
await api.updateClubMember(clubId, profileId, { roles, status }) await api.updateClubMember(clubId, profileId, { roles, status })
setClubEditModal(null) setClubEditModal(null)
await load() if (clubOrgMode) await reloadClubMembers()
else await loadPlatform()
} catch (e) { } catch (e) {
alert(e.message || String(e)) alert(e.message || String(e))
} }
@ -114,7 +204,30 @@ function AdminUsersPage() {
try { try {
await api.removeClubMember(clubEditModal.clubId, clubEditModal.profileId) await api.removeClubMember(clubEditModal.clubId, clubEditModal.profileId)
setClubEditModal(null) setClubEditModal(null)
await load() 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))
} }
@ -122,13 +235,39 @@ function AdminUsersPage() {
return ( return (
<div className="app-page"> <div className="app-page">
<AdminPageNav /> <AdminPageNav clubOrgOnly={clubOrgMode} />
<h1 style={{ marginTop: 0 }}>Portal-Nutzer &amp; Vereine</h1>
<p style={{ color: 'var(--text2)', maxWidth: '52rem', lineHeight: 1.55, marginBottom: '1.25rem' }}> <h1 style={{ marginTop: 0 }}>Nutzer &amp; Vereinsrollen</h1>
Alle Konten mit Vereinszuordnungen. Hier kannst du die <strong>Portal-Rolle</strong> (Zugriff auf
Admin-Funktionen) und das <strong>Tier</strong> setzen sowie Nutzer explizit einem Verein mit Rollen {clubOrgMode ? (
zuordnen. <p style={{ color: 'var(--text2)', maxWidth: '52rem', lineHeight: 1.55, marginBottom: '1.25rem' }}>
</p> Du verwaltest nur Mitglieder des ausgewählten Vereins und deren <strong>Rollen in diesem Verein</strong>.
Portal-Rollen und andere Vereine sind hier nicht sichtbar.
</p>
) : (
<p style={{ color: 'var(--text2)', maxWidth: '52rem', lineHeight: 1.55, marginBottom: '1.25rem' }}>
Gesamtübersicht aller Konten und Vereinszuordnungen. <strong>Portal-Administrator</strong> und{' '}
<strong>Super-Administrator</strong> steuern den Zugriff auf die Plattform-Administration; alles Weitere
(z.B. Trainer, Vereinsadmin) legst du pro Verein fest. Abonnement/Tier ist derzeit nicht freigeschaltet.
</p>
)}
{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>
@ -136,142 +275,163 @@ 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: 'grid', gap: '1rem' }}>
{users.map((row) => { <div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem', alignItems: 'center' }}>
const tierValue = portalDraft[row.id]?.tier ?? row.tier ?? 'free' <button type="button" className="btn btn-primary" onClick={() => setAddMemberOpen(true)}>
const tierChoices = [...TIER_OPTIONS] Mitglied hinzufügen (Profil-ID)
if (tierValue && !tierChoices.includes(tierValue)) tierChoices.unshift(tierValue) </button>
return ( <button type="button" className="btn btn-secondary" onClick={() => reloadClubMembers()}>
<div key={row.id} className="card"> Aktualisieren
<div style={{ display: 'flex', justifyContent: 'space-between', flexWrap: 'wrap', gap: '0.75rem' }}> </button>
<div> </div>
<strong style={{ fontSize: '1.05rem' }}> {!clubMembers.length ? (
{row.name || '—'} <span style={{ color: 'var(--text2)', fontWeight: 400 }}>#{row.id}</span> <p className="muted">Keine Mitglieder in diesem Verein.</p>
</strong> ) : (
<div style={{ fontSize: '0.875rem', color: 'var(--text2)' }}>{row.email || '—'}</div> clubMembers.map((m) => (
<div style={{ fontSize: '0.78rem', color: 'var(--text3)', marginTop: '0.35rem' }}> <div key={m.membership_id} className="card">
Verifiziert: {row.email_verified ? 'ja' : 'nein'} <div style={{ display: 'flex', justifyContent: 'space-between', flexWrap: 'wrap', gap: '0.75rem' }}>
</div>
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem', alignItems: 'flex-end' }}>
<div> <div>
<label className="form-label" style={{ fontSize: '0.75rem' }}> <strong style={{ fontSize: '1.05rem' }}>
Portal-Rolle {m.name || '—'} <span style={{ color: 'var(--text2)', fontWeight: 400 }}>#{m.profile_id}</span>
</label> </strong>
<select <div style={{ fontSize: '0.875rem', color: 'var(--text2)' }}>{m.email || '—'}</div>
className="form-input" <div style={{ fontSize: '0.78rem', color: 'var(--text3)', marginTop: '0.35rem' }}>
style={{ minWidth: '140px' }} Status: {m.status} · Verifiziert: {m.email_verified ? 'ja' : 'nein'}
value={(portalDraft[row.id]?.role || row.role || 'user').toLowerCase()} </div>
onChange={(e) => <div style={{ fontSize: '0.82rem', marginTop: '0.35rem' }}>
setPortalDraft((prev) => ({ Rollen: {(m.roles || []).join(', ') || '—'}
...prev, </div>
[row.id]: { ...prev[row.id], role: e.target.value, tier: prev[row.id]?.tier ?? row.tier },
}))
}
>
{portalRoleChoices.map((r) => (
<option key={r} value={r}>
{ROLE_LABEL[r] || r}
</option>
))}
</select>
</div> </div>
<div>
<label className="form-label" style={{ fontSize: '0.75rem' }}>
Tier
</label>
<select
className="form-input"
style={{ minWidth: '120px' }}
value={tierValue}
onChange={(e) =>
setPortalDraft((prev) => ({
...prev,
[row.id]: {
...prev[row.id],
tier: e.target.value,
role: prev[row.id]?.role ?? row.role,
},
}))
}
>
{tierChoices.map((t) => (
<option key={t} value={t}>
{t}
</option>
))}
</select>
</div>
<button type="button" className="btn btn-secondary" onClick={() => savePortal(row.id)}>
Portal speichern
</button>
<button <button
type="button" type="button"
className="btn btn-primary" className="btn btn-secondary"
disabled={!clubs.length} onClick={() =>
title={!clubs.length ? 'Zuerst einen Verein anlegen' : undefined} setClubEditModal({
onClick={() => { clubId: selectedClubId,
if (!clubs.length) return clubName: selectedClubLabel,
setAssignRoles(['trainer']) profileId: m.profile_id,
setAssignModal({ profileLabel: m.name || m.email,
profileId: row.id, roles: [...(m.roles || [])],
profileLabel: row.name || row.email || `#${row.id}`, status: (m.status || 'active').toLowerCase(),
clubId: clubs[0]?.id ?? '',
}) })
}} }
> >
Verein zuweisen Bearbeiten
</button> </button>
</div> </div>
</div> </div>
))
)}
</div>
) : (
<div style={{ display: 'grid', gap: '1rem' }}>
{platformUsers.map((row) => {
const portalRoleChoices = portalRoleSelectOptions(isSuperadminViewer, row.role)
return (
<div key={row.id} className="card">
<div style={{ display: 'flex', justifyContent: 'space-between', flexWrap: 'wrap', gap: '0.75rem' }}>
<div>
<strong style={{ fontSize: '1.05rem' }}>
{row.name || '—'} <span style={{ color: 'var(--text2)', fontWeight: 400 }}>#{row.id}</span>
</strong>
<div style={{ fontSize: '0.875rem', color: 'var(--text2)' }}>{row.email || '—'}</div>
<div style={{ fontSize: '0.78rem', color: 'var(--text3)', marginTop: '0.35rem' }}>
Verifiziert: {row.email_verified ? 'ja' : 'nein'}
</div>
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem', alignItems: 'flex-end' }}>
<div>
<label className="form-label" style={{ fontSize: '0.75rem' }}>
Portal-Zugriff
</label>
<select
className="form-input"
style={{ minWidth: '200px' }}
value={(portalDraft[row.id]?.role || row.role || 'user').toLowerCase()}
onChange={(e) =>
setPortalDraft((prev) => ({
...prev,
[row.id]: { role: e.target.value },
}))
}
>
{portalRoleChoices.map((r) => (
<option key={r.value} value={r.value}>
{r.label}
</option>
))}
</select>
</div>
<button type="button" className="btn btn-secondary" onClick={() => savePortal(row.id)}>
Portal speichern
</button>
<button
type="button"
className="btn btn-primary"
disabled={!clubs.length}
title={!clubs.length ? 'Zuerst einen Verein anlegen' : undefined}
onClick={() => {
if (!clubs.length) return
setAssignRoles(['trainer'])
setAssignModal({
profileId: row.id,
profileLabel: row.name || row.email || `#${row.id}`,
clubId: clubs[0]?.id ?? '',
})
}}
>
Verein zuweisen
</button>
</div>
</div>
<div style={{ marginTop: '1rem', paddingTop: '0.75rem', borderTop: '1px solid var(--border)' }}> <div style={{ marginTop: '1rem', paddingTop: '0.75rem', borderTop: '1px solid var(--border)' }}>
<strong style={{ fontSize: '0.85rem' }}>Vereinsmitgliedschaften</strong> <strong style={{ fontSize: '0.85rem' }}>Vereinsmitgliedschaften</strong>
{!row.clubs?.length ? ( {!row.clubs?.length ? (
<p style={{ color: 'var(--text2)', fontSize: '0.875rem', margin: '0.35rem 0 0' }}> <p style={{ color: 'var(--text2)', fontSize: '0.875rem', margin: '0.35rem 0 0' }}>
Keine Zuordnung. Keine Zuordnung.
</p> </p>
) : ( ) : (
<ul style={{ margin: '0.5rem 0 0', paddingLeft: '1.2rem', fontSize: '0.9rem' }}> <ul style={{ margin: '0.5rem 0 0', paddingLeft: '1.2rem', fontSize: '0.9rem' }}>
{row.clubs.map((c) => ( {(row.clubs || []).map((c) => (
<li key={c.id} style={{ marginBottom: '0.35rem' }}> <li key={c.id} style={{ marginBottom: '0.35rem' }}>
<strong>{c.name}</strong> <strong>{c.name}</strong>
{c.abbreviation ? ` (${c.abbreviation})` : ''} {' '} {c.abbreviation ? ` (${c.abbreviation})` : ''} {' '}
{(c.roles || []).join(', ') || '—'} {(c.roles || []).join(', ') || '—'}
{c.membership_status === 'inactive' ? ( {c.membership_status === 'inactive' ? (
<span style={{ color: 'var(--text3)', fontSize: '0.8rem' }}> (inaktiv)</span> <span style={{ color: 'var(--text3)', fontSize: '0.8rem' }}> (inaktiv)</span>
) : null}{' '} ) : null}{' '}
<button <button
type="button" type="button"
style={{ style={{
marginLeft: '0.35rem', marginLeft: '0.35rem',
fontSize: '0.75rem', fontSize: '0.75rem',
padding: '0.12rem 0.45rem', padding: '0.12rem 0.45rem',
borderRadius: '6px', borderRadius: '6px',
border: '1px solid var(--border)', border: '1px solid var(--border)',
background: 'var(--surface2)', background: 'var(--surface2)',
cursor: 'pointer', cursor: 'pointer',
}} }}
onClick={() => onClick={() =>
setClubEditModal({ setClubEditModal({
clubId: c.id, clubId: c.id,
clubName: c.name, clubName: c.name,
profileId: row.id, profileId: row.id,
profileLabel: row.name || row.email, profileLabel: row.name || row.email,
roles: [...(c.roles || [])], roles: [...(c.roles || [])],
status: (c.membership_status || 'active').toLowerCase(), status: (c.membership_status || 'active').toLowerCase(),
}) })
} }
> >
bearbeiten bearbeiten
</button> </button>
</li> </li>
))} ))}
</ul> </ul>
)} )}
</div>
</div> </div>
</div>
) )
})} })}
</div> </div>
@ -346,11 +506,7 @@ function AdminUsersPage() {
<button type="button" className="btn btn-primary" style={{ flex: 1 }} onClick={submitAssignClub}> <button type="button" className="btn btn-primary" style={{ flex: 1 }} onClick={submitAssignClub}>
Zuweisen Zuweisen
</button> </button>
<button <button type="button" className="btn btn-secondary" onClick={() => setAssignModal(null)}>
type="button"
className="btn btn-secondary"
onClick={() => setAssignModal(null)}
>
Abbrechen Abbrechen
</button> </button>
</div> </div>
@ -358,6 +514,77 @@ 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={{
@ -444,5 +671,3 @@ function AdminUsersPage() {
</div> </div>
) )
} }
export default AdminUsersPage

View File

@ -21,7 +21,6 @@ import {
} from 'lucide-react' } from 'lucide-react'
import { useAuth } from '../context/AuthContext' import { useAuth } from '../context/AuthContext'
import api from '../utils/api' import api from '../utils/api'
import AdminPageNav from '../components/AdminPageNav'
import { resolveMediaAssetFileUrl } from '../utils/exerciseMediaUrl' import { resolveMediaAssetFileUrl } from '../utils/exerciseMediaUrl'
const LC_OPTIONS = [ const LC_OPTIONS = [
@ -234,6 +233,7 @@ export default function MediaLibraryPage() {
const { user } = useAuth() const { user } = useAuth()
const isPlatformAdmin = user?.role === 'admin' || user?.role === 'superadmin' const isPlatformAdmin = user?.role === 'admin' || user?.role === 'superadmin'
const isSuperadmin = user?.role === 'superadmin' const isSuperadmin = user?.role === 'superadmin'
const hasClubOrgAdmin = (user?.clubs || []).some((c) => (c.roles || []).includes('club_admin'))
const archiveVisOptions = useMemo( const archiveVisOptions = useMemo(
() => VIS_OPTIONS.filter((o) => o.value !== 'official' || isSuperadmin), () => VIS_OPTIONS.filter((o) => o.value !== 'official' || isSuperadmin),
@ -538,22 +538,25 @@ export default function MediaLibraryPage() {
return ( return (
<div className="app-page media-library"> <div className="app-page media-library">
{isPlatformAdmin ? <AdminPageNav /> : null}
<div className="media-library__container"> <div className="media-library__container">
<header className="media-library__hero"> <header className="media-library__hero">
<div className="media-library__hero-row"> <div className="media-library__hero-row">
<h1 className="media-library__title">Medienbibliothek</h1> <h1 className="media-library__title">Medienbibliothek</h1>
<div className="media-library__hero-links"> <div className="media-library__hero-links">
<Link to="/">Übersicht</Link>
<Link to="/exercises">Übungen</Link> <Link to="/exercises">Übungen</Link>
{isPlatformAdmin ? <Link to="/admin/hierarchy">Admin</Link> : null} {isPlatformAdmin ? <Link to="/admin/hierarchy">Plattform-Admin</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">
Veröffentlichte Medien (Verein/Plattform) und eigene Uploads Privat steuert nur, wer das Asset in der Offizielle und vereinsfreigegebene Medien sind für alle passenden Nutzer sichtbar. Eigene private Medien
Datenbank sieht; der Ablageordner folgt dem gewählten Verein wie bei Verein. Plattform-Admins wählen den kannst du bearbeiten, veröffentlichen oder in den Papierkorb legen; im Papierkorb siehst du als Standardnutzer
Zielverein bei privatem Archiv-Upload aktiv. Suche durchsucht Bezeichner, Speicherpfad, Copyright und Tags. nur deine eigenen privaten Objekte, als Vereinsadmin zusätzlich den Vereins-Papierkorb. Vereins-Rollen können
Bearbeiten über das Menü Bulk in der unteren Leiste. Vereins-Medien verwalten, aber nicht bis Offiziell anheben das bleibt dem Superadmin vorbehalten.
Plattform-Admins geben beim privaten Upload den Zielverein an (club_id).
</p> </p>
</header> </header>