From 58a38702b933ff6b7763567ede7633851457613c Mon Sep 17 00:00:00 2001 From: Lars Date: Sat, 9 May 2026 09:13:38 +0200 Subject: [PATCH 1/6] feat(org-inbox): implement join request inbox for platform and club admins - Added new API endpoint to retrieve join requests accessible by platform admins and club admins. - Implemented frontend components to display join requests in the inbox, including navigation updates and badge notifications. - Enhanced sidebar and navigation to conditionally show inbox based on user permissions. - Updated styles for inbox components and added responsive design for dashboard integration. - Introduced context management for inbox state and notifications on join request actions. --- backend/routers/club_join_requests.py | 68 +++++- frontend/src/App.jsx | 15 +- frontend/src/app.css | 154 ++++++++++++ .../components/DashboardOrgInboxWidget.jsx | 57 +++++ frontend/src/components/DesktopSidebar.jsx | 9 +- frontend/src/config/appNav.js | 28 +-- frontend/src/context/OrgInboxContext.jsx | 87 +++++++ frontend/src/pages/ClubsPage.jsx | 3 + frontend/src/pages/Dashboard.jsx | 2 + frontend/src/pages/InboxPage.jsx | 222 ++++++++++++++++++ frontend/src/utils/api.js | 6 + 11 files changed, 631 insertions(+), 20 deletions(-) create mode 100644 frontend/src/components/DashboardOrgInboxWidget.jsx create mode 100644 frontend/src/context/OrgInboxContext.jsx create mode 100644 frontend/src/pages/InboxPage.jsx diff --git a/backend/routers/club_join_requests.py b/backend/routers/club_join_requests.py index dae7767..415fd29 100644 --- a/backend/routers/club_join_requests.py +++ b/backend/routers/club_join_requests.py @@ -6,7 +6,7 @@ from typing import Any, Dict, List, Optional from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel, Field -from club_tenancy import can_manage_club_org +from club_tenancy import can_manage_club_org, is_platform_admin from db import get_db, get_cursor, r2d from tenant_context import TenantContext, get_tenant_context @@ -106,6 +106,72 @@ def _response_one(cur, req_id: int, viewer_profile_id: int) -> Dict[str, Any]: return r2d(row) +def _can_access_org_inbox(cur, profile_id: int, global_role: Optional[str]) -> bool: + """Posteingang (Beitrittsanträge bearbeiten): Plattform-Admin oder Vereinsadmin in mind. einem Verein.""" + if is_platform_admin(global_role): + return True + cur.execute( + """ + SELECT 1 + FROM club_members cm + INNER JOIN club_member_roles r ON r.club_member_id = cm.id AND r.role_code = 'club_admin' + WHERE cm.profile_id = %s AND cm.status = 'active' + LIMIT 1 + """, + (profile_id,), + ) + return cur.fetchone() is not None + + +def _club_ids_manageable_by_user(cur, profile_id: int, global_role: Optional[str]) -> List[int]: + """Club-IDs, für die der Nutzer Beitrittsanträge sehen darf.""" + if is_platform_admin(global_role): + cur.execute("SELECT id FROM clubs WHERE status = 'active' ORDER BY name") + return [int(r["id"]) for r in cur.fetchall()] + cur.execute( + """ + SELECT DISTINCT cm.club_id + FROM club_members cm + INNER JOIN club_member_roles r ON r.club_member_id = cm.id AND r.role_code = 'club_admin' + WHERE cm.profile_id = %s AND cm.status = 'active' + """, + (profile_id,), + ) + return [int(r["club_id"]) for r in cur.fetchall()] + + +@router.get("/me/inbox/join-requests") +def list_inbox_join_requests(tenant: TenantContext = Depends(get_tenant_context)): + """ + Alle offenen Beitrittsanträge, die der Nutzer bearbeiten darf: + Plattform-Admin: alle Vereine; sonst nur Vereine mit Rolle club_admin. + """ + pid = tenant.profile_id + role = tenant.global_role + with get_db() as conn: + cur = get_cursor(conn) + if not _can_access_org_inbox(cur, pid, role): + raise HTTPException(status_code=403, detail="Kein Zugriff auf den Organisations-Posteingang") + club_ids = _club_ids_manageable_by_user(cur, pid, role) + if not club_ids: + return [] + cur.execute( + """ + SELECT r.id, r.profile_id, r.club_id, r.status, r.message, r.created_at, + c.name AS club_name, c.abbreviation AS club_abbreviation, + p.name AS applicant_name, p.email AS applicant_email + FROM club_membership_requests r + INNER JOIN clubs c ON c.id = r.club_id + INNER JOIN profiles p ON p.id = r.profile_id + WHERE r.status = 'pending' + AND r.club_id = ANY(%s) + ORDER BY r.created_at ASC + """, + (club_ids,), + ) + return [r2d(row) for row in cur.fetchall()] + + @router.get("/me/club-join-requests") def get_my_join_requests(tenant: TenantContext = Depends(get_tenant_context)): pid = tenant.profile_id diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 1c68048..e416967 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -9,6 +9,7 @@ import { Outlet, } from 'react-router-dom' import { AuthProvider, useAuth } from './context/AuthContext' +import { OrgInboxProvider, useOrgInbox } from './context/OrgInboxContext' import DesktopSidebar from './components/DesktopSidebar' import { getMainNavItems } from './config/appNav' import LoginPage from './pages/LoginPage' @@ -20,6 +21,7 @@ import ExercisesListPage from './pages/ExercisesListPage' import ExerciseDetailPage from './pages/ExerciseDetailPage' import ExerciseFormPage from './pages/ExerciseFormPage' import ClubsPage from './pages/ClubsPage' +import InboxPage from './pages/InboxPage' import SkillsPage from './pages/SkillsPage' import TrainingPlanningPage from './pages/TrainingPlanningPage' import TrainingFrameworkProgramsListPage from './pages/TrainingFrameworkProgramsListPage' @@ -38,7 +40,8 @@ import './app.css' // Bottom Navigation (Mobile) function Nav({ isAdmin }) { - const items = getMainNavItems(isAdmin) + const { canAccessOrgInbox, inboxCount } = useOrgInbox() + const items = getMainNavItems(isAdmin, { showInbox: canAccessOrgInbox }) const loc = useLocation() const navItemActive = (pathname, item, routerIsActive) => { @@ -58,6 +61,11 @@ function Nav({ isAdmin }) { } > + {item.to === '/inbox' && inboxCount > 0 ? ( + + {inboxCount > 99 ? '99+' : inboxCount} + + ) : null} {item.shortLabel || item.label} ))} @@ -98,7 +106,7 @@ function ProtectedLayout() { const isAdmin = user?.role === 'admin' || user?.role === 'superadmin' return ( - <> +
@@ -114,7 +122,7 @@ function ProtectedLayout() {
- +
) } @@ -167,6 +175,7 @@ function AppRoutes() { } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/app.css b/frontend/src/app.css index bccdf36..7faf38b 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -198,6 +198,7 @@ body { font-family: var(--font); background: var(--bg); color: var(--text1); -we min-width: 68px; max-width: 108px; min-height: 48px; + position: relative; display: flex; flex-direction: column; align-items: center; @@ -6376,3 +6377,156 @@ a.analysis-split__nav-item { .media-library__preview-fallback .btn { margin-top: 12px; } + +/* Organisation: Posteingang (Nav-Badge, Sidebar, Dashboard-Widget) */ +.nav-item__badge { + position: absolute; + top: 2px; + right: 6px; + min-width: 1.1rem; + padding: 1px 5px; + border-radius: 999px; + font-size: 10px; + font-weight: 700; + line-height: 1.2; + background: var(--accent); + color: #fff; + box-shadow: 0 0 0 1px var(--surface); +} +.desktop-sidebar__badge { + margin-left: auto; + flex-shrink: 0; + min-width: 1.25rem; + padding: 2px 6px; + border-radius: 999px; + font-size: 11px; + font-weight: 700; + line-height: 1.2; + background: var(--accent); + color: #fff; +} +.desktop-sidebar__link > span:not(.desktop-sidebar__badge) { + flex: 1; + min-width: 0; +} + +.dashboard-org-inbox-widget { + display: none; +} +@media (min-width: 1024px) { + .dashboard-org-inbox-widget { + display: block; + margin-bottom: 1.25rem; + } +} +.dashboard-org-inbox-widget__inner { + padding: 1rem 1.1rem; +} +.dashboard-org-inbox-widget__head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + margin-bottom: 0.35rem; +} +.dashboard-org-inbox-widget__title { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 1rem; + font-weight: 600; + margin: 0; +} +.dashboard-org-inbox-widget__icon { + color: var(--accent); + flex-shrink: 0; +} +.dashboard-org-inbox-widget__badge { + font-size: 0.8rem; + font-weight: 700; + min-width: 1.5rem; + text-align: center; + padding: 2px 8px; + border-radius: 999px; + background: var(--accent); + color: #fff; +} +.dashboard-org-inbox-widget__lead { + margin: 0 0 0.65rem; + font-size: 0.92rem; +} +.dashboard-org-inbox-widget__list { + list-style: none; + margin: 0 0 0.85rem; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.45rem; +} +.dashboard-org-inbox-widget__item { + display: flex; + flex-wrap: wrap; + gap: 0.35rem 0.75rem; + font-size: 0.9rem; + padding: 0.35rem 0; + border-bottom: 1px solid var(--border); +} +.dashboard-org-inbox-widget__item:last-child { + border-bottom: none; +} +.dashboard-org-inbox-widget__club { + font-weight: 600; + color: var(--text1); +} +.dashboard-org-inbox-widget__applicant { + color: var(--text2); +} +.dashboard-org-inbox-widget__footer { + margin-top: 0.25rem; +} + +.inbox-page__header { + display: flex; + flex-wrap: wrap; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; + margin-bottom: 1rem; +} +.inbox-page__list { + display: flex; + flex-direction: column; + gap: 0.75rem; +} +.inbox-request-card { + display: flex; + flex-wrap: wrap; + gap: 1rem; + justify-content: space-between; + align-items: flex-start; +} +.inbox-request-card__club { + font-size: 0.85rem; + font-weight: 600; + color: var(--accent); + margin-bottom: 0.2rem; +} +.inbox-request-card__applicant { + display: block; + font-size: 1.02rem; +} +.inbox-request-card__meta { + font-size: 0.86rem; + margin-top: 0.25rem; +} +.inbox-request-card__message { + margin: 0.5rem 0 0; + font-size: 0.92rem; + line-height: 1.45; +} +.inbox-request-card__actions { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + flex-shrink: 0; +} diff --git a/frontend/src/components/DashboardOrgInboxWidget.jsx b/frontend/src/components/DashboardOrgInboxWidget.jsx new file mode 100644 index 0000000..eafb532 --- /dev/null +++ b/frontend/src/components/DashboardOrgInboxWidget.jsx @@ -0,0 +1,57 @@ +import { Link } from 'react-router-dom' +import { Inbox } from 'lucide-react' +import { useOrgInbox } from '../context/OrgInboxContext' + +/** + * Desktop-Dashboard: Hinweis auf offene Beitrittsanträge (nur ab 1024px sichtbar via CSS). + */ +export default function DashboardOrgInboxWidget() { + const { canAccessOrgInbox, inboxJoinRequests, inboxCount } = useOrgInbox() + + if (!canAccessOrgInbox) return null + + const preview = (inboxJoinRequests || []).slice(0, 5) + + return ( +
+
+
+

+ + Posteingang +

+ {inboxCount > 0 ? ( + + {inboxCount} + + ) : null} +
+

+ {inboxCount === 0 + ? 'Keine offenen Beitrittsanträge.' + : `${inboxCount} offene Beitrittsantrag${inboxCount === 1 ? '' : 'e'}.`} +

+ {preview.length > 0 ? ( +
    + {preview.map((req) => ( +
  • + {req.club_name || 'Verein'} + + {req.applicant_name || req.applicant_email || 'Bewerber/in'} + +
  • + ))} +
+ ) : null} +
+ + Zum Posteingang + +
+
+
+ ) +} diff --git a/frontend/src/components/DesktopSidebar.jsx b/frontend/src/components/DesktopSidebar.jsx index a918e17..781b25f 100644 --- a/frontend/src/components/DesktopSidebar.jsx +++ b/frontend/src/components/DesktopSidebar.jsx @@ -1,6 +1,7 @@ import { NavLink, useLocation } from 'react-router-dom' import { LogOut } from 'lucide-react' import { getMainNavItems } from '../config/appNav' +import { useOrgInbox } from '../context/OrgInboxContext' import ActiveClubSwitcher from './ActiveClubSwitcher' function sidebarLinkActive(pathname, item, routerIsActive) { @@ -17,7 +18,8 @@ export default function DesktopSidebar({ onLogout }) { const loc = useLocation() - const items = getMainNavItems(isAdmin) + const { canAccessOrgInbox, inboxCount } = useOrgInbox() + const items = getMainNavItems(isAdmin, { showInbox: canAccessOrgInbox }) const tier = user?.tier || '' return ( @@ -42,6 +44,11 @@ export default function DesktopSidebar({ > {item.label} + {item.to === '/inbox' && inboxCount > 0 ? ( + + {inboxCount > 99 ? '99+' : inboxCount} + + ) : null} ))} diff --git a/frontend/src/config/appNav.js b/frontend/src/config/appNav.js index ea87dd6..667faeb 100644 --- a/frontend/src/config/appNav.js +++ b/frontend/src/config/appNav.js @@ -5,7 +5,8 @@ import { Building2, Settings, Shield, - Target + Target, + Inbox } from 'lucide-react' /** @@ -15,30 +16,27 @@ import { * @typedef {{ to: string, label: string, shortLabel?: string, end?: boolean, Icon: import('react').ForwardRefExoticComponent }} AppNavItem */ -/** @returns {Omit[]} */ -function baseItems() { - return [ +/** @param {{ showInbox?: boolean }} opts */ +function baseItems(opts = {}) { + const showInbox = !!opts.showInbox + const items = [ { to: '/', label: 'Übersicht', end: true }, + ...(showInbox ? [{ to: '/inbox', label: 'Posteingang', shortLabel: 'Post' }] : []), { to: '/exercises', label: 'Übungen', shortLabel: 'Übungen' }, { to: '/planning', label: 'Planung' }, { to: '/clubs', label: 'Vereine' }, { to: '/trainer-contexts', label: 'Meine Bereiche', shortLabel: 'Bereiche' }, { to: '/settings', label: 'Einstellungen', shortLabel: 'Einst.' } ] + return items } -/** @param {boolean} isAdmin */ -export function getMainNavItems(isAdmin) { - const icons = [ - LayoutDashboard, - BookOpen, - Calendar, - Building2, - Target, - Settings - ] +/** @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 raw = baseItems().map((item, i) => ({ + const raw = baseItems(opts).map((item, i) => ({ ...item, Icon: icons[i] })) diff --git a/frontend/src/context/OrgInboxContext.jsx b/frontend/src/context/OrgInboxContext.jsx new file mode 100644 index 0000000..625a66a --- /dev/null +++ b/frontend/src/context/OrgInboxContext.jsx @@ -0,0 +1,87 @@ +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState, +} from 'react' +import api from '../utils/api' + +const OrgInboxContext = createContext(null) + +export function canAccessOrgInbox(user) { + if (!user?.id) return false + if (user.role === 'admin' || user.role === 'superadmin') return true + return (user.clubs || []).some((c) => (c.roles || []).includes('club_admin')) +} + +/** Nach Annahme/Ablehnung: Posteingang-Badges & Widget aktualisieren */ +export function notifyOrgInboxChanged() { + window.dispatchEvent(new Event('shinkan:inbox-changed')) +} + +export function OrgInboxProvider({ user, children }) { + const [items, setItems] = useState([]) + const canAccess = useMemo(() => canAccessOrgInbox(user), [user]) + + const refresh = useCallback(async () => { + if (!canAccess) { + setItems([]) + return + } + try { + const data = await api.getInboxJoinRequests() + setItems(Array.isArray(data) ? data : []) + } catch { + setItems([]) + } + }, [canAccess]) + + useEffect(() => { + if (!canAccess) { + setItems([]) + return undefined + } + let cancelled = false + ;(async () => { + try { + const data = await api.getInboxJoinRequests() + if (!cancelled) setItems(Array.isArray(data) ? data : []) + } catch { + if (!cancelled) setItems([]) + } + })() + return () => { + cancelled = true + } + }, [canAccess, user?.id]) + + useEffect(() => { + const onChange = () => { + refresh() + } + window.addEventListener('shinkan:inbox-changed', onChange) + return () => window.removeEventListener('shinkan:inbox-changed', onChange) + }, [refresh]) + + const value = useMemo( + () => ({ + inboxJoinRequests: items, + inboxCount: items.length, + refreshOrgInbox: refresh, + canAccessOrgInbox: canAccess, + }), + [items, refresh, canAccess] + ) + + return {children} +} + +export function useOrgInbox() { + const ctx = useContext(OrgInboxContext) + if (!ctx) { + throw new Error('useOrgInbox must be used within OrgInboxProvider') + } + return ctx +} diff --git a/frontend/src/pages/ClubsPage.jsx b/frontend/src/pages/ClubsPage.jsx index 64347f2..dfa7e3c 100644 --- a/frontend/src/pages/ClubsPage.jsx +++ b/frontend/src/pages/ClubsPage.jsx @@ -1,5 +1,6 @@ import React, { useState, useEffect, useMemo } from 'react' import api from '../utils/api' +import { notifyOrgInboxChanged } from '../context/OrgInboxContext' import { useAuth } from '../context/AuthContext' import PageSectionNav from '../components/PageSectionNav' @@ -647,6 +648,7 @@ function ClubsPage() { if (!confirm('Antrag ablehnen?')) return try { await api.rejectClubJoinRequest(membersAdminClubId, req.id) + notifyOrgInboxChanged() await reloadMembersAdmin() } catch (err) { alert(err.message || String(err)) @@ -1255,6 +1257,7 @@ function ClubsPage() { acceptJoinModal.id, acceptJoinModal.roles.length ? acceptJoinModal.roles : ['trainer'] ) + notifyOrgInboxChanged() setAcceptJoinModal(null) await reloadMembersAdmin() await loadData() diff --git a/frontend/src/pages/Dashboard.jsx b/frontend/src/pages/Dashboard.jsx index 6dc8ee9..e744684 100644 --- a/frontend/src/pages/Dashboard.jsx +++ b/frontend/src/pages/Dashboard.jsx @@ -5,6 +5,7 @@ import { useAuth } from '../context/AuthContext' import api from '../utils/api' import EmailVerificationBanner from '../components/EmailVerificationBanner' import DashboardTrainingVisibilityWidget from '../components/DashboardTrainingVisibilityWidget' +import DashboardOrgInboxWidget from '../components/DashboardOrgInboxWidget' function unitWhenLabel(u) { const d = u.planned_date ? String(u.planned_date).slice(0, 10) : '' @@ -183,6 +184,7 @@ function Dashboard() { {user?.id ? ( <> +
diff --git a/frontend/src/pages/InboxPage.jsx b/frontend/src/pages/InboxPage.jsx new file mode 100644 index 0000000..2aead00 --- /dev/null +++ b/frontend/src/pages/InboxPage.jsx @@ -0,0 +1,222 @@ +import React, { useState, useEffect, useCallback } from 'react' +import { Link } from 'react-router-dom' +import api from '../utils/api' +import { notifyOrgInboxChanged, useOrgInbox } from '../context/OrgInboxContext' + +const CLUB_ROLE_OPTIONS = [ + { code: 'club_admin', label: 'Vereinsadmin' }, + { code: 'trainer', label: 'Trainer' }, + { code: 'division_lead', label: 'Spartenleitung' }, + { code: 'content_editor', label: 'Inhalte bearbeiten' }, +] + +function formatWhen(iso) { + if (!iso) return '' + const s = String(iso) + const d = s.includes('T') ? s.split('T')[0] : s.slice(0, 10) + const t = s.includes('T') ? s.split('T')[1] : '' + const time = t ? t.slice(0, 5) : '' + return time ? `${d} · ${time}` : d +} + +export default function InboxPage() { + const { canAccessOrgInbox, refreshOrgInbox, inboxJoinRequests } = useOrgInbox() + const [loading, setLoading] = useState(true) + const [acceptModal, setAcceptModal] = useState(null) + + const load = useCallback(async () => { + if (!canAccessOrgInbox) { + setLoading(false) + return + } + setLoading(true) + try { + await refreshOrgInbox() + } finally { + setLoading(false) + } + }, [canAccessOrgInbox, refreshOrgInbox]) + + useEffect(() => { + load() + }, [load]) + + if (!canAccessOrgInbox) { + return ( +
+

Posteingang

+

Kein Zugriff. Nur Plattform-Admins und Vereinsadmins sehen den Posteingang.

+

+ Zur Übersicht +

+
+ ) + } + + return ( +
+
+
+

+ Posteingang +

+

+ Offene Beitrittsanträge zu Vereinen, für die du zuständig bist. +

+
+ +
+ + {loading ? ( +
+
+
+ ) : inboxJoinRequests.length === 0 ? ( +
+

+ Keine offenen Beitrittsanträge. +

+
+ ) : ( +
+ {inboxJoinRequests.map((req) => ( +
+
+
+ {req.club_name || 'Verein'} + {req.club_abbreviation ? ( + + ({req.club_abbreviation}) + + ) : null} +
+ + {req.applicant_name || req.applicant_email || 'Bewerber/in'} + +
+ {req.applicant_email} · Profil #{req.profile_id} · {formatWhen(req.created_at)} +
+ {req.message ?

{req.message}

: null} +
+
+ + +
+
+ ))} +
+ )} + + {acceptModal && ( +
+
+

Antrag annehmen

+

{acceptModal.label}

+
+ Rollen bei Aufnahme +
+ {CLUB_ROLE_OPTIONS.map((opt) => ( + + ))} +
+
+
+ + +
+
+
+ )} +
+ ) +} diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js index 8bf5c55..fe5e7b3 100644 --- a/frontend/src/utils/api.js +++ b/frontend/src/utils/api.js @@ -264,6 +264,11 @@ export async function rejectClubJoinRequest(clubId, requestId) { }) } +/** Aggregierter Posteingang: offene Beitrittsanträge für Vereins-/Plattform-Admins. */ +export async function getInboxJoinRequests() { + return request('/api/me/inbox/join-requests') +} + export async function listDivisions(clubId) { const query = clubId ? `?club_id=${clubId}` : '' return request(`/api/divisions${query}`) @@ -1326,6 +1331,7 @@ export const api = { listClubJoinRequests, acceptClubJoinRequest, rejectClubJoinRequest, + getInboxJoinRequests, listDivisions, createDivision, updateDivision, From c46f5f99be75f83e10b5b8d913dfc65799fc7fcf Mon Sep 17 00:00:00 2001 From: Lars Date: Sat, 9 May 2026 10:02:56 +0200 Subject: [PATCH 2/6] 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 (
)} + + {pwdModal ? ( +
+
+

Login-Passwort setzen

+

{pwdModal.label}

+

+ Mindestens 8 Zeichen. Das Passwort liegt dem Nutzer nicht automatisch vor — gib es sicher weiter. +

+
+ + setPwdNew(e.target.value)} + /> +
+
+ + setPwdNew2(e.target.value)} + /> +
+
+ + +
+
+
+ ) : null}
) } diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js index fe5e7b3..0946db3 100644 --- a/frontend/src/utils/api.js +++ b/frontend/src/utils/api.js @@ -139,6 +139,14 @@ export async function updateProfile(profileId, data) { }) } +/** Neues Login-Passwort für anderes Profil (Super-/Portal- oder Vereinsadmin). */ +export async function managementPasswordReset(profileId, newPassword) { + return request(`/api/profiles/${profileId}/management-password-reset`, { + method: 'POST', + body: JSON.stringify({ new_password: newPassword }), + }) +} + export async function changePassword(newPassword) { return request('/api/auth/pin', { method: 'PUT', @@ -1308,6 +1316,7 @@ export const api = { listProfiles, listAdminUsers, updateProfile, + managementPasswordReset, changePassword, verifyEmail, resendVerification, From 624c19dcba09de0a6cb14fa147f13b77010b497b Mon Sep 17 00:00:00 2001 From: Lars Date: Sat, 9 May 2026 10:32:33 +0200 Subject: [PATCH 4/6] feat(auth, profiles, club_memberships): enhance password reset and club admin management - Integrated a new password reset mechanism for user accounts, allowing admins to send reset links via email. - Updated the management password reset functionality to differentiate between direct password setting and email link requests. - Added validation to ensure at least one active club admin remains when modifying club member roles. - Improved the user interface for password management in the admin panel, providing clearer feedback and options for password resets. --- backend/password_reset_mail.py | 68 ++++++++++++++++++ backend/routers/auth.py | 33 ++++----- backend/routers/club_memberships.py | 93 +++++++++++++++++++++--- backend/routers/profiles.py | 87 ++++++++++++++++++---- frontend/src/pages/AdminUsersPage.jsx | 100 ++++++++++++++++++-------- frontend/src/utils/api.js | 13 +++- 6 files changed, 316 insertions(+), 78 deletions(-) create mode 100644 backend/password_reset_mail.py diff --git a/backend/password_reset_mail.py b/backend/password_reset_mail.py new file mode 100644 index 0000000..5d53066 --- /dev/null +++ b/backend/password_reset_mail.py @@ -0,0 +1,68 @@ +"""Gemeinsame Passwort-Link-Erzeugung (Sessions) + Mailtext — wie /auth/forgot-password.""" +from __future__ import annotations + +import os +import secrets +from datetime import datetime, timedelta +from typing import Any + +RESET_TOKEN_PREFIX = "reset_" + + +def public_reset_link(token: str) -> str: + base = (os.getenv("APP_URL") or "https://shinkan.jinkendo.de").rstrip("/") + return f"{base}/reset-password?token={token}" + + +def revoke_pending_password_resets_for_profile(cur: Any, profile_id: int) -> None: + """Entfernt alte Reset-Sessions eines Profils, damit nur der neueste Link aktiv ist.""" + cur.execute( + """ + DELETE FROM sessions + WHERE profile_id = %s AND token LIKE %s + """, + (profile_id, f"{RESET_TOKEN_PREFIX}%"), + ) + + +def insert_password_reset_session(cur: Any, profile_id: int, *, hours_valid: int = 1) -> str: + """ + Legt reset_-Session an. Gibt den Klartext-Token zurück (wie bei forgot-password). + """ + raw = secrets.token_urlsafe(32) + expires = datetime.now() + timedelta(hours=hours_valid) + cur.execute( + """ + INSERT INTO sessions (token, profile_id, expires_at, created_at) + VALUES (%s, %s, %s, CURRENT_TIMESTAMP) + """, + (f"{RESET_TOKEN_PREFIX}{raw}", profile_id, expires.isoformat()), + ) + return raw + + +def password_reset_email_body(*, recipient_name: str | None, token: str, intro: str) -> str: + name = (recipient_name or "").strip() or "Kollege/Kollegin" + link = public_reset_link(token) + return f"""Hallo {name}, + +{intro} + +Neues Passwort setzen: +{link} + +Der Link ist 1 Stunde gültig. Erst wenn du ihn nutzt und ein neues Passwort wählst, wird dein bestehendes +Passwort ersetzt — bis dahin kannst du dich wie gewohnt anmelden. + +Falls du diese Anfrage nicht erwartest, ignoriere diese E-Mail; dein Zugang bleibt unverändert. + +Dein Shinkan Jinkendo Team +""" + + +def issue_password_reset_via_email(cur: Any, send_email_fn, *, profile_id: int, email: str, name: str | None, intro: str) -> bool: + """Session anlegen und Mail schicken (send_email_fn wie routers.auth.send_email).""" + revoke_pending_password_resets_for_profile(cur, profile_id) + raw_token = insert_password_reset_session(cur, profile_id) + body = password_reset_email_body(recipient_name=name, token=raw_token, intro=intro) + return send_email_fn(email, "Passwort-Link – Shinkan Jinkendo", body) diff --git a/backend/routers/auth.py b/backend/routers/auth.py index cb8bcc8..ee08f33 100644 --- a/backend/routers/auth.py +++ b/backend/routers/auth.py @@ -20,6 +20,11 @@ from slowapi.util import get_remote_address from db import get_db, get_cursor from auth import hash_pin, verify_pin, make_token, require_auth from models import LoginRequest, PasswordResetRequest, PasswordResetConfirm, RegisterRequest +from password_reset_mail import ( + insert_password_reset_session, + password_reset_email_body, + revoke_pending_password_resets_for_profile, +) router = APIRouter(prefix="/api/auth", tags=["auth"]) limiter = Limiter(key_func=get_remote_address) @@ -116,28 +121,14 @@ async def password_reset_request(req: PasswordResetRequest, request: Request): if not prof: # Don't reveal if email exists return {"ok": True, "message": "Falls die E-Mail existiert, wurde ein Reset-Link gesendet."} + revoke_pending_password_resets_for_profile(cur, prof["id"]) + raw_token = insert_password_reset_session(cur, prof["id"]) - # Generate reset token - token = secrets.token_urlsafe(32) - expires = datetime.now() + timedelta(hours=1) - - # Store in sessions table (reuse mechanism) - cur.execute("INSERT INTO sessions (token, profile_id, expires_at, created_at) VALUES (%s,%s,%s,CURRENT_TIMESTAMP)", - (f"reset_{token}", prof['id'], expires.isoformat())) - - app_base = (os.getenv("APP_URL") or "https://shinkan.jinkendo.de").rstrip("/") - reset_body = f"""Hallo {prof['name']}, - -Du hast einen Passwort-Reset angefordert. - -Reset-Link: {app_base}/reset-password?token={token} - -Der Link ist 1 Stunde gültig. - -Falls du diese Anfrage nicht gestellt hast, ignoriere diese E-Mail. - -Dein Shinkan Jinkendo Team -""" + reset_body = password_reset_email_body( + recipient_name=prof.get("name"), + token=raw_token, + intro="Du hast einen Passwort-Reset angefordert.", + ) if not send_email(email, "Passwort zurücksetzen – Shinkan Jinkendo", reset_body): print("[SMTP] Passwort-Reset konnte nicht gesendet werden (SMTP prüfen).") diff --git a/backend/routers/club_memberships.py b/backend/routers/club_memberships.py index 9eb6385..91f9f7b 100644 --- a/backend/routers/club_memberships.py +++ b/backend/routers/club_memberships.py @@ -50,6 +50,44 @@ def _club_exists(cur, club_id: int) -> bool: return cur.fetchone() is not None +def _count_other_active_club_admins(cur, club_id: int, exclude_profile_id: int) -> int: + """Aktive Vereinsadmins im Verein, außer exclude_profile_id.""" + cur.execute( + """ + SELECT COUNT(DISTINCT cm.profile_id)::int AS n + FROM club_members cm + INNER JOIN club_member_roles r ON r.club_member_id = cm.id AND r.role_code = 'club_admin' + WHERE cm.club_id = %s AND cm.status = 'active' AND cm.profile_id <> %s + """, + (club_id, exclude_profile_id), + ) + row = cur.fetchone() + try: + return int(row["n"]) if row is not None else 0 + except (KeyError, TypeError, ValueError): + return 0 + + +def _member_is_active_club_admin(cur, club_id: int, profile_id: int) -> bool: + cur.execute( + """ + SELECT 1 + FROM club_members cm + INNER JOIN club_member_roles r ON r.club_member_id = cm.id AND r.role_code = 'club_admin' + WHERE cm.club_id = %s AND cm.profile_id = %s AND cm.status = 'active' + LIMIT 1 + """, + (club_id, profile_id), + ) + return cur.fetchone() is not None + + +_LAST_CLUB_ADMIN_MSG = ( + "Mindestens ein aktiver Vereinsadmin muss im Verein verbleiben. " + "Weise die Rolle zuerst einem anderen Mitglied zu oder aktiviere einen anderen Vereinsadmin." +) + + class ClubMemberUpsert(BaseModel): profile_id: int = Field(..., ge=1) roles: List[str] = Field(default_factory=list, description="Mindestens eine Vereinsrolle") @@ -78,6 +116,7 @@ def list_club_members( f""" SELECT cm.id AS membership_id, cm.profile_id, cm.status, cm.created_at, cm.updated_at, p.email, p.name, COALESCE(p.email_verified, false) AS email_verified, + lower(trim(COALESCE(p.role, 'user'))) AS portal_role, COALESCE( ARRAY_AGG(DISTINCT r.role_code) FILTER (WHERE r.role_code IS NOT NULL), ARRAY[]::varchar[] @@ -86,7 +125,7 @@ def list_club_members( INNER JOIN profiles p ON p.id = cm.profile_id LEFT JOIN club_member_roles r ON r.club_member_id = cm.id WHERE cm.club_id = %s {status_clause} - GROUP BY cm.id, cm.profile_id, cm.status, cm.created_at, cm.updated_at, p.email, p.name, p.email_verified + GROUP BY cm.id, cm.profile_id, cm.status, cm.created_at, cm.updated_at, p.email, p.name, p.email_verified, p.role ORDER BY p.name NULLS LAST, p.email """, (club_id,), @@ -154,6 +193,7 @@ def _one_member(cur, club_id: int, profile_id: int) -> Dict[str, Any]: """ SELECT cm.id AS membership_id, cm.profile_id, cm.status, cm.created_at, cm.updated_at, p.email, p.name, COALESCE(p.email_verified, false) AS email_verified, + lower(trim(COALESCE(p.role, 'user'))) AS portal_role, COALESCE( ARRAY_AGG(DISTINCT r.role_code) FILTER (WHERE r.role_code IS NOT NULL), ARRAY[]::varchar[] @@ -162,7 +202,7 @@ def _one_member(cur, club_id: int, profile_id: int) -> Dict[str, Any]: INNER JOIN profiles p ON p.id = cm.profile_id LEFT JOIN club_member_roles r ON r.club_member_id = cm.id WHERE cm.club_id = %s AND cm.profile_id = %s - GROUP BY cm.id, cm.profile_id, cm.status, cm.created_at, cm.updated_at, p.email, p.name, p.email_verified + GROUP BY cm.id, cm.profile_id, cm.status, cm.created_at, cm.updated_at, p.email, p.name, p.email_verified, p.role """, (club_id, profile_id), ) @@ -217,21 +257,50 @@ def update_club_member( if body.roles is None and body.status is None: return _one_member(cur, club_id, profile_id) + cur.execute( + """ + SELECT cm.status, + COALESCE( + ARRAY_AGG(r.role_code) FILTER (WHERE r.role_code IS NOT NULL), + ARRAY[]::varchar[] + ) AS roles + FROM club_members cm + LEFT JOIN club_member_roles r ON r.club_member_id = cm.id + WHERE cm.id = %s + GROUP BY cm.status + """, + (cm_id,), + ) + cur_row = cur.fetchone() + current_status = (cur_row["status"] or "active").strip().lower() if cur_row else "active" + cr = cur_row.get("roles") if cur_row else [] + if hasattr(cr, "tolist"): + cr = cr.tolist() + current_roles = list(cr) + + new_status = body.status.strip().lower() if body.status is not None else current_status + if body.status is not None and new_status not in _ALLOWED_STATUS: + raise HTTPException(status_code=400, detail="status muss active oder inactive sein") + + new_roles = _normalize_roles(body.roles) if body.roles is not None else current_roles + if not new_roles: + raise HTTPException(status_code=400, detail="Mindestens eine Rolle angeben") + + effective_admin = new_status == "active" and "club_admin" in set(new_roles) + if not effective_admin: + others = _count_other_active_club_admins(cur, club_id, profile_id) + if others < 1: + raise HTTPException(status_code=400, detail=_LAST_CLUB_ADMIN_MSG) + if body.status is not None: - st = body.status.strip().lower() - if st not in _ALLOWED_STATUS: - raise HTTPException(status_code=400, detail="status muss active oder inactive sein") cur.execute( "UPDATE club_members SET status = %s, updated_at = NOW() WHERE id = %s", - (st, cm_id), + (new_status, cm_id), ) if body.roles is not None: - roles = _normalize_roles(body.roles) - if not roles: - raise HTTPException(status_code=400, detail="Mindestens eine Rolle angeben") cur.execute("DELETE FROM club_member_roles WHERE club_member_id = %s", (cm_id,)) - for rc in roles: + for rc in new_roles: cur.execute( """ INSERT INTO club_member_roles (club_member_id, role_code) @@ -258,6 +327,10 @@ def delete_club_member( raise HTTPException(status_code=404, detail="Verein nicht gefunden") _assert_manage(cur, tenant, club_id) + if _member_is_active_club_admin(cur, club_id, profile_id): + if _count_other_active_club_admins(cur, club_id, profile_id) < 1: + raise HTTPException(status_code=400, detail=_LAST_CLUB_ADMIN_MSG) + cur.execute( "DELETE FROM club_members WHERE club_id = %s AND profile_id = %s RETURNING id", (club_id, profile_id), diff --git a/backend/routers/profiles.py b/backend/routers/profiles.py index 51f704d..3c2cbb2 100644 --- a/backend/routers/profiles.py +++ b/backend/routers/profiles.py @@ -11,7 +11,7 @@ from fastapi import APIRouter, HTTPException, Header, Depends from psycopg2.extras import Json -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, field_validator from db import get_db, get_cursor, r2d from auth import require_auth, hash_pin @@ -28,16 +28,35 @@ from models import ProfileCreate, ProfileUpdate router = APIRouter(prefix="/api", tags=["profiles"]) _ALLOWED_PORTAL_ROLES = frozenset({"user", "trainer", "admin", "superadmin"}) +_CLUB_BLOCKED_PORTAL_TARGETS = frozenset({"admin", "superadmin"}) class ManagementPasswordResetBody(BaseModel): - """Von Super-/Portal- oder Vereinsadmin gesetztes neues Login-Passwort.""" + """Optional: Nur Super-Admins dürfen `new_password` setzen — sonst E-Mail-Link (wie Passwort vergessen).""" - new_password: str = Field(..., min_length=8, max_length=128) + new_password: Optional[str] = Field(None, min_length=8, max_length=128) + + @field_validator("new_password", mode="before") + @classmethod + def _empty_pw_none(cls, v): + if v is None: + return None + if isinstance(v, str) and not v.strip(): + return None + return v -def _assert_can_management_password_reset(cur, tenant: TenantContext, target_pid: int) -> None: - """Superadmin / Portal-Admin global; Vereinsadmin nur für aktives Mitglied gemeinsamen Vereins.""" +def _target_portal_role_lower(cur, target_pid: int) -> str: + cur.execute("SELECT lower(trim(COALESCE(role, ''))) AS r FROM profiles WHERE id = %s", (target_pid,)) + row = cur.fetchone() + return (row.get("r") or "user") if row else "user" + + +def _assert_can_management_password_help(cur, tenant: TenantContext, target_pid: int, *, via_email: bool) -> None: + """ + Wer darf eines anderen Accounts Passwort-Helfer nutzen (E-Mail-Link oder — nur Superadmin — direktes Setzen)? + Superadmin / Portal-Admin global; Vereinsadmin nur für aktives Mitglied in gemeinsam verwaltetem Verein. + """ viewer_pid = int(tenant.profile_id) if target_pid == viewer_pid: raise HTTPException(status_code=400, detail="Eigenes Passwort unter Einstellungen ändern") @@ -62,6 +81,11 @@ def _assert_can_management_password_reset(cur, tenant: TenantContext, target_pid status_code=403, detail="Nur für Nutzer, die in mindestens einem deiner Vereine (als Admin) aktiv sind", ) + if via_email and _target_portal_role_lower(cur, target_pid) in _CLUB_BLOCKED_PORTAL_TARGETS: + raise HTTPException( + status_code=403, + detail="Für Konten mit Portal-Administrator- oder Super-Administrator-Rolle ist das nur für Super-Admins möglich", + ) # ── Current User Profile ────────────────────────────────────────────────────── @@ -185,22 +209,59 @@ def management_password_reset( tenant: TenantContext = Depends(get_tenant_context), ): """ - Neues Passwort (PIN-Hash) für ein anderes Profil setzen. - Erlaubt: Superadmin, Portal-Admin, oder Vereinsadmin für Ziel in gemeinsam verwaltetem Verein. + Standard: E-Mail mit Reset-Link wie „Passwort vergessen“ — der PIN-Hash bleibt bis zur Bestätigung unverändert. + Nur Super-Admins können optional `new_password` setzen (Ausnahme). """ + from routers.auth import send_email + from password_reset_mail import issue_password_reset_via_email + try: target = int(pid) except ValueError: raise HTTPException(status_code=400, detail="Ungültige Profil-ID") + + direct = body.new_password is not None + with get_db() as conn: cur = get_cursor(conn) - _assert_can_management_password_reset(cur, tenant, target) - cur.execute("SELECT id FROM profiles WHERE id = %s", (target,)) - if not cur.fetchone(): + cur.execute("SELECT id, email, name FROM profiles WHERE id = %s", (target,)) + row = cur.fetchone() + if not row: raise HTTPException(status_code=404, detail="Profil nicht gefunden") - new_hash = hash_pin(body.new_password) - cur.execute("UPDATE profiles SET pin_hash = %s, updated_at = NOW() WHERE id = %s", (new_hash, target)) - return {"ok": True} + + if direct: + role_raw = tenant.global_role or "" + if not is_superadmin(role_raw): + raise HTTPException( + status_code=403, + detail="Direktes Setzen eines Passworts ist nur Super-Admins vorbehalten. Bitte E-Mail-Link verwenden.", + ) + _assert_can_management_password_help(cur, tenant, target, via_email=False) + new_hash = hash_pin(body.new_password) + cur.execute( + "UPDATE profiles SET pin_hash = %s, updated_at = NOW() WHERE id = %s", + (new_hash, target), + ) + return {"ok": True, "mode": "direct"} + + _assert_can_management_password_help(cur, tenant, target, via_email=True) + email = (row.get("email") or "").strip() + if not email: + raise HTTPException( + status_code=400, + detail="Für dieses Profil ist keine E-Mail-Adresse hinterlegt; ein Reset-Link kann nicht versendet werden.", + ) + name = row.get("name") + intro = "Ein Administrator hat für dein Konto einen sicheren Link zum Setzen eines neuen Passworts angefordert." + sent = issue_password_reset_via_email( + cur, + send_email, + profile_id=target, + email=email.lower(), + name=name, + intro=intro, + ) + return {"ok": True, "mode": "email", "email_sent": bool(sent)} @router.get("/profiles/{pid}") diff --git a/frontend/src/pages/AdminUsersPage.jsx b/frontend/src/pages/AdminUsersPage.jsx index d03139a..b5e269e 100644 --- a/frontend/src/pages/AdminUsersPage.jsx +++ b/frontend/src/pages/AdminUsersPage.jsx @@ -33,6 +33,11 @@ function clubSelectOptions(user, allClubs, isPlatformAdmin) { } /** Plattform-Rollen im UI (Tier/Abo entfällt bis auf Weiteres). */ +function isEscalatedPortalRole(role) { + const r = (role || 'user').toLowerCase() + return r === 'admin' || r === 'superadmin' +} + function portalRoleSelectOptions(viewerIsSuperadmin, currentRole) { const base = [ { value: 'user', label: PORTAL_ROLE_LABEL.user }, @@ -236,8 +241,26 @@ export default function AdminUsersPage() { } } - const submitPasswordReset = async () => { + const submitPasswordEmail = async () => { if (!pwdModal) return + try { + const res = await api.managementPasswordReset(pwdModal.profileId, null) + setPwdModal(null) + setPwdNew('') + setPwdNew2('') + let msg = + 'Sofern eine E-Mail-Adresse hinterlegt ist, wurde ein Link zum Setzen eines neuen Passworts versendet. Das bisherige Passwort bleibt bis zur Bestätigung im Link aktiv.' + if (res?.email_sent === false) { + msg += ' Hinweis: Der E-Mail-Versand ist fehlgeschlagen (SMTP prüfen).' + } + alert(msg) + } catch (e) { + alert(e.message || String(e)) + } + } + + const submitPasswordDirect = async () => { + if (!pwdModal || !isSuperadminViewer) return if (pwdNew.length < 8) { alert('Mindestens 8 Zeichen.') return @@ -251,7 +274,7 @@ export default function AdminUsersPage() { setPwdModal(null) setPwdNew('') setPwdNew2('') - alert('Neues Passwort wurde gesetzt.') + alert('Neues Passwort wurde direkt gesetzt.') } catch (e) { alert(e.message || String(e)) } @@ -377,7 +400,7 @@ export default function AdminUsersPage() { > Bearbeiten - {m.profile_id !== user?.id ? ( + {m.profile_id !== user?.id && !isEscalatedPortalRole(m.portal_role) ? ( ) : null}
@@ -448,7 +471,7 @@ export default function AdminUsersPage() { }) } > - Passwort setzen + Passwort / Link +
+ {isSuperadminViewer ? ( + <> +
+

+ Ausnahme: Passwort direkt setzen (nur bei Bedarf). Das bisherige Passwort ist danach ungültig. +

+
+ + setPwdNew(e.target.value)} + /> +
+
+ + setPwdNew2(e.target.value)} + /> +
+ + + ) : null} +
diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js index 0946db3..cedb03b 100644 --- a/frontend/src/utils/api.js +++ b/frontend/src/utils/api.js @@ -139,11 +139,18 @@ export async function updateProfile(profileId, data) { }) } -/** Neues Login-Passwort für anderes Profil (Super-/Portal- oder Vereinsadmin). */ -export async function managementPasswordReset(profileId, newPassword) { +/** + * Passwort anderer Konten: Standard leerer Body → E-Mail mit Reset-Link (wie Passwort vergessen). + * Nur Super-Admins dürfen `newPassword` setzen (direktes Überschreiben des Passwort-Hashes). + */ +export async function managementPasswordReset(profileId, newPassword = null) { + const body = {} + if (newPassword != null && String(newPassword).trim() !== '') { + body.new_password = newPassword + } return request(`/api/profiles/${profileId}/management-password-reset`, { method: 'POST', - body: JSON.stringify({ new_password: newPassword }), + body: JSON.stringify(body), }) } From 24c70c5ea03eca363c2d6ec4b1ad31a27c835a67 Mon Sep 17 00:00:00 2001 From: Lars Date: Sat, 9 May 2026 10:42:56 +0200 Subject: [PATCH 5/6] feat(memberships, profiles, clubs): enhance active club membership handling - Introduced a new utility function to filter and return only active club memberships, improving role management and access control. - Updated various components and pages to utilize the new active club memberships function, ensuring only relevant memberships are considered. - Enhanced user interface elements to reflect the status of club memberships, including visual indicators for inactive memberships. - Improved backend logic for resolving tenant contexts and managing club roles based on active memberships. --- backend/club_tenancy.py | 7 +- backend/routers/profiles.py | 4 +- backend/tenant_context.py | 30 ++++++-- backend/tests/test_access_layer.py | 34 +++++++++ frontend/src/App.jsx | 5 +- .../src/components/ActiveClubSwitcher.jsx | 4 +- .../components/InactiveMembershipBanner.jsx | 39 ++++++++++ frontend/src/components/Navigation.jsx | 4 +- .../TrainingPlanExerciseVisibilityPanel.jsx | 3 +- frontend/src/context/AuthContext.jsx | 9 ++- frontend/src/context/OrgInboxContext.jsx | 3 +- frontend/src/pages/AccountSettingsPage.jsx | 23 ++++-- frontend/src/pages/AdminUsersPage.jsx | 76 ++++++++++++++++--- frontend/src/pages/ClubsPage.jsx | 14 ++-- frontend/src/pages/ExercisesListPage.jsx | 5 +- frontend/src/pages/MediaLibraryPage.jsx | 3 +- frontend/src/pages/TrainingPlanningPage.jsx | 5 +- frontend/src/utils/activeClub.js | 11 ++- frontend/src/utils/exercisePermissions.js | 6 +- 19 files changed, 238 insertions(+), 47 deletions(-) create mode 100644 frontend/src/components/InactiveMembershipBanner.jsx diff --git a/backend/club_tenancy.py b/backend/club_tenancy.py index d665dd4..ee9ee04 100644 --- a/backend/club_tenancy.py +++ b/backend/club_tenancy.py @@ -91,7 +91,12 @@ def can_manage_club_org(cur, profile_id: int, club_id: int, global_role: Optiona def can_plan_in_club(cur, profile_id: int, club_id: int, global_role: Optional[str]) -> bool: - """Trainingsgruppen anlegen / planen: Admin-Rollen im Verein oder Plattform.""" + """Trainingsgruppe anlegen u.Ä.; Vereins-rollentrainer, Content-Editor, Spartenleitung … + + Hinweis: ``content_editor`` ist derzeit zusammen mit ``trainer``/``division_lead`` in diesem + gemeinsamen Strang gebündelt — u.a. Vereinsübungen bearbeiten (s. exercises) und + Trainingsgruppen unter ``clubs``. Es gibt noch keine eigene Nur-Content-Guard pro Endpunkt. + """ if is_platform_admin(global_role): return True return has_club_role( diff --git a/backend/routers/profiles.py b/backend/routers/profiles.py index 3c2cbb2..3785b84 100644 --- a/backend/routers/profiles.py +++ b/backend/routers/profiles.py @@ -94,7 +94,7 @@ def get_current_profile( session=Depends(require_auth), x_active_club_id: Optional[str] = Header(default=None, alias="X-Active-Club-Id"), ): - """Profil inkl. Vereinsmitgliedschaften; effective_club_id = aufgelöster Request-Kontext (Header vor Profilfeld).""" + """Profil inkl. Vereinsmitgliedschaften (aktive und temporär deaktivierte Zugänge); effective_club_id nur bei aktivem Vereinszugang.""" profile_id = session["profile_id"] with get_db() as conn: cur = get_cursor(conn) @@ -104,7 +104,7 @@ def get_current_profile( raise HTTPException(404, "Profil nicht gefunden") data = r2d(row) data.pop("pin_hash", None) - clubs = memberships_with_roles(cur, profile_id) + clubs = memberships_with_roles(cur, profile_id, active_only=False) data["clubs"] = clubs ac_raw = data.get("active_club_id") stored_ac = int(ac_raw) if ac_raw is not None and ac_raw != "" else None diff --git a/backend/tenant_context.py b/backend/tenant_context.py index 5bb970a..2327dc4 100644 --- a/backend/tenant_context.py +++ b/backend/tenant_context.py @@ -21,6 +21,22 @@ def _club_exists(cur, club_id: int) -> bool: return cur.fetchone() is not None +def memberships_for_tenant_resolution( + memberships: List[Dict[str, Any]], +) -> List[Dict[str, Any]]: + """ + Nur Zeilen mit aktivem Vereinszugang (cm.status = 'active'). + Wird genutzt, wenn /profiles/me alle Mitgliedschaften inkl. inaktiver liefert. + """ + out: List[Dict[str, Any]] = [] + for r in memberships: + st_raw = r.get("membership_status") + st = str(st_raw if st_raw is not None else "active").strip().lower() + if st == "active": + out.append(r) + return out + + def parse_active_club_header(raw: Optional[str]) -> Optional[int]: """Parst X-Active-Club-Id; leer → None. Ungültig → HTTP 400.""" if raw is None: @@ -97,7 +113,9 @@ def resolve_tenant_context( invalid_header_policy: str = "reject", ) -> TenantContext: """ - Mitgliedschaften: wenn nicht übergeben, wird aus der DB geladen (aktive Mitgliedschaften). + Mitgliedschaften: wenn nicht übergeben, lädt ``active_only=True`` aus der DB. + Übergabe z. B. von ``/profiles/me``: Liste darf auch **deaktivierte** Vereinszugänge + (`membership_status` = inactive) enthalten — für ``club_ids`` und Mandantenwahl werden nur aktive verwendet. Auflösung effective_club_id: - Plattform-Admin: Header setzt beliebigen existierenden Verein; ohne Header → gespeichertes @@ -110,9 +128,11 @@ def resolve_tenant_context( header_cid = parse_active_club_header(header_raw) if memberships is None: - memberships = memberships_with_roles(cur, profile_id, active_only=True) + membership_rows = memberships_with_roles(cur, profile_id, active_only=True) + else: + membership_rows = memberships_for_tenant_resolution(memberships) - club_ids = frozenset(int(r["id"]) for r in memberships if r.get("id") is not None) + club_ids = frozenset(int(r["id"]) for r in membership_rows if r.get("id") is not None) if is_platform_admin(role_lc): if header_cid is not None: @@ -131,7 +151,7 @@ def resolve_tenant_context( global_role=role_lc, effective_club_id=effective, club_ids=club_ids, - memberships=memberships, + memberships=membership_rows, ) chosen_header = header_cid @@ -159,7 +179,7 @@ def resolve_tenant_context( global_role=role_lc, effective_club_id=effective, club_ids=club_ids, - memberships=memberships, + memberships=membership_rows, ) diff --git a/backend/tests/test_access_layer.py b/backend/tests/test_access_layer.py index ba8a628..825cee9 100644 --- a/backend/tests/test_access_layer.py +++ b/backend/tests/test_access_layer.py @@ -125,3 +125,37 @@ def test_resolve_platform_admin_no_header_stored_invalid(monkeypatch): stored_active_club_id=123, ) assert ctx.effective_club_id is None + + +def test_resolve_trainer_club_ids_excludes_inactive_memberships(): + """Nur aktive Vereinszugänge zählen für Mandant / Header-Validierung.""" + cur = object() + ctx = resolve_tenant_context( + cur, + profile_id=9, + global_role="user", + header_raw=None, + memberships=[ + {"id": 10, "membership_status": "inactive"}, + {"id": 20, "membership_status": "active"}, + ], + stored_active_club_id=None, + invalid_header_policy="ignore", + ) + assert ctx.club_ids == frozenset({20}) + assert ctx.effective_club_id == 20 + + +def test_resolve_all_memberships_inactive_no_effective_club(): + cur = object() + ctx = resolve_tenant_context( + cur, + profile_id=9, + global_role="user", + header_raw=None, + memberships=[{"id": 10, "membership_status": "inactive"}], + stored_active_club_id=10, + invalid_header_policy="ignore", + ) + assert ctx.club_ids == frozenset() + assert ctx.effective_club_id is None diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 091487c..d7e7841 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -38,13 +38,15 @@ import AdminHomeRedirect from './components/AdminHomeRedirect' import PlatformAdminRoute from './components/PlatformAdminRoute' import MediaLibraryPage from './pages/MediaLibraryPage' import ActiveClubSwitcher from './components/ActiveClubSwitcher' +import InactiveMembershipBanner from './components/InactiveMembershipBanner' +import { activeClubMemberships } from './utils/activeClub' 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')) + return activeClubMemberships(currentUser?.clubs).some((c) => (c.roles || []).includes('club_admin')) } // Bottom Navigation (Mobile) @@ -126,6 +128,7 @@ function ProtectedLayout() {
+