diff --git a/backend/tenant_context.py b/backend/tenant_context.py index aac56e0..5bb970a 100644 --- a/backend/tenant_context.py +++ b/backend/tenant_context.py @@ -80,7 +80,7 @@ def library_content_visibility_sql( class TenantContext: profile_id: int global_role: str - # Header > gespeichertes Profil > Fallback; Plattform-Admin ohne Header oft None + # Header > gespeichertes Profil > Fallback; Plattform-Admin ohne Header: Profil-Verein wenn existent effective_club_id: Optional[int] club_ids: frozenset[int] memberships: List[Dict[str, Any]] @@ -100,7 +100,8 @@ def resolve_tenant_context( Mitgliedschaften: wenn nicht übergeben, wird aus der DB geladen (aktive Mitgliedschaften). Auflösung effective_club_id: - - Plattform-Admin: Header setzt beliebigen existierenden Verein; ohne Header → None. + - Plattform-Admin: Header setzt beliebigen existierenden Verein; ohne Header → gespeichertes + active_club_id falls der Verein existiert, sonst None. - Sonst: gültiger Header zwingend Mitgliedschaft — bei ``reject`` sonst 403, bei ``ignore`` wie ohne Header. Ohne gültigen Header: gespeichertes active_club_id wenn Mitglied; sonst einziger Verein; bei mehreren ohne gültige Vorgabe → min(club_ids) (Fallback). @@ -118,6 +119,11 @@ def resolve_tenant_context( if not _club_exists(cur, header_cid): raise HTTPException(status_code=400, detail="Verein nicht gefunden") effective = header_cid + elif ( + stored_active_club_id is not None + and _club_exists(cur, stored_active_club_id) + ): + effective = stored_active_club_id else: effective = None return TenantContext( diff --git a/backend/tests/test_access_layer.py b/backend/tests/test_access_layer.py index 11654b3..ba8a628 100644 --- a/backend/tests/test_access_layer.py +++ b/backend/tests/test_access_layer.py @@ -2,7 +2,7 @@ import pytest from fastapi import HTTPException -from tenant_context import library_content_visibility_sql, parse_active_club_header +from tenant_context import library_content_visibility_sql, parse_active_club_header, resolve_tenant_context def test_library_visibility_sql_platform_admin_no_filter(): @@ -74,3 +74,54 @@ def test_parse_active_club_header_non_positive(): with pytest.raises(HTTPException) as exc: parse_active_club_header("0") assert exc.value.status_code == 400 + + +def test_resolve_platform_admin_uses_stored_club_without_header(monkeypatch): + """Ohne X-Active-Club-Id: effective wie gespeichertes Profil (Sync mit Dropdown/DB).""" + cur = object() + + def fake_exists(c, cid): + return cid == 99 + + monkeypatch.setattr("tenant_context._club_exists", fake_exists) + ctx = resolve_tenant_context( + cur, + profile_id=1, + global_role="admin", + header_raw=None, + memberships=[{"id": 10}], + stored_active_club_id=99, + ) + assert ctx.effective_club_id == 99 + + +def test_resolve_platform_admin_header_overrides_stored(monkeypatch): + cur = object() + + def fake_exists(c, cid): + return cid in (5, 99) + + monkeypatch.setattr("tenant_context._club_exists", fake_exists) + ctx = resolve_tenant_context( + cur, + profile_id=1, + global_role="superadmin", + header_raw="5", + memberships=[{"id": 10}], + stored_active_club_id=99, + ) + assert ctx.effective_club_id == 5 + + +def test_resolve_platform_admin_no_header_stored_invalid(monkeypatch): + cur = object() + monkeypatch.setattr("tenant_context._club_exists", lambda c, cid: False) + ctx = resolve_tenant_context( + cur, + profile_id=1, + global_role="admin", + header_raw=None, + memberships=[{"id": 1}], + stored_active_club_id=123, + ) + assert ctx.effective_club_id is None diff --git a/backend/version.py b/backend/version.py index 9b81e42..2b819a6 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,13 +1,13 @@ # Shinkan Jinkendo Version Information -APP_VERSION = "0.8.58" +APP_VERSION = "0.8.59" BUILD_DATE = "2026-05-07" DB_SCHEMA_VERSION = "20260508049" MODULE_VERSIONS = { "auth": "1.2.1", # Login-Rate-Limit 30/minute pro IP (vorher 5/minute) "profiles": "1.7.0", # exercise_list_prefs JSONB (Standard Übungsfilter); Patch via ProfileUpdate + Json() - "tenant_context": "1.0.4", # pytest: Unit test_access_layer.py + optional Integration test_access_layer_integration (PostgreSQL) + "tenant_context": "1.0.5", # Plattform-Admin: effective_club ohne Header aus Profil active_club_id wenn Verein existiert "clubs": "0.4.1", # Alle geschützten Endpoints Depends(get_tenant_context); profile_id/role aus TenantContext "club_memberships": "1.0.1", # Depends(get_tenant_context) "club_join_requests": "1.0.1", # Depends(get_tenant_context) @@ -29,6 +29,14 @@ MODULE_VERSIONS = { } CHANGELOG = [ + { + "version": "0.8.59", + "date": "2026-05-07", + "changes": [ + "Aktiver Verein: Backend resolve_tenant_context für Plattform-Admin ohne X-Active-Club-Id nutzt gespeichertes active_club_id wenn der Verein existiert (kein effective=null nach hartem Reload mehr)", + "Frontend: Nach Vereinswechsel Profil neu laden; Dropdown-Wert aus effective_club_id / active_club_id / LocalStorage abgestimmt (getResolvedActiveClubIdForUi)", + ], + }, { "version": "0.8.58", "date": "2026-05-07", diff --git a/frontend/src/components/ActiveClubSwitcher.jsx b/frontend/src/components/ActiveClubSwitcher.jsx index 0243b79..489eb24 100644 --- a/frontend/src/components/ActiveClubSwitcher.jsx +++ b/frontend/src/components/ActiveClubSwitcher.jsx @@ -1,4 +1,5 @@ import { useAuth } from '../context/AuthContext' +import { getResolvedActiveClubIdForUi } from '../utils/activeClub' /** * Zeigt einen Vereins-Umschalter, wenn der Nutzer mehreren Vereinen zugeordnet ist. @@ -9,10 +10,7 @@ export default function ActiveClubSwitcher({ variant = 'sidebar' }) { const clubs = user?.clubs || [] if (clubs.length <= 1) return null - const selectClubId = - user?.active_club_id != null && clubs.some((c) => c.id === user.active_club_id) - ? user.active_club_id - : clubs[0]?.id + const selectClubId = getResolvedActiveClubIdForUi(user) const isMobile = variant === 'mobile' diff --git a/frontend/src/components/Navigation.jsx b/frontend/src/components/Navigation.jsx index f56485f..e9c98de 100644 --- a/frontend/src/components/Navigation.jsx +++ b/frontend/src/components/Navigation.jsx @@ -1,5 +1,6 @@ import { Link, useLocation, useNavigate } from 'react-router-dom' import { useAuth } from '../context/AuthContext' +import { getResolvedActiveClubIdForUi } from '../utils/activeClub' function Navigation() { const location = useLocation() @@ -7,10 +8,7 @@ function Navigation() { const { user, logout, setActiveClub } = useAuth() const clubs = user?.clubs || [] - const selectClubId = - user?.active_club_id != null && clubs.some((c) => c.id === user.active_club_id) - ? user.active_club_id - : clubs[0]?.id + const selectClubId = getResolvedActiveClubIdForUi(user) const handleLogout = async () => { await logout() diff --git a/frontend/src/context/AuthContext.jsx b/frontend/src/context/AuthContext.jsx index 57b676b..5e06078 100644 --- a/frontend/src/context/AuthContext.jsx +++ b/frontend/src/context/AuthContext.jsx @@ -64,11 +64,25 @@ export function AuthProvider({ children }) { const uid = userRef.current?.id if (!Number.isFinite(cid) || cid < 1 || !uid) return localStorage.setItem(ACTIVE_CLUB_STORAGE_KEY, String(cid)) - setUser((prev) => (prev?.id ? { ...prev, active_club_id: cid } : prev)) + setUser((prev) => + prev?.id + ? { ...prev, active_club_id: cid, effective_club_id: cid } + : prev + ) try { await api.updateProfile(uid, { active_club_id: cid }) + const profile = await api.getCurrentProfile() + syncStoredActiveClub(profile) + setUser(profile) } catch (e) { console.error(e) + try { + const profile = await api.getCurrentProfile() + syncStoredActiveClub(profile) + setUser(profile) + } catch { + /* ignore */ + } } }, []) diff --git a/frontend/src/utils/activeClub.js b/frontend/src/utils/activeClub.js new file mode 100644 index 0000000..3c97671 --- /dev/null +++ b/frontend/src/utils/activeClub.js @@ -0,0 +1,32 @@ +import { ACTIVE_CLUB_STORAGE_KEY } from './api' + +/** + * Einheitliche Anzeige des aktiven Vereins: Abgleich mit effective_club_id, active_club_id, + * LocalStorage (Request-Header-Quelle), sonst erster Verein der Liste. + */ +export function getResolvedActiveClubIdForUi(user) { + const clubs = user?.clubs || [] + if (!clubs.length) return null + + const idInClubs = (id) => + id != null && + id !== '' && + clubs.some((c) => Number(c.id) === Number(id)) + + const eff = user?.effective_club_id + if (idInClubs(eff)) return Number(eff) + + const ac = user?.active_club_id + if (idInClubs(ac)) return Number(ac) + + try { + const ls = localStorage.getItem(ACTIVE_CLUB_STORAGE_KEY) + if (ls && /^\d+$/.test(ls.trim()) && clubs.some((c) => String(c.id) === ls.trim())) { + return Number(ls.trim()) + } + } catch { + /* ignore */ + } + + return Number(clubs[0].id) +}