From 24c70c5ea03eca363c2d6ec4b1ad31a27c835a67 Mon Sep 17 00:00:00 2001 From: Lars Date: Sat, 9 May 2026 10:42:56 +0200 Subject: [PATCH] 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() {
+