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
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:
parent
58a38702b9
commit
c46f5f99be
|
|
@ -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[]
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
8
frontend/src/components/AdminHomeRedirect.jsx
Normal file
8
frontend/src/components/AdminHomeRedirect.jsx
Normal 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 />
|
||||
}
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
10
frontend/src/components/PlatformAdminRoute.jsx
Normal file
10
frontend/src/components/PlatformAdminRoute.jsx
Normal 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
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 & 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 & 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
|
||||
|
|
|
|||
|
|
@ -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 & 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>
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user