feat(memberships, profiles, clubs): enhance active club membership handling
All checks were successful
Deploy Development / deploy (push) Successful in 40s
Test Suite / pytest-backend (push) Successful in 25s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 7s
Test Suite / playwright-tests (push) Successful in 23s

- 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.
This commit is contained in:
Lars 2026-05-09 10:42:56 +02:00
parent 624c19dcba
commit 24c70c5ea0
19 changed files with 238 additions and 47 deletions

View File

@ -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: 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): if is_platform_admin(global_role):
return True return True
return has_club_role( return has_club_role(

View File

@ -94,7 +94,7 @@ def get_current_profile(
session=Depends(require_auth), session=Depends(require_auth),
x_active_club_id: Optional[str] = Header(default=None, alias="X-Active-Club-Id"), 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"] profile_id = session["profile_id"]
with get_db() as conn: with get_db() as conn:
cur = get_cursor(conn) cur = get_cursor(conn)
@ -104,7 +104,7 @@ def get_current_profile(
raise HTTPException(404, "Profil nicht gefunden") raise HTTPException(404, "Profil nicht gefunden")
data = r2d(row) data = r2d(row)
data.pop("pin_hash", None) 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 data["clubs"] = clubs
ac_raw = data.get("active_club_id") ac_raw = data.get("active_club_id")
stored_ac = int(ac_raw) if ac_raw is not None and ac_raw != "" else None stored_ac = int(ac_raw) if ac_raw is not None and ac_raw != "" else None

View File

@ -21,6 +21,22 @@ def _club_exists(cur, club_id: int) -> bool:
return cur.fetchone() is not None 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]: def parse_active_club_header(raw: Optional[str]) -> Optional[int]:
"""Parst X-Active-Club-Id; leer → None. Ungültig → HTTP 400.""" """Parst X-Active-Club-Id; leer → None. Ungültig → HTTP 400."""
if raw is None: if raw is None:
@ -97,7 +113,9 @@ def resolve_tenant_context(
invalid_header_policy: str = "reject", invalid_header_policy: str = "reject",
) -> TenantContext: ) -> 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: Auflösung effective_club_id:
- Plattform-Admin: Header setzt beliebigen existierenden Verein; ohne Header gespeichertes - 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) header_cid = parse_active_club_header(header_raw)
if memberships is None: 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 is_platform_admin(role_lc):
if header_cid is not None: if header_cid is not None:
@ -131,7 +151,7 @@ def resolve_tenant_context(
global_role=role_lc, global_role=role_lc,
effective_club_id=effective, effective_club_id=effective,
club_ids=club_ids, club_ids=club_ids,
memberships=memberships, memberships=membership_rows,
) )
chosen_header = header_cid chosen_header = header_cid
@ -159,7 +179,7 @@ def resolve_tenant_context(
global_role=role_lc, global_role=role_lc,
effective_club_id=effective, effective_club_id=effective,
club_ids=club_ids, club_ids=club_ids,
memberships=memberships, memberships=membership_rows,
) )

View File

@ -125,3 +125,37 @@ def test_resolve_platform_admin_no_header_stored_invalid(monkeypatch):
stored_active_club_id=123, stored_active_club_id=123,
) )
assert ctx.effective_club_id is None 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

View File

@ -38,13 +38,15 @@ import AdminHomeRedirect from './components/AdminHomeRedirect'
import PlatformAdminRoute from './components/PlatformAdminRoute' 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 InactiveMembershipBanner from './components/InactiveMembershipBanner'
import { activeClubMemberships } from './utils/activeClub'
import './app.css' import './app.css'
/** Shield-„Admin“: Portal-Admins oder Vereinsorganisation (Zugriff mindestens /admin/users). */ /** Shield-„Admin“: Portal-Admins oder Vereinsorganisation (Zugriff mindestens /admin/users). */
function computeShowAdminNav(currentUser) { function computeShowAdminNav(currentUser) {
const plat = currentUser?.role === 'admin' || currentUser?.role === 'superadmin' const plat = currentUser?.role === 'admin' || currentUser?.role === 'superadmin'
if (plat) return true 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) // Bottom Navigation (Mobile)
@ -126,6 +128,7 @@ function ProtectedLayout() {
<ActiveClubSwitcher variant="mobile" /> <ActiveClubSwitcher variant="mobile" />
</div> </div>
<div className="app-main"> <div className="app-main">
<InactiveMembershipBanner />
<Outlet /> <Outlet />
</div> </div>
<Nav showAdminNav={showAdminNav} /> <Nav showAdminNav={showAdminNav} />

View File

@ -1,5 +1,5 @@
import { useAuth } from '../context/AuthContext' 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. * 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' }) { export default function ActiveClubSwitcher({ variant = 'sidebar' }) {
const { user, setActiveClub } = useAuth() const { user, setActiveClub } = useAuth()
const clubs = user?.clubs || [] const clubs = activeClubMemberships(user?.clubs)
if (clubs.length <= 1) return null if (clubs.length <= 1) return null
const selectClubId = getResolvedActiveClubIdForUi(user) const selectClubId = getResolvedActiveClubIdForUi(user)

View File

@ -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 (
<div
role="status"
className="inactive-membership-banner"
style={{
marginBottom: '0.75rem',
padding: '0.65rem 0.85rem',
borderRadius: '8px',
background: 'var(--surface2, #2a2a2a)',
border: '1px solid color-mix(in srgb, var(--warning, #d4a012) 45%, transparent)',
color: 'var(--text1)',
fontSize: '0.88rem',
lineHeight: 1.45,
}}
>
<strong>Vereinszugang vorübergehend deaktiviert</strong>
<span style={{ display: 'block', marginTop: '0.35rem' }}>
Für {inactive.length === 1 ? 'den Verein' : 'die Vereine'}{' '}
<strong>{names}</strong>{' '}
ist der Zugang zu Vereinsinhalten ausgesetzt du kannst dich weiterhin anmelden und z.&nbsp;B.
öffentliche Inhalte oder andere Vereine nutzen. Bei Fragen wende dich an eine:n Vereinsadministrator:in.
</span>
</div>
)
}

View File

@ -1,13 +1,13 @@
import { Link, useLocation, useNavigate } from 'react-router-dom' import { Link, useLocation, useNavigate } from 'react-router-dom'
import { useAuth } from '../context/AuthContext' import { useAuth } from '../context/AuthContext'
import { getResolvedActiveClubIdForUi } from '../utils/activeClub' import { getResolvedActiveClubIdForUi, activeClubMemberships } from '../utils/activeClub'
function Navigation() { function Navigation() {
const location = useLocation() const location = useLocation()
const navigate = useNavigate() const navigate = useNavigate()
const { user, logout, setActiveClub } = useAuth() const { user, logout, setActiveClub } = useAuth()
const clubs = user?.clubs || [] const clubs = activeClubMemberships(user?.clubs)
const selectClubId = getResolvedActiveClubIdForUi(user) const selectClubId = getResolvedActiveClubIdForUi(user)
const handleLogout = async () => { const handleLogout = async () => {

View File

@ -1,6 +1,7 @@
import React, { useCallback, useMemo, useState } from 'react' import React, { useCallback, useMemo, useState } from 'react'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import api from '../utils/api' import api from '../utils/api'
import { activeClubMemberships } from '../utils/activeClub'
const VIS_LABELS = { const VIS_LABELS = {
private: 'Privat', private: 'Privat',
@ -48,7 +49,7 @@ function userMayPromote(user, targetClubId, createdBy) {
const role = String(user.role || '').toLowerCase() const role = String(user.role || '').toLowerCase()
if (role === 'admin' || role === 'superadmin') return true if (role === 'admin' || role === 'superadmin') return true
if (createdBy != null && Number(createdBy) === Number(user.id)) return true if (createdBy != null && Number(createdBy) === Number(user.id)) return true
const row = (user.clubs || []).find((c) => Number(c.id) === Number(targetClubId)) const row = activeClubMemberships(user?.clubs).find((c) => Number(c.id) === Number(targetClubId))
if (!row || !Array.isArray(row.roles)) return false if (!row || !Array.isArray(row.roles)) return false
return row.roles.includes('club_admin') return row.roles.includes('club_admin')
} }

View File

@ -1,10 +1,11 @@
import { createContext, useContext, useState, useEffect, useCallback, useRef } from 'react' import { createContext, useContext, useState, useEffect, useCallback, useRef } from 'react'
import api, { ACTIVE_CLUB_STORAGE_KEY } from '../utils/api' import api, { ACTIVE_CLUB_STORAGE_KEY } from '../utils/api'
import { activeClubMemberships } from '../utils/activeClub'
const AuthContext = createContext(null) const AuthContext = createContext(null)
function syncStoredActiveClub(profile) { function syncStoredActiveClub(profile) {
const clubs = profile?.clubs || [] const clubs = activeClubMemberships(profile?.clubs)
const ids = new Set(clubs.map((c) => String(c.id))) const ids = new Set(clubs.map((c) => String(c.id)))
const eff = profile?.effective_club_id const eff = profile?.effective_club_id
if (eff != null && eff !== '' && ids.has(String(eff))) { if (eff != null && eff !== '' && ids.has(String(eff))) {
@ -24,6 +25,12 @@ function syncStoredActiveClub(profile) {
} }
if (clubs.length >= 1) { if (clubs.length >= 1) {
localStorage.setItem(ACTIVE_CLUB_STORAGE_KEY, String(clubs[0].id)) localStorage.setItem(ACTIVE_CLUB_STORAGE_KEY, String(clubs[0].id))
return
}
try {
localStorage.removeItem(ACTIVE_CLUB_STORAGE_KEY)
} catch {
/* ignore */
} }
} }

View File

@ -7,13 +7,14 @@ import {
useState, useState,
} from 'react' } from 'react'
import api from '../utils/api' import api from '../utils/api'
import { activeClubMemberships } from '../utils/activeClub'
const OrgInboxContext = createContext(null) const OrgInboxContext = createContext(null)
export function canAccessOrgInbox(user) { export function canAccessOrgInbox(user) {
if (!user?.id) return false if (!user?.id) return false
if (user.role === 'admin' || user.role === 'superadmin') return true if (user.role === 'admin' || user.role === 'superadmin') return true
return (user.clubs || []).some((c) => (c.roles || []).includes('club_admin')) return activeClubMemberships(user.clubs).some((c) => (c.roles || []).includes('club_admin'))
} }
/** Nach Annahme/Ablehnung: Posteingang-Badges & Widget aktualisieren */ /** Nach Annahme/Ablehnung: Posteingang-Badges & Widget aktualisieren */

View File

@ -252,13 +252,22 @@ function AccountSettingsPage() {
<span style={{ lineHeight: 1.45 }}> <span style={{ lineHeight: 1.45 }}>
{user?.clubs?.length ? ( {user?.clubs?.length ? (
<> <>
{user.clubs.map((c) => ( {user.clubs.map((c) => {
<div key={c.id}> const mem = (c.membership_status || 'active').toString().trim().toLowerCase()
<strong style={{ color: 'var(--text1)' }}>{c.name}</strong> const inactive = mem === 'inactive'
{': '} return (
{(c.roles || []).length ? (c.roles || []).join(', ') : '—'} <div key={c.id}>
</div> <strong style={{ color: 'var(--text1)' }}>{c.name}</strong>
))} {inactive ? (
<span style={{ color: 'var(--warning, #d4a012)', marginLeft: '0.35rem', fontSize: '0.82rem' }}>
(Vereinszugang deaktiviert)
</span>
) : null}
{': '}
{(c.roles || []).length ? (c.roles || []).join(', ') : '—'}
</div>
)
})}
</> </>
) : ( ) : (
'—' '—'

View File

@ -2,6 +2,7 @@ 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'
import { activeClubMemberships } from '../utils/activeClub'
import AdminPageNav from '../components/AdminPageNav' import AdminPageNav from '../components/AdminPageNav'
const CLUB_ROLE_OPTIONS = [ const CLUB_ROLE_OPTIONS = [
@ -19,7 +20,7 @@ const PORTAL_ROLE_LABEL = {
} }
function clubAdminClubIds(user) { function clubAdminClubIds(user) {
return (user?.clubs || []) return activeClubMemberships(user?.clubs)
.filter((c) => Array.isArray(c.roles) && c.roles.includes('club_admin')) .filter((c) => Array.isArray(c.roles) && c.roles.includes('club_admin'))
.map((c) => c.id) .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 () => { const removeClubMembership = async () => {
if (!clubEditModal) return if (!clubEditModal) return
if (!confirm('Mitgliedschaft in diesem Verein wirklich entfernen?')) return if (!confirm('Mitgliedschaft in diesem Verein wirklich entfernen?')) return
@ -288,8 +312,9 @@ export default function AdminUsersPage() {
{clubOrgMode ? ( {clubOrgMode ? (
<p style={{ color: 'var(--text2)', maxWidth: '52rem', lineHeight: 1.55, marginBottom: '1.25rem' }}> <p style={{ color: 'var(--text2)', maxWidth: '52rem', lineHeight: 1.55, marginBottom: '1.25rem' }}>
Du verwaltest nur Mitglieder des ausgewählten Vereins und deren <strong>Rollen in diesem Verein</strong>. Du verwaltest Mitglieder des ausgewählten Vereins. <strong>Vereinszugang deaktivieren</strong> sperrt nur die
Portal-Rollen und andere Vereine sind hier nicht sichtbar. Sicht auf Vereinsinhalte der Login des Nutzers bleibt möglich. Wiederherstellen über aktivieren oder
Bearbeiten.
</p> </p>
) : ( ) : (
<> <>
@ -368,7 +393,9 @@ export default function AdminUsersPage() {
{!clubMembers.length ? ( {!clubMembers.length ? (
<p className="muted">Keine Mitglieder in diesem Verein.</p> <p className="muted">Keine Mitglieder in diesem Verein.</p>
) : ( ) : (
clubMembers.map((m) => ( clubMembers.map((m) => {
const memStatus = (m.status || 'active').toLowerCase()
return (
<div key={m.membership_id} className="card"> <div key={m.membership_id} className="card">
<div style={{ display: 'flex', justifyContent: 'space-between', flexWrap: 'wrap', gap: '0.75rem' }}> <div style={{ display: 'flex', justifyContent: 'space-between', flexWrap: 'wrap', gap: '0.75rem' }}>
<div> <div>
@ -377,7 +404,12 @@ export default function AdminUsersPage() {
</strong> </strong>
<div style={{ fontSize: '0.875rem', color: 'var(--text2)' }}>{m.email || '—'}</div> <div style={{ fontSize: '0.875rem', color: 'var(--text2)' }}>{m.email || '—'}</div>
<div style={{ fontSize: '0.78rem', color: 'var(--text3)', marginTop: '0.35rem' }}> <div style={{ fontSize: '0.78rem', color: 'var(--text3)', marginTop: '0.35rem' }}>
Status: {m.status} · Verifiziert: {m.email_verified ? 'ja' : 'nein'} Vereinszugang:{' '}
<strong style={{ color: memStatus === 'active' ? 'var(--text1)' : 'var(--warning, #d4a012)' }}>
{memStatus === 'active' ? 'aktiv' : 'deaktiviert'}
</strong>
{' '}
· Verifiziert: {m.email_verified ? 'ja' : 'nein'}
</div> </div>
<div style={{ fontSize: '0.82rem', marginTop: '0.35rem' }}> <div style={{ fontSize: '0.82rem', marginTop: '0.35rem' }}>
Rollen: {(m.roles || []).join(', ') || '—'} Rollen: {(m.roles || []).join(', ') || '—'}
@ -394,12 +426,31 @@ export default function AdminUsersPage() {
profileId: m.profile_id, profileId: m.profile_id,
profileLabel: m.name || m.email, profileLabel: m.name || m.email,
roles: [...(m.roles || [])], roles: [...(m.roles || [])],
status: (m.status || 'active').toLowerCase(), status: memStatus,
}) })
} }
> >
Bearbeiten Bearbeiten
</button> </button>
{m.profile_id !== user?.id ? (
memStatus === 'inactive' ? (
<button
type="button"
className="btn btn-primary"
onClick={() => toggleMemberClubAccess(m, true)}
>
Vereinszugang aktivieren
</button>
) : (
<button
type="button"
className="btn btn-secondary"
onClick={() => toggleMemberClubAccess(m, false)}
>
Vereinszugang deaktivieren
</button>
)
) : null}
{m.profile_id !== user?.id && !isEscalatedPortalRole(m.portal_role) ? ( {m.profile_id !== user?.id && !isEscalatedPortalRole(m.portal_role) ? (
<button <button
type="button" type="button"
@ -417,7 +468,8 @@ export default function AdminUsersPage() {
</div> </div>
</div> </div>
</div> </div>
)) )
})
)} )}
</div> </div>
) : ( ) : (
@ -721,8 +773,12 @@ export default function AdminUsersPage() {
<p style={{ color: 'var(--text2)', fontSize: '0.9rem' }}> <p style={{ color: 'var(--text2)', fontSize: '0.9rem' }}>
{clubEditModal.profileLabel} {clubEditModal.clubName} {clubEditModal.profileLabel} {clubEditModal.clubName}
</p> </p>
<p className="muted" style={{ fontSize: '0.82rem', lineHeight: 1.45 }}>
Deaktiviert betrifft nur den Zugriff auf Inhalte dieses Vereins; Login und andere Vereine bleiben
unberührt.
</p>
<div className="form-row"> <div className="form-row">
<label className="form-label">Status</label> <label className="form-label">Vereinszugang</label>
<select <select
className="form-input" className="form-input"
value={clubEditModal.status} value={clubEditModal.status}
@ -730,8 +786,8 @@ export default function AdminUsersPage() {
setClubEditModal((prev) => (prev ? { ...prev, status: e.target.value } : prev)) setClubEditModal((prev) => (prev ? { ...prev, status: e.target.value } : prev))
} }
> >
<option value="active">aktiv</option> <option value="active">aktiv sieht Vereinsinhalte</option>
<option value="inactive">inaktiv</option> <option value="inactive">deaktiviert kein Zugriff auf Vereinsinhalte</option>
</select> </select>
</div> </div>
<div className="form-row"> <div className="form-row">

View File

@ -2,6 +2,7 @@ import React, { useState, useEffect, useMemo } from 'react'
import api from '../utils/api' import api from '../utils/api'
import { notifyOrgInboxChanged } from '../context/OrgInboxContext' import { notifyOrgInboxChanged } from '../context/OrgInboxContext'
import { useAuth } from '../context/AuthContext' import { useAuth } from '../context/AuthContext'
import { activeClubMemberships } from '../utils/activeClub'
import PageSectionNav from '../components/PageSectionNav' import PageSectionNav from '../components/PageSectionNav'
const CLUB_ROLE_OPTIONS = [ const CLUB_ROLE_OPTIONS = [
@ -41,7 +42,7 @@ function ClubsPage() {
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 clubAdminClubIds = new Set( const clubAdminClubIds = new Set(
(user?.clubs || []) activeClubMemberships(user?.clubs)
.filter((c) => (c.roles || []).includes('club_admin')) .filter((c) => (c.roles || []).includes('club_admin'))
.map((c) => c.id) .map((c) => c.id)
) )
@ -49,7 +50,7 @@ function ClubsPage() {
const canCreateClub = isPlatformAdmin const canCreateClub = isPlatformAdmin
const canManageOrgSomewhere = isPlatformAdmin || clubAdminClubIds.size > 0 const canManageOrgSomewhere = isPlatformAdmin || clubAdminClubIds.size > 0
const canCreateTrainingGroup = const canCreateTrainingGroup =
isPlatformAdmin || (Array.isArray(user?.clubs) && user.clubs.length > 0) isPlatformAdmin || activeClubMemberships(user?.clubs).length > 0
const canEditGroup = (g) => const canEditGroup = (g) =>
isPlatformAdmin || isPlatformAdmin ||
@ -1310,7 +1311,7 @@ function ClubsPage() {
{editMemberModal.name || editMemberModal.email} (#{editMemberModal.profile_id}) {editMemberModal.name || editMemberModal.email} (#{editMemberModal.profile_id})
</p> </p>
<div className="form-row"> <div className="form-row">
<label className="form-label">Status</label> <label className="form-label">Vereinszugang</label>
<select <select
className="form-input" className="form-input"
value={editMemberModal.status || 'active'} value={editMemberModal.status || 'active'}
@ -1318,10 +1319,13 @@ function ClubsPage() {
setEditMemberModal((prev) => (prev ? { ...prev, status: e.target.value } : prev)) setEditMemberModal((prev) => (prev ? { ...prev, status: e.target.value } : prev))
} }
> >
<option value="active">aktiv</option> <option value="active">aktiv sieht Vereinsinhalte</option>
<option value="inactive">inaktiv</option> <option value="inactive">deaktiviert weiter anmeldbar, keine Vereinsinhalte</option>
</select> </select>
</div> </div>
<p className="muted" style={{ fontSize: '0.82rem', lineHeight: 1.45 }}>
Deaktivierung gilt nur für diesen Verein; der Login-Account bleibt aktiv.
</p>
<div className="form-row"> <div className="form-row">
<span className="form-label">Rollen</span> <span className="form-label">Rollen</span>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem' }}> <div style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem' }}>

View File

@ -14,6 +14,7 @@ import {
} from 'lucide-react' } from 'lucide-react'
import api from '../utils/api' import api from '../utils/api'
import { useAuth } from '../context/AuthContext' import { useAuth } from '../context/AuthContext'
import { activeClubMemberships } from '../utils/activeClub'
import { SKILL_LEVEL_OPTIONS } from '../constants/skillLevels' import { SKILL_LEVEL_OPTIONS } from '../constants/skillLevels'
import MultiSelectCombo from '../components/MultiSelectCombo' import MultiSelectCombo from '../components/MultiSelectCombo'
import ExerciseFocusRulePicker from '../components/ExerciseFocusRulePicker' import ExerciseFocusRulePicker from '../components/ExerciseFocusRulePicker'
@ -508,7 +509,7 @@ function ExercisesListPage() {
const clubNameById = useMemo(() => { const clubNameById = useMemo(() => {
const m = {} const m = {}
for (const c of user?.clubs || []) { for (const c of activeClubMemberships(user?.clubs)) {
if (c?.id != null) m[Number(c.id)] = c.name || `#${c.id}` if (c?.id != null) m[Number(c.id)] = c.name || `#${c.id}`
} }
return m return m
@ -1206,7 +1207,7 @@ function ExercisesListPage() {
onChange={(e) => setBulkClubSelect(e.target.value)} onChange={(e) => setBulkClubSelect(e.target.value)}
> >
<option value="">Aktiver Verein (Vereins-Umschalter / Header)</option> <option value="">Aktiver Verein (Vereins-Umschalter / Header)</option>
{(user?.clubs || []).map((c) => ( {activeClubMemberships(user?.clubs).map((c) => (
<option key={c.id} value={String(c.id)}> <option key={c.id} value={String(c.id)}>
{c.name || `#${c.id}`} {c.name || `#${c.id}`}
</option> </option>

View File

@ -21,6 +21,7 @@ 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 { activeClubMemberships } from '../utils/activeClub'
import { resolveMediaAssetFileUrl } from '../utils/exerciseMediaUrl' import { resolveMediaAssetFileUrl } from '../utils/exerciseMediaUrl'
const LC_OPTIONS = [ const LC_OPTIONS = [
@ -233,7 +234,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 hasClubOrgAdmin = activeClubMemberships(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),

View File

@ -2,6 +2,7 @@ import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'
import { Link, useSearchParams } from 'react-router-dom' import { Link, useSearchParams } from 'react-router-dom'
import api from '../utils/api' import api from '../utils/api'
import { useAuth } from '../context/AuthContext' import { useAuth } from '../context/AuthContext'
import { activeClubMemberships } from '../utils/activeClub'
import ExercisePickerModal from '../components/ExercisePickerModal' import ExercisePickerModal from '../components/ExercisePickerModal'
import ExercisePeekModal from '../components/ExercisePeekModal' import ExercisePeekModal from '../components/ExercisePeekModal'
import TrainingUnitSectionsEditor from '../components/TrainingUnitSectionsEditor' import TrainingUnitSectionsEditor from '../components/TrainingUnitSectionsEditor'
@ -284,13 +285,13 @@ function TrainingPlanningPage() {
const r = (user?.role || '').toLowerCase() const r = (user?.role || '').toLowerCase()
if (r === 'admin' || r === 'superadmin') return true if (r === 'admin' || r === 'superadmin') return true
if (selectedGroupClubIdMemo == null || !Number.isFinite(selectedGroupClubIdMemo)) return false if (selectedGroupClubIdMemo == null || !Number.isFinite(selectedGroupClubIdMemo)) return false
const row = (user?.clubs || []).find((c) => Number(c.id) === selectedGroupClubIdMemo) const row = activeClubMemberships(user?.clubs).find((c) => Number(c.id) === selectedGroupClubIdMemo)
return Array.isArray(row?.roles) && row.roles.includes('club_admin') return Array.isArray(row?.roles) && row.roles.includes('club_admin')
}, [user?.role, user?.clubs, selectedGroupClubIdMemo]) }, [user?.role, user?.clubs, selectedGroupClubIdMemo])
const clubAdminClubIdSet = useMemo(() => { const clubAdminClubIdSet = useMemo(() => {
const ids = [] const ids = []
for (const c of user?.clubs || []) { for (const c of activeClubMemberships(user?.clubs)) {
if (Array.isArray(c.roles) && c.roles.includes('club_admin')) { if (Array.isArray(c.roles) && c.roles.includes('club_admin')) {
const id = Number(c.id) const id = Number(c.id)
if (Number.isFinite(id)) ids.push(id) if (Number.isFinite(id)) ids.push(id)

View File

@ -1,11 +1,18 @@
import { ACTIVE_CLUB_STORAGE_KEY } from './api' import { ACTIVE_CLUB_STORAGE_KEY } from './api'
/** Nur Mitgliedschaften mit aktivem Vereinszugang (Backend: club_members.status). */
export function activeClubMemberships(clubs) {
return (clubs || []).filter(
(c) => (c.membership_status || 'active').toString().trim().toLowerCase() === 'active'
)
}
/** /**
* Einheitliche Anzeige des aktiven Vereins: Abgleich mit effective_club_id, active_club_id, * Einheitliche Anzeige des aktiven Vereins: Abgleich mit effective_club_id, active_club_id,
* LocalStorage (Request-Header-Quelle), sonst erster Verein der Liste. * LocalStorage (Request-Header-Quelle), sonst erster **aktiver** Verein der Liste.
*/ */
export function getResolvedActiveClubIdForUi(user) { export function getResolvedActiveClubIdForUi(user) {
const clubs = user?.clubs || [] const clubs = activeClubMemberships(user?.clubs)
if (!clubs.length) return null if (!clubs.length) return null
const idInClubs = (id) => const idInClubs = (id) =>

View File

@ -1,12 +1,14 @@
import { activeClubMemberships } from './activeClub'
function userIsClubAdminForClub(user, clubId) { function userIsClubAdminForClub(user, clubId) {
if (clubId == null || user == null) return false if (clubId == null || user == null) return false
const cid = Number(clubId) const cid = Number(clubId)
const row = (user.clubs || []).find((c) => Number(c.id) === cid) const row = activeClubMemberships(user.clubs).find((c) => Number(c.id) === cid)
return Array.isArray(row?.roles) && row.roles.includes('club_admin') return Array.isArray(row?.roles) && row.roles.includes('club_admin')
} }
function userHasAnyClubAdminRole(user) { function userHasAnyClubAdminRole(user) {
return (user?.clubs || []).some((c) => Array.isArray(c.roles) && c.roles.includes('club_admin')) return activeClubMemberships(user?.clubs).some((c) => Array.isArray(c.roles) && c.roles.includes('club_admin'))
} }
/** /**