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(
f"""
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(
ARRAY_AGG(DISTINCT r.role_code) FILTER (WHERE r.role_code IS NOT NULL),
ARRAY[]::varchar[]
@ -153,7 +153,7 @@ def _one_member(cur, club_id: int, profile_id: int) -> Dict[str, Any]:
cur.execute(
"""
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(
ARRAY_AGG(DISTINCT r.role_code) FILTER (WHERE r.role_code IS NOT NULL),
ARRAY[]::varchar[]

View File

@ -377,17 +377,94 @@ def _relocate_asset_file_if_governance_changed(
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()
if lc not in _LIFECYCLE_LIST_FILTERS:
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":
return "ma.lifecycle_state = 'active'"
return active_block, active_params
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":
return "ma.lifecycle_state = 'trash_hidden'"
return "ma.lifecycle_state IN ('active', 'trash_soft', 'trash_hidden')"
return (
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]:
@ -789,7 +866,6 @@ def list_media_assets(
limit: int = Query(30, ge=1, le=100),
offset: int = Query(0, ge=0),
):
lc_where = _lifecycle_where_sql(lifecycle)
mk = (media_kind or "all").strip().lower()
if mk not in _MEDIA_KIND_FILTERS:
raise HTTPException(status_code=400, detail="Ungültiger media_kind")
@ -831,6 +907,9 @@ def list_media_assets(
with get_db() as conn:
cur = get_cursor(conn)
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)
if uploaded_by is not None and not show_uploader:
raise HTTPException(status_code=403, detail="Uploader-Filter nicht erlaubt")
@ -863,7 +942,7 @@ def list_media_assets(
)
params: list[Any] = (
[is_adm, profile_id, profile_id]
vis_params
+ club_sql_params
+ uploaded_params
+ search_params
@ -880,24 +959,7 @@ def list_media_assets(
FROM media_assets ma
LEFT JOIN profiles pr ON pr.id = ma.uploaded_by_profile_id
LEFT JOIN clubs cl ON cl.id = ma.club_id
WHERE {lc_where}
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'
)
)
)
WHERE {vis_main_sql}
{club_sql}
{uploaded_sql}
{media_kind_sql}
@ -907,7 +969,7 @@ def list_media_assets(
params,
)
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]
usage_map = _usage_for_media_assets(cur, asset_ids)
for r in rows:

View File

@ -34,14 +34,23 @@ import AdminMaturityModelsPage from './pages/AdminMaturityModelsPage'
import TrainerContextsPage from './pages/TrainerContextsPage'
import MediaWikiImportPage from './pages/MediaWikiImportPage'
import AdminUsersPage from './pages/AdminUsersPage'
import AdminHomeRedirect from './components/AdminHomeRedirect'
import PlatformAdminRoute from './components/PlatformAdminRoute'
import MediaLibraryPage from './pages/MediaLibraryPage'
import ActiveClubSwitcher from './components/ActiveClubSwitcher'
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)
function Nav({ isAdmin }) {
function Nav({ showAdminNav }) {
const { canAccessOrgInbox, inboxCount } = useOrgInbox()
const items = getMainNavItems(isAdmin, { showInbox: canAccessOrgInbox })
const items = getMainNavItems(showAdminNav, { showInbox: canAccessOrgInbox })
const loc = useLocation()
const navItemActive = (pathname, item, routerIsActive) => {
@ -103,11 +112,11 @@ function ProtectedLayout() {
return <Navigate to="/login" replace />
}
const isAdmin = user?.role === 'admin' || user?.role === 'superadmin'
const showAdminNav = computeShowAdminNav(user)
return (
<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__column">
<div className="app-header app-header--mobile app-header--mobile-stack">
@ -119,7 +128,7 @@ function ProtectedLayout() {
<div className="app-main">
<Outlet />
</div>
<Nav isAdmin={isAdmin} />
<Nav showAdminNav={showAdminNav} />
</div>
</div>
</OrgInboxProvider>
@ -183,12 +192,40 @@ function AppRoutes() {
<Route path="planning/run/:unitId/coach" element={<TrainingCoachPage />} />
<Route path="planning/run/:unitId" element={<TrainingUnitRunPage />} />
<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/hierarchy" element={<AdminHierarchyPage />} />
<Route path="admin/maturity-models" element={<AdminMaturityModelsPage />} />
<Route path="admin/catalogs" element={<AdminCatalogsPage />} />
<Route path="admin/mediawiki-import" element={<MediaWikiImportPage />} />
<Route
path="admin/hierarchy"
element={
<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>

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 { TreePine, FolderTree, Download, Grid3x3, Users, Images } from 'lucide-react'
import { TreePine, FolderTree, Download, Grid3x3, Users } from 'lucide-react'
/**
* Admin-Seiten-Navigation (horizontal)
* Wechselt zwischen verschiedenen Admin-Seiten
* Nutzer-Verwaltung: eingeschränkte Tabs für Vereinsorga ohne Plattform-Admin.
*/
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: '/media', label: 'Medien', icon: Images },
{ to: '/admin/mediawiki-import', label: 'Wiki-Import', icon: Download }
]
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 },
]
return (
<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).
*/
export default function DesktopSidebar({
isAdmin,
showAdminNav,
user,
onLogout
}) {
const loc = useLocation()
const { canAccessOrgInbox, inboxCount } = useOrgInbox()
const items = getMainNavItems(isAdmin, { showInbox: canAccessOrgInbox })
const items = getMainNavItems(showAdminNav, { showInbox: canAccessOrgInbox })
const tier = user?.tier || ''
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,
BookOpen,
Calendar,
Images,
Building2,
Settings,
Shield,
@ -24,6 +25,7 @@ function baseItems(opts = {}) {
...(showInbox ? [{ to: '/inbox', label: 'Posteingang', shortLabel: 'Post' }] : []),
{ to: '/exercises', label: 'Übungen', shortLabel: 'Übungen' },
{ to: '/planning', label: 'Planung' },
{ to: '/media', label: 'Medien', shortLabel: 'Medien' },
{ to: '/clubs', label: 'Vereine' },
{ to: '/trainer-contexts', label: 'Meine Bereiche', shortLabel: 'Bereiche' },
{ to: '/settings', label: 'Einstellungen', shortLabel: 'Einst.' }
@ -34,7 +36,16 @@ function baseItems(opts = {}) {
/** @param {boolean} isAdmin @param {{ showInbox?: boolean }} opts */
export function getMainNavItems(isAdmin, opts = {}) {
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) => ({
...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 { useAuth } from '../context/AuthContext'
import api from '../utils/api'
@ -11,68 +11,157 @@ const CLUB_ROLE_OPTIONS = [
{ code: 'content_editor', label: 'Inhalte bearbeiten' },
]
const TIER_OPTIONS = ['free', 'premium', 'pro', 'enterprise']
const ROLE_LABEL = {
const PORTAL_ROLE_LABEL = {
user: 'Nutzer',
trainer: 'Trainer',
admin: 'Portal-Admin',
superadmin: 'Super-Admin',
trainer: 'Portal-Trainer',
admin: 'Portal-Administrator',
superadmin: 'Super-Administrator',
}
function AdminUsersPage() {
const { user } = useAuth()
const isSuper = user?.role === 'superadmin'
const isPlatformAdmin = user?.role === 'admin' || user?.role === 'superadmin'
const portalRoleChoices = isSuper
? ['user', 'trainer', 'admin', 'superadmin']
: ['user', 'trainer', 'admin']
function clubAdminClubIds(user) {
return (user?.clubs || [])
.filter((c) => Array.isArray(c.roles) && c.roles.includes('club_admin'))
.map((c) => c.id)
}
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 [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 load = async () => {
setError('')
try {
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)
}
}
const selectableClubs = useMemo(
() => clubSelectOptions(user, clubs, isPlatformAdmin),
[user, clubs, isPlatformAdmin]
)
useEffect(() => {
if (!isPlatformAdmin) return
load()
}, [isPlatformAdmin])
if (!clubOrgMode) return
if (selectedClubId == null || !managedClubIds.includes(selectedClubId)) {
setSelectedClubId(managedClubIds[0] ?? null)
}
}, [clubOrgMode, managedClubIds, selectedClubId])
if (!isPlatformAdmin) {
return <Navigate to="/" replace />
}
const loadPlatform = useCallback(async () => {
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 dr = portalDraft[profileId]
if (!dr) return
try {
await api.updateProfile(profileId, { role: dr.role, tier: dr.tier })
await load()
await api.updateProfile(profileId, { role: dr.role })
await loadPlatform()
} catch (e) {
alert(e.message || String(e))
}
@ -90,7 +179,7 @@ function AdminUsersPage() {
await api.addClubMember(clubId, { profile_id: profileId, roles: assignRoles })
setAssignModal(null)
setAssignRoles(['trainer'])
await load()
await loadPlatform()
} catch (e) {
alert(e.message || String(e))
}
@ -102,7 +191,8 @@ function AdminUsersPage() {
try {
await api.updateClubMember(clubId, profileId, { roles, status })
setClubEditModal(null)
await load()
if (clubOrgMode) await reloadClubMembers()
else await loadPlatform()
} catch (e) {
alert(e.message || String(e))
}
@ -114,7 +204,30 @@ function AdminUsersPage() {
try {
await api.removeClubMember(clubEditModal.clubId, clubEditModal.profileId)
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) {
alert(e.message || String(e))
}
@ -122,13 +235,39 @@ function AdminUsersPage() {
return (
<div className="app-page">
<AdminPageNav />
<h1 style={{ marginTop: 0 }}>Portal-Nutzer &amp; Vereine</h1>
<p style={{ color: 'var(--text2)', maxWidth: '52rem', lineHeight: 1.55, marginBottom: '1.25rem' }}>
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
zuordnen.
</p>
<AdminPageNav clubOrgOnly={clubOrgMode} />
<h1 style={{ marginTop: 0 }}>Nutzer &amp; Vereinsrollen</h1>
{clubOrgMode ? (
<p style={{ color: 'var(--text2)', maxWidth: '52rem', lineHeight: 1.55, marginBottom: '1.25rem' }}>
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 ? (
<p style={{ color: 'var(--text2)' }}>Laden</p>
@ -136,142 +275,163 @@ function AdminUsersPage() {
<div className="card" style={{ borderColor: 'var(--danger)', color: 'var(--danger)' }}>
{error}
</div>
) : (
) : clubOrgMode ? (
<div style={{ display: 'grid', gap: '1rem' }}>
{users.map((row) => {
const tierValue = portalDraft[row.id]?.tier ?? row.tier ?? 'free'
const tierChoices = [...TIER_OPTIONS]
if (tierValue && !tierChoices.includes(tierValue)) tierChoices.unshift(tierValue)
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 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) => (
<div key={m.membership_id} className="card">
<div style={{ display: 'flex', justifyContent: 'space-between', flexWrap: 'wrap', gap: '0.75rem' }}>
<div>
<label className="form-label" style={{ fontSize: '0.75rem' }}>
Portal-Rolle
</label>
<select
className="form-input"
style={{ minWidth: '140px' }}
value={(portalDraft[row.id]?.role || row.role || 'user').toLowerCase()}
onChange={(e) =>
setPortalDraft((prev) => ({
...prev,
[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>
<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' }}>
Status: {m.status} · Verifiziert: {m.email_verified ? 'ja' : 'nein'}
</div>
<div style={{ fontSize: '0.82rem', marginTop: '0.35rem' }}>
Rollen: {(m.roles || []).join(', ') || '—'}
</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
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 ?? '',
className="btn btn-secondary"
onClick={() =>
setClubEditModal({
clubId: selectedClubId,
clubName: selectedClubLabel,
profileId: m.profile_id,
profileLabel: m.name || m.email,
roles: [...(m.roles || [])],
status: (m.status || 'active').toLowerCase(),
})
}}
}
>
Verein zuweisen
Bearbeiten
</button>
</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)' }}>
<strong style={{ fontSize: '0.85rem' }}>Vereinsmitgliedschaften</strong>
{!row.clubs?.length ? (
<p style={{ color: 'var(--text2)', fontSize: '0.875rem', margin: '0.35rem 0 0' }}>
Keine Zuordnung.
</p>
) : (
<ul style={{ margin: '0.5rem 0 0', paddingLeft: '1.2rem', fontSize: '0.9rem' }}>
{row.clubs.map((c) => (
<li key={c.id} style={{ marginBottom: '0.35rem' }}>
<strong>{c.name}</strong>
{c.abbreviation ? ` (${c.abbreviation})` : ''} {' '}
{(c.roles || []).join(', ') || '—'}
{c.membership_status === 'inactive' ? (
<span style={{ color: 'var(--text3)', fontSize: '0.8rem' }}> (inaktiv)</span>
) : null}{' '}
<button
type="button"
style={{
marginLeft: '0.35rem',
fontSize: '0.75rem',
padding: '0.12rem 0.45rem',
borderRadius: '6px',
border: '1px solid var(--border)',
background: 'var(--surface2)',
cursor: 'pointer',
}}
onClick={() =>
setClubEditModal({
clubId: c.id,
clubName: c.name,
profileId: row.id,
profileLabel: row.name || row.email,
roles: [...(c.roles || [])],
status: (c.membership_status || 'active').toLowerCase(),
})
}
>
bearbeiten
</button>
</li>
))}
</ul>
)}
<div style={{ marginTop: '1rem', paddingTop: '0.75rem', borderTop: '1px solid var(--border)' }}>
<strong style={{ fontSize: '0.85rem' }}>Vereinsmitgliedschaften</strong>
{!row.clubs?.length ? (
<p style={{ color: 'var(--text2)', fontSize: '0.875rem', margin: '0.35rem 0 0' }}>
Keine Zuordnung.
</p>
) : (
<ul style={{ margin: '0.5rem 0 0', paddingLeft: '1.2rem', fontSize: '0.9rem' }}>
{(row.clubs || []).map((c) => (
<li key={c.id} style={{ marginBottom: '0.35rem' }}>
<strong>{c.name}</strong>
{c.abbreviation ? ` (${c.abbreviation})` : ''} {' '}
{(c.roles || []).join(', ') || '—'}
{c.membership_status === 'inactive' ? (
<span style={{ color: 'var(--text3)', fontSize: '0.8rem' }}> (inaktiv)</span>
) : null}{' '}
<button
type="button"
style={{
marginLeft: '0.35rem',
fontSize: '0.75rem',
padding: '0.12rem 0.45rem',
borderRadius: '6px',
border: '1px solid var(--border)',
background: 'var(--surface2)',
cursor: 'pointer',
}}
onClick={() =>
setClubEditModal({
clubId: c.id,
clubName: c.name,
profileId: row.id,
profileLabel: row.name || row.email,
roles: [...(c.roles || [])],
status: (c.membership_status || 'active').toLowerCase(),
})
}
>
bearbeiten
</button>
</li>
))}
</ul>
)}
</div>
</div>
</div>
)
})}
</div>
@ -346,11 +506,7 @@ function AdminUsersPage() {
<button type="button" className="btn btn-primary" style={{ flex: 1 }} onClick={submitAssignClub}>
Zuweisen
</button>
<button
type="button"
className="btn btn-secondary"
onClick={() => setAssignModal(null)}
>
<button type="button" className="btn btn-secondary" onClick={() => setAssignModal(null)}>
Abbrechen
</button>
</div>
@ -358,6 +514,77 @@ 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={{
@ -444,5 +671,3 @@ function AdminUsersPage() {
</div>
)
}
export default AdminUsersPage

View File

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