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(
|
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[]
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
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 { 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">
|
||||||
|
|
|
||||||
|
|
@ -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 (
|
||||||
|
|
|
||||||
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,
|
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,
|
||||||
|
|
|
||||||
|
|
@ -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 & Vereine</h1>
|
|
||||||
<p style={{ color: 'var(--text2)', maxWidth: '52rem', lineHeight: 1.55, marginBottom: '1.25rem' }}>
|
<h1 style={{ marginTop: 0 }}>Nutzer & 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
|
|
||||||
|
|
|
||||||
|
|
@ -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 & 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>
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user