Administration für Vereinsadmin, Sperren von Usern, Inbox, Medienmanger für alle, etc. #28
|
|
@ -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"
|
||||
|
|
|
|||
68
backend/password_reset_mail.py
Normal file
68
backend/password_reset_mail.py
Normal 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)
|
||||
|
|
@ -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).")
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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()]
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)."""
|
||||
|
|
|
|||
|
|
@ -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()]
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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()]
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
8
frontend/src/components/AdminHomeRedirect.jsx
Normal file
8
frontend/src/components/AdminHomeRedirect.jsx
Normal 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 />
|
||||
}
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
57
frontend/src/components/DashboardOrgInboxWidget.jsx
Normal file
57
frontend/src/components/DashboardOrgInboxWidget.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
39
frontend/src/components/InactiveMembershipBanner.jsx
Normal file
39
frontend/src/components/InactiveMembershipBanner.jsx
Normal 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. B.
|
||||
öffentliche Inhalte oder andere Vereine nutzen. Bei Fragen wende dich an eine:n Vereinsadministrator:in.
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 () => {
|
||||
|
|
|
|||
10
frontend/src/components/PlatformAdminRoute.jsx
Normal file
10
frontend/src/components/PlatformAdminRoute.jsx
Normal 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
|
||||
}
|
||||
|
|
@ -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')
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
}))
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
88
frontend/src/context/OrgInboxContext.jsx
Normal file
88
frontend/src/context/OrgInboxContext.jsx
Normal 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
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
) : (
|
||||
'—'
|
||||
|
|
|
|||
|
|
@ -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 & 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 & 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 …) 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. B. bestimmte Medien-/Governance-Aktionen).
|
||||
</li>
|
||||
<li>
|
||||
<strong>Super-Administrator</strong> — volle Plattform-Governance (u. 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
|
||||
|
|
|
|||
|
|
@ -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' }}>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
222
frontend/src/pages/InboxPage.jsx
Normal file
222
frontend/src/pages/InboxPage.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 & 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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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) =>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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'))
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user