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