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:
"""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(

View File

@ -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

View File

@ -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,
)

View File

@ -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

View File

@ -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() {
<ActiveClubSwitcher variant="mobile" />
</div>
<div className="app-main">
<InactiveMembershipBanner />
<Outlet />
</div>
<Nav showAdminNav={showAdminNav} />

View File

@ -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)

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 { useAuth } from '../context/AuthContext'
import { getResolvedActiveClubIdForUi } from '../utils/activeClub'
import { getResolvedActiveClubIdForUi, activeClubMemberships } from '../utils/activeClub'
function Navigation() {
const location = useLocation()
const navigate = useNavigate()
const { user, logout, setActiveClub } = useAuth()
const clubs = user?.clubs || []
const clubs = activeClubMemberships(user?.clubs)
const selectClubId = getResolvedActiveClubIdForUi(user)
const handleLogout = async () => {

View File

@ -1,6 +1,7 @@
import React, { useCallback, useMemo, useState } from 'react'
import { Link } from 'react-router-dom'
import api from '../utils/api'
import { activeClubMemberships } from '../utils/activeClub'
const VIS_LABELS = {
private: 'Privat',
@ -48,7 +49,7 @@ function userMayPromote(user, targetClubId, createdBy) {
const role = String(user.role || '').toLowerCase()
if (role === 'admin' || role === 'superadmin') 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
return row.roles.includes('club_admin')
}

View File

@ -1,10 +1,11 @@
import { createContext, useContext, useState, useEffect, useCallback, useRef } from 'react'
import api, { ACTIVE_CLUB_STORAGE_KEY } from '../utils/api'
import { activeClubMemberships } from '../utils/activeClub'
const AuthContext = createContext(null)
function syncStoredActiveClub(profile) {
const clubs = profile?.clubs || []
const clubs = activeClubMemberships(profile?.clubs)
const ids = new Set(clubs.map((c) => String(c.id)))
const eff = profile?.effective_club_id
if (eff != null && eff !== '' && ids.has(String(eff))) {
@ -24,6 +25,12 @@ function syncStoredActiveClub(profile) {
}
if (clubs.length >= 1) {
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,
} from 'react'
import api from '../utils/api'
import { activeClubMemberships } from '../utils/activeClub'
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'))
return activeClubMemberships(user.clubs).some((c) => (c.roles || []).includes('club_admin'))
}
/** Nach Annahme/Ablehnung: Posteingang-Badges & Widget aktualisieren */

View File

@ -252,13 +252,22 @@ function AccountSettingsPage() {
<span style={{ lineHeight: 1.45 }}>
{user?.clubs?.length ? (
<>
{user.clubs.map((c) => (
<div key={c.id}>
<strong style={{ color: 'var(--text1)' }}>{c.name}</strong>
{': '}
{(c.roles || []).length ? (c.roles || []).join(', ') : '—'}
</div>
))}
{user.clubs.map((c) => {
const mem = (c.membership_status || 'active').toString().trim().toLowerCase()
const inactive = mem === 'inactive'
return (
<div key={c.id}>
<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 { 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 ? (
<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>.
Portal-Rollen und andere Vereine sind hier nicht sichtbar.
Du verwaltest Mitglieder des ausgewählten Vereins. <strong>Vereinszugang deaktivieren</strong> sperrt nur die
Sicht auf Vereinsinhalte der Login des Nutzers bleibt möglich. Wiederherstellen über aktivieren oder
Bearbeiten.
</p>
) : (
<>
@ -368,7 +393,9 @@ export default function AdminUsersPage() {
{!clubMembers.length ? (
<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 style={{ display: 'flex', justifyContent: 'space-between', flexWrap: 'wrap', gap: '0.75rem' }}>
<div>
@ -377,7 +404,12 @@ export default function AdminUsersPage() {
</strong>
<div style={{ fontSize: '0.875rem', color: 'var(--text2)' }}>{m.email || '—'}</div>
<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 style={{ fontSize: '0.82rem', marginTop: '0.35rem' }}>
Rollen: {(m.roles || []).join(', ') || '—'}
@ -394,12 +426,31 @@ export default function AdminUsersPage() {
profileId: m.profile_id,
profileLabel: m.name || m.email,
roles: [...(m.roles || [])],
status: (m.status || 'active').toLowerCase(),
status: memStatus,
})
}
>
Bearbeiten
</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) ? (
<button
type="button"
@ -417,7 +468,8 @@ export default function AdminUsersPage() {
</div>
</div>
</div>
))
)
})
)}
</div>
) : (
@ -721,8 +773,12 @@ export default function AdminUsersPage() {
<p style={{ color: 'var(--text2)', fontSize: '0.9rem' }}>
{clubEditModal.profileLabel} {clubEditModal.clubName}
</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">
<label className="form-label">Status</label>
<label className="form-label">Vereinszugang</label>
<select
className="form-input"
value={clubEditModal.status}
@ -730,8 +786,8 @@ export default function AdminUsersPage() {
setClubEditModal((prev) => (prev ? { ...prev, status: e.target.value } : prev))
}
>
<option value="active">aktiv</option>
<option value="inactive">inaktiv</option>
<option value="active">aktiv sieht Vereinsinhalte</option>
<option value="inactive">deaktiviert kein Zugriff auf Vereinsinhalte</option>
</select>
</div>
<div className="form-row">

View File

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

View File

@ -14,6 +14,7 @@ import {
} from 'lucide-react'
import api from '../utils/api'
import { useAuth } from '../context/AuthContext'
import { activeClubMemberships } from '../utils/activeClub'
import { SKILL_LEVEL_OPTIONS } from '../constants/skillLevels'
import MultiSelectCombo from '../components/MultiSelectCombo'
import ExerciseFocusRulePicker from '../components/ExerciseFocusRulePicker'
@ -508,7 +509,7 @@ function ExercisesListPage() {
const clubNameById = useMemo(() => {
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}`
}
return m
@ -1206,7 +1207,7 @@ function ExercisesListPage() {
onChange={(e) => setBulkClubSelect(e.target.value)}
>
<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)}>
{c.name || `#${c.id}`}
</option>

View File

@ -21,6 +21,7 @@ import {
} from 'lucide-react'
import { useAuth } from '../context/AuthContext'
import api from '../utils/api'
import { activeClubMemberships } from '../utils/activeClub'
import { resolveMediaAssetFileUrl } from '../utils/exerciseMediaUrl'
const LC_OPTIONS = [
@ -233,7 +234,7 @@ export default function MediaLibraryPage() {
const { user } = useAuth()
const isPlatformAdmin = user?.role === 'admin' || 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(
() => 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 api from '../utils/api'
import { useAuth } from '../context/AuthContext'
import { activeClubMemberships } from '../utils/activeClub'
import ExercisePickerModal from '../components/ExercisePickerModal'
import ExercisePeekModal from '../components/ExercisePeekModal'
import TrainingUnitSectionsEditor from '../components/TrainingUnitSectionsEditor'
@ -284,13 +285,13 @@ function TrainingPlanningPage() {
const r = (user?.role || '').toLowerCase()
if (r === 'admin' || r === 'superadmin') return true
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')
}, [user?.role, user?.clubs, selectedGroupClubIdMemo])
const clubAdminClubIdSet = useMemo(() => {
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')) {
const id = Number(c.id)
if (Number.isFinite(id)) ids.push(id)

View File

@ -1,11 +1,18 @@
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,
* LocalStorage (Request-Header-Quelle), sonst erster Verein der Liste.
* LocalStorage (Request-Header-Quelle), sonst erster **aktiver** Verein der Liste.
*/
export function getResolvedActiveClubIdForUi(user) {
const clubs = user?.clubs || []
const clubs = activeClubMemberships(user?.clubs)
if (!clubs.length) return null
const idInClubs = (id) =>

View File

@ -1,12 +1,14 @@
import { activeClubMemberships } from './activeClub'
function userIsClubAdminForClub(user, clubId) {
if (clubId == null || user == null) return false
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')
}
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'))
}
/**