diff --git a/backend/club_tenancy.py b/backend/club_tenancy.py index ee9ee04..3a28f6d 100644 --- a/backend/club_tenancy.py +++ b/backend/club_tenancy.py @@ -203,25 +203,45 @@ def exercise_visible_to_profile( created_by: Optional[int], global_role: Optional[str], ) -> bool: - """Leserechte einer Übung. Für neue Codepfade lieber `library_content_visible_to_profile` verwenden.""" - if is_platform_admin(global_role): + """ + Leserechte einer Übung (und analoger Bibliotheksobjekte). + + Vereinsbezogene Inhalte (visibility club): aktiv nur mit **aktiver** Mitgliedschaft in diesem Verein. + Mitgliedschaft mit status **inactive** sperrt — auch für Plattform-/Super-Admins — solange eine + Mitgliedschaft existiert. + + Ist man kein Mitglied dieses Vereins, behalten Plattform-Admins den bisherigen „Audit“-Zugang + zum Vereinskontext ohne eigene Mitgliedschaft. + """ + vis = (visibility or "").strip().lower() + plat = is_platform_admin(global_role) + pid = int(profile_id) + + if vis == "official": return True - if visibility == "official": + if created_by is not None and int(created_by) == pid: return True - if created_by is not None and created_by == profile_id: - return True - if visibility == "private": + if vis == "private": + return plat + if vis != "club": return False - if visibility == "club": - if exercise_club_id is None: - return False - cur.execute( - """ - SELECT 1 FROM club_members - WHERE profile_id = %s AND club_id = %s AND status = 'active' - LIMIT 1 - """, - (profile_id, exercise_club_id), - ) - return cur.fetchone() is not None - return False + if exercise_club_id is None: + return False + try: + ecid = int(exercise_club_id) + except (TypeError, ValueError): + return False + cur.execute( + """ + SELECT cm.status + FROM club_members cm + WHERE cm.profile_id = %s AND cm.club_id = %s + LIMIT 1 + """, + (pid, ecid), + ) + row = cur.fetchone() + if row is None: + return plat + st_raw = row["status"] if isinstance(row, dict) else row[0] + return str(st_raw or "active").strip().lower() == "active" diff --git a/backend/routers/exercise_progression_graphs.py b/backend/routers/exercise_progression_graphs.py index 124b681..80dfdd1 100644 --- a/backend/routers/exercise_progression_graphs.py +++ b/backend/routers/exercise_progression_graphs.py @@ -206,32 +206,22 @@ def list_progression_graphs(tenant: TenantContext = Depends(get_tenant_context)) role = tenant.global_role with get_db() as conn: cur = get_cursor(conn) - if is_platform_admin(role): - cur.execute( - """ - SELECT g.*, - (SELECT COUNT(*) FROM exercise_progression_edges e WHERE e.graph_id = g.id) AS edges_count - FROM exercise_progression_graphs g - ORDER BY g.updated_at DESC NULLS LAST, g.name - """ - ) - else: - vis_sql, vis_params = library_content_visibility_sql( - alias="g", - profile_id=profile_id, - role=role, - effective_club_id=tenant.effective_club_id, - ) - cur.execute( - f""" - SELECT g.*, - (SELECT COUNT(*) FROM exercise_progression_edges e WHERE e.graph_id = g.id) AS edges_count - FROM exercise_progression_graphs g - WHERE ({vis_sql}) - ORDER BY g.updated_at DESC NULLS LAST, g.name - """, - vis_params, - ) + vis_sql, vis_params = library_content_visibility_sql( + alias="g", + profile_id=profile_id, + role=role, + effective_club_id=tenant.effective_club_id, + ) + cur.execute( + f""" + SELECT g.*, + (SELECT COUNT(*) FROM exercise_progression_edges e WHERE e.graph_id = g.id) AS edges_count + FROM exercise_progression_graphs g + WHERE ({vis_sql}) + ORDER BY g.updated_at DESC NULLS LAST, g.name + """, + vis_params, + ) return [r2d(r) for r in cur.fetchall()] diff --git a/backend/routers/exercises.py b/backend/routers/exercises.py index ad075ee..f7a2d3e 100644 --- a/backend/routers/exercises.py +++ b/backend/routers/exercises.py @@ -1542,15 +1542,14 @@ def list_exercises( params = [] role = tenant.global_role - if not is_platform_admin(role): - vis_sql, vis_params = library_content_visibility_sql( - alias="e", - profile_id=profile_id, - role=role, - effective_club_id=tenant.effective_club_id, - ) - where.append(vis_sql) - params.extend(vis_params) + vis_sql, vis_params = library_content_visibility_sql( + alias="e", + profile_id=profile_id, + role=role, + effective_club_id=tenant.effective_club_id, + ) + where.append(vis_sql) + params.extend(vis_params) if created_by_me: where.append("e.created_by = %s") diff --git a/backend/routers/media_assets.py b/backend/routers/media_assets.py index 756580d..787546b 100644 --- a/backend/routers/media_assets.py +++ b/backend/routers/media_assets.py @@ -380,25 +380,42 @@ def _relocate_asset_file_if_governance_changed( def _list_active_visibility_clause(is_plat: bool, profile_id: int) -> tuple[str, list[Any]]: - """Sichtbare aktive Einträge: Plattform-Admin alles; sonst official + eigene private + Verein als Mitglied.""" - sql = """( - %s - OR lower(trim(ma.visibility)) = 'official' - OR ( - lower(trim(ma.visibility)) = 'private' - AND ma.uploaded_by_profile_id = %s - ) - OR ( - lower(trim(ma.visibility)) = 'club' - AND EXISTS ( - SELECT 1 FROM club_members cm - WHERE cm.profile_id = %s - AND cm.club_id = ma.club_id - AND cm.status = 'active' - ) - ) + """Sichtbare aktive Einträge: official; private (eigen oder Plattform-Admin); Verein wie Bibliotheks-SQL.""" + parts = ["lower(trim(ma.visibility)) = 'official'"] + vals: list[Any] = [] + + if is_plat: + parts.append("lower(trim(ma.visibility)) = 'private'") + else: + parts.append("(lower(trim(ma.visibility)) = 'private' AND ma.uploaded_by_profile_id = %s)") + vals.append(profile_id) + + club_plat = ( + "(lower(trim(ma.visibility)) = 'club' AND ma.club_id IS NOT NULL AND (" + "EXISTS (SELECT 1 FROM club_members cm WHERE cm.profile_id = %s AND cm.club_id = ma.club_id " + "AND cm.status = 'active') OR NOT EXISTS (SELECT 1 FROM club_members cm2 " + "WHERE cm2.profile_id = %s AND cm2.club_id = ma.club_id)))" + ) + + if is_plat: + parts.append(club_plat) + vals.extend([profile_id, profile_id]) + else: + parts.append( + """( + lower(trim(ma.visibility)) = 'club' + AND EXISTS ( + SELECT 1 FROM club_members cm + WHERE cm.profile_id = %s + AND cm.club_id = ma.club_id + AND cm.status = 'active' + ) )""" - return sql, [is_plat, profile_id, profile_id] + ) + vals.append(profile_id) + + sql = "(" + " OR ".join(parts) + ")" + return sql, vals def _list_trash_visibility_clause( diff --git a/backend/routers/training_framework_programs.py b/backend/routers/training_framework_programs.py index cc8a367..9855463 100644 --- a/backend/routers/training_framework_programs.py +++ b/backend/routers/training_framework_programs.py @@ -350,21 +350,18 @@ def list_training_framework_programs(tenant: TenantContext = Depends(get_tenant_ LEFT JOIN focus_areas fa ON fa.id = fp.focus_area_id LEFT JOIN style_directions sd ON sd.id = fp.style_direction_id """ - if is_platform_admin(role): - cur.execute(base_sel + " ORDER BY fp.updated_at DESC NULLS LAST, fp.title") - else: - vis_clause, vis_params = library_content_visibility_sql( - alias="fp", - profile_id=profile_id, - role=role, - effective_club_id=tenant.effective_club_id, - ) - cur.execute( - base_sel - + f""" WHERE ({vis_clause}) + vis_clause, vis_params = library_content_visibility_sql( + alias="fp", + profile_id=profile_id, + role=role, + effective_club_id=tenant.effective_club_id, + ) + cur.execute( + base_sel + + f""" WHERE ({vis_clause}) ORDER BY fp.updated_at DESC NULLS LAST, fp.title""", - vis_params, - ) + vis_params, + ) return [r2d(r) for r in cur.fetchall()] diff --git a/backend/routers/training_planning.py b/backend/routers/training_planning.py index 8a25bde..78bc771 100644 --- a/backend/routers/training_planning.py +++ b/backend/routers/training_planning.py @@ -896,25 +896,14 @@ def list_training_plan_templates(tenant: TenantContext = Depends(get_tenant_cont role = tenant.global_role with get_db() as conn: cur = get_cursor(conn) - if is_platform_admin(role): - cur.execute( - """ - SELECT t.*, - (SELECT COUNT(*) FROM training_plan_template_sections s WHERE s.template_id = t.id) - AS sections_count - FROM training_plan_templates t - ORDER BY t.updated_at DESC NULLS LAST, t.name - """ - ) - else: - vis_clause, vis_params = library_content_visibility_sql( - alias="t", - profile_id=profile_id, - role=role, - effective_club_id=tenant.effective_club_id, - ) - cur.execute( - f""" + vis_clause, vis_params = library_content_visibility_sql( + alias="t", + profile_id=profile_id, + role=role, + effective_club_id=tenant.effective_club_id, + ) + cur.execute( + f""" SELECT t.*, (SELECT COUNT(*) FROM training_plan_template_sections s WHERE s.template_id = t.id) AS sections_count @@ -922,8 +911,8 @@ def list_training_plan_templates(tenant: TenantContext = Depends(get_tenant_cont WHERE ({vis_clause}) ORDER BY t.updated_at DESC NULLS LAST, t.name """, - vis_params, - ) + vis_params, + ) return [r2d(r) for r in cur.fetchall()] diff --git a/backend/tenant_context.py b/backend/tenant_context.py index 2327dc4..e1c9309 100644 --- a/backend/tenant_context.py +++ b/backend/tenant_context.py @@ -61,21 +61,36 @@ def library_content_visibility_sql( effective_club_id: Optional[int], ) -> tuple[str, List[Any]]: """ - WHERE-Baustein für Bibliothekslisten (Übungen, Vorlagen, Rahmenprogramme): - official, eigene private, club nur im aktiven Vereinskontext (effective_club_id). - Plattform-Admin: keine Einschränkung (TRUE). - Ohne effective_club_id: kein club-Zweig (nur official + private). + WHERE-Baustein für Bibliothekslisten (Übungen, Vorlagen, Rahmenprogramme, …): + + - official immer + - private: eigene (Norm) bzw. alle (Plattform-Admin/Superadmin) + - club: nur mit **aktivem** Vereinszugang; Existenz einer nur **inactive** Mitgliedschaft schließt + aus — auch bei Plattform-Rolle. Ist man **kein** Mitglied des Vereins, behalten Plattform-Admins + Zugriff (Audit). + Für Nicht-Plattform: club-Zweig nur mit effective_club_id (Mandantenfilter). """ - if is_platform_admin(role): - return "TRUE", [] + plat = is_platform_admin(role) + parts: List[str] = [f"{alias}.visibility = 'official'"] + params: List[Any] = [] - parts: List[str] = [ - f"{alias}.visibility = 'official'", - f"({alias}.visibility = 'private' AND {alias}.created_by = %s)", - ] - params: List[Any] = [profile_id] + if plat: + parts.append(f"({alias}.visibility = 'private')") + else: + parts.append(f"({alias}.visibility = 'private' AND {alias}.created_by = %s)") + params.append(profile_id) - if effective_club_id is not None: + club_ok_plat = ( + f"({alias}.visibility = 'club' AND {alias}.club_id IS NOT NULL AND (" + f"EXISTS (SELECT 1 FROM club_members cm WHERE cm.profile_id = %s AND cm.club_id = {alias}.club_id " + f"AND cm.status = 'active') OR NOT EXISTS (SELECT 1 FROM club_members cm2 WHERE cm2.profile_id = %s " + f"AND cm2.club_id = {alias}.club_id)))" + ) + + if plat: + parts.append(club_ok_plat) + params.extend([profile_id, profile_id]) + elif effective_club_id is not None: parts.append( f"""( {alias}.visibility = 'club' diff --git a/backend/tests/test_access_layer.py b/backend/tests/test_access_layer.py index 825cee9..d74fe7a 100644 --- a/backend/tests/test_access_layer.py +++ b/backend/tests/test_access_layer.py @@ -5,26 +5,27 @@ from fastapi import HTTPException from tenant_context import library_content_visibility_sql, parse_active_club_header, resolve_tenant_context -def test_library_visibility_sql_platform_admin_no_filter(): +def test_library_visibility_sql_platform_admin_restricts_club_by_membership(): sql, params = library_content_visibility_sql( alias="e", profile_id=1, role="admin", effective_club_id=None, ) - assert sql == "TRUE" - assert params == [] + assert "e.visibility = 'official'" in sql + assert "club_members" in sql + assert params == [1, 1] -def test_library_visibility_sql_superadmin(): +def test_library_visibility_sql_superadmin_uses_membership_clause_for_club_visibility(): sql, params = library_content_visibility_sql( alias="fp", profile_id=2, role="superadmin", effective_club_id=100, ) - assert sql == "TRUE" - assert params == [] + assert "club_members" in sql + assert params == [2, 2] def test_library_visibility_sql_trainer_without_active_club_no_shared_club_branch(): diff --git a/frontend/src/pages/AdminUsersPage.jsx b/frontend/src/pages/AdminUsersPage.jsx index 22794ed..8552ea5 100644 --- a/frontend/src/pages/AdminUsersPage.jsx +++ b/frontend/src/pages/AdminUsersPage.jsx @@ -562,7 +562,10 @@ export default function AdminUsersPage() { {c.abbreviation ? ` (${c.abbreviation})` : ''} —{' '} {(c.roles || []).join(', ') || '—'} {c.membership_status === 'inactive' ? ( - (inaktiv) + + {' '} + (Vereinszugang deaktiviert) + ) : null}{' '} + {isSuperadminViewer && c.membership_status === 'inactive' ? ( + + ) : null} ))} diff --git a/frontend/src/pages/ClubsPage.jsx b/frontend/src/pages/ClubsPage.jsx index b123593..3e61004 100644 --- a/frontend/src/pages/ClubsPage.jsx +++ b/frontend/src/pages/ClubsPage.jsx @@ -196,6 +196,28 @@ function ClubsPage() { } } + const toggleMembersAdminClubAccess = async (m, activate) => { + if (!membersAdminClubId || !canManageClub(membersAdminClubId)) return + const st = activate ? 'active' : 'inactive' + if ( + !activate && + !confirm( + `Vereinszugang für „${m.name || m.email || '#' + m.profile_id}“ hier deaktivieren? Login bleibt möglich, Vereinsinhalte nicht — auch bei Super-Admins.`, + ) + ) { + return + } + try { + await api.updateClubMember(membersAdminClubId, m.profile_id, { + roles: [...(m.roles || [])], + status: st, + }) + await reloadMembersAdmin() + } catch (err) { + alert(err.message || String(err)) + } + } + const handleEdit = (item, type) => { setEditing(item) setModalType(type) @@ -667,17 +689,41 @@ function ClubsPage() {

Mitglieder

+ {isPlatformAdmin ? ( +

+ Liste enthält aktive und deaktivierte Vereinszugänge. + Deaktiviert gilt pro Verein (ohne Kontosperre) — auch für Super-Admins ohne aktive Mitgliedschaft in diesem + Verein kein Zugriff auf dessen Vereinsinhalte. Wiederherstellen über die Schaltflächen oder Mitglied + bearbeiten. +

+ ) : ( +

+ Deaktivierte Vereinszugänge sind hervorgehoben —{' '} + Anmeldung bleibt möglich, Vereinsinhalte dieser Zuordnung nicht. +

+ )} {clubMembersAdmin.length === 0 ? (

Noch keine Mitglieder erfasst.

) : (
- {clubMembersAdmin.map((m) => ( + {clubMembersAdmin.map((m) => { + const memStatus = (m.status || 'active').toLowerCase() + const inactiveRow = memStatus === 'inactive' + const portalLabel = (m.portal_role || '').trim() + return (
{m.name || m.email}
- {m.email} · #{m.profile_id} · {m.status} + {m.email} · #{m.profile_id} + {' · '} + + Vereinszugang:{' '} + + {inactiveRow ? 'deaktiviert' : 'aktiv'} + + + {portalLabel ? ( + · Portal: {portalLabel} + ) : null}
Rollen: {(m.roles || []).join(', ') || '—'}
- +
+ + {m.profile_id !== user?.id ? ( + inactiveRow ? ( + + ) : ( + + ) + ) : null} +
- ))} + ) + })}
)}