diff --git a/backend/club_tenancy.py b/backend/club_tenancy.py index d665dd4..3a28f6d 100644 --- a/backend/club_tenancy.py +++ b/backend/club_tenancy.py @@ -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( @@ -198,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/password_reset_mail.py b/backend/password_reset_mail.py new file mode 100644 index 0000000..5d53066 --- /dev/null +++ b/backend/password_reset_mail.py @@ -0,0 +1,68 @@ +"""Gemeinsame Passwort-Link-Erzeugung (Sessions) + Mailtext — wie /auth/forgot-password.""" +from __future__ import annotations + +import os +import secrets +from datetime import datetime, timedelta +from typing import Any + +RESET_TOKEN_PREFIX = "reset_" + + +def public_reset_link(token: str) -> str: + base = (os.getenv("APP_URL") or "https://shinkan.jinkendo.de").rstrip("/") + return f"{base}/reset-password?token={token}" + + +def revoke_pending_password_resets_for_profile(cur: Any, profile_id: int) -> None: + """Entfernt alte Reset-Sessions eines Profils, damit nur der neueste Link aktiv ist.""" + cur.execute( + """ + DELETE FROM sessions + WHERE profile_id = %s AND token LIKE %s + """, + (profile_id, f"{RESET_TOKEN_PREFIX}%"), + ) + + +def insert_password_reset_session(cur: Any, profile_id: int, *, hours_valid: int = 1) -> str: + """ + Legt reset_-Session an. Gibt den Klartext-Token zurück (wie bei forgot-password). + """ + raw = secrets.token_urlsafe(32) + expires = datetime.now() + timedelta(hours=hours_valid) + cur.execute( + """ + INSERT INTO sessions (token, profile_id, expires_at, created_at) + VALUES (%s, %s, %s, CURRENT_TIMESTAMP) + """, + (f"{RESET_TOKEN_PREFIX}{raw}", profile_id, expires.isoformat()), + ) + return raw + + +def password_reset_email_body(*, recipient_name: str | None, token: str, intro: str) -> str: + name = (recipient_name or "").strip() or "Kollege/Kollegin" + link = public_reset_link(token) + return f"""Hallo {name}, + +{intro} + +Neues Passwort setzen: +{link} + +Der Link ist 1 Stunde gültig. Erst wenn du ihn nutzt und ein neues Passwort wählst, wird dein bestehendes +Passwort ersetzt — bis dahin kannst du dich wie gewohnt anmelden. + +Falls du diese Anfrage nicht erwartest, ignoriere diese E-Mail; dein Zugang bleibt unverändert. + +Dein Shinkan Jinkendo Team +""" + + +def issue_password_reset_via_email(cur: Any, send_email_fn, *, profile_id: int, email: str, name: str | None, intro: str) -> bool: + """Session anlegen und Mail schicken (send_email_fn wie routers.auth.send_email).""" + revoke_pending_password_resets_for_profile(cur, profile_id) + raw_token = insert_password_reset_session(cur, profile_id) + body = password_reset_email_body(recipient_name=name, token=raw_token, intro=intro) + return send_email_fn(email, "Passwort-Link – Shinkan Jinkendo", body) diff --git a/backend/routers/auth.py b/backend/routers/auth.py index cb8bcc8..ee08f33 100644 --- a/backend/routers/auth.py +++ b/backend/routers/auth.py @@ -20,6 +20,11 @@ from slowapi.util import get_remote_address from db import get_db, get_cursor from auth import hash_pin, verify_pin, make_token, require_auth from models import LoginRequest, PasswordResetRequest, PasswordResetConfirm, RegisterRequest +from password_reset_mail import ( + insert_password_reset_session, + password_reset_email_body, + revoke_pending_password_resets_for_profile, +) router = APIRouter(prefix="/api/auth", tags=["auth"]) limiter = Limiter(key_func=get_remote_address) @@ -116,28 +121,14 @@ async def password_reset_request(req: PasswordResetRequest, request: Request): if not prof: # Don't reveal if email exists return {"ok": True, "message": "Falls die E-Mail existiert, wurde ein Reset-Link gesendet."} + revoke_pending_password_resets_for_profile(cur, prof["id"]) + raw_token = insert_password_reset_session(cur, prof["id"]) - # Generate reset token - token = secrets.token_urlsafe(32) - expires = datetime.now() + timedelta(hours=1) - - # Store in sessions table (reuse mechanism) - cur.execute("INSERT INTO sessions (token, profile_id, expires_at, created_at) VALUES (%s,%s,%s,CURRENT_TIMESTAMP)", - (f"reset_{token}", prof['id'], expires.isoformat())) - - app_base = (os.getenv("APP_URL") or "https://shinkan.jinkendo.de").rstrip("/") - reset_body = f"""Hallo {prof['name']}, - -Du hast einen Passwort-Reset angefordert. - -Reset-Link: {app_base}/reset-password?token={token} - -Der Link ist 1 Stunde gültig. - -Falls du diese Anfrage nicht gestellt hast, ignoriere diese E-Mail. - -Dein Shinkan Jinkendo Team -""" + reset_body = password_reset_email_body( + recipient_name=prof.get("name"), + token=raw_token, + intro="Du hast einen Passwort-Reset angefordert.", + ) if not send_email(email, "Passwort zurücksetzen – Shinkan Jinkendo", reset_body): print("[SMTP] Passwort-Reset konnte nicht gesendet werden (SMTP prüfen).") diff --git a/backend/routers/club_join_requests.py b/backend/routers/club_join_requests.py index dae7767..415fd29 100644 --- a/backend/routers/club_join_requests.py +++ b/backend/routers/club_join_requests.py @@ -6,7 +6,7 @@ from typing import Any, Dict, List, Optional from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel, Field -from club_tenancy import can_manage_club_org +from club_tenancy import can_manage_club_org, is_platform_admin from db import get_db, get_cursor, r2d from tenant_context import TenantContext, get_tenant_context @@ -106,6 +106,72 @@ def _response_one(cur, req_id: int, viewer_profile_id: int) -> Dict[str, Any]: return r2d(row) +def _can_access_org_inbox(cur, profile_id: int, global_role: Optional[str]) -> bool: + """Posteingang (Beitrittsanträge bearbeiten): Plattform-Admin oder Vereinsadmin in mind. einem Verein.""" + if is_platform_admin(global_role): + return True + cur.execute( + """ + SELECT 1 + FROM club_members cm + INNER JOIN club_member_roles r ON r.club_member_id = cm.id AND r.role_code = 'club_admin' + WHERE cm.profile_id = %s AND cm.status = 'active' + LIMIT 1 + """, + (profile_id,), + ) + return cur.fetchone() is not None + + +def _club_ids_manageable_by_user(cur, profile_id: int, global_role: Optional[str]) -> List[int]: + """Club-IDs, für die der Nutzer Beitrittsanträge sehen darf.""" + if is_platform_admin(global_role): + cur.execute("SELECT id FROM clubs WHERE status = 'active' ORDER BY name") + return [int(r["id"]) for r in cur.fetchall()] + cur.execute( + """ + SELECT DISTINCT cm.club_id + FROM club_members cm + INNER JOIN club_member_roles r ON r.club_member_id = cm.id AND r.role_code = 'club_admin' + WHERE cm.profile_id = %s AND cm.status = 'active' + """, + (profile_id,), + ) + return [int(r["club_id"]) for r in cur.fetchall()] + + +@router.get("/me/inbox/join-requests") +def list_inbox_join_requests(tenant: TenantContext = Depends(get_tenant_context)): + """ + Alle offenen Beitrittsanträge, die der Nutzer bearbeiten darf: + Plattform-Admin: alle Vereine; sonst nur Vereine mit Rolle club_admin. + """ + pid = tenant.profile_id + role = tenant.global_role + with get_db() as conn: + cur = get_cursor(conn) + if not _can_access_org_inbox(cur, pid, role): + raise HTTPException(status_code=403, detail="Kein Zugriff auf den Organisations-Posteingang") + club_ids = _club_ids_manageable_by_user(cur, pid, role) + if not club_ids: + return [] + cur.execute( + """ + SELECT r.id, r.profile_id, r.club_id, r.status, r.message, r.created_at, + c.name AS club_name, c.abbreviation AS club_abbreviation, + p.name AS applicant_name, p.email AS applicant_email + FROM club_membership_requests r + INNER JOIN clubs c ON c.id = r.club_id + INNER JOIN profiles p ON p.id = r.profile_id + WHERE r.status = 'pending' + AND r.club_id = ANY(%s) + ORDER BY r.created_at ASC + """, + (club_ids,), + ) + return [r2d(row) for row in cur.fetchall()] + + @router.get("/me/club-join-requests") def get_my_join_requests(tenant: TenantContext = Depends(get_tenant_context)): pid = tenant.profile_id diff --git a/backend/routers/club_memberships.py b/backend/routers/club_memberships.py index d1a4a6a..91f9f7b 100644 --- a/backend/routers/club_memberships.py +++ b/backend/routers/club_memberships.py @@ -50,6 +50,44 @@ def _club_exists(cur, club_id: int) -> bool: return cur.fetchone() is not None +def _count_other_active_club_admins(cur, club_id: int, exclude_profile_id: int) -> int: + """Aktive Vereinsadmins im Verein, außer exclude_profile_id.""" + cur.execute( + """ + SELECT COUNT(DISTINCT cm.profile_id)::int AS n + FROM club_members cm + INNER JOIN club_member_roles r ON r.club_member_id = cm.id AND r.role_code = 'club_admin' + WHERE cm.club_id = %s AND cm.status = 'active' AND cm.profile_id <> %s + """, + (club_id, exclude_profile_id), + ) + row = cur.fetchone() + try: + return int(row["n"]) if row is not None else 0 + except (KeyError, TypeError, ValueError): + return 0 + + +def _member_is_active_club_admin(cur, club_id: int, profile_id: int) -> bool: + cur.execute( + """ + SELECT 1 + FROM club_members cm + INNER JOIN club_member_roles r ON r.club_member_id = cm.id AND r.role_code = 'club_admin' + WHERE cm.club_id = %s AND cm.profile_id = %s AND cm.status = 'active' + LIMIT 1 + """, + (club_id, profile_id), + ) + return cur.fetchone() is not None + + +_LAST_CLUB_ADMIN_MSG = ( + "Mindestens ein aktiver Vereinsadmin muss im Verein verbleiben. " + "Weise die Rolle zuerst einem anderen Mitglied zu oder aktiviere einen anderen Vereinsadmin." +) + + class ClubMemberUpsert(BaseModel): profile_id: int = Field(..., ge=1) roles: List[str] = Field(default_factory=list, description="Mindestens eine Vereinsrolle") @@ -77,7 +115,8 @@ def list_club_members( cur.execute( f""" SELECT cm.id AS membership_id, cm.profile_id, cm.status, cm.created_at, cm.updated_at, - p.email, p.name, + p.email, p.name, COALESCE(p.email_verified, false) AS email_verified, + lower(trim(COALESCE(p.role, 'user'))) AS portal_role, COALESCE( ARRAY_AGG(DISTINCT r.role_code) FILTER (WHERE r.role_code IS NOT NULL), ARRAY[]::varchar[] @@ -86,7 +125,7 @@ def list_club_members( INNER JOIN profiles p ON p.id = cm.profile_id LEFT JOIN club_member_roles r ON r.club_member_id = cm.id WHERE cm.club_id = %s {status_clause} - GROUP BY cm.id, cm.profile_id, cm.status, cm.created_at, cm.updated_at, p.email, p.name + GROUP BY cm.id, cm.profile_id, cm.status, cm.created_at, cm.updated_at, p.email, p.name, p.email_verified, p.role ORDER BY p.name NULLS LAST, p.email """, (club_id,), @@ -153,7 +192,8 @@ def _one_member(cur, club_id: int, profile_id: int) -> Dict[str, Any]: cur.execute( """ SELECT cm.id AS membership_id, cm.profile_id, cm.status, cm.created_at, cm.updated_at, - p.email, p.name, + p.email, p.name, COALESCE(p.email_verified, false) AS email_verified, + lower(trim(COALESCE(p.role, 'user'))) AS portal_role, COALESCE( ARRAY_AGG(DISTINCT r.role_code) FILTER (WHERE r.role_code IS NOT NULL), ARRAY[]::varchar[] @@ -162,7 +202,7 @@ def _one_member(cur, club_id: int, profile_id: int) -> Dict[str, Any]: INNER JOIN profiles p ON p.id = cm.profile_id LEFT JOIN club_member_roles r ON r.club_member_id = cm.id WHERE cm.club_id = %s AND cm.profile_id = %s - GROUP BY cm.id, cm.profile_id, cm.status, cm.created_at, cm.updated_at, p.email, p.name + GROUP BY cm.id, cm.profile_id, cm.status, cm.created_at, cm.updated_at, p.email, p.name, p.email_verified, p.role """, (club_id, profile_id), ) @@ -217,21 +257,50 @@ def update_club_member( if body.roles is None and body.status is None: return _one_member(cur, club_id, profile_id) + cur.execute( + """ + SELECT cm.status, + COALESCE( + ARRAY_AGG(r.role_code) FILTER (WHERE r.role_code IS NOT NULL), + ARRAY[]::varchar[] + ) AS roles + FROM club_members cm + LEFT JOIN club_member_roles r ON r.club_member_id = cm.id + WHERE cm.id = %s + GROUP BY cm.status + """, + (cm_id,), + ) + cur_row = cur.fetchone() + current_status = (cur_row["status"] or "active").strip().lower() if cur_row else "active" + cr = cur_row.get("roles") if cur_row else [] + if hasattr(cr, "tolist"): + cr = cr.tolist() + current_roles = list(cr) + + new_status = body.status.strip().lower() if body.status is not None else current_status + if body.status is not None and new_status not in _ALLOWED_STATUS: + raise HTTPException(status_code=400, detail="status muss active oder inactive sein") + + new_roles = _normalize_roles(body.roles) if body.roles is not None else current_roles + if not new_roles: + raise HTTPException(status_code=400, detail="Mindestens eine Rolle angeben") + + effective_admin = new_status == "active" and "club_admin" in set(new_roles) + if not effective_admin: + others = _count_other_active_club_admins(cur, club_id, profile_id) + if others < 1: + raise HTTPException(status_code=400, detail=_LAST_CLUB_ADMIN_MSG) + if body.status is not None: - st = body.status.strip().lower() - if st not in _ALLOWED_STATUS: - raise HTTPException(status_code=400, detail="status muss active oder inactive sein") cur.execute( "UPDATE club_members SET status = %s, updated_at = NOW() WHERE id = %s", - (st, cm_id), + (new_status, cm_id), ) if body.roles is not None: - roles = _normalize_roles(body.roles) - if not roles: - raise HTTPException(status_code=400, detail="Mindestens eine Rolle angeben") cur.execute("DELETE FROM club_member_roles WHERE club_member_id = %s", (cm_id,)) - for rc in roles: + for rc in new_roles: cur.execute( """ INSERT INTO club_member_roles (club_member_id, role_code) @@ -258,6 +327,10 @@ def delete_club_member( raise HTTPException(status_code=404, detail="Verein nicht gefunden") _assert_manage(cur, tenant, club_id) + if _member_is_active_club_admin(cur, club_id, profile_id): + if _count_other_active_club_admins(cur, club_id, profile_id) < 1: + raise HTTPException(status_code=400, detail=_LAST_CLUB_ADMIN_MSG) + cur.execute( "DELETE FROM club_members WHERE club_id = %s AND profile_id = %s RETURNING id", (club_id, profile_id), 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 7efd304..787546b 100644 --- a/backend/routers/media_assets.py +++ b/backend/routers/media_assets.py @@ -377,17 +377,111 @@ def _relocate_asset_file_if_governance_changed( return new_key -def _lifecycle_where_sql(lifecycle: str) -> str: + + +def _list_active_visibility_clause(is_plat: bool, profile_id: int) -> tuple[str, list[Any]]: + """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' + ) + )""" + ) + vals.append(profile_id) + + sql = "(" + " OR ".join(parts) + ")" + return sql, vals + + +def _list_trash_visibility_clause( + is_plat: bool, + is_sup: bool, + profile_id: int, + admin_club_ids: set[int], +) -> tuple[str, list[Any]]: + """ + Papierkorb nur für eigene private Medien; Vereins-Admins zusätzlich Vereins-Papierkorb ihres Vereins. + Official/Plattform: Superadmin oder Plattform-Admin sieht alles im Papierkorb. + """ + if is_plat or is_sup: + return "(TRUE)", [] + parts: list[str] = [] + vals: list[Any] = [] + parts.append( + "(lower(trim(ma.visibility)) = 'private' AND ma.uploaded_by_profile_id = %s)", + ) + vals.append(profile_id) + if admin_club_ids: + parts.append( + "(lower(trim(ma.visibility)) = 'club' AND ma.club_id = ANY(%s))", + ) + vals.append(list(admin_club_ids)) + return "(" + " OR ".join(parts) + ")", vals + + +def _list_main_visibility_where( + lifecycle: str, + is_plat: bool, + is_sup: bool, + profile_id: int, + admin_club_ids: set[int], +) -> tuple[str, list[Any]]: + """ + Kombiniert lifecycle mit Leserechten. Papierkorb-Stufen für normale Nutzer stark eingeschränkt. + """ lc = (lifecycle or "active").strip().lower() if lc not in _LIFECYCLE_LIST_FILTERS: raise HTTPException(status_code=400, detail="Ungültiger lifecycle-Filter") + + active_sql, active_params = _list_active_visibility_clause(is_plat, profile_id) + trash_sql, trash_params = _list_trash_visibility_clause( + is_plat, is_sup, profile_id, admin_club_ids + ) + active_block = f"(ma.lifecycle_state = 'active' AND {active_sql})" + trash_block = ( + f"(ma.lifecycle_state IN ('trash_soft', 'trash_hidden') AND {trash_sql})" + ) + if lc == "active": - return "ma.lifecycle_state = 'active'" + return active_block, active_params if lc == "trash_soft": - return "ma.lifecycle_state = 'trash_soft'" + return ( + f"(ma.lifecycle_state = 'trash_soft' AND {trash_sql})", + trash_params, + ) if lc == "trash_hidden": - return "ma.lifecycle_state = 'trash_hidden'" - return "ma.lifecycle_state IN ('active', 'trash_soft', 'trash_hidden')" + return ( + f"(ma.lifecycle_state = 'trash_hidden' AND {trash_sql})", + trash_params, + ) + # all + combined = f"(({active_block}) OR ({trash_block}))" + return combined, active_params + trash_params def _fetch_asset_file_row(cur: Any, asset_id: int) -> Optional[dict]: @@ -789,7 +883,6 @@ def list_media_assets( limit: int = Query(30, ge=1, le=100), offset: int = Query(0, ge=0), ): - lc_where = _lifecycle_where_sql(lifecycle) mk = (media_kind or "all").strip().lower() if mk not in _MEDIA_KIND_FILTERS: raise HTTPException(status_code=400, detail="Ungültiger media_kind") @@ -831,6 +924,9 @@ def list_media_assets( with get_db() as conn: cur = get_cursor(conn) admin_club_ids = club_ids_for_profile_with_roles(cur, profile_id, "club_admin") + vis_main_sql, vis_params = _list_main_visibility_where( + lifecycle, is_adm, sup, profile_id, admin_club_ids + ) show_uploader = sup or is_adm or bool(admin_club_ids) if uploaded_by is not None and not show_uploader: raise HTTPException(status_code=403, detail="Uploader-Filter nicht erlaubt") @@ -863,7 +959,7 @@ def list_media_assets( ) params: list[Any] = ( - [is_adm, profile_id, profile_id] + vis_params + club_sql_params + uploaded_params + search_params @@ -880,24 +976,7 @@ def list_media_assets( FROM media_assets ma LEFT JOIN profiles pr ON pr.id = ma.uploaded_by_profile_id LEFT JOIN clubs cl ON cl.id = ma.club_id - WHERE {lc_where} - AND ( - %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' - ) - ) - ) + WHERE {vis_main_sql} {club_sql} {uploaded_sql} {media_kind_sql} @@ -907,7 +986,7 @@ def list_media_assets( params, ) rows = [r2d(r) for r in cur.fetchall()] - show_club = sup or is_adm + show_club = sup or is_adm or bool(admin_club_ids) asset_ids = [int(r["id"]) for r in rows] usage_map = _usage_for_media_assets(cur, asset_ids) for r in rows: diff --git a/backend/routers/profiles.py b/backend/routers/profiles.py index daa2dda..3785b84 100644 --- a/backend/routers/profiles.py +++ b/backend/routers/profiles.py @@ -11,15 +11,81 @@ from fastapi import APIRouter, HTTPException, Header, Depends from psycopg2.extras import Json +from pydantic import BaseModel, Field, field_validator + from db import get_db, get_cursor, r2d from auth import require_auth, hash_pin -from club_tenancy import assert_club_member, memberships_with_roles, is_platform_admin +from club_tenancy import ( + assert_club_member, + club_ids_for_profile_with_roles, + is_platform_admin, + is_superadmin, + memberships_with_roles, +) from tenant_context import resolve_tenant_context, TenantContext, get_tenant_context from models import ProfileCreate, ProfileUpdate router = APIRouter(prefix="/api", tags=["profiles"]) _ALLOWED_PORTAL_ROLES = frozenset({"user", "trainer", "admin", "superadmin"}) +_CLUB_BLOCKED_PORTAL_TARGETS = frozenset({"admin", "superadmin"}) + + +class ManagementPasswordResetBody(BaseModel): + """Optional: Nur Super-Admins dürfen `new_password` setzen — sonst E-Mail-Link (wie Passwort vergessen).""" + + new_password: Optional[str] = Field(None, min_length=8, max_length=128) + + @field_validator("new_password", mode="before") + @classmethod + def _empty_pw_none(cls, v): + if v is None: + return None + if isinstance(v, str) and not v.strip(): + return None + return v + + +def _target_portal_role_lower(cur, target_pid: int) -> str: + cur.execute("SELECT lower(trim(COALESCE(role, ''))) AS r FROM profiles WHERE id = %s", (target_pid,)) + row = cur.fetchone() + return (row.get("r") or "user") if row else "user" + + +def _assert_can_management_password_help(cur, tenant: TenantContext, target_pid: int, *, via_email: bool) -> None: + """ + Wer darf eines anderen Accounts Passwort-Helfer nutzen (E-Mail-Link oder — nur Superadmin — direktes Setzen)? + Superadmin / Portal-Admin global; Vereinsadmin nur für aktives Mitglied in gemeinsam verwaltetem Verein. + """ + viewer_pid = int(tenant.profile_id) + if target_pid == viewer_pid: + raise HTTPException(status_code=400, detail="Eigenes Passwort unter Einstellungen ändern") + role_raw = tenant.global_role or "" + if is_superadmin(role_raw): + return + if is_platform_admin(role_raw): + return + managed = club_ids_for_profile_with_roles(cur, viewer_pid, "club_admin") + if not managed: + raise HTTPException(status_code=403, detail="Keine Berechtigung") + cur.execute( + """ + SELECT 1 FROM club_members + WHERE profile_id = %s AND club_id = ANY(%s) AND status = 'active' + LIMIT 1 + """, + (target_pid, list(managed)), + ) + if not cur.fetchone(): + raise HTTPException( + status_code=403, + detail="Nur für Nutzer, die in mindestens einem deiner Vereine (als Admin) aktiv sind", + ) + if via_email and _target_portal_role_lower(cur, target_pid) in _CLUB_BLOCKED_PORTAL_TARGETS: + raise HTTPException( + status_code=403, + detail="Für Konten mit Portal-Administrator- oder Super-Administrator-Rolle ist das nur für Super-Admins möglich", + ) # ── Current User Profile ────────────────────────────────────────────────────── @@ -28,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) @@ -38,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 @@ -136,6 +202,68 @@ def profile_document(pid: str) -> dict: return d +@router.post("/profiles/{pid}/management-password-reset") +def management_password_reset( + pid: str, + body: ManagementPasswordResetBody, + tenant: TenantContext = Depends(get_tenant_context), +): + """ + Standard: E-Mail mit Reset-Link wie „Passwort vergessen“ — der PIN-Hash bleibt bis zur Bestätigung unverändert. + Nur Super-Admins können optional `new_password` setzen (Ausnahme). + """ + from routers.auth import send_email + from password_reset_mail import issue_password_reset_via_email + + try: + target = int(pid) + except ValueError: + raise HTTPException(status_code=400, detail="Ungültige Profil-ID") + + direct = body.new_password is not None + + with get_db() as conn: + cur = get_cursor(conn) + cur.execute("SELECT id, email, name FROM profiles WHERE id = %s", (target,)) + row = cur.fetchone() + if not row: + raise HTTPException(status_code=404, detail="Profil nicht gefunden") + + if direct: + role_raw = tenant.global_role or "" + if not is_superadmin(role_raw): + raise HTTPException( + status_code=403, + detail="Direktes Setzen eines Passworts ist nur Super-Admins vorbehalten. Bitte E-Mail-Link verwenden.", + ) + _assert_can_management_password_help(cur, tenant, target, via_email=False) + new_hash = hash_pin(body.new_password) + cur.execute( + "UPDATE profiles SET pin_hash = %s, updated_at = NOW() WHERE id = %s", + (new_hash, target), + ) + return {"ok": True, "mode": "direct"} + + _assert_can_management_password_help(cur, tenant, target, via_email=True) + email = (row.get("email") or "").strip() + if not email: + raise HTTPException( + status_code=400, + detail="Für dieses Profil ist keine E-Mail-Adresse hinterlegt; ein Reset-Link kann nicht versendet werden.", + ) + name = row.get("name") + intro = "Ein Administrator hat für dein Konto einen sicheren Link zum Setzen eines neuen Passworts angefordert." + sent = issue_password_reset_via_email( + cur, + send_email, + profile_id=target, + email=email.lower(), + name=name, + intro=intro, + ) + return {"ok": True, "mode": "email", "email_sent": bool(sent)} + + @router.get("/profiles/{pid}") def get_profile(pid: str, session=Depends(require_auth)): """Profil nach ID — nur eigenes Profil oder Plattform-Admin (wie PUT).""" 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 5bb970a..e1c9309 100644 --- a/backend/tenant_context.py +++ b/backend/tenant_context.py @@ -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: @@ -45,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' @@ -97,7 +128,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 +143,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 +166,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 +194,7 @@ def resolve_tenant_context( global_role=role_lc, effective_club_id=effective, club_ids=club_ids, - memberships=memberships, + memberships=membership_rows, ) diff --git a/backend/tests/test_access_layer.py b/backend/tests/test_access_layer.py index ba8a628..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(): @@ -125,3 +126,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 diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 1c68048..d7e7841 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -9,6 +9,7 @@ import { Outlet, } from 'react-router-dom' import { AuthProvider, useAuth } from './context/AuthContext' +import { OrgInboxProvider, useOrgInbox } from './context/OrgInboxContext' import DesktopSidebar from './components/DesktopSidebar' import { getMainNavItems } from './config/appNav' import LoginPage from './pages/LoginPage' @@ -20,6 +21,7 @@ import ExercisesListPage from './pages/ExercisesListPage' import ExerciseDetailPage from './pages/ExerciseDetailPage' import ExerciseFormPage from './pages/ExerciseFormPage' import ClubsPage from './pages/ClubsPage' +import InboxPage from './pages/InboxPage' import SkillsPage from './pages/SkillsPage' import TrainingPlanningPage from './pages/TrainingPlanningPage' import TrainingFrameworkProgramsListPage from './pages/TrainingFrameworkProgramsListPage' @@ -32,13 +34,25 @@ import AdminMaturityModelsPage from './pages/AdminMaturityModelsPage' import TrainerContextsPage from './pages/TrainerContextsPage' import MediaWikiImportPage from './pages/MediaWikiImportPage' import AdminUsersPage from './pages/AdminUsersPage' +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 activeClubMemberships(currentUser?.clubs).some((c) => (c.roles || []).includes('club_admin')) +} + // Bottom Navigation (Mobile) -function Nav({ isAdmin }) { - const items = getMainNavItems(isAdmin) +function Nav({ showAdminNav }) { + const { canAccessOrgInbox, inboxCount } = useOrgInbox() + const items = getMainNavItems(showAdminNav, { showInbox: canAccessOrgInbox }) const loc = useLocation() const navItemActive = (pathname, item, routerIsActive) => { @@ -58,6 +72,11 @@ function Nav({ isAdmin }) { } > + {item.to === '/inbox' && inboxCount > 0 ? ( + + {inboxCount > 99 ? '99+' : inboxCount} + + ) : null} {item.shortLabel || item.label} ))} @@ -95,11 +114,11 @@ function ProtectedLayout() { return } - const isAdmin = user?.role === 'admin' || user?.role === 'superadmin' + const showAdminNav = computeShowAdminNav(user) return ( - <> - + +
@@ -109,12 +128,13 @@ function ProtectedLayout() {
+
-
- +
) } @@ -167,6 +187,7 @@ function AppRoutes() { } /> } /> + } /> } /> } /> } /> @@ -174,12 +195,40 @@ function AppRoutes() { } /> } /> } /> - } /> + } /> } /> - } /> - } /> - } /> - } /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> } /> diff --git a/frontend/src/app.css b/frontend/src/app.css index bccdf36..7faf38b 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -198,6 +198,7 @@ body { font-family: var(--font); background: var(--bg); color: var(--text1); -we min-width: 68px; max-width: 108px; min-height: 48px; + position: relative; display: flex; flex-direction: column; align-items: center; @@ -6376,3 +6377,156 @@ a.analysis-split__nav-item { .media-library__preview-fallback .btn { margin-top: 12px; } + +/* Organisation: Posteingang (Nav-Badge, Sidebar, Dashboard-Widget) */ +.nav-item__badge { + position: absolute; + top: 2px; + right: 6px; + min-width: 1.1rem; + padding: 1px 5px; + border-radius: 999px; + font-size: 10px; + font-weight: 700; + line-height: 1.2; + background: var(--accent); + color: #fff; + box-shadow: 0 0 0 1px var(--surface); +} +.desktop-sidebar__badge { + margin-left: auto; + flex-shrink: 0; + min-width: 1.25rem; + padding: 2px 6px; + border-radius: 999px; + font-size: 11px; + font-weight: 700; + line-height: 1.2; + background: var(--accent); + color: #fff; +} +.desktop-sidebar__link > span:not(.desktop-sidebar__badge) { + flex: 1; + min-width: 0; +} + +.dashboard-org-inbox-widget { + display: none; +} +@media (min-width: 1024px) { + .dashboard-org-inbox-widget { + display: block; + margin-bottom: 1.25rem; + } +} +.dashboard-org-inbox-widget__inner { + padding: 1rem 1.1rem; +} +.dashboard-org-inbox-widget__head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + margin-bottom: 0.35rem; +} +.dashboard-org-inbox-widget__title { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 1rem; + font-weight: 600; + margin: 0; +} +.dashboard-org-inbox-widget__icon { + color: var(--accent); + flex-shrink: 0; +} +.dashboard-org-inbox-widget__badge { + font-size: 0.8rem; + font-weight: 700; + min-width: 1.5rem; + text-align: center; + padding: 2px 8px; + border-radius: 999px; + background: var(--accent); + color: #fff; +} +.dashboard-org-inbox-widget__lead { + margin: 0 0 0.65rem; + font-size: 0.92rem; +} +.dashboard-org-inbox-widget__list { + list-style: none; + margin: 0 0 0.85rem; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.45rem; +} +.dashboard-org-inbox-widget__item { + display: flex; + flex-wrap: wrap; + gap: 0.35rem 0.75rem; + font-size: 0.9rem; + padding: 0.35rem 0; + border-bottom: 1px solid var(--border); +} +.dashboard-org-inbox-widget__item:last-child { + border-bottom: none; +} +.dashboard-org-inbox-widget__club { + font-weight: 600; + color: var(--text1); +} +.dashboard-org-inbox-widget__applicant { + color: var(--text2); +} +.dashboard-org-inbox-widget__footer { + margin-top: 0.25rem; +} + +.inbox-page__header { + display: flex; + flex-wrap: wrap; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; + margin-bottom: 1rem; +} +.inbox-page__list { + display: flex; + flex-direction: column; + gap: 0.75rem; +} +.inbox-request-card { + display: flex; + flex-wrap: wrap; + gap: 1rem; + justify-content: space-between; + align-items: flex-start; +} +.inbox-request-card__club { + font-size: 0.85rem; + font-weight: 600; + color: var(--accent); + margin-bottom: 0.2rem; +} +.inbox-request-card__applicant { + display: block; + font-size: 1.02rem; +} +.inbox-request-card__meta { + font-size: 0.86rem; + margin-top: 0.25rem; +} +.inbox-request-card__message { + margin: 0.5rem 0 0; + font-size: 0.92rem; + line-height: 1.45; +} +.inbox-request-card__actions { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + flex-shrink: 0; +} diff --git a/frontend/src/components/ActiveClubSwitcher.jsx b/frontend/src/components/ActiveClubSwitcher.jsx index 489eb24..867729b 100644 --- a/frontend/src/components/ActiveClubSwitcher.jsx +++ b/frontend/src/components/ActiveClubSwitcher.jsx @@ -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) diff --git a/frontend/src/components/AdminHomeRedirect.jsx b/frontend/src/components/AdminHomeRedirect.jsx new file mode 100644 index 0000000..aac8fdd --- /dev/null +++ b/frontend/src/components/AdminHomeRedirect.jsx @@ -0,0 +1,8 @@ +import { Navigate } from 'react-router-dom' +import { useAuth } from '../context/AuthContext' + +export default function AdminHomeRedirect() { + const { user } = useAuth() + const isPlat = user?.role === 'admin' || user?.role === 'superadmin' + return +} diff --git a/frontend/src/components/AdminPageNav.jsx b/frontend/src/components/AdminPageNav.jsx index adf2221..6970eef 100644 --- a/frontend/src/components/AdminPageNav.jsx +++ b/frontend/src/components/AdminPageNav.jsx @@ -1,19 +1,20 @@ import { NavLink } from 'react-router-dom' -import { TreePine, FolderTree, Download, Grid3x3, Users, Images } from 'lucide-react' +import { TreePine, FolderTree, Download, Grid3x3, Users } from 'lucide-react' /** * Admin-Seiten-Navigation (horizontal) - * Wechselt zwischen verschiedenen Admin-Seiten + * Nutzer-Verwaltung: eingeschränkte Tabs für Vereinsorga ohne Plattform-Admin. */ -export default function AdminPageNav() { - const pages = [ - { to: '/admin/hierarchy', label: 'Hierarchie', icon: TreePine }, - { to: '/admin/users', label: 'Nutzer', icon: Users }, - { to: '/admin/maturity-models', label: 'Fähigkeitsmatrix', icon: Grid3x3 }, - { to: '/admin/catalogs', label: 'Kataloge', icon: FolderTree }, - { to: '/media', label: 'Medien', icon: Images }, - { to: '/admin/mediawiki-import', label: 'Wiki-Import', icon: Download } - ] +export default function AdminPageNav({ clubOrgOnly = false }) { + const pages = clubOrgOnly + ? [{ to: '/admin/users', label: 'Nutzer', icon: Users }] + : [ + { to: '/admin/hierarchy', label: 'Hierarchie', icon: TreePine }, + { to: '/admin/users', label: 'Nutzer', icon: Users }, + { to: '/admin/maturity-models', label: 'Fähigkeitsmatrix', icon: Grid3x3 }, + { to: '/admin/catalogs', label: 'Kataloge', icon: FolderTree }, + { to: '/admin/mediawiki-import', label: 'Wiki-Import', icon: Download }, + ] return ( diff --git a/frontend/src/components/InactiveMembershipBanner.jsx b/frontend/src/components/InactiveMembershipBanner.jsx new file mode 100644 index 0000000..0039749 --- /dev/null +++ b/frontend/src/components/InactiveMembershipBanner.jsx @@ -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 ( +
+ Vereinszugang vorübergehend deaktiviert + + Für {inactive.length === 1 ? 'den Verein' : 'die Vereine'}{' '} + {names}{' '} + 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. + +
+ ) +} diff --git a/frontend/src/components/Navigation.jsx b/frontend/src/components/Navigation.jsx index e9c98de..07e1fed 100644 --- a/frontend/src/components/Navigation.jsx +++ b/frontend/src/components/Navigation.jsx @@ -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 () => { diff --git a/frontend/src/components/PlatformAdminRoute.jsx b/frontend/src/components/PlatformAdminRoute.jsx new file mode 100644 index 0000000..35119c2 --- /dev/null +++ b/frontend/src/components/PlatformAdminRoute.jsx @@ -0,0 +1,10 @@ +import { Navigate } from 'react-router-dom' +import { useAuth } from '../context/AuthContext' + +/** Nur Plattform-Admins (admin/superadmin); Vereinsorga → /admin/users */ +export default function PlatformAdminRoute({ children }) { + const { user } = useAuth() + const ok = user?.role === 'admin' || user?.role === 'superadmin' + if (!ok) return + return children +} diff --git a/frontend/src/components/TrainingPlanExerciseVisibilityPanel.jsx b/frontend/src/components/TrainingPlanExerciseVisibilityPanel.jsx index a14990c..8f26e2f 100644 --- a/frontend/src/components/TrainingPlanExerciseVisibilityPanel.jsx +++ b/frontend/src/components/TrainingPlanExerciseVisibilityPanel.jsx @@ -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') } diff --git a/frontend/src/config/appNav.js b/frontend/src/config/appNav.js index ea87dd6..4a10fee 100644 --- a/frontend/src/config/appNav.js +++ b/frontend/src/config/appNav.js @@ -2,10 +2,11 @@ import { LayoutDashboard, BookOpen, Calendar, + Images, Building2, Settings, Shield, - Target + Inbox } from 'lucide-react' /** @@ -15,30 +16,35 @@ import { * @typedef {{ to: string, label: string, shortLabel?: string, end?: boolean, Icon: import('react').ForwardRefExoticComponent }} AppNavItem */ -/** @returns {Omit[]} */ -function baseItems() { - return [ +/** @param {{ showInbox?: boolean }} opts */ +function baseItems(opts = {}) { + const showInbox = !!opts.showInbox + const items = [ { to: '/', label: 'Übersicht', end: true }, + ...(showInbox ? [{ to: '/inbox', label: 'Posteingang', shortLabel: 'Post' }] : []), { to: '/exercises', label: 'Übungen', shortLabel: 'Übungen' }, { to: '/planning', label: 'Planung' }, + { to: '/media', label: 'Medien', shortLabel: 'Medien' }, { to: '/clubs', label: 'Vereine' }, - { to: '/trainer-contexts', label: 'Meine Bereiche', shortLabel: 'Bereiche' }, { to: '/settings', label: 'Einstellungen', shortLabel: 'Einst.' } ] + return items } -/** @param {boolean} isAdmin */ -export function getMainNavItems(isAdmin) { +/** @param {boolean} isAdmin @param {{ showInbox?: boolean }} opts */ +export function getMainNavItems(isAdmin, opts = {}) { + const showInbox = !!opts.showInbox const icons = [ LayoutDashboard, + ...(showInbox ? [Inbox] : []), BookOpen, Calendar, + Images, Building2, - Target, - Settings + Settings, ] - const raw = baseItems().map((item, i) => ({ + const raw = baseItems(opts).map((item, i) => ({ ...item, Icon: icons[i] })) diff --git a/frontend/src/context/AuthContext.jsx b/frontend/src/context/AuthContext.jsx index 5e06078..50f7d20 100644 --- a/frontend/src/context/AuthContext.jsx +++ b/frontend/src/context/AuthContext.jsx @@ -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 */ } } diff --git a/frontend/src/context/OrgInboxContext.jsx b/frontend/src/context/OrgInboxContext.jsx new file mode 100644 index 0000000..8cbfe8d --- /dev/null +++ b/frontend/src/context/OrgInboxContext.jsx @@ -0,0 +1,88 @@ +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + 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 activeClubMemberships(user.clubs).some((c) => (c.roles || []).includes('club_admin')) +} + +/** Nach Annahme/Ablehnung: Posteingang-Badges & Widget aktualisieren */ +export function notifyOrgInboxChanged() { + window.dispatchEvent(new Event('shinkan:inbox-changed')) +} + +export function OrgInboxProvider({ user, children }) { + const [items, setItems] = useState([]) + const canAccess = useMemo(() => canAccessOrgInbox(user), [user]) + + const refresh = useCallback(async () => { + if (!canAccess) { + setItems([]) + return + } + try { + const data = await api.getInboxJoinRequests() + setItems(Array.isArray(data) ? data : []) + } catch { + setItems([]) + } + }, [canAccess]) + + useEffect(() => { + if (!canAccess) { + setItems([]) + return undefined + } + let cancelled = false + ;(async () => { + try { + const data = await api.getInboxJoinRequests() + if (!cancelled) setItems(Array.isArray(data) ? data : []) + } catch { + if (!cancelled) setItems([]) + } + })() + return () => { + cancelled = true + } + }, [canAccess, user?.id]) + + useEffect(() => { + const onChange = () => { + refresh() + } + window.addEventListener('shinkan:inbox-changed', onChange) + return () => window.removeEventListener('shinkan:inbox-changed', onChange) + }, [refresh]) + + const value = useMemo( + () => ({ + inboxJoinRequests: items, + inboxCount: items.length, + refreshOrgInbox: refresh, + canAccessOrgInbox: canAccess, + }), + [items, refresh, canAccess] + ) + + return {children} +} + +export function useOrgInbox() { + const ctx = useContext(OrgInboxContext) + if (!ctx) { + throw new Error('useOrgInbox must be used within OrgInboxProvider') + } + return ctx +} diff --git a/frontend/src/pages/AccountSettingsPage.jsx b/frontend/src/pages/AccountSettingsPage.jsx index 28fdc64..4dc5224 100644 --- a/frontend/src/pages/AccountSettingsPage.jsx +++ b/frontend/src/pages/AccountSettingsPage.jsx @@ -252,13 +252,22 @@ function AccountSettingsPage() { {user?.clubs?.length ? ( <> - {user.clubs.map((c) => ( -
- {c.name} - {': '} - {(c.roles || []).length ? (c.roles || []).join(', ') : '—'} -
- ))} + {user.clubs.map((c) => { + const mem = (c.membership_status || 'active').toString().trim().toLowerCase() + const inactive = mem === 'inactive' + return ( +
+ {c.name} + {inactive ? ( + + (Vereinszugang deaktiviert) + + ) : null} + {': '} + {(c.roles || []).length ? (c.roles || []).join(', ') : '—'} +
+ ) + })} ) : ( '—' diff --git a/frontend/src/pages/AdminUsersPage.jsx b/frontend/src/pages/AdminUsersPage.jsx index 68902ac..8552ea5 100644 --- a/frontend/src/pages/AdminUsersPage.jsx +++ b/frontend/src/pages/AdminUsersPage.jsx @@ -1,7 +1,8 @@ -import { useEffect, useState } from 'react' +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 = [ @@ -11,68 +12,165 @@ const CLUB_ROLE_OPTIONS = [ { code: 'content_editor', label: 'Inhalte bearbeiten' }, ] -const TIER_OPTIONS = ['free', 'premium', 'pro', 'enterprise'] - -const ROLE_LABEL = { +const PORTAL_ROLE_LABEL = { user: 'Nutzer', - trainer: 'Trainer', - admin: 'Portal-Admin', - superadmin: 'Super-Admin', + trainer: 'Portal-Trainer', + admin: 'Portal-Administrator', + superadmin: 'Super-Administrator', } -function AdminUsersPage() { - const { user } = useAuth() - const isSuper = user?.role === 'superadmin' - const isPlatformAdmin = user?.role === 'admin' || user?.role === 'superadmin' - const portalRoleChoices = isSuper - ? ['user', 'trainer', 'admin', 'superadmin'] - : ['user', 'trainer', 'admin'] +function clubAdminClubIds(user) { + return activeClubMemberships(user?.clubs) + .filter((c) => Array.isArray(c.roles) && c.roles.includes('club_admin')) + .map((c) => c.id) +} - const [users, setUsers] = useState([]) +function clubSelectOptions(user, allClubs, isPlatformAdmin) { + if (!isPlatformAdmin) { + const ids = new Set(clubAdminClubIds(user)) + return (allClubs || []).filter((c) => ids.has(c.id)) + } + return allClubs || [] +} + +/** Plattform-Rollen im UI (Tier/Abo entfällt bis auf Weiteres). */ +function isEscalatedPortalRole(role) { + const r = (role || 'user').toLowerCase() + return r === 'admin' || r === 'superadmin' +} + +function portalRoleSelectOptions(viewerIsSuperadmin, currentRole) { + const base = [ + { value: 'user', label: PORTAL_ROLE_LABEL.user }, + { value: 'trainer', label: `${PORTAL_ROLE_LABEL.trainer} (Legacy)` }, + { value: 'admin', label: PORTAL_ROLE_LABEL.admin }, + ] + const cur = (currentRole || 'user').toLowerCase() + if (viewerIsSuperadmin) base.push({ value: 'superadmin', label: PORTAL_ROLE_LABEL.superadmin }) + const values = new Set(base.map((x) => x.value)) + if (cur && !values.has(cur)) { + base.unshift({ value: cur, label: cur }) + } + return base +} + +export default function AdminUsersPage() { + const { user } = useAuth() + const isPlatformAdmin = user?.role === 'admin' || user?.role === 'superadmin' + const isSuperadminViewer = user?.role === 'superadmin' + const managedClubIds = useMemo(() => clubAdminClubIds(user), [user]) + const clubOrgMode = !isPlatformAdmin && managedClubIds.length > 0 + const canAccess = isPlatformAdmin || clubOrgMode + + const [platformUsers, setPlatformUsers] = useState([]) const [clubs, setClubs] = useState([]) + const [clubMembers, setClubMembers] = useState([]) + const [selectedClubId, setSelectedClubId] = useState( + () => managedClubIds[0] ?? null + ) const [loading, setLoading] = useState(true) const [error, setError] = useState('') const [portalDraft, setPortalDraft] = useState({}) const [assignModal, setAssignModal] = useState(null) const [assignRoles, setAssignRoles] = useState(['trainer']) const [clubEditModal, setClubEditModal] = useState(null) + const [addMemberOpen, setAddMemberOpen] = useState(false) + const [newMemberProfileId, setNewMemberProfileId] = useState('') + const [newMemberRoles, setNewMemberRoles] = useState(['trainer']) + const [pwdModal, setPwdModal] = useState(null) + const [pwdNew, setPwdNew] = useState('') + const [pwdNew2, setPwdNew2] = useState('') - const load = async () => { - setError('') - try { - const [u, c] = await Promise.all([api.listAdminUsers(), api.listClubs()]) - setUsers(u) - setClubs(c) - const d = {} - for (const row of u) { - d[row.id] = { - role: (row.role || 'user').toLowerCase(), - tier: row.tier || 'free', - } - } - setPortalDraft(d) - } catch (e) { - setError(e.message || String(e)) - } finally { - setLoading(false) - } - } + const selectableClubs = useMemo( + () => clubSelectOptions(user, clubs, isPlatformAdmin), + [user, clubs, isPlatformAdmin] + ) useEffect(() => { - if (!isPlatformAdmin) return - load() - }, [isPlatformAdmin]) + if (!clubOrgMode) return + if (selectedClubId == null || !managedClubIds.includes(selectedClubId)) { + setSelectedClubId(managedClubIds[0] ?? null) + } + }, [clubOrgMode, managedClubIds, selectedClubId]) - if (!isPlatformAdmin) { - return - } + const loadPlatform = useCallback(async () => { + const [u, c] = await Promise.all([api.listAdminUsers(), api.listClubs()]) + setPlatformUsers(Array.isArray(u) ? u : []) + setClubs(Array.isArray(c) ? c : []) + const d = {} + for (const row of u || []) { + d[row.id] = { role: (row.role || 'user').toLowerCase() } + } + setPortalDraft(d) + }, []) + + useEffect(() => { + if (!canAccess) return + if (clubOrgMode) return + let cancelled = false + ;(async () => { + setError('') + setLoading(true) + try { + await loadPlatform() + } catch (e) { + if (!cancelled) setError(e.message || String(e)) + } finally { + if (!cancelled) setLoading(false) + } + })() + return () => { + cancelled = true + } + }, [canAccess, clubOrgMode, loadPlatform]) + + useEffect(() => { + if (!canAccess || !clubOrgMode || !selectedClubId) return + let cancelled = false + ;(async () => { + setError('') + setLoading(true) + try { + const [c, m] = await Promise.all([ + api.listClubs(), + api.listClubMembers(selectedClubId, { includeInactive: true }), + ]) + if (!cancelled) { + setClubs(Array.isArray(c) ? c : []) + setClubMembers(Array.isArray(m) ? m : []) + } + } catch (e) { + if (!cancelled) setError(e.message || String(e)) + } finally { + if (!cancelled) setLoading(false) + } + })() + return () => { + cancelled = true + } + }, [canAccess, clubOrgMode, selectedClubId]) + + const reloadClubMembers = useCallback(async () => { + if (!selectedClubId) return + try { + const m = await api.listClubMembers(selectedClubId, { includeInactive: true }) + setClubMembers(Array.isArray(m) ? m : []) + } catch { + setClubMembers([]) + } + }, [selectedClubId]) + + if (!canAccess) return + + const selectedClubLabel = + selectableClubs.find((c) => c.id === selectedClubId)?.name || 'Verein' const savePortal = async (profileId) => { const dr = portalDraft[profileId] if (!dr) return try { - await api.updateProfile(profileId, { role: dr.role, tier: dr.tier }) - await load() + await api.updateProfile(profileId, { role: dr.role }) + await loadPlatform() } catch (e) { alert(e.message || String(e)) } @@ -90,7 +188,7 @@ function AdminUsersPage() { await api.addClubMember(clubId, { profile_id: profileId, roles: assignRoles }) setAssignModal(null) setAssignRoles(['trainer']) - await load() + await loadPlatform() } catch (e) { alert(e.message || String(e)) } @@ -102,7 +200,31 @@ function AdminUsersPage() { try { await api.updateClubMember(clubId, profileId, { roles, status }) setClubEditModal(null) - await load() + if (clubOrgMode) await reloadClubMembers() + else await loadPlatform() + } catch (e) { + alert(e.message || String(e)) + } + } + + 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)) } @@ -114,7 +236,69 @@ function AdminUsersPage() { try { await api.removeClubMember(clubEditModal.clubId, clubEditModal.profileId) setClubEditModal(null) - await load() + if (clubOrgMode) await reloadClubMembers() + else await loadPlatform() + } catch (e) { + alert(e.message || String(e)) + } + } + + const submitAddClubMember = async () => { + const raw = parseInt(String(newMemberProfileId).trim(), 10) + if (!Number.isFinite(raw) || raw < 1) { + alert('Gültige Profil-ID eingeben.') + return + } + if (!selectedClubId) return + if (!newMemberRoles.length) { + alert('Mindestens eine Vereinsrolle.') + return + } + try { + await api.addClubMember(selectedClubId, { profile_id: raw, roles: newMemberRoles }) + setAddMemberOpen(false) + setNewMemberProfileId('') + setNewMemberRoles(['trainer']) + await reloadClubMembers() + } catch (e) { + alert(e.message || String(e)) + } + } + + const submitPasswordEmail = async () => { + if (!pwdModal) return + try { + const res = await api.managementPasswordReset(pwdModal.profileId, null) + setPwdModal(null) + setPwdNew('') + setPwdNew2('') + let msg = + 'Sofern eine E-Mail-Adresse hinterlegt ist, wurde ein Link zum Setzen eines neuen Passworts versendet. Das bisherige Passwort bleibt bis zur Bestätigung im Link aktiv.' + if (res?.email_sent === false) { + msg += ' Hinweis: Der E-Mail-Versand ist fehlgeschlagen (SMTP prüfen).' + } + alert(msg) + } catch (e) { + alert(e.message || String(e)) + } + } + + const submitPasswordDirect = async () => { + if (!pwdModal || !isSuperadminViewer) return + if (pwdNew.length < 8) { + alert('Mindestens 8 Zeichen.') + return + } + if (pwdNew !== pwdNew2) { + alert('Die beiden Passwörter stimmen nicht überein.') + return + } + try { + await api.managementPasswordReset(pwdModal.profileId, pwdNew) + setPwdModal(null) + setPwdNew('') + setPwdNew2('') + alert('Neues Passwort wurde direkt gesetzt.') } catch (e) { alert(e.message || String(e)) } @@ -122,13 +306,73 @@ function AdminUsersPage() { return (
- -

Portal-Nutzer & Vereine

-

- Alle Konten mit Vereinszuordnungen. Hier kannst du die Portal-Rolle (Zugriff auf - Admin-Funktionen) und das Tier setzen sowie Nutzer explizit einem Verein mit Rollen - zuordnen. -

+ + +

Nutzer & Vereinsrollen

+ + {clubOrgMode ? ( +

+ Du verwaltest Mitglieder des ausgewählten Vereins. Vereinszugang deaktivieren sperrt nur die + Sicht auf Vereinsinhalte — der Login des Nutzers bleibt möglich. Wiederherstellen über „aktivieren“ oder + Bearbeiten. +

+ ) : ( + <> +

+ Gesamtübersicht aller Konten und Vereinszuordnungen. Portal-Einstellungen steuern nur den + Zugang zur plattformweiten Administration (Kataloge, Hierarchie, globale Nutzerliste usw.); + Vereinsarbeit (Trainer, Vereinsadmin …) bleiben Vereinsrollen. Abonnement/Tier ist + derzeit nicht freigeschaltet. +

+
+ Die vier Portal-Zugriffsstufen: +
    +
  • + Nutzer — Standardnutzer ohne Plattform-Admin-Bereiche. +
  • +
  • + Portal-Trainer (Legacy) — ältere Kennzeichnung auf Profil-Ebene; organisatorisch ist + „Trainer“ in der Regel eine Vereinsrolle. +
  • +
  • + Portal-Administrator — Zugang zu allen geschützten /admin-Bereichen + (Außer: einige Funktionen nur Superadmin, z. B. bestimmte Medien-/Governance-Aktionen). +
  • +
  • + Super-Administrator — volle Plattform-Governance (u. a. offizielle Medien, + Superadmin-Rolle vergeben, harte Lifecycle-Aktionen an Medien). +
  • +
+
+ + )} + + {clubOrgMode && managedClubIds.length > 1 ? ( +
+ + +
+ ) : null} {loading ? (

Laden…

@@ -136,142 +380,250 @@ function AdminUsersPage() {
{error}
- ) : ( + ) : clubOrgMode ? (
- {users.map((row) => { - const tierValue = portalDraft[row.id]?.tier ?? row.tier ?? 'free' - const tierChoices = [...TIER_OPTIONS] - if (tierValue && !tierChoices.includes(tierValue)) tierChoices.unshift(tierValue) - return ( -
-
-
- - {row.name || '—'} #{row.id} - -
{row.email || '—'}
-
- Verifiziert: {row.email_verified ? 'ja' : 'nein'} -
-
-
+
+ + +
+ {!clubMembers.length ? ( +

Keine Mitglieder in diesem Verein.

+ ) : ( + clubMembers.map((m) => { + const memStatus = (m.status || 'active').toLowerCase() + return ( +
+
- - -
-
- - -
- - -
-
- -
- Vereinsmitgliedschaften - {!row.clubs?.length ? ( -

- Keine Zuordnung. -

- ) : ( -
    - {row.clubs.map((c) => ( -
  • - {c.name} - {c.abbreviation ? ` (${c.abbreviation})` : ''} —{' '} - {(c.roles || []).join(', ') || '—'} - {c.membership_status === 'inactive' ? ( - (inaktiv) - ) : null}{' '} + Bearbeiten + + {m.profile_id !== user?.id ? ( + memStatus === 'inactive' ? ( -
  • - ))} -
- )} + ) : ( + + ) + ) : null} + {m.profile_id !== user?.id && !isEscalatedPortalRole(m.portal_role) ? ( + + ) : null} +
+
+
+ ) + }) + )} +
+ ) : ( +
+ {platformUsers.map((row) => { + const portalRoleChoices = portalRoleSelectOptions(isSuperadminViewer, row.role) + return ( +
+
+
+ + {row.name || '—'} #{row.id} + +
{row.email || '—'}
+
+ Verifiziert: {row.email_verified ? 'ja' : 'nein'} +
+
+
+
+ + +
+ + + +
+
+ +
+ Vereinsmitgliedschaften + {!row.clubs?.length ? ( +

+ Keine Zuordnung. +

+ ) : ( +
    + {(row.clubs || []).map((c) => ( +
  • + {c.name} + {c.abbreviation ? ` (${c.abbreviation})` : ''} —{' '} + {(c.roles || []).join(', ') || '—'} + {c.membership_status === 'inactive' ? ( + + {' '} + (Vereinszugang deaktiviert) + + ) : null}{' '} + + {isSuperadminViewer && c.membership_status === 'inactive' ? ( + + ) : null} +
  • + ))} +
+ )} +
-
) })}
@@ -346,11 +698,7 @@ function AdminUsersPage() { -
@@ -358,6 +706,77 @@ function AdminUsersPage() { )} + {addMemberOpen && clubOrgMode ? ( +
+
+

Mitglied hinzufügen

+

+ Verein: {selectedClubLabel} +

+
+ + setNewMemberProfileId(e.target.value)} + placeholder="z. B. 42" + /> +
+
+ Rollen +
+ {CLUB_ROLE_OPTIONS.map((opt) => ( + + ))} +
+
+
+ + +
+
+
+ ) : null} + {clubEditModal && (
{clubEditModal.profileLabel} → {clubEditModal.clubName}

+

+ „Deaktiviert“ betrifft nur den Zugriff auf Inhalte dieses Vereins; Login und andere Vereine bleiben + unberührt. +

- +
@@ -441,8 +864,88 @@ function AdminUsersPage() {
)} + + {pwdModal ? ( +
+
+

Passwort zurücksetzen

+

{pwdModal.label}

+

+ Standard: Es wird ein sicherer Link per E-Mail verschickt (wie „Passwort vergessen“). Das bisherige + Passwort bleibt gültig, bis die Person den Link nutzt und ein neues Passwort wählt. +

+
+ +
+ {isSuperadminViewer ? ( + <> +
+

+ Ausnahme: Passwort direkt setzen (nur bei Bedarf). Das bisherige Passwort ist danach ungültig. +

+
+ + setPwdNew(e.target.value)} + /> +
+
+ + setPwdNew2(e.target.value)} + /> +
+ + + ) : null} +
+ +
+
+
+ ) : null} ) } - -export default AdminUsersPage diff --git a/frontend/src/pages/ClubsPage.jsx b/frontend/src/pages/ClubsPage.jsx index 64347f2..3e61004 100644 --- a/frontend/src/pages/ClubsPage.jsx +++ b/frontend/src/pages/ClubsPage.jsx @@ -1,6 +1,8 @@ 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 = [ @@ -40,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) ) @@ -48,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 || @@ -194,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) @@ -647,6 +671,7 @@ function ClubsPage() { if (!confirm('Antrag ablehnen?')) return try { await api.rejectClubJoinRequest(membersAdminClubId, req.id) + notifyOrgInboxChanged() await reloadMembersAdmin() } catch (err) { alert(err.message || String(err)) @@ -664,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} +
- ))} + ) + })}
)} @@ -1255,6 +1332,7 @@ function ClubsPage() { acceptJoinModal.id, acceptJoinModal.roles.length ? acceptJoinModal.roles : ['trainer'] ) + notifyOrgInboxChanged() setAcceptJoinModal(null) await reloadMembersAdmin() await loadData() @@ -1307,7 +1385,7 @@ function ClubsPage() { {editMemberModal.name || editMemberModal.email} (#{editMemberModal.profile_id})

- +
+

+ Deaktivierung gilt nur für diesen Verein; der Login-Account bleibt aktiv. +

Rollen
diff --git a/frontend/src/pages/Dashboard.jsx b/frontend/src/pages/Dashboard.jsx index 6dc8ee9..e744684 100644 --- a/frontend/src/pages/Dashboard.jsx +++ b/frontend/src/pages/Dashboard.jsx @@ -5,6 +5,7 @@ import { useAuth } from '../context/AuthContext' import api from '../utils/api' import EmailVerificationBanner from '../components/EmailVerificationBanner' import DashboardTrainingVisibilityWidget from '../components/DashboardTrainingVisibilityWidget' +import DashboardOrgInboxWidget from '../components/DashboardOrgInboxWidget' function unitWhenLabel(u) { const d = u.planned_date ? String(u.planned_date).slice(0, 10) : '' @@ -183,6 +184,7 @@ function Dashboard() { {user?.id ? ( <> +
diff --git a/frontend/src/pages/ExercisesListPage.jsx b/frontend/src/pages/ExercisesListPage.jsx index 62b85c5..aed1082 100644 --- a/frontend/src/pages/ExercisesListPage.jsx +++ b/frontend/src/pages/ExercisesListPage.jsx @@ -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)} > - {(user?.clubs || []).map((c) => ( + {activeClubMemberships(user?.clubs).map((c) => ( diff --git a/frontend/src/pages/InboxPage.jsx b/frontend/src/pages/InboxPage.jsx new file mode 100644 index 0000000..2aead00 --- /dev/null +++ b/frontend/src/pages/InboxPage.jsx @@ -0,0 +1,222 @@ +import React, { useState, useEffect, useCallback } from 'react' +import { Link } from 'react-router-dom' +import api from '../utils/api' +import { notifyOrgInboxChanged, useOrgInbox } from '../context/OrgInboxContext' + +const CLUB_ROLE_OPTIONS = [ + { code: 'club_admin', label: 'Vereinsadmin' }, + { code: 'trainer', label: 'Trainer' }, + { code: 'division_lead', label: 'Spartenleitung' }, + { code: 'content_editor', label: 'Inhalte bearbeiten' }, +] + +function formatWhen(iso) { + if (!iso) return '' + const s = String(iso) + const d = s.includes('T') ? s.split('T')[0] : s.slice(0, 10) + const t = s.includes('T') ? s.split('T')[1] : '' + const time = t ? t.slice(0, 5) : '' + return time ? `${d} · ${time}` : d +} + +export default function InboxPage() { + const { canAccessOrgInbox, refreshOrgInbox, inboxJoinRequests } = useOrgInbox() + const [loading, setLoading] = useState(true) + const [acceptModal, setAcceptModal] = useState(null) + + const load = useCallback(async () => { + if (!canAccessOrgInbox) { + setLoading(false) + return + } + setLoading(true) + try { + await refreshOrgInbox() + } finally { + setLoading(false) + } + }, [canAccessOrgInbox, refreshOrgInbox]) + + useEffect(() => { + load() + }, [load]) + + if (!canAccessOrgInbox) { + return ( +
+

Posteingang

+

Kein Zugriff. Nur Plattform-Admins und Vereinsadmins sehen den Posteingang.

+

+ Zur Übersicht +

+
+ ) + } + + return ( +
+
+
+

+ Posteingang +

+

+ Offene Beitrittsanträge zu Vereinen, für die du zuständig bist. +

+
+ +
+ + {loading ? ( +
+
+
+ ) : inboxJoinRequests.length === 0 ? ( +
+

+ Keine offenen Beitrittsanträge. +

+
+ ) : ( +
+ {inboxJoinRequests.map((req) => ( +
+
+
+ {req.club_name || 'Verein'} + {req.club_abbreviation ? ( + + ({req.club_abbreviation}) + + ) : null} +
+ + {req.applicant_name || req.applicant_email || 'Bewerber/in'} + +
+ {req.applicant_email} · Profil #{req.profile_id} · {formatWhen(req.created_at)} +
+ {req.message ?

{req.message}

: null} +
+
+ + +
+
+ ))} +
+ )} + + {acceptModal && ( +
+
+

Antrag annehmen

+

{acceptModal.label}

+
+ Rollen bei Aufnahme +
+ {CLUB_ROLE_OPTIONS.map((opt) => ( + + ))} +
+
+
+ + +
+
+
+ )} +
+ ) +} diff --git a/frontend/src/pages/MediaLibraryPage.jsx b/frontend/src/pages/MediaLibraryPage.jsx index 8c39e0a..7a2f236 100644 --- a/frontend/src/pages/MediaLibraryPage.jsx +++ b/frontend/src/pages/MediaLibraryPage.jsx @@ -21,7 +21,7 @@ import { } from 'lucide-react' import { useAuth } from '../context/AuthContext' import api from '../utils/api' -import AdminPageNav from '../components/AdminPageNav' +import { activeClubMemberships } from '../utils/activeClub' import { resolveMediaAssetFileUrl } from '../utils/exerciseMediaUrl' const LC_OPTIONS = [ @@ -234,6 +234,7 @@ export default function MediaLibraryPage() { const { user } = useAuth() const isPlatformAdmin = user?.role === 'admin' || user?.role === 'superadmin' const isSuperadmin = user?.role === 'superadmin' + const hasClubOrgAdmin = activeClubMemberships(user?.clubs).some((c) => (c.roles || []).includes('club_admin')) const archiveVisOptions = useMemo( () => VIS_OPTIONS.filter((o) => o.value !== 'official' || isSuperadmin), @@ -538,22 +539,25 @@ export default function MediaLibraryPage() { return (
- {isPlatformAdmin ? : null} -

Medienbibliothek

+ Übersicht Übungen - {isPlatformAdmin ? Admin : null} + {isPlatformAdmin ? Plattform-Admin : null} + {hasClubOrgAdmin || isPlatformAdmin ? ( + Nutzer & Organisation + ) : null}

- Veröffentlichte Medien (Verein/Plattform) und eigene Uploads — „Privat“ steuert nur, wer das Asset in der - Datenbank sieht; der Ablageordner folgt dem gewählten Verein wie bei „Verein“. Plattform-Admins wählen den - Zielverein bei privatem Archiv-Upload aktiv. Suche durchsucht Bezeichner, Speicherpfad, Copyright und Tags. - Bearbeiten über das Menü — Bulk in der unteren Leiste. + Offizielle und vereinsfreigegebene Medien sind für alle passenden Nutzer sichtbar. Eigene private Medien + kannst du bearbeiten, veröffentlichen oder in den Papierkorb legen; im Papierkorb siehst du als Standardnutzer + nur deine eigenen privaten Objekte, als Vereinsadmin zusätzlich den Vereins-Papierkorb. Vereins-Rollen können + Vereins-Medien verwalten, aber nicht bis „Offiziell“ anheben — das bleibt dem Superadmin vorbehalten. + Plattform-Admins geben beim privaten Upload den Zielverein an (club_id).

diff --git a/frontend/src/pages/TrainingPlanningPage.jsx b/frontend/src/pages/TrainingPlanningPage.jsx index e41289b..77ffa61 100644 --- a/frontend/src/pages/TrainingPlanningPage.jsx +++ b/frontend/src/pages/TrainingPlanningPage.jsx @@ -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) diff --git a/frontend/src/utils/activeClub.js b/frontend/src/utils/activeClub.js index 3c97671..2aa95fe 100644 --- a/frontend/src/utils/activeClub.js +++ b/frontend/src/utils/activeClub.js @@ -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) => diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js index 8bf5c55..cedb03b 100644 --- a/frontend/src/utils/api.js +++ b/frontend/src/utils/api.js @@ -139,6 +139,21 @@ export async function updateProfile(profileId, data) { }) } +/** + * Passwort anderer Konten: Standard leerer Body → E-Mail mit Reset-Link (wie Passwort vergessen). + * Nur Super-Admins dürfen `newPassword` setzen (direktes Überschreiben des Passwort-Hashes). + */ +export async function managementPasswordReset(profileId, newPassword = null) { + const body = {} + if (newPassword != null && String(newPassword).trim() !== '') { + body.new_password = newPassword + } + return request(`/api/profiles/${profileId}/management-password-reset`, { + method: 'POST', + body: JSON.stringify(body), + }) +} + export async function changePassword(newPassword) { return request('/api/auth/pin', { method: 'PUT', @@ -264,6 +279,11 @@ export async function rejectClubJoinRequest(clubId, requestId) { }) } +/** Aggregierter Posteingang: offene Beitrittsanträge für Vereins-/Plattform-Admins. */ +export async function getInboxJoinRequests() { + return request('/api/me/inbox/join-requests') +} + export async function listDivisions(clubId) { const query = clubId ? `?club_id=${clubId}` : '' return request(`/api/divisions${query}`) @@ -1303,6 +1323,7 @@ export const api = { listProfiles, listAdminUsers, updateProfile, + managementPasswordReset, changePassword, verifyEmail, resendVerification, @@ -1326,6 +1347,7 @@ export const api = { listClubJoinRequests, acceptClubJoinRequest, rejectClubJoinRequest, + getInboxJoinRequests, listDivisions, createDivision, updateDivision, diff --git a/frontend/src/utils/exercisePermissions.js b/frontend/src/utils/exercisePermissions.js index c66e297..800e473 100644 --- a/frontend/src/utils/exercisePermissions.js +++ b/frontend/src/utils/exercisePermissions.js @@ -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')) } /**