From c46f5f99be75f83e10b5b8d913dfc65799fc7fcf Mon Sep 17 00:00:00 2001 From: Lars Date: Sat, 9 May 2026 10:02:56 +0200 Subject: [PATCH] feat(admin): enhance admin navigation and user management features - 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. --- backend/routers/club_memberships.py | 4 +- backend/routers/media_assets.py | 114 +++- frontend/src/App.jsx | 57 +- frontend/src/components/AdminHomeRedirect.jsx | 8 + frontend/src/components/AdminPageNav.jsx | 23 +- frontend/src/components/DesktopSidebar.jsx | 4 +- .../src/components/PlatformAdminRoute.jsx | 10 + frontend/src/config/appNav.js | 13 +- frontend/src/pages/AdminUsersPage.jsx | 593 ++++++++++++------ frontend/src/pages/MediaLibraryPage.jsx | 19 +- 10 files changed, 601 insertions(+), 244 deletions(-) create mode 100644 frontend/src/components/AdminHomeRedirect.jsx create mode 100644 frontend/src/components/PlatformAdminRoute.jsx diff --git a/backend/routers/club_memberships.py b/backend/routers/club_memberships.py index d1a4a6a..f88cf1d 100644 --- a/backend/routers/club_memberships.py +++ b/backend/routers/club_memberships.py @@ -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[] diff --git a/backend/routers/media_assets.py b/backend/routers/media_assets.py index 7efd304..d33aa1d 100644 --- a/backend/routers/media_assets.py +++ b/backend/routers/media_assets.py @@ -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: diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index e416967..091487c 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -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 } - const isAdmin = user?.role === 'admin' || user?.role === 'superadmin' + const showAdminNav = computeShowAdminNav(user) return ( - +
@@ -119,7 +128,7 @@ function ProtectedLayout() {
-
@@ -183,12 +192,40 @@ function AppRoutes() { } /> } /> } /> - } /> + } /> } /> - } /> - } /> - } /> - } /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> } /> diff --git a/frontend/src/components/AdminHomeRedirect.jsx b/frontend/src/components/AdminHomeRedirect.jsx new file mode 100644 index 0000000..aac8fdd --- /dev/null +++ b/frontend/src/components/AdminHomeRedirect.jsx @@ -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 +} diff --git a/frontend/src/components/AdminPageNav.jsx b/frontend/src/components/AdminPageNav.jsx index adf2221..6970eef 100644 --- a/frontend/src/components/AdminPageNav.jsx +++ b/frontend/src/components/AdminPageNav.jsx @@ -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 (