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() {
+
diff --git a/frontend/src/components/ActiveClubSwitcher.jsx b/frontend/src/components/ActiveClubSwitcher.jsx
index 489eb24..867729b 100644
--- a/frontend/src/components/ActiveClubSwitcher.jsx
+++ b/frontend/src/components/ActiveClubSwitcher.jsx
@@ -1,5 +1,5 @@
import { useAuth } from '../context/AuthContext'
-import { getResolvedActiveClubIdForUi } from '../utils/activeClub'
+import { activeClubMemberships, getResolvedActiveClubIdForUi } from '../utils/activeClub'
/**
* Zeigt einen Vereins-Umschalter, wenn der Nutzer mehreren Vereinen zugeordnet ist.
@@ -7,7 +7,7 @@ import { getResolvedActiveClubIdForUi } from '../utils/activeClub'
*/
export default function ActiveClubSwitcher({ variant = 'sidebar' }) {
const { user, setActiveClub } = useAuth()
- const clubs = user?.clubs || []
+ const clubs = activeClubMemberships(user?.clubs)
if (clubs.length <= 1) return null
const selectClubId = getResolvedActiveClubIdForUi(user)
diff --git a/frontend/src/components/InactiveMembershipBanner.jsx b/frontend/src/components/InactiveMembershipBanner.jsx
new file mode 100644
index 0000000..0039749
--- /dev/null
+++ b/frontend/src/components/InactiveMembershipBanner.jsx
@@ -0,0 +1,39 @@
+import { useAuth } from '../context/AuthContext'
+
+/**
+ * Hinweis, wenn der Vereinszugang (Mitgliedschaft) deaktiviert wurde — Login bleibt möglich.
+ */
+export default function InactiveMembershipBanner() {
+ const { user } = useAuth()
+ const inactive = (user?.clubs || []).filter(
+ (c) => (c.membership_status || '').toString().trim().toLowerCase() === 'inactive'
+ )
+ if (!inactive.length) return null
+
+ const names = inactive.map((c) => c.name || `Verein #${c.id}`).join(', ')
+
+ return (
+
+ Vereinszugang vorübergehend deaktiviert
+
+ Für {inactive.length === 1 ? 'den Verein' : 'die Vereine'}{' '}
+ {names}{' '}
+ ist der Zugang zu Vereinsinhalten ausgesetzt — du kannst dich weiterhin anmelden und z. B.
+ öffentliche Inhalte oder andere Vereine nutzen. Bei Fragen wende dich an eine:n Vereinsadministrator:in.
+
+
+ )
+ })}
>
) : (
'—'
diff --git a/frontend/src/pages/AdminUsersPage.jsx b/frontend/src/pages/AdminUsersPage.jsx
index b5e269e..22794ed 100644
--- a/frontend/src/pages/AdminUsersPage.jsx
+++ b/frontend/src/pages/AdminUsersPage.jsx
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react'
import { Navigate } from 'react-router-dom'
import { useAuth } from '../context/AuthContext'
import api from '../utils/api'
+import { activeClubMemberships } from '../utils/activeClub'
import AdminPageNav from '../components/AdminPageNav'
const CLUB_ROLE_OPTIONS = [
@@ -19,7 +20,7 @@ const PORTAL_ROLE_LABEL = {
}
function clubAdminClubIds(user) {
- return (user?.clubs || [])
+ return activeClubMemberships(user?.clubs)
.filter((c) => Array.isArray(c.roles) && c.roles.includes('club_admin'))
.map((c) => c.id)
}
@@ -206,6 +207,29 @@ export default function AdminUsersPage() {
}
}
+ const toggleMemberClubAccess = async (m, activate) => {
+ if (!selectedClubId) return
+ const st = activate ? 'active' : 'inactive'
+ if (
+ !activate &&
+ !confirm(
+ `Vereinszugang für „${m.name || m.email || '#' + m.profile_id}“ in ${selectedClubLabel} deaktivieren? ` +
+ 'Die Person bleibt anmeldbar, sieht aber keine Inhalte dieses Vereins mehr (Login bleibt unverändert).'
+ )
+ ) {
+ return
+ }
+ try {
+ await api.updateClubMember(selectedClubId, m.profile_id, {
+ roles: [...(m.roles || [])],
+ status: st,
+ })
+ await reloadClubMembers()
+ } catch (e) {
+ alert(e.message || String(e))
+ }
+ }
+
const removeClubMembership = async () => {
if (!clubEditModal) return
if (!confirm('Mitgliedschaft in diesem Verein wirklich entfernen?')) return
@@ -288,8 +312,9 @@ export default function AdminUsersPage() {
{clubOrgMode ? (
- Du verwaltest nur Mitglieder des ausgewählten Vereins und deren Rollen in diesem Verein.
- Portal-Rollen und andere Vereine sind hier nicht sichtbar.
+ Du verwaltest Mitglieder des ausgewählten Vereins. Vereinszugang deaktivieren sperrt nur die
+ Sicht auf Vereinsinhalte — der Login des Nutzers bleibt möglich. Wiederherstellen über „aktivieren“ oder
+ Bearbeiten.