From 624c19dcba09de0a6cb14fa147f13b77010b497b Mon Sep 17 00:00:00 2001 From: Lars Date: Sat, 9 May 2026 10:32:33 +0200 Subject: [PATCH] feat(auth, profiles, club_memberships): enhance password reset and club admin management - Integrated a new password reset mechanism for user accounts, allowing admins to send reset links via email. - Updated the management password reset functionality to differentiate between direct password setting and email link requests. - Added validation to ensure at least one active club admin remains when modifying club member roles. - Improved the user interface for password management in the admin panel, providing clearer feedback and options for password resets. --- backend/password_reset_mail.py | 68 ++++++++++++++++++ backend/routers/auth.py | 33 ++++----- backend/routers/club_memberships.py | 93 +++++++++++++++++++++--- backend/routers/profiles.py | 87 ++++++++++++++++++---- frontend/src/pages/AdminUsersPage.jsx | 100 ++++++++++++++++++-------- frontend/src/utils/api.js | 13 +++- 6 files changed, 316 insertions(+), 78 deletions(-) create mode 100644 backend/password_reset_mail.py diff --git a/backend/password_reset_mail.py b/backend/password_reset_mail.py new file mode 100644 index 0000000..5d53066 --- /dev/null +++ b/backend/password_reset_mail.py @@ -0,0 +1,68 @@ +"""Gemeinsame Passwort-Link-Erzeugung (Sessions) + Mailtext — wie /auth/forgot-password.""" +from __future__ import annotations + +import os +import secrets +from datetime import datetime, timedelta +from typing import Any + +RESET_TOKEN_PREFIX = "reset_" + + +def public_reset_link(token: str) -> str: + base = (os.getenv("APP_URL") or "https://shinkan.jinkendo.de").rstrip("/") + return f"{base}/reset-password?token={token}" + + +def revoke_pending_password_resets_for_profile(cur: Any, profile_id: int) -> None: + """Entfernt alte Reset-Sessions eines Profils, damit nur der neueste Link aktiv ist.""" + cur.execute( + """ + DELETE FROM sessions + WHERE profile_id = %s AND token LIKE %s + """, + (profile_id, f"{RESET_TOKEN_PREFIX}%"), + ) + + +def insert_password_reset_session(cur: Any, profile_id: int, *, hours_valid: int = 1) -> str: + """ + Legt reset_-Session an. Gibt den Klartext-Token zurück (wie bei forgot-password). + """ + raw = secrets.token_urlsafe(32) + expires = datetime.now() + timedelta(hours=hours_valid) + cur.execute( + """ + INSERT INTO sessions (token, profile_id, expires_at, created_at) + VALUES (%s, %s, %s, CURRENT_TIMESTAMP) + """, + (f"{RESET_TOKEN_PREFIX}{raw}", profile_id, expires.isoformat()), + ) + return raw + + +def password_reset_email_body(*, recipient_name: str | None, token: str, intro: str) -> str: + name = (recipient_name or "").strip() or "Kollege/Kollegin" + link = public_reset_link(token) + return f"""Hallo {name}, + +{intro} + +Neues Passwort setzen: +{link} + +Der Link ist 1 Stunde gültig. Erst wenn du ihn nutzt und ein neues Passwort wählst, wird dein bestehendes +Passwort ersetzt — bis dahin kannst du dich wie gewohnt anmelden. + +Falls du diese Anfrage nicht erwartest, ignoriere diese E-Mail; dein Zugang bleibt unverändert. + +Dein Shinkan Jinkendo Team +""" + + +def issue_password_reset_via_email(cur: Any, send_email_fn, *, profile_id: int, email: str, name: str | None, intro: str) -> bool: + """Session anlegen und Mail schicken (send_email_fn wie routers.auth.send_email).""" + revoke_pending_password_resets_for_profile(cur, profile_id) + raw_token = insert_password_reset_session(cur, profile_id) + body = password_reset_email_body(recipient_name=name, token=raw_token, intro=intro) + return send_email_fn(email, "Passwort-Link – Shinkan Jinkendo", body) diff --git a/backend/routers/auth.py b/backend/routers/auth.py index cb8bcc8..ee08f33 100644 --- a/backend/routers/auth.py +++ b/backend/routers/auth.py @@ -20,6 +20,11 @@ from slowapi.util import get_remote_address from db import get_db, get_cursor from auth import hash_pin, verify_pin, make_token, require_auth from models import LoginRequest, PasswordResetRequest, PasswordResetConfirm, RegisterRequest +from password_reset_mail import ( + insert_password_reset_session, + password_reset_email_body, + revoke_pending_password_resets_for_profile, +) router = APIRouter(prefix="/api/auth", tags=["auth"]) limiter = Limiter(key_func=get_remote_address) @@ -116,28 +121,14 @@ async def password_reset_request(req: PasswordResetRequest, request: Request): if not prof: # Don't reveal if email exists return {"ok": True, "message": "Falls die E-Mail existiert, wurde ein Reset-Link gesendet."} + revoke_pending_password_resets_for_profile(cur, prof["id"]) + raw_token = insert_password_reset_session(cur, prof["id"]) - # Generate reset token - token = secrets.token_urlsafe(32) - expires = datetime.now() + timedelta(hours=1) - - # Store in sessions table (reuse mechanism) - cur.execute("INSERT INTO sessions (token, profile_id, expires_at, created_at) VALUES (%s,%s,%s,CURRENT_TIMESTAMP)", - (f"reset_{token}", prof['id'], expires.isoformat())) - - app_base = (os.getenv("APP_URL") or "https://shinkan.jinkendo.de").rstrip("/") - reset_body = f"""Hallo {prof['name']}, - -Du hast einen Passwort-Reset angefordert. - -Reset-Link: {app_base}/reset-password?token={token} - -Der Link ist 1 Stunde gültig. - -Falls du diese Anfrage nicht gestellt hast, ignoriere diese E-Mail. - -Dein Shinkan Jinkendo Team -""" + reset_body = password_reset_email_body( + recipient_name=prof.get("name"), + token=raw_token, + intro="Du hast einen Passwort-Reset angefordert.", + ) if not send_email(email, "Passwort zurücksetzen – Shinkan Jinkendo", reset_body): print("[SMTP] Passwort-Reset konnte nicht gesendet werden (SMTP prüfen).") diff --git a/backend/routers/club_memberships.py b/backend/routers/club_memberships.py index 9eb6385..91f9f7b 100644 --- a/backend/routers/club_memberships.py +++ b/backend/routers/club_memberships.py @@ -50,6 +50,44 @@ def _club_exists(cur, club_id: int) -> bool: return cur.fetchone() is not None +def _count_other_active_club_admins(cur, club_id: int, exclude_profile_id: int) -> int: + """Aktive Vereinsadmins im Verein, außer exclude_profile_id.""" + cur.execute( + """ + SELECT COUNT(DISTINCT cm.profile_id)::int AS n + FROM club_members cm + INNER JOIN club_member_roles r ON r.club_member_id = cm.id AND r.role_code = 'club_admin' + WHERE cm.club_id = %s AND cm.status = 'active' AND cm.profile_id <> %s + """, + (club_id, exclude_profile_id), + ) + row = cur.fetchone() + try: + return int(row["n"]) if row is not None else 0 + except (KeyError, TypeError, ValueError): + return 0 + + +def _member_is_active_club_admin(cur, club_id: int, profile_id: int) -> bool: + cur.execute( + """ + SELECT 1 + FROM club_members cm + INNER JOIN club_member_roles r ON r.club_member_id = cm.id AND r.role_code = 'club_admin' + WHERE cm.club_id = %s AND cm.profile_id = %s AND cm.status = 'active' + LIMIT 1 + """, + (club_id, profile_id), + ) + return cur.fetchone() is not None + + +_LAST_CLUB_ADMIN_MSG = ( + "Mindestens ein aktiver Vereinsadmin muss im Verein verbleiben. " + "Weise die Rolle zuerst einem anderen Mitglied zu oder aktiviere einen anderen Vereinsadmin." +) + + class ClubMemberUpsert(BaseModel): profile_id: int = Field(..., ge=1) roles: List[str] = Field(default_factory=list, description="Mindestens eine Vereinsrolle") @@ -78,6 +116,7 @@ def list_club_members( f""" SELECT cm.id AS membership_id, cm.profile_id, cm.status, cm.created_at, cm.updated_at, 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, p.email_verified + 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,), @@ -154,6 +193,7 @@ def _one_member(cur, club_id: int, profile_id: int) -> Dict[str, Any]: """ SELECT cm.id AS membership_id, cm.profile_id, cm.status, cm.created_at, cm.updated_at, 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, p.email_verified + GROUP BY cm.id, cm.profile_id, cm.status, cm.created_at, cm.updated_at, p.email, p.name, p.email_verified, p.role """, (club_id, profile_id), ) @@ -217,21 +257,50 @@ def update_club_member( if body.roles is None and body.status is None: return _one_member(cur, club_id, profile_id) + cur.execute( + """ + SELECT cm.status, + COALESCE( + ARRAY_AGG(r.role_code) FILTER (WHERE r.role_code IS NOT NULL), + ARRAY[]::varchar[] + ) AS roles + FROM club_members cm + LEFT JOIN club_member_roles r ON r.club_member_id = cm.id + WHERE cm.id = %s + GROUP BY cm.status + """, + (cm_id,), + ) + cur_row = cur.fetchone() + current_status = (cur_row["status"] or "active").strip().lower() if cur_row else "active" + cr = cur_row.get("roles") if cur_row else [] + if hasattr(cr, "tolist"): + cr = cr.tolist() + current_roles = list(cr) + + new_status = body.status.strip().lower() if body.status is not None else current_status + if body.status is not None and new_status not in _ALLOWED_STATUS: + raise HTTPException(status_code=400, detail="status muss active oder inactive sein") + + new_roles = _normalize_roles(body.roles) if body.roles is not None else current_roles + if not new_roles: + raise HTTPException(status_code=400, detail="Mindestens eine Rolle angeben") + + effective_admin = new_status == "active" and "club_admin" in set(new_roles) + if not effective_admin: + others = _count_other_active_club_admins(cur, club_id, profile_id) + if others < 1: + raise HTTPException(status_code=400, detail=_LAST_CLUB_ADMIN_MSG) + if body.status is not None: - st = body.status.strip().lower() - if st not in _ALLOWED_STATUS: - raise HTTPException(status_code=400, detail="status muss active oder inactive sein") cur.execute( "UPDATE club_members SET status = %s, updated_at = NOW() WHERE id = %s", - (st, cm_id), + (new_status, cm_id), ) if body.roles is not None: - roles = _normalize_roles(body.roles) - if not roles: - raise HTTPException(status_code=400, detail="Mindestens eine Rolle angeben") cur.execute("DELETE FROM club_member_roles WHERE club_member_id = %s", (cm_id,)) - for rc in roles: + for rc in new_roles: cur.execute( """ INSERT INTO club_member_roles (club_member_id, role_code) @@ -258,6 +327,10 @@ def delete_club_member( raise HTTPException(status_code=404, detail="Verein nicht gefunden") _assert_manage(cur, tenant, club_id) + if _member_is_active_club_admin(cur, club_id, profile_id): + if _count_other_active_club_admins(cur, club_id, profile_id) < 1: + raise HTTPException(status_code=400, detail=_LAST_CLUB_ADMIN_MSG) + cur.execute( "DELETE FROM club_members WHERE club_id = %s AND profile_id = %s RETURNING id", (club_id, profile_id), diff --git a/backend/routers/profiles.py b/backend/routers/profiles.py index 51f704d..3c2cbb2 100644 --- a/backend/routers/profiles.py +++ b/backend/routers/profiles.py @@ -11,7 +11,7 @@ from fastapi import APIRouter, HTTPException, Header, Depends from psycopg2.extras import Json -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, field_validator from db import get_db, get_cursor, r2d from auth import require_auth, hash_pin @@ -28,16 +28,35 @@ 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): - """Von Super-/Portal- oder Vereinsadmin gesetztes neues Login-Passwort.""" + """Optional: Nur Super-Admins dürfen `new_password` setzen — sonst E-Mail-Link (wie Passwort vergessen).""" - new_password: str = Field(..., min_length=8, max_length=128) + 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 _assert_can_management_password_reset(cur, tenant: TenantContext, target_pid: int) -> None: - """Superadmin / Portal-Admin global; Vereinsadmin nur für aktives Mitglied gemeinsamen Vereins.""" +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") @@ -62,6 +81,11 @@ def _assert_can_management_password_reset(cur, tenant: TenantContext, target_pid 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 ────────────────────────────────────────────────────── @@ -185,22 +209,59 @@ def management_password_reset( tenant: TenantContext = Depends(get_tenant_context), ): """ - Neues Passwort (PIN-Hash) für ein anderes Profil setzen. - Erlaubt: Superadmin, Portal-Admin, oder Vereinsadmin für Ziel in gemeinsam verwaltetem Verein. + 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) - _assert_can_management_password_reset(cur, tenant, target) - cur.execute("SELECT id FROM profiles WHERE id = %s", (target,)) - if not cur.fetchone(): + 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") - 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} + + 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}") diff --git a/frontend/src/pages/AdminUsersPage.jsx b/frontend/src/pages/AdminUsersPage.jsx index d03139a..b5e269e 100644 --- a/frontend/src/pages/AdminUsersPage.jsx +++ b/frontend/src/pages/AdminUsersPage.jsx @@ -33,6 +33,11 @@ function clubSelectOptions(user, allClubs, isPlatformAdmin) { } /** 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 }, @@ -236,8 +241,26 @@ export default function AdminUsersPage() { } } - const submitPasswordReset = async () => { + 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 @@ -251,7 +274,7 @@ export default function AdminUsersPage() { setPwdModal(null) setPwdNew('') setPwdNew2('') - alert('Neues Passwort wurde gesetzt.') + alert('Neues Passwort wurde direkt gesetzt.') } catch (e) { alert(e.message || String(e)) } @@ -377,7 +400,7 @@ export default function AdminUsersPage() { > Bearbeiten - {m.profile_id !== user?.id ? ( + {m.profile_id !== user?.id && !isEscalatedPortalRole(m.portal_role) ? ( ) : null} @@ -448,7 +471,7 @@ export default function AdminUsersPage() { }) } > - Passwort setzen + Passwort / Link + + {isSuperadminViewer ? ( + <> +
+

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

+
+ + setPwdNew(e.target.value)} + /> +
+
+ + setPwdNew2(e.target.value)} + /> +
+ + + ) : null} +
diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js index 0946db3..cedb03b 100644 --- a/frontend/src/utils/api.js +++ b/frontend/src/utils/api.js @@ -139,11 +139,18 @@ export async function updateProfile(profileId, data) { }) } -/** Neues Login-Passwort für anderes Profil (Super-/Portal- oder Vereinsadmin). */ -export async function managementPasswordReset(profileId, newPassword) { +/** + * 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({ new_password: newPassword }), + body: JSON.stringify(body), }) }