feat(auth, profiles, club_memberships): enhance password reset and club admin management
All checks were successful
Deploy Development / deploy (push) Successful in 34s
Test Suite / pytest-backend (push) Successful in 25s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 7s
Test Suite / playwright-tests (push) Successful in 23s
All checks were successful
Deploy Development / deploy (push) Successful in 34s
Test Suite / pytest-backend (push) Successful in 25s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 7s
Test Suite / playwright-tests (push) Successful in 23s
- 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.
This commit is contained in:
parent
f54372d7b5
commit
624c19dcba
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 db import get_db, get_cursor
|
||||||
from auth import hash_pin, verify_pin, make_token, require_auth
|
from auth import hash_pin, verify_pin, make_token, require_auth
|
||||||
from models import LoginRequest, PasswordResetRequest, PasswordResetConfirm, RegisterRequest
|
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"])
|
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||||
limiter = Limiter(key_func=get_remote_address)
|
limiter = Limiter(key_func=get_remote_address)
|
||||||
|
|
@ -116,28 +121,14 @@ async def password_reset_request(req: PasswordResetRequest, request: Request):
|
||||||
if not prof:
|
if not prof:
|
||||||
# Don't reveal if email exists
|
# Don't reveal if email exists
|
||||||
return {"ok": True, "message": "Falls die E-Mail existiert, wurde ein Reset-Link gesendet."}
|
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
|
reset_body = password_reset_email_body(
|
||||||
token = secrets.token_urlsafe(32)
|
recipient_name=prof.get("name"),
|
||||||
expires = datetime.now() + timedelta(hours=1)
|
token=raw_token,
|
||||||
|
intro="Du hast einen Passwort-Reset angefordert.",
|
||||||
# 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
|
|
||||||
"""
|
|
||||||
if not send_email(email, "Passwort zurücksetzen – Shinkan Jinkendo", reset_body):
|
if not send_email(email, "Passwort zurücksetzen – Shinkan Jinkendo", reset_body):
|
||||||
print("[SMTP] Passwort-Reset konnte nicht gesendet werden (SMTP prüfen).")
|
print("[SMTP] Passwort-Reset konnte nicht gesendet werden (SMTP prüfen).")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,44 @@ def _club_exists(cur, club_id: int) -> bool:
|
||||||
return cur.fetchone() is not None
|
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):
|
class ClubMemberUpsert(BaseModel):
|
||||||
profile_id: int = Field(..., ge=1)
|
profile_id: int = Field(..., ge=1)
|
||||||
roles: List[str] = Field(default_factory=list, description="Mindestens eine Vereinsrolle")
|
roles: List[str] = Field(default_factory=list, description="Mindestens eine Vereinsrolle")
|
||||||
|
|
@ -78,6 +116,7 @@ def list_club_members(
|
||||||
f"""
|
f"""
|
||||||
SELECT cm.id AS membership_id, cm.profile_id, cm.status, cm.created_at, cm.updated_at,
|
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,
|
p.email, p.name, COALESCE(p.email_verified, false) AS email_verified,
|
||||||
|
lower(trim(COALESCE(p.role, 'user'))) AS portal_role,
|
||||||
COALESCE(
|
COALESCE(
|
||||||
ARRAY_AGG(DISTINCT r.role_code) FILTER (WHERE r.role_code IS NOT NULL),
|
ARRAY_AGG(DISTINCT r.role_code) FILTER (WHERE r.role_code IS NOT NULL),
|
||||||
ARRAY[]::varchar[]
|
ARRAY[]::varchar[]
|
||||||
|
|
@ -86,7 +125,7 @@ def list_club_members(
|
||||||
INNER JOIN profiles p ON p.id = cm.profile_id
|
INNER JOIN profiles p ON p.id = cm.profile_id
|
||||||
LEFT JOIN club_member_roles r ON r.club_member_id = cm.id
|
LEFT JOIN club_member_roles r ON r.club_member_id = cm.id
|
||||||
WHERE cm.club_id = %s {status_clause}
|
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
|
ORDER BY p.name NULLS LAST, p.email
|
||||||
""",
|
""",
|
||||||
(club_id,),
|
(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,
|
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,
|
p.email, p.name, COALESCE(p.email_verified, false) AS email_verified,
|
||||||
|
lower(trim(COALESCE(p.role, 'user'))) AS portal_role,
|
||||||
COALESCE(
|
COALESCE(
|
||||||
ARRAY_AGG(DISTINCT r.role_code) FILTER (WHERE r.role_code IS NOT NULL),
|
ARRAY_AGG(DISTINCT r.role_code) FILTER (WHERE r.role_code IS NOT NULL),
|
||||||
ARRAY[]::varchar[]
|
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
|
INNER JOIN profiles p ON p.id = cm.profile_id
|
||||||
LEFT JOIN club_member_roles r ON r.club_member_id = cm.id
|
LEFT JOIN club_member_roles r ON r.club_member_id = cm.id
|
||||||
WHERE cm.club_id = %s AND cm.profile_id = %s
|
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),
|
(club_id, profile_id),
|
||||||
)
|
)
|
||||||
|
|
@ -217,21 +257,50 @@ def update_club_member(
|
||||||
if body.roles is None and body.status is None:
|
if body.roles is None and body.status is None:
|
||||||
return _one_member(cur, club_id, profile_id)
|
return _one_member(cur, club_id, profile_id)
|
||||||
|
|
||||||
if body.status is not None:
|
cur.execute(
|
||||||
st = body.status.strip().lower()
|
"""
|
||||||
if st not in _ALLOWED_STATUS:
|
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")
|
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:
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"UPDATE club_members SET status = %s, updated_at = NOW() WHERE id = %s",
|
"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:
|
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,))
|
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(
|
cur.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO club_member_roles (club_member_id, role_code)
|
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")
|
raise HTTPException(status_code=404, detail="Verein nicht gefunden")
|
||||||
_assert_manage(cur, tenant, club_id)
|
_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(
|
cur.execute(
|
||||||
"DELETE FROM club_members WHERE club_id = %s AND profile_id = %s RETURNING id",
|
"DELETE FROM club_members WHERE club_id = %s AND profile_id = %s RETURNING id",
|
||||||
(club_id, profile_id),
|
(club_id, profile_id),
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ from fastapi import APIRouter, HTTPException, Header, Depends
|
||||||
|
|
||||||
from psycopg2.extras import Json
|
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 db import get_db, get_cursor, r2d
|
||||||
from auth import require_auth, hash_pin
|
from auth import require_auth, hash_pin
|
||||||
|
|
@ -28,16 +28,35 @@ from models import ProfileCreate, ProfileUpdate
|
||||||
router = APIRouter(prefix="/api", tags=["profiles"])
|
router = APIRouter(prefix="/api", tags=["profiles"])
|
||||||
|
|
||||||
_ALLOWED_PORTAL_ROLES = frozenset({"user", "trainer", "admin", "superadmin"})
|
_ALLOWED_PORTAL_ROLES = frozenset({"user", "trainer", "admin", "superadmin"})
|
||||||
|
_CLUB_BLOCKED_PORTAL_TARGETS = frozenset({"admin", "superadmin"})
|
||||||
|
|
||||||
|
|
||||||
class ManagementPasswordResetBody(BaseModel):
|
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:
|
def _target_portal_role_lower(cur, target_pid: int) -> str:
|
||||||
"""Superadmin / Portal-Admin global; Vereinsadmin nur für aktives Mitglied gemeinsamen Vereins."""
|
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)
|
viewer_pid = int(tenant.profile_id)
|
||||||
if target_pid == viewer_pid:
|
if target_pid == viewer_pid:
|
||||||
raise HTTPException(status_code=400, detail="Eigenes Passwort unter Einstellungen ändern")
|
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,
|
status_code=403,
|
||||||
detail="Nur für Nutzer, die in mindestens einem deiner Vereine (als Admin) aktiv sind",
|
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 ──────────────────────────────────────────────────────
|
# ── Current User Profile ──────────────────────────────────────────────────────
|
||||||
|
|
@ -185,22 +209,59 @@ def management_password_reset(
|
||||||
tenant: TenantContext = Depends(get_tenant_context),
|
tenant: TenantContext = Depends(get_tenant_context),
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Neues Passwort (PIN-Hash) für ein anderes Profil setzen.
|
Standard: E-Mail mit Reset-Link wie „Passwort vergessen“ — der PIN-Hash bleibt bis zur Bestätigung unverändert.
|
||||||
Erlaubt: Superadmin, Portal-Admin, oder Vereinsadmin für Ziel in gemeinsam verwaltetem Verein.
|
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:
|
try:
|
||||||
target = int(pid)
|
target = int(pid)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
raise HTTPException(status_code=400, detail="Ungültige Profil-ID")
|
raise HTTPException(status_code=400, detail="Ungültige Profil-ID")
|
||||||
|
|
||||||
|
direct = body.new_password is not None
|
||||||
|
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
cur = get_cursor(conn)
|
cur = get_cursor(conn)
|
||||||
_assert_can_management_password_reset(cur, tenant, target)
|
cur.execute("SELECT id, email, name FROM profiles WHERE id = %s", (target,))
|
||||||
cur.execute("SELECT id FROM profiles WHERE id = %s", (target,))
|
row = cur.fetchone()
|
||||||
if not cur.fetchone():
|
if not row:
|
||||||
raise HTTPException(status_code=404, detail="Profil nicht gefunden")
|
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)
|
new_hash = hash_pin(body.new_password)
|
||||||
cur.execute("UPDATE profiles SET pin_hash = %s, updated_at = NOW() WHERE id = %s", (new_hash, target))
|
cur.execute(
|
||||||
return {"ok": True}
|
"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}")
|
@router.get("/profiles/{pid}")
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,11 @@ function clubSelectOptions(user, allClubs, isPlatformAdmin) {
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Plattform-Rollen im UI (Tier/Abo entfällt bis auf Weiteres). */
|
/** 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) {
|
function portalRoleSelectOptions(viewerIsSuperadmin, currentRole) {
|
||||||
const base = [
|
const base = [
|
||||||
{ value: 'user', label: PORTAL_ROLE_LABEL.user },
|
{ value: 'user', label: PORTAL_ROLE_LABEL.user },
|
||||||
|
|
@ -236,8 +241,26 @@ export default function AdminUsersPage() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const submitPasswordReset = async () => {
|
const submitPasswordEmail = async () => {
|
||||||
if (!pwdModal) return
|
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) {
|
if (pwdNew.length < 8) {
|
||||||
alert('Mindestens 8 Zeichen.')
|
alert('Mindestens 8 Zeichen.')
|
||||||
return
|
return
|
||||||
|
|
@ -251,7 +274,7 @@ export default function AdminUsersPage() {
|
||||||
setPwdModal(null)
|
setPwdModal(null)
|
||||||
setPwdNew('')
|
setPwdNew('')
|
||||||
setPwdNew2('')
|
setPwdNew2('')
|
||||||
alert('Neues Passwort wurde gesetzt.')
|
alert('Neues Passwort wurde direkt gesetzt.')
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
alert(e.message || String(e))
|
alert(e.message || String(e))
|
||||||
}
|
}
|
||||||
|
|
@ -377,7 +400,7 @@ export default function AdminUsersPage() {
|
||||||
>
|
>
|
||||||
Bearbeiten
|
Bearbeiten
|
||||||
</button>
|
</button>
|
||||||
{m.profile_id !== user?.id ? (
|
{m.profile_id !== user?.id && !isEscalatedPortalRole(m.portal_role) ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-secondary"
|
className="btn btn-secondary"
|
||||||
|
|
@ -388,7 +411,7 @@ export default function AdminUsersPage() {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
Passwort setzen
|
Passwort-Link senden
|
||||||
</button>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -448,7 +471,7 @@ export default function AdminUsersPage() {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
Passwort setzen
|
Passwort / Link
|
||||||
</button>
|
</button>
|
||||||
<button type="button" className="btn btn-secondary" onClick={() => savePortal(row.id)}>
|
<button type="button" className="btn btn-secondary" onClick={() => savePortal(row.id)}>
|
||||||
Portal speichern
|
Portal speichern
|
||||||
|
|
@ -778,10 +801,22 @@ export default function AdminUsersPage() {
|
||||||
width: '100%',
|
width: '100%',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<h2 style={{ marginTop: 0 }}>Login-Passwort setzen</h2>
|
<h2 style={{ marginTop: 0 }}>Passwort zurücksetzen</h2>
|
||||||
<p style={{ color: 'var(--text2)', fontSize: '0.9rem' }}>{pwdModal.label}</p>
|
<p style={{ color: 'var(--text2)', fontSize: '0.9rem' }}>{pwdModal.label}</p>
|
||||||
<p className="muted" style={{ fontSize: '0.82rem', marginBottom: '0.75rem' }}>
|
<p className="muted" style={{ fontSize: '0.82rem', marginBottom: '0.75rem' }}>
|
||||||
Mindestens 8 Zeichen. Das Passwort liegt dem Nutzer nicht automatisch vor — gib es sicher weiter.
|
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>
|
</p>
|
||||||
<div className="form-row">
|
<div className="form-row">
|
||||||
<label className="form-label">Neues Passwort</label>
|
<label className="form-label">Neues Passwort</label>
|
||||||
|
|
@ -803,20 +838,23 @@ export default function AdminUsersPage() {
|
||||||
onChange={(e) => setPwdNew2(e.target.value)}
|
onChange={(e) => setPwdNew2(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '1rem' }}>
|
<button type="button" className="btn btn-secondary" style={{ width: '100%' }} onClick={submitPasswordDirect}>
|
||||||
<button type="button" className="btn btn-primary" style={{ flex: 1 }} onClick={submitPasswordReset}>
|
Passwort direkt setzen
|
||||||
Übernehmen
|
|
||||||
</button>
|
</button>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
<div style={{ marginTop: '1rem' }}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-secondary"
|
className="btn btn-secondary"
|
||||||
|
style={{ width: '100%' }}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setPwdModal(null)
|
setPwdModal(null)
|
||||||
setPwdNew('')
|
setPwdNew('')
|
||||||
setPwdNew2('')
|
setPwdNew2('')
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Abbrechen
|
Schließen
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -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`, {
|
return request(`/api/profiles/${profileId}/management-password-reset`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ new_password: newPassword }),
|
body: JSON.stringify(body),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user