Merge pull request 'Administration für Vereinsadmin, Sperren von Usern, Inbox, Medienmanger für alle, etc.' (#28) from develop into main
All checks were successful
Deploy Production / deploy (push) Successful in 37s
Test Suite / pytest-backend (push) Successful in 25s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 7s
Test Suite / playwright-tests (push) Successful in 26s

Reviewed-on: #28
This commit is contained in:
Lars 2026-05-09 11:01:02 +02:00
commit b19940c997
38 changed files with 2202 additions and 446 deletions

View File

@ -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"

View File

@ -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_<token>-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)

View File

@ -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).")

View File

@ -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

View File

@ -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),

View File

@ -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()]

View File

@ -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")

View File

@ -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:

View File

@ -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)."""

View File

@ -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()]

View File

@ -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()]

View File

@ -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,
)

View File

@ -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

View File

@ -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.Icon size={26} strokeWidth={2} />
{item.to === '/inbox' && inboxCount > 0 ? (
<span className="nav-item__badge" aria-label={`${inboxCount} offen`}>
{inboxCount > 99 ? '99+' : inboxCount}
</span>
) : null}
<span>{item.shortLabel || item.label}</span>
</NavLink>
))}
@ -95,11 +114,11 @@ function ProtectedLayout() {
return <Navigate to="/login" replace />
}
const isAdmin = user?.role === 'admin' || user?.role === 'superadmin'
const showAdminNav = computeShowAdminNav(user)
return (
<>
<DesktopSidebar isAdmin={isAdmin} user={user} onLogout={handleLogout} />
<OrgInboxProvider user={user}>
<DesktopSidebar showAdminNav={showAdminNav} user={user} onLogout={handleLogout} />
<div className="app-shell">
<div className="app-shell__column">
<div className="app-header app-header--mobile app-header--mobile-stack">
@ -109,12 +128,13 @@ function ProtectedLayout() {
<ActiveClubSwitcher variant="mobile" />
</div>
<div className="app-main">
<InactiveMembershipBanner />
<Outlet />
</div>
<Nav isAdmin={isAdmin} />
<Nav showAdminNav={showAdminNav} />
</div>
</div>
</>
</OrgInboxProvider>
)
}
@ -167,6 +187,7 @@ function AppRoutes() {
<Route path=":id" element={<ExerciseDetailPage />} />
</Route>
<Route path="clubs" element={<ClubsPage />} />
<Route path="inbox" element={<InboxPage />} />
<Route path="skills" element={<SkillsPage />} />
<Route path="planning/framework-programs/new" element={<TrainingFrameworkProgramEditPage />} />
<Route path="planning/framework-programs/:id" element={<TrainingFrameworkProgramEditPage />} />
@ -174,12 +195,40 @@ function AppRoutes() {
<Route path="planning/run/:unitId/coach" element={<TrainingCoachPage />} />
<Route path="planning/run/:unitId" element={<TrainingUnitRunPage />} />
<Route path="planning" element={<TrainingPlanningPage />} />
<Route path="admin" element={<Navigate to="/admin/hierarchy" replace />} />
<Route path="admin" element={<AdminHomeRedirect />} />
<Route path="admin/users" element={<AdminUsersPage />} />
<Route path="admin/hierarchy" element={<AdminHierarchyPage />} />
<Route path="admin/maturity-models" element={<AdminMaturityModelsPage />} />
<Route path="admin/catalogs" element={<AdminCatalogsPage />} />
<Route path="admin/mediawiki-import" element={<MediaWikiImportPage />} />
<Route
path="admin/hierarchy"
element={
<PlatformAdminRoute>
<AdminHierarchyPage />
</PlatformAdminRoute>
}
/>
<Route
path="admin/maturity-models"
element={
<PlatformAdminRoute>
<AdminMaturityModelsPage />
</PlatformAdminRoute>
}
/>
<Route
path="admin/catalogs"
element={
<PlatformAdminRoute>
<AdminCatalogsPage />
</PlatformAdminRoute>
}
/>
<Route
path="admin/mediawiki-import"
element={
<PlatformAdminRoute>
<MediaWikiImportPage />
</PlatformAdminRoute>
}
/>
<Route path="trainer-contexts" element={<TrainerContextsPage />} />
</Route>

View File

@ -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;
}

View File

@ -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)

View File

@ -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 <Navigate to={isPlat ? '/admin/hierarchy' : '/admin/users'} replace />
}

View File

@ -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 (
<nav className="admin-top-nav" aria-label="Administration">

View File

@ -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 (
<section
className="dashboard-org-inbox-widget"
aria-labelledby="dash-org-inbox-title"
>
<div className="dashboard-org-inbox-widget__inner card">
<div className="dashboard-org-inbox-widget__head">
<h2 id="dash-org-inbox-title" className="dashboard-org-inbox-widget__title">
<Inbox size={20} strokeWidth={2} aria-hidden className="dashboard-org-inbox-widget__icon" />
Posteingang
</h2>
{inboxCount > 0 ? (
<span className="dashboard-org-inbox-widget__badge" aria-live="polite">
{inboxCount}
</span>
) : null}
</div>
<p className="muted dashboard-org-inbox-widget__lead">
{inboxCount === 0
? 'Keine offenen Beitrittsanträge.'
: `${inboxCount} offene Beitrittsantrag${inboxCount === 1 ? '' : 'e'}.`}
</p>
{preview.length > 0 ? (
<ul className="dashboard-org-inbox-widget__list">
{preview.map((req) => (
<li key={`${req.club_id}-${req.id}`} className="dashboard-org-inbox-widget__item">
<span className="dashboard-org-inbox-widget__club">{req.club_name || 'Verein'}</span>
<span className="dashboard-org-inbox-widget__applicant">
{req.applicant_name || req.applicant_email || 'Bewerber/in'}
</span>
</li>
))}
</ul>
) : null}
<div className="dashboard-org-inbox-widget__footer">
<Link to="/inbox" className="btn btn-secondary" style={{ textDecoration: 'none' }}>
Zum Posteingang
</Link>
</div>
</div>
</section>
)
}

View File

@ -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.Icon size={20} strokeWidth={2} />
<span>{item.label}</span>
{item.to === '/inbox' && inboxCount > 0 ? (
<span className="desktop-sidebar__badge" aria-label={`${inboxCount} offen`}>
{inboxCount > 99 ? '99+' : inboxCount}
</span>
) : null}
</NavLink>
))}
</nav>

View File

@ -0,0 +1,39 @@
import { useAuth } from '../context/AuthContext'
/**
* Hinweis, wenn der Vereinszugang (Mitgliedschaft) deaktiviert wurde — Login bleibt möglich.
*/
export default function InactiveMembershipBanner() {
const { user } = useAuth()
const inactive = (user?.clubs || []).filter(
(c) => (c.membership_status || '').toString().trim().toLowerCase() === 'inactive'
)
if (!inactive.length) return null
const names = inactive.map((c) => c.name || `Verein #${c.id}`).join(', ')
return (
<div
role="status"
className="inactive-membership-banner"
style={{
marginBottom: '0.75rem',
padding: '0.65rem 0.85rem',
borderRadius: '8px',
background: 'var(--surface2, #2a2a2a)',
border: '1px solid color-mix(in srgb, var(--warning, #d4a012) 45%, transparent)',
color: 'var(--text1)',
fontSize: '0.88rem',
lineHeight: 1.45,
}}
>
<strong>Vereinszugang vorübergehend deaktiviert</strong>
<span style={{ display: 'block', marginTop: '0.35rem' }}>
Für {inactive.length === 1 ? 'den Verein' : 'die Vereine'}{' '}
<strong>{names}</strong>{' '}
ist der Zugang zu Vereinsinhalten ausgesetzt — du kannst dich weiterhin anmelden und z.&nbsp;B.
öffentliche Inhalte oder andere Vereine nutzen. Bei Fragen wende dich an eine:n Vereinsadministrator:in.
</span>
</div>
)
}

View File

@ -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 () => {

View File

@ -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 <Navigate to="/admin/users" replace />
return children
}

View File

@ -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')
}

View File

@ -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<AppNavItem, 'Icon'>[]} */
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]
}))

View File

@ -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 */
}
}

View File

@ -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 <OrgInboxContext.Provider value={value}>{children}</OrgInboxContext.Provider>
}
export function useOrgInbox() {
const ctx = useContext(OrgInboxContext)
if (!ctx) {
throw new Error('useOrgInbox must be used within OrgInboxProvider')
}
return ctx
}

View File

@ -252,13 +252,22 @@ function AccountSettingsPage() {
<span style={{ lineHeight: 1.45 }}>
{user?.clubs?.length ? (
<>
{user.clubs.map((c) => (
<div key={c.id}>
<strong style={{ color: 'var(--text1)' }}>{c.name}</strong>
{': '}
{(c.roles || []).length ? (c.roles || []).join(', ') : '—'}
</div>
))}
{user.clubs.map((c) => {
const mem = (c.membership_status || 'active').toString().trim().toLowerCase()
const inactive = mem === 'inactive'
return (
<div key={c.id}>
<strong style={{ color: 'var(--text1)' }}>{c.name}</strong>
{inactive ? (
<span style={{ color: 'var(--warning, #d4a012)', marginLeft: '0.35rem', fontSize: '0.82rem' }}>
(Vereinszugang deaktiviert)
</span>
) : null}
{': '}
{(c.roles || []).length ? (c.roles || []).join(', ') : '—'}
</div>
)
})}
</>
) : (
'—'

View File

@ -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 <Navigate to="/" replace />
}
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 <Navigate to="/" replace />
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 (
<div className="app-page">
<AdminPageNav />
<h1 style={{ marginTop: 0 }}>Portal-Nutzer &amp; Vereine</h1>
<p style={{ color: 'var(--text2)', maxWidth: '52rem', lineHeight: 1.55, marginBottom: '1.25rem' }}>
Alle Konten mit Vereinszuordnungen. Hier kannst du die <strong>Portal-Rolle</strong> (Zugriff auf
Admin-Funktionen) und das <strong>Tier</strong> setzen sowie Nutzer explizit einem Verein mit Rollen
zuordnen.
</p>
<AdminPageNav clubOrgOnly={clubOrgMode} />
<h1 style={{ marginTop: 0 }}>Nutzer &amp; Vereinsrollen</h1>
{clubOrgMode ? (
<p style={{ color: 'var(--text2)', maxWidth: '52rem', lineHeight: 1.55, marginBottom: '1.25rem' }}>
Du verwaltest Mitglieder des ausgewählten Vereins. <strong>Vereinszugang deaktivieren</strong> sperrt nur die
Sicht auf Vereinsinhalte — der Login des Nutzers bleibt möglich. Wiederherstellen über „aktivieren“ oder
Bearbeiten.
</p>
) : (
<>
<p style={{ color: 'var(--text2)', maxWidth: '52rem', lineHeight: 1.55, marginBottom: '0.75rem' }}>
Gesamtübersicht aller Konten und Vereinszuordnungen. <strong>Portal</strong>-Einstellungen steuern nur den
Zugang zur <strong>plattformweiten Administration</strong> (Kataloge, Hierarchie, globale Nutzerliste usw.);
Vereinsarbeit (Trainer, Vereinsadmin&nbsp;…) bleiben <strong>Vereinsrollen</strong>. Abonnement/Tier ist
derzeit nicht freigeschaltet.
</p>
<div
className="card"
style={{
marginBottom: '1.25rem',
padding: '0.85rem 1rem',
maxWidth: '52rem',
fontSize: '0.92rem',
lineHeight: 1.5,
color: 'var(--text2)',
}}
>
<strong style={{ color: 'var(--text1)' }}>Die vier Portal-Zugriffsstufen:</strong>
<ul style={{ margin: '0.5rem 0 0', paddingLeft: '1.2rem' }}>
<li>
<strong>Nutzer</strong> — Standardnutzer ohne Plattform-Admin-Bereiche.
</li>
<li>
<strong>Portal-Trainer (Legacy)</strong> — ältere Kennzeichnung auf Profil-Ebene; organisatorisch ist
„Trainer“ in der Regel eine <strong>Vereinsrolle</strong>.
</li>
<li>
<strong>Portal-Administrator</strong> — Zugang zu allen geschützten <code>/admin</code>-Bereichen
(Außer: einige Funktionen nur Superadmin, z.&nbsp;B. bestimmte Medien-/Governance-Aktionen).
</li>
<li>
<strong>Super-Administrator</strong> — volle Plattform-Governance (u.&nbsp;a. offizielle Medien,
Superadmin-Rolle vergeben, harte Lifecycle-Aktionen an Medien).
</li>
</ul>
</div>
</>
)}
{clubOrgMode && managedClubIds.length > 1 ? (
<div className="form-row" style={{ maxWidth: '24rem', marginBottom: '1rem' }}>
<label className="form-label">Verein für Verwaltung</label>
<select
className="form-input"
value={selectedClubId ?? ''}
onChange={(e) => setSelectedClubId(parseInt(e.target.value, 10))}
>
{selectableClubs.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
</div>
) : null}
{loading ? (
<p style={{ color: 'var(--text2)' }}>Laden…</p>
@ -136,142 +380,250 @@ function AdminUsersPage() {
<div className="card" style={{ borderColor: 'var(--danger)', color: 'var(--danger)' }}>
{error}
</div>
) : (
) : clubOrgMode ? (
<div style={{ display: 'grid', gap: '1rem' }}>
{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 (
<div key={row.id} className="card">
<div style={{ display: 'flex', justifyContent: 'space-between', flexWrap: 'wrap', gap: '0.75rem' }}>
<div>
<strong style={{ fontSize: '1.05rem' }}>
{row.name || '—'} <span style={{ color: 'var(--text2)', fontWeight: 400 }}>#{row.id}</span>
</strong>
<div style={{ fontSize: '0.875rem', color: 'var(--text2)' }}>{row.email || '—'}</div>
<div style={{ fontSize: '0.78rem', color: 'var(--text3)', marginTop: '0.35rem' }}>
Verifiziert: {row.email_verified ? 'ja' : 'nein'}
</div>
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem', alignItems: 'flex-end' }}>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem', alignItems: 'center' }}>
<button type="button" className="btn btn-primary" onClick={() => setAddMemberOpen(true)}>
Mitglied hinzufügen (Profil-ID)
</button>
<button type="button" className="btn btn-secondary" onClick={() => reloadClubMembers()}>
Aktualisieren
</button>
</div>
{!clubMembers.length ? (
<p className="muted">Keine Mitglieder in diesem Verein.</p>
) : (
clubMembers.map((m) => {
const memStatus = (m.status || 'active').toLowerCase()
return (
<div key={m.membership_id} className="card">
<div style={{ display: 'flex', justifyContent: 'space-between', flexWrap: 'wrap', gap: '0.75rem' }}>
<div>
<label className="form-label" style={{ fontSize: '0.75rem' }}>
Portal-Rolle
</label>
<select
className="form-input"
style={{ minWidth: '140px' }}
value={(portalDraft[row.id]?.role || row.role || 'user').toLowerCase()}
onChange={(e) =>
setPortalDraft((prev) => ({
...prev,
[row.id]: { ...prev[row.id], role: e.target.value, tier: prev[row.id]?.tier ?? row.tier },
}))
<strong style={{ fontSize: '1.05rem' }}>
{m.name || '—'} <span style={{ color: 'var(--text2)', fontWeight: 400 }}>#{m.profile_id}</span>
</strong>
<div style={{ fontSize: '0.875rem', color: 'var(--text2)' }}>{m.email || '—'}</div>
<div style={{ fontSize: '0.78rem', color: 'var(--text3)', marginTop: '0.35rem' }}>
Vereinszugang:{' '}
<strong style={{ color: memStatus === 'active' ? 'var(--text1)' : 'var(--warning, #d4a012)' }}>
{memStatus === 'active' ? 'aktiv' : 'deaktiviert'}
</strong>
{' '}
· Verifiziert: {m.email_verified ? 'ja' : 'nein'}
</div>
<div style={{ fontSize: '0.82rem', marginTop: '0.35rem' }}>
Rollen: {(m.roles || []).join(', ') || '—'}
</div>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem', alignItems: 'stretch' }}>
<button
type="button"
className="btn btn-secondary"
onClick={() =>
setClubEditModal({
clubId: selectedClubId,
clubName: selectedClubLabel,
profileId: m.profile_id,
profileLabel: m.name || m.email,
roles: [...(m.roles || [])],
status: memStatus,
})
}
>
{portalRoleChoices.map((r) => (
<option key={r} value={r}>
{ROLE_LABEL[r] || r}
</option>
))}
</select>
</div>
<div>
<label className="form-label" style={{ fontSize: '0.75rem' }}>
Tier
</label>
<select
className="form-input"
style={{ minWidth: '120px' }}
value={tierValue}
onChange={(e) =>
setPortalDraft((prev) => ({
...prev,
[row.id]: {
...prev[row.id],
tier: e.target.value,
role: prev[row.id]?.role ?? row.role,
},
}))
}
>
{tierChoices.map((t) => (
<option key={t} value={t}>
{t}
</option>
))}
</select>
</div>
<button type="button" className="btn btn-secondary" onClick={() => savePortal(row.id)}>
Portal speichern
</button>
<button
type="button"
className="btn btn-primary"
disabled={!clubs.length}
title={!clubs.length ? 'Zuerst einen Verein anlegen' : undefined}
onClick={() => {
if (!clubs.length) return
setAssignRoles(['trainer'])
setAssignModal({
profileId: row.id,
profileLabel: row.name || row.email || `#${row.id}`,
clubId: clubs[0]?.id ?? '',
})
}}
>
Verein zuweisen
</button>
</div>
</div>
<div style={{ marginTop: '1rem', paddingTop: '0.75rem', borderTop: '1px solid var(--border)' }}>
<strong style={{ fontSize: '0.85rem' }}>Vereinsmitgliedschaften</strong>
{!row.clubs?.length ? (
<p style={{ color: 'var(--text2)', fontSize: '0.875rem', margin: '0.35rem 0 0' }}>
Keine Zuordnung.
</p>
) : (
<ul style={{ margin: '0.5rem 0 0', paddingLeft: '1.2rem', fontSize: '0.9rem' }}>
{row.clubs.map((c) => (
<li key={c.id} style={{ marginBottom: '0.35rem' }}>
<strong>{c.name}</strong>
{c.abbreviation ? ` (${c.abbreviation})` : ''} —{' '}
{(c.roles || []).join(', ') || '—'}
{c.membership_status === 'inactive' ? (
<span style={{ color: 'var(--text3)', fontSize: '0.8rem' }}> (inaktiv)</span>
) : null}{' '}
Bearbeiten
</button>
{m.profile_id !== user?.id ? (
memStatus === 'inactive' ? (
<button
type="button"
style={{
marginLeft: '0.35rem',
fontSize: '0.75rem',
padding: '0.12rem 0.45rem',
borderRadius: '6px',
border: '1px solid var(--border)',
background: 'var(--surface2)',
cursor: 'pointer',
}}
onClick={() =>
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
</button>
</li>
))}
</ul>
)}
) : (
<button
type="button"
className="btn btn-secondary"
onClick={() => toggleMemberClubAccess(m, false)}
>
Vereinszugang deaktivieren
</button>
)
) : null}
{m.profile_id !== user?.id && !isEscalatedPortalRole(m.portal_role) ? (
<button
type="button"
className="btn btn-secondary"
onClick={() =>
setPwdModal({
profileId: m.profile_id,
label: m.name || m.email || `Profil #${m.profile_id}`,
})
}
>
Passwort-Link senden
</button>
) : null}
</div>
</div>
</div>
)
})
)}
</div>
) : (
<div style={{ display: 'grid', gap: '1rem' }}>
{platformUsers.map((row) => {
const portalRoleChoices = portalRoleSelectOptions(isSuperadminViewer, row.role)
return (
<div key={row.id} className="card">
<div style={{ display: 'flex', justifyContent: 'space-between', flexWrap: 'wrap', gap: '0.75rem' }}>
<div>
<strong style={{ fontSize: '1.05rem' }}>
{row.name || '—'} <span style={{ color: 'var(--text2)', fontWeight: 400 }}>#{row.id}</span>
</strong>
<div style={{ fontSize: '0.875rem', color: 'var(--text2)' }}>{row.email || '—'}</div>
<div style={{ fontSize: '0.78rem', color: 'var(--text3)', marginTop: '0.35rem' }}>
Verifiziert: {row.email_verified ? 'ja' : 'nein'}
</div>
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem', alignItems: 'flex-end' }}>
<div>
<label className="form-label" style={{ fontSize: '0.75rem' }}>
Portal-Zugriff
</label>
<select
className="form-input"
style={{ minWidth: '200px' }}
value={(portalDraft[row.id]?.role || row.role || 'user').toLowerCase()}
onChange={(e) =>
setPortalDraft((prev) => ({
...prev,
[row.id]: { role: e.target.value },
}))
}
>
{portalRoleChoices.map((r) => (
<option key={r.value} value={r.value}>
{r.label}
</option>
))}
</select>
</div>
<button
type="button"
className="btn btn-secondary"
disabled={row.id === user?.id}
title={row.id === user?.id ? 'Eigenes Passwort unter Einstellungen' : undefined}
onClick={() =>
setPwdModal({
profileId: row.id,
label: row.name || row.email || `#${row.id}`,
})
}
>
Passwort / Link
</button>
<button type="button" className="btn btn-secondary" onClick={() => savePortal(row.id)}>
Portal speichern
</button>
<button
type="button"
className="btn btn-primary"
disabled={!clubs.length}
title={!clubs.length ? 'Zuerst einen Verein anlegen' : undefined}
onClick={() => {
if (!clubs.length) return
setAssignRoles(['trainer'])
setAssignModal({
profileId: row.id,
profileLabel: row.name || row.email || `#${row.id}`,
clubId: clubs[0]?.id ?? '',
})
}}
>
Verein zuweisen
</button>
</div>
</div>
<div style={{ marginTop: '1rem', paddingTop: '0.75rem', borderTop: '1px solid var(--border)' }}>
<strong style={{ fontSize: '0.85rem' }}>Vereinsmitgliedschaften</strong>
{!row.clubs?.length ? (
<p style={{ color: 'var(--text2)', fontSize: '0.875rem', margin: '0.35rem 0 0' }}>
Keine Zuordnung.
</p>
) : (
<ul style={{ margin: '0.5rem 0 0', paddingLeft: '1.2rem', fontSize: '0.9rem' }}>
{(row.clubs || []).map((c) => (
<li key={c.id} style={{ marginBottom: '0.35rem' }}>
<strong>{c.name}</strong>
{c.abbreviation ? ` (${c.abbreviation})` : ''} —{' '}
{(c.roles || []).join(', ') || '—'}
{c.membership_status === 'inactive' ? (
<span style={{ color: 'var(--warning, #d4a012)', fontSize: '0.8rem', fontWeight: 600 }}>
{' '}
(Vereinszugang deaktiviert)
</span>
) : null}{' '}
<button
type="button"
style={{
marginLeft: '0.35rem',
fontSize: '0.75rem',
padding: '0.12rem 0.45rem',
borderRadius: '6px',
border: '1px solid var(--border)',
background: 'var(--surface2)',
cursor: 'pointer',
}}
onClick={() =>
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
</button>
{isSuperadminViewer && c.membership_status === 'inactive' ? (
<button
type="button"
style={{
marginLeft: '0.35rem',
fontSize: '0.75rem',
padding: '0.12rem 0.45rem',
borderRadius: '6px',
border: '1px solid var(--accent, #0366d6)',
background: 'var(--surface)',
cursor: 'pointer',
}}
onClick={async () => {
try {
await api.updateClubMember(c.id, row.id, {
roles: [...(c.roles || [])],
status: 'active',
})
await loadPlatform()
} catch (e) {
alert(e.message || String(e))
}
}}
>
Zugang aktivieren
</button>
) : null}
</li>
))}
</ul>
)}
</div>
</div>
</div>
)
})}
</div>
@ -346,11 +698,7 @@ function AdminUsersPage() {
<button type="button" className="btn btn-primary" style={{ flex: 1 }} onClick={submitAssignClub}>
Zuweisen
</button>
<button
type="button"
className="btn btn-secondary"
onClick={() => setAssignModal(null)}
>
<button type="button" className="btn btn-secondary" onClick={() => setAssignModal(null)}>
Abbrechen
</button>
</div>
@ -358,6 +706,77 @@ function AdminUsersPage() {
</div>
)}
{addMemberOpen && clubOrgMode ? (
<div
style={{
position: 'fixed',
inset: 0,
background: 'rgba(0,0,0,0.5)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1200,
padding: '1rem',
}}
>
<div
style={{
background: 'var(--surface)',
borderRadius: '12px',
padding: '1.5rem',
maxWidth: '440px',
width: '100%',
}}
>
<h2 style={{ marginTop: 0 }}>Mitglied hinzufügen</h2>
<p className="muted" style={{ fontSize: '0.9rem' }}>
Verein: <strong>{selectedClubLabel}</strong>
</p>
<div className="form-row">
<label className="form-label">Profil-ID</label>
<input
className="form-input"
inputMode="numeric"
value={newMemberProfileId}
onChange={(e) => setNewMemberProfileId(e.target.value)}
placeholder="z. B. 42"
/>
</div>
<div className="form-row">
<span className="form-label">Rollen</span>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem' }}>
{CLUB_ROLE_OPTIONS.map((opt) => (
<label key={opt.code} style={{ display: 'flex', alignItems: 'center', gap: '0.35rem' }}>
<input
type="checkbox"
checked={newMemberRoles.includes(opt.code)}
onChange={() => {
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}
</label>
))}
</div>
</div>
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '1rem' }}>
<button type="button" className="btn btn-primary" style={{ flex: 1 }} onClick={submitAddClubMember}>
Hinzufügen
</button>
<button type="button" className="btn btn-secondary" onClick={() => setAddMemberOpen(false)}>
Abbrechen
</button>
</div>
</div>
</div>
) : null}
{clubEditModal && (
<div
style={{
@ -384,8 +803,12 @@ function AdminUsersPage() {
<p style={{ color: 'var(--text2)', fontSize: '0.9rem' }}>
{clubEditModal.profileLabel} → {clubEditModal.clubName}
</p>
<p className="muted" style={{ fontSize: '0.82rem', lineHeight: 1.45 }}>
„Deaktiviert“ betrifft nur den Zugriff auf Inhalte dieses Vereins; Login und andere Vereine bleiben
unberührt.
</p>
<div className="form-row">
<label className="form-label">Status</label>
<label className="form-label">Vereinszugang</label>
<select
className="form-input"
value={clubEditModal.status}
@ -393,8 +816,8 @@ function AdminUsersPage() {
setClubEditModal((prev) => (prev ? { ...prev, status: e.target.value } : prev))
}
>
<option value="active">aktiv</option>
<option value="inactive">inaktiv</option>
<option value="active">aktiv — sieht Vereinsinhalte</option>
<option value="inactive">deaktiviert — kein Zugriff auf Vereinsinhalte</option>
</select>
</div>
<div className="form-row">
@ -441,8 +864,88 @@ function AdminUsersPage() {
</div>
</div>
)}
{pwdModal ? (
<div
style={{
position: 'fixed',
inset: 0,
background: 'rgba(0,0,0,0.5)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1250,
padding: '1rem',
}}
>
<div
style={{
background: 'var(--surface)',
borderRadius: '12px',
padding: '1.5rem',
maxWidth: '440px',
width: '100%',
}}
>
<h2 style={{ marginTop: 0 }}>Passwort zurücksetzen</h2>
<p style={{ color: 'var(--text2)', fontSize: '0.9rem' }}>{pwdModal.label}</p>
<p className="muted" style={{ fontSize: '0.82rem', marginBottom: '0.75rem' }}>
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.
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
<button type="button" className="btn btn-primary" onClick={submitPasswordEmail}>
Reset-Link per E-Mail senden
</button>
</div>
{isSuperadminViewer ? (
<>
<hr style={{ margin: '1rem 0', borderColor: 'var(--border, #333)' }} />
<p className="muted" style={{ fontSize: '0.82rem', marginBottom: '0.5rem' }}>
Ausnahme: Passwort direkt setzen (nur bei Bedarf). Das bisherige Passwort ist danach ungültig.
</p>
<div className="form-row">
<label className="form-label">Neues Passwort</label>
<input
type="password"
className="form-input"
autoComplete="new-password"
value={pwdNew}
onChange={(e) => setPwdNew(e.target.value)}
/>
</div>
<div className="form-row">
<label className="form-label">Wiederholen</label>
<input
type="password"
className="form-input"
autoComplete="new-password"
value={pwdNew2}
onChange={(e) => setPwdNew2(e.target.value)}
/>
</div>
<button type="button" className="btn btn-secondary" style={{ width: '100%' }} onClick={submitPasswordDirect}>
Passwort direkt setzen
</button>
</>
) : null}
<div style={{ marginTop: '1rem' }}>
<button
type="button"
className="btn btn-secondary"
style={{ width: '100%' }}
onClick={() => {
setPwdModal(null)
setPwdNew('')
setPwdNew2('')
}}
>
Schließen
</button>
</div>
</div>
</div>
) : null}
</div>
)
}
export default AdminUsersPage

View File

@ -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() {
<div className="card">
<h3 style={{ marginTop: 0 }}>Mitglieder</h3>
{isPlatformAdmin ? (
<p
className="muted"
style={{ fontSize: '0.85rem', marginTop: '-0.35rem', marginBottom: '0.85rem', lineHeight: 1.45 }}
>
Liste enthält <strong style={{ color: 'var(--text1)' }}>aktive und deaktivierte</strong> 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.
</p>
) : (
<p
className="muted"
style={{ fontSize: '0.85rem', marginTop: '-0.35rem', marginBottom: '0.85rem', lineHeight: 1.45 }}
>
Deaktivierte Vereinszugänge sind hervorgehoben —{' '}
<strong>Anmeldung</strong> bleibt möglich, <strong>Vereinsinhalte</strong> dieser Zuordnung nicht.
</p>
)}
{clubMembersAdmin.length === 0 ? (
<p style={{ color: 'var(--text2)' }}>Noch keine Mitglieder erfasst.</p>
) : (
<div style={{ display: 'grid', gap: '0.65rem' }}>
{clubMembersAdmin.map((m) => (
{clubMembersAdmin.map((m) => {
const memStatus = (m.status || 'active').toLowerCase()
const inactiveRow = memStatus === 'inactive'
const portalLabel = (m.portal_role || '').trim()
return (
<div
key={m.membership_id}
style={{
padding: '0.65rem',
borderRadius: '8px',
background: 'var(--surface2)',
background: inactiveRow ? 'color-mix(in srgb, var(--warning, #884400) 12%, var(--surface2))' : 'var(--surface2)',
border: inactiveRow ? '1px solid color-mix(in srgb, var(--warning, #d4a012) 40%, transparent)' : undefined,
display: 'flex',
justifyContent: 'space-between',
flexWrap: 'wrap',
@ -684,21 +733,49 @@ function ClubsPage() {
<div>
<strong>{m.name || m.email}</strong>
<div style={{ fontSize: '0.8rem', color: 'var(--text2)' }}>
{m.email} · #{m.profile_id} · {m.status}
{m.email} · #{m.profile_id}
{' · '}
<span style={{ fontWeight: 600 }}>
Vereinszugang:{' '}
<span style={{ color: inactiveRow ? 'var(--warning, #d4a012)' : 'inherit' }}>
{inactiveRow ? 'deaktiviert' : 'aktiv'}
</span>
</span>
{portalLabel ? (
<span style={{ color: 'var(--text3)', marginLeft: '0.35rem' }}> · Portal: {portalLabel}</span>
) : null}
</div>
<div style={{ fontSize: '0.8rem', marginTop: '0.25rem' }}>
Rollen: {(m.roles || []).join(', ') || '—'}
</div>
</div>
<button
type="button"
className="btn btn-secondary"
onClick={() => setEditMemberModal(m)}
>
Bearbeiten
</button>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem', alignItems: 'stretch' }}>
<button type="button" className="btn btn-secondary" onClick={() => setEditMemberModal(m)}>
Mitglied bearbeiten
</button>
{m.profile_id !== user?.id ? (
inactiveRow ? (
<button
type="button"
className="btn btn-primary"
onClick={() => toggleMembersAdminClubAccess(m, true)}
>
Vereinszugang aktivieren
</button>
) : (
<button
type="button"
className="btn btn-secondary"
onClick={() => toggleMembersAdminClubAccess(m, false)}
>
Vereinszugang deaktivieren
</button>
)
) : null}
</div>
</div>
))}
)
})}
</div>
)}
</div>
@ -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})
</p>
<div className="form-row">
<label className="form-label">Status</label>
<label className="form-label">Vereinszugang</label>
<select
className="form-input"
value={editMemberModal.status || 'active'}
@ -1315,10 +1393,13 @@ function ClubsPage() {
setEditMemberModal((prev) => (prev ? { ...prev, status: e.target.value } : prev))
}
>
<option value="active">aktiv</option>
<option value="inactive">inaktiv</option>
<option value="active">aktiv — sieht Vereinsinhalte</option>
<option value="inactive">deaktiviert — weiter anmeldbar, keine Vereinsinhalte</option>
</select>
</div>
<p className="muted" style={{ fontSize: '0.82rem', lineHeight: 1.45 }}>
Deaktivierung gilt nur für diesen Verein; der Login-Account bleibt aktiv.
</p>
<div className="form-row">
<span className="form-label">Rollen</span>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem' }}>

View File

@ -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 ? (
<>
<DashboardOrgInboxWidget />
<section className="dashboard-section" aria-labelledby="dash-phase0-title">
<div className="dashboard-section__header">
<div className="dashboard-section__headline">

View File

@ -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)}
>
<option value="">Aktiver Verein (Vereins-Umschalter / Header)</option>
{(user?.clubs || []).map((c) => (
{activeClubMemberships(user?.clubs).map((c) => (
<option key={c.id} value={String(c.id)}>
{c.name || `#${c.id}`}
</option>

View File

@ -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 (
<div className="app-page">
<h1 className="page-title">Posteingang</h1>
<p className="muted">Kein Zugriff. Nur Plattform-Admins und Vereinsadmins sehen den Posteingang.</p>
<p>
<Link to="/">Zur Übersicht</Link>
</p>
</div>
)
}
return (
<div className="app-page inbox-page">
<div className="inbox-page__header">
<div>
<h1 className="page-title" style={{ marginBottom: '6px' }}>
Posteingang
</h1>
<p className="muted" style={{ marginTop: 0 }}>
Offene Beitrittsanträge zu Vereinen, für die du zuständig bist.
</p>
</div>
<button type="button" className="btn btn-secondary" onClick={() => load()} disabled={loading}>
Aktualisieren
</button>
</div>
{loading ? (
<div style={{ padding: '2rem', textAlign: 'center' }}>
<div className="spinner" />
</div>
) : inboxJoinRequests.length === 0 ? (
<div className="card" style={{ padding: '1.25rem' }}>
<p style={{ margin: 0 }} className="muted">
Keine offenen Beitrittsanträge.
</p>
</div>
) : (
<div className="inbox-page__list">
{inboxJoinRequests.map((req) => (
<div key={`${req.club_id}-${req.id}`} className="card inbox-request-card">
<div className="inbox-request-card__main">
<div className="inbox-request-card__club">
{req.club_name || 'Verein'}
{req.club_abbreviation ? (
<span className="muted" style={{ marginLeft: '0.35rem' }}>
({req.club_abbreviation})
</span>
) : null}
</div>
<strong className="inbox-request-card__applicant">
{req.applicant_name || req.applicant_email || 'Bewerber/in'}
</strong>
<div className="muted inbox-request-card__meta">
{req.applicant_email} · Profil #{req.profile_id} · {formatWhen(req.created_at)}
</div>
{req.message ? <p className="inbox-request-card__message">{req.message}</p> : null}
</div>
<div className="inbox-request-card__actions">
<button
type="button"
className="btn btn-primary"
onClick={() =>
setAcceptModal({
id: req.id,
club_id: req.club_id,
label: req.applicant_name || req.applicant_email,
roles: ['trainer'],
})
}
>
Annehmen
</button>
<button
type="button"
className="btn btn-secondary"
onClick={async () => {
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
</button>
</div>
</div>
))}
</div>
)}
{acceptModal && (
<div
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
background: 'rgba(0,0,0,0.5)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1100,
padding: '1rem',
}}
>
<div
style={{
background: 'var(--surface)',
borderRadius: '12px',
padding: '1.5rem',
maxWidth: '480px',
width: '100%',
}}
>
<h2 style={{ marginTop: 0 }}>Antrag annehmen</h2>
<p style={{ color: 'var(--text2)' }}>{acceptModal.label}</p>
<div className="form-row">
<span className="form-label">Rollen bei Aufnahme</span>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem' }}>
{CLUB_ROLE_OPTIONS.map((opt) => (
<label key={opt.code} style={{ display: 'flex', alignItems: 'center', gap: '0.35rem' }}>
<input
type="checkbox"
checked={acceptModal.roles.includes(opt.code)}
onChange={() => {
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}
</label>
))}
</div>
</div>
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '1rem' }}>
<button
type="button"
className="btn btn-primary"
style={{ flex: 1 }}
onClick={async () => {
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
</button>
<button type="button" className="btn btn-secondary" onClick={() => setAcceptModal(null)}>
Abbrechen
</button>
</div>
</div>
</div>
)}
</div>
)
}

View File

@ -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 (
<div className="app-page media-library">
{isPlatformAdmin ? <AdminPageNav /> : null}
<div className="media-library__container">
<header className="media-library__hero">
<div className="media-library__hero-row">
<h1 className="media-library__title">Medienbibliothek</h1>
<div className="media-library__hero-links">
<Link to="/">Übersicht</Link>
<Link to="/exercises">Übungen</Link>
{isPlatformAdmin ? <Link to="/admin/hierarchy">Admin</Link> : null}
{isPlatformAdmin ? <Link to="/admin/hierarchy">Plattform-Admin</Link> : null}
{hasClubOrgAdmin || isPlatformAdmin ? (
<Link to="/admin/users">Nutzer &amp; Organisation</Link>
) : null}
</div>
</div>
<p className="media-library__intro">
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).
</p>
</header>

View File

@ -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)

View File

@ -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) =>

View File

@ -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,

View File

@ -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'))
}
/**