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/DashboardOrgInboxWidget.jsx b/frontend/src/components/DashboardOrgInboxWidget.jsx
new file mode 100644
index 0000000..eafb532
--- /dev/null
+++ b/frontend/src/components/DashboardOrgInboxWidget.jsx
@@ -0,0 +1,57 @@
+import { Link } from 'react-router-dom'
+import { Inbox } from 'lucide-react'
+import { useOrgInbox } from '../context/OrgInboxContext'
+
+/**
+ * Desktop-Dashboard: Hinweis auf offene Beitrittsanträge (nur ab 1024px sichtbar via CSS).
+ */
+export default function DashboardOrgInboxWidget() {
+ const { canAccessOrgInbox, inboxJoinRequests, inboxCount } = useOrgInbox()
+
+ if (!canAccessOrgInbox) return null
+
+ const preview = (inboxJoinRequests || []).slice(0, 5)
+
+ return (
+
+
+
+
+
+ Posteingang
+
+ {inboxCount > 0 ? (
+
+ {inboxCount}
+
+ ) : null}
+
+
+ {inboxCount === 0
+ ? 'Keine offenen Beitrittsanträge.'
+ : `${inboxCount} offene Beitrittsantrag${inboxCount === 1 ? '' : 'e'}.`}
+
+ {preview.length > 0 ? (
+
+ {preview.map((req) => (
+
+ {req.club_name || 'Verein'}
+
+ {req.applicant_name || req.applicant_email || 'Bewerber/in'}
+
+
+ ))}
+
+ ) : null}
+
+
+ Zum Posteingang
+
+
+
+
+ )
+}
diff --git a/frontend/src/components/DesktopSidebar.jsx b/frontend/src/components/DesktopSidebar.jsx
index a918e17..a4af493 100644
--- a/frontend/src/components/DesktopSidebar.jsx
+++ b/frontend/src/components/DesktopSidebar.jsx
@@ -1,6 +1,7 @@
import { NavLink, useLocation } from 'react-router-dom'
import { LogOut } from 'lucide-react'
import { getMainNavItems } from '../config/appNav'
+import { useOrgInbox } from '../context/OrgInboxContext'
import ActiveClubSwitcher from './ActiveClubSwitcher'
function sidebarLinkActive(pathname, item, routerIsActive) {
@@ -12,12 +13,13 @@ function sidebarLinkActive(pathname, item, routerIsActive) {
* Desktop-Sidebar (≥1024px) — Sichtbarkeit via CSS (.desktop-sidebar).
*/
export default function DesktopSidebar({
- isAdmin,
+ showAdminNav,
user,
onLogout
}) {
const loc = useLocation()
- const items = getMainNavItems(isAdmin)
+ const { canAccessOrgInbox, inboxCount } = useOrgInbox()
+ const items = getMainNavItems(showAdminNav, { showInbox: canAccessOrgInbox })
const tier = user?.tier || ''
return (
@@ -42,6 +44,11 @@ export default function DesktopSidebar({
>
{item.label}
+ {item.to === '/inbox' && inboxCount > 0 ? (
+
+ {inboxCount > 99 ? '99+' : inboxCount}
+
+ ) : null}
))}
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 ? (
+
+ Verein für Verwaltung
+ setSelectedClubId(parseInt(e.target.value, 10))}
+ >
+ {selectableClubs.map((c) => (
+
+ {c.name}
+
+ ))}
+
+
+ ) : 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'}
-
-
-
+
+ setAddMemberOpen(true)}>
+ Mitglied hinzufügen (Profil-ID)
+
+ reloadClubMembers()}>
+ Aktualisieren
+
+
+ {!clubMembers.length ? (
+
Keine Mitglieder in diesem Verein.
+ ) : (
+ clubMembers.map((m) => {
+ const memStatus = (m.status || 'active').toLowerCase()
+ return (
+
+
-
- Portal-Rolle
-
-
- setPortalDraft((prev) => ({
- ...prev,
- [row.id]: { ...prev[row.id], role: e.target.value, tier: prev[row.id]?.tier ?? row.tier },
- }))
+
+ {m.name || '—'} #{m.profile_id}
+
+ {m.email || '—'}
+
+ Vereinszugang:{' '}
+
+ {memStatus === 'active' ? 'aktiv' : 'deaktiviert'}
+
+ {' '}
+ · Verifiziert: {m.email_verified ? 'ja' : 'nein'}
+
+
+ Rollen: {(m.roles || []).join(', ') || '—'}
+
+
+
+
+ setClubEditModal({
+ clubId: selectedClubId,
+ clubName: selectedClubLabel,
+ profileId: m.profile_id,
+ profileLabel: m.name || m.email,
+ roles: [...(m.roles || [])],
+ status: memStatus,
+ })
}
>
- {portalRoleChoices.map((r) => (
-
- {ROLE_LABEL[r] || r}
-
- ))}
-
-
-
-
- Tier
-
-
- setPortalDraft((prev) => ({
- ...prev,
- [row.id]: {
- ...prev[row.id],
- tier: e.target.value,
- role: prev[row.id]?.role ?? row.role,
- },
- }))
- }
- >
- {tierChoices.map((t) => (
-
- {t}
-
- ))}
-
-
-
savePortal(row.id)}>
- Portal speichern
-
-
{
- if (!clubs.length) return
- setAssignRoles(['trainer'])
- setAssignModal({
- profileId: row.id,
- profileLabel: row.name || row.email || `#${row.id}`,
- clubId: clubs[0]?.id ?? '',
- })
- }}
- >
- Verein zuweisen
-
-
-
-
-
-
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' ? (
- setClubEditModal({
- clubId: c.id,
- clubName: c.name,
- profileId: row.id,
- profileLabel: row.name || row.email,
- roles: [...(c.roles || [])],
- status: (c.membership_status || 'active').toLowerCase(),
- })
- }
+ className="btn btn-primary"
+ onClick={() => toggleMemberClubAccess(m, true)}
>
- bearbeiten
+ Vereinszugang aktivieren
-
- ))}
-
- )}
+ ) : (
+
toggleMemberClubAccess(m, false)}
+ >
+ Vereinszugang deaktivieren
+
+ )
+ ) : null}
+ {m.profile_id !== user?.id && !isEscalatedPortalRole(m.portal_role) ? (
+
+ setPwdModal({
+ profileId: m.profile_id,
+ label: m.name || m.email || `Profil #${m.profile_id}`,
+ })
+ }
+ >
+ Passwort-Link senden
+
+ ) : null}
+
+
+
+ )
+ })
+ )}
+
+ ) : (
+
+ {platformUsers.map((row) => {
+ const portalRoleChoices = portalRoleSelectOptions(isSuperadminViewer, row.role)
+ return (
+
+
+
+
+ {row.name || '—'} #{row.id}
+
+
{row.email || '—'}
+
+ Verifiziert: {row.email_verified ? 'ja' : 'nein'}
+
+
+
+
+
+ Portal-Zugriff
+
+
+ setPortalDraft((prev) => ({
+ ...prev,
+ [row.id]: { role: e.target.value },
+ }))
+ }
+ >
+ {portalRoleChoices.map((r) => (
+
+ {r.label}
+
+ ))}
+
+
+
+ setPwdModal({
+ profileId: row.id,
+ label: row.name || row.email || `#${row.id}`,
+ })
+ }
+ >
+ Passwort / Link
+
+
savePortal(row.id)}>
+ Portal speichern
+
+
{
+ if (!clubs.length) return
+ setAssignRoles(['trainer'])
+ setAssignModal({
+ profileId: row.id,
+ profileLabel: row.name || row.email || `#${row.id}`,
+ clubId: clubs[0]?.id ?? '',
+ })
+ }}
+ >
+ Verein zuweisen
+
+
+
+
+
+
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}{' '}
+
+ setClubEditModal({
+ clubId: c.id,
+ clubName: c.name,
+ profileId: row.id,
+ profileLabel: row.name || row.email,
+ roles: [...(c.roles || [])],
+ status: (c.membership_status || 'active').toLowerCase(),
+ })
+ }
+ >
+ bearbeiten
+
+ {isSuperadminViewer && c.membership_status === 'inactive' ? (
+ {
+ try {
+ await api.updateClubMember(c.id, row.id, {
+ roles: [...(c.roles || [])],
+ status: 'active',
+ })
+ await loadPlatform()
+ } catch (e) {
+ alert(e.message || String(e))
+ }
+ }}
+ >
+ Zugang aktivieren
+
+ ) : null}
+
+ ))}
+
+ )}
+
-
)
})}
@@ -346,11 +698,7 @@ function AdminUsersPage() {
Zuweisen
-
setAssignModal(null)}
- >
+ setAssignModal(null)}>
Abbrechen
@@ -358,6 +706,77 @@ function AdminUsersPage() {
)}
+ {addMemberOpen && clubOrgMode ? (
+
+
+
Mitglied hinzufügen
+
+ Verein: {selectedClubLabel}
+
+
+ Profil-ID
+ setNewMemberProfileId(e.target.value)}
+ placeholder="z. B. 42"
+ />
+
+
+
Rollen
+
+ {CLUB_ROLE_OPTIONS.map((opt) => (
+
+ {
+ setNewMemberRoles((prev) => {
+ const s = new Set(prev)
+ if (s.has(opt.code)) s.delete(opt.code)
+ else s.add(opt.code)
+ const out = Array.from(s)
+ return out.length ? out : ['trainer']
+ })
+ }}
+ />
+ {opt.label}
+
+ ))}
+
+
+
+
+ Hinzufügen
+
+ setAddMemberOpen(false)}>
+ Abbrechen
+
+
+
+
+ ) : null}
+
{clubEditModal && (
{clubEditModal.profileLabel} → {clubEditModal.clubName}
+
+ „Deaktiviert“ betrifft nur den Zugriff auf Inhalte dieses Vereins; Login und andere Vereine bleiben
+ unberührt.
+
- Status
+ Vereinszugang
(prev ? { ...prev, status: e.target.value } : prev))
}
>
- aktiv
- inaktiv
+ aktiv — sieht Vereinsinhalte
+ deaktiviert — kein Zugriff auf Vereinsinhalte
@@ -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.
+
+
+
+ Reset-Link per E-Mail senden
+
+
+ {isSuperadminViewer ? (
+ <>
+
+
+ Ausnahme: Passwort direkt setzen (nur bei Bedarf). Das bisherige Passwort ist danach ungültig.
+
+
+ Neues Passwort
+ setPwdNew(e.target.value)}
+ />
+
+
+ Wiederholen
+ setPwdNew2(e.target.value)}
+ />
+
+
+ Passwort direkt setzen
+
+ >
+ ) : null}
+
+ {
+ setPwdModal(null)
+ setPwdNew('')
+ setPwdNew2('')
+ }}
+ >
+ Schließen
+
+
+
+
+ ) : 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(', ') || '—'}
-
setEditMemberModal(m)}
- >
- Bearbeiten
-
+
+ setEditMemberModal(m)}>
+ Mitglied bearbeiten
+
+ {m.profile_id !== user?.id ? (
+ inactiveRow ? (
+ toggleMembersAdminClubAccess(m, true)}
+ >
+ Vereinszugang aktivieren
+
+ ) : (
+ toggleMembersAdminClubAccess(m, false)}
+ >
+ Vereinszugang deaktivieren
+
+ )
+ ) : 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})
- Status
+ Vereinszugang
(prev ? { ...prev, status: e.target.value } : prev))
}
>
- aktiv
- inaktiv
+ aktiv — sieht Vereinsinhalte
+ deaktiviert — weiter anmeldbar, keine Vereinsinhalte
+
+ 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)}
>
Aktiver Verein (Vereins-Umschalter / Header)
- {(user?.clubs || []).map((c) => (
+ {activeClubMemberships(user?.clubs).map((c) => (
{c.name || `#${c.id}`}
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.
+
+
+
load()} disabled={loading}>
+ Aktualisieren
+
+
+
+ {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}
+
+
+
+ setAcceptModal({
+ id: req.id,
+ club_id: req.club_id,
+ label: req.applicant_name || req.applicant_email,
+ roles: ['trainer'],
+ })
+ }
+ >
+ Annehmen
+
+ {
+ if (!confirm('Antrag ablehnen?')) return
+ try {
+ await api.rejectClubJoinRequest(req.club_id, req.id)
+ notifyOrgInboxChanged()
+ await load()
+ } catch (err) {
+ alert(err.message || String(err))
+ }
+ }}
+ >
+ Ablehnen
+
+
+
+ ))}
+
+ )}
+
+ {acceptModal && (
+
+
+
Antrag annehmen
+
{acceptModal.label}
+
+
Rollen bei Aufnahme
+
+ {CLUB_ROLE_OPTIONS.map((opt) => (
+
+ {
+ setAcceptModal((prev) => {
+ if (!prev) return prev
+ const set = new Set(prev.roles)
+ if (set.has(opt.code)) set.delete(opt.code)
+ else set.add(opt.code)
+ const roles = Array.from(set)
+ return { ...prev, roles: roles.length ? roles : ['trainer'] }
+ })
+ }}
+ />
+ {opt.label}
+
+ ))}
+
+
+
+ {
+ try {
+ await api.acceptClubJoinRequest(
+ acceptModal.club_id,
+ acceptModal.id,
+ acceptModal.roles.length ? acceptModal.roles : ['trainer']
+ )
+ setAcceptModal(null)
+ notifyOrgInboxChanged()
+ await load()
+ } catch (err) {
+ alert(err.message || String(err))
+ }
+ }}
+ >
+ Aufnehmen
+
+ setAcceptModal(null)}>
+ Abbrechen
+
+
+
+
+ )}
+
+ )
+}
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'))
}
/**