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