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
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:
parent
624c19dcba
commit
24c70c5ea0
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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} />
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
39
frontend/src/components/InactiveMembershipBanner.jsx
Normal file
39
frontend/src/components/InactiveMembershipBanner.jsx
Normal 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. B.
|
||||||
|
öffentliche Inhalte oder andere Vereine nutzen. Bei Fragen wende dich an eine:n Vereinsadministrator:in.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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 () => {
|
||||||
|
|
|
||||||
|
|
@ -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')
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 */
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 */
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
'—'
|
'—'
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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' }}>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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) =>
|
||||||
|
|
|
||||||
|
|
@ -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'))
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user