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

- 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:
Lars 2026-05-09 10:32:33 +02:00
parent f54372d7b5
commit 624c19dcba
6 changed files with 316 additions and 78 deletions

View File

@ -0,0 +1,68 @@
"""Gemeinsame Passwort-Link-Erzeugung (Sessions) + Mailtext — wie /auth/forgot-password."""
from __future__ import annotations
import os
import secrets
from datetime import datetime, timedelta
from typing import Any
RESET_TOKEN_PREFIX = "reset_"
def public_reset_link(token: str) -> str:
base = (os.getenv("APP_URL") or "https://shinkan.jinkendo.de").rstrip("/")
return f"{base}/reset-password?token={token}"
def revoke_pending_password_resets_for_profile(cur: Any, profile_id: int) -> None:
"""Entfernt alte Reset-Sessions eines Profils, damit nur der neueste Link aktiv ist."""
cur.execute(
"""
DELETE FROM sessions
WHERE profile_id = %s AND token LIKE %s
""",
(profile_id, f"{RESET_TOKEN_PREFIX}%"),
)
def insert_password_reset_session(cur: Any, profile_id: int, *, hours_valid: int = 1) -> str:
"""
Legt reset_<token>-Session an. Gibt den Klartext-Token zurück (wie bei forgot-password).
"""
raw = secrets.token_urlsafe(32)
expires = datetime.now() + timedelta(hours=hours_valid)
cur.execute(
"""
INSERT INTO sessions (token, profile_id, expires_at, created_at)
VALUES (%s, %s, %s, CURRENT_TIMESTAMP)
""",
(f"{RESET_TOKEN_PREFIX}{raw}", profile_id, expires.isoformat()),
)
return raw
def password_reset_email_body(*, recipient_name: str | None, token: str, intro: str) -> str:
name = (recipient_name or "").strip() or "Kollege/Kollegin"
link = public_reset_link(token)
return f"""Hallo {name},
{intro}
Neues Passwort setzen:
{link}
Der Link ist 1 Stunde gültig. Erst wenn du ihn nutzt und ein neues Passwort wählst, wird dein bestehendes
Passwort ersetzt — bis dahin kannst du dich wie gewohnt anmelden.
Falls du diese Anfrage nicht erwartest, ignoriere diese E-Mail; dein Zugang bleibt unverändert.
Dein Shinkan Jinkendo Team
"""
def issue_password_reset_via_email(cur: Any, send_email_fn, *, profile_id: int, email: str, name: str | None, intro: str) -> bool:
"""Session anlegen und Mail schicken (send_email_fn wie routers.auth.send_email)."""
revoke_pending_password_resets_for_profile(cur, profile_id)
raw_token = insert_password_reset_session(cur, profile_id)
body = password_reset_email_body(recipient_name=name, token=raw_token, intro=intro)
return send_email_fn(email, "Passwort-Link – Shinkan Jinkendo", body)

View File

@ -20,6 +20,11 @@ from slowapi.util import get_remote_address
from db import get_db, get_cursor from 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).")

View File

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

View File

@ -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")
new_hash = hash_pin(body.new_password)
cur.execute("UPDATE profiles SET pin_hash = %s, updated_at = NOW() WHERE id = %s", (new_hash, target)) if direct:
return {"ok": True} 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}") @router.get("/profiles/{pid}")

View File

@ -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,45 +801,60 @@ 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> </p>
<div className="form-row"> <div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
<label className="form-label">Neues Passwort</label> <button type="button" className="btn btn-primary" onClick={submitPasswordEmail}>
<input Reset-Link per E-Mail senden
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>
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '1rem' }}>
<button type="button" className="btn btn-primary" style={{ flex: 1 }} onClick={submitPasswordReset}>
Übernehmen
</button> </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 <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>

View File

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