feat(profiles): implement management password reset functionality for admins
All checks were successful
Deploy Development / deploy (push) Successful in 34s
Test Suite / pytest-backend (push) Successful in 24s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 7s
Test Suite / playwright-tests (push) Successful in 23s

- Added a new endpoint for superadmins and platform admins to reset passwords for other profiles.
- Introduced a management password reset feature in the admin user management page, allowing for secure password updates.
- Enhanced user interface to support password reset actions, including validation and feedback for successful updates.
- Updated API utility functions to handle the new password reset request.
This commit is contained in:
Lars 2026-05-09 10:15:16 +02:00
parent c46f5f99be
commit f54372d7b5
6 changed files with 255 additions and 28 deletions

View File

@ -86,7 +86,7 @@ def list_club_members(
INNER JOIN profiles p ON p.id = cm.profile_id
LEFT JOIN club_member_roles r ON r.club_member_id = cm.id
WHERE cm.club_id = %s {status_clause}
GROUP BY cm.id, cm.profile_id, cm.status, cm.created_at, cm.updated_at, p.email, p.name
GROUP BY cm.id, cm.profile_id, cm.status, cm.created_at, cm.updated_at, p.email, p.name, p.email_verified
ORDER BY p.name NULLS LAST, p.email
""",
(club_id,),
@ -162,7 +162,7 @@ def _one_member(cur, club_id: int, profile_id: int) -> Dict[str, Any]:
INNER JOIN profiles p ON p.id = cm.profile_id
LEFT JOIN club_member_roles r ON r.club_member_id = cm.id
WHERE cm.club_id = %s AND cm.profile_id = %s
GROUP BY cm.id, cm.profile_id, cm.status, cm.created_at, cm.updated_at, p.email, p.name
GROUP BY cm.id, cm.profile_id, cm.status, cm.created_at, cm.updated_at, p.email, p.name, p.email_verified
""",
(club_id, profile_id),
)

View File

@ -379,7 +379,7 @@ def _relocate_asset_file_if_governance_changed(
def _fetch_asset_file_row(cur: Any, asset_id: int) -> Optional[dict]:
def _list_active_visibility_clause(is_plat: bool, profile_id: int) -> tuple[str, list[Any]]:
"""Sichtbare aktive Einträge: Plattform-Admin alles; sonst official + eigene private + Verein als Mitglied."""
sql = """(
%s

View File

@ -11,9 +11,17 @@ from fastapi import APIRouter, HTTPException, Header, Depends
from psycopg2.extras import Json
from pydantic import BaseModel, Field
from db import get_db, get_cursor, r2d
from auth import require_auth, hash_pin
from club_tenancy import assert_club_member, memberships_with_roles, is_platform_admin
from club_tenancy import (
assert_club_member,
club_ids_for_profile_with_roles,
is_platform_admin,
is_superadmin,
memberships_with_roles,
)
from tenant_context import resolve_tenant_context, TenantContext, get_tenant_context
from models import ProfileCreate, ProfileUpdate
@ -22,6 +30,40 @@ router = APIRouter(prefix="/api", tags=["profiles"])
_ALLOWED_PORTAL_ROLES = frozenset({"user", "trainer", "admin", "superadmin"})
class ManagementPasswordResetBody(BaseModel):
"""Von Super-/Portal- oder Vereinsadmin gesetztes neues Login-Passwort."""
new_password: str = Field(..., min_length=8, max_length=128)
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."""
viewer_pid = int(tenant.profile_id)
if target_pid == viewer_pid:
raise HTTPException(status_code=400, detail="Eigenes Passwort unter Einstellungen ändern")
role_raw = tenant.global_role or ""
if is_superadmin(role_raw):
return
if is_platform_admin(role_raw):
return
managed = club_ids_for_profile_with_roles(cur, viewer_pid, "club_admin")
if not managed:
raise HTTPException(status_code=403, detail="Keine Berechtigung")
cur.execute(
"""
SELECT 1 FROM club_members
WHERE profile_id = %s AND club_id = ANY(%s) AND status = 'active'
LIMIT 1
""",
(target_pid, list(managed)),
)
if not cur.fetchone():
raise HTTPException(
status_code=403,
detail="Nur für Nutzer, die in mindestens einem deiner Vereine (als Admin) aktiv sind",
)
# ── Current User Profile ──────────────────────────────────────────────────────
@router.get("/profiles/me")
def get_current_profile(
@ -136,6 +178,31 @@ def profile_document(pid: str) -> dict:
return d
@router.post("/profiles/{pid}/management-password-reset")
def management_password_reset(
pid: str,
body: ManagementPasswordResetBody,
tenant: TenantContext = Depends(get_tenant_context),
):
"""
Neues Passwort (PIN-Hash) für ein anderes Profil setzen.
Erlaubt: Superadmin, Portal-Admin, oder Vereinsadmin für Ziel in gemeinsam verwaltetem Verein.
"""
try:
target = int(pid)
except ValueError:
raise HTTPException(status_code=400, detail="Ungültige Profil-ID")
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():
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}
@router.get("/profiles/{pid}")
def get_profile(pid: str, session=Depends(require_auth)):
"""Profil nach ID — nur eigenes Profil oder Plattform-Admin (wie PUT)."""

View File

@ -6,7 +6,6 @@ import {
Building2,
Settings,
Shield,
Target,
Inbox
} from 'lucide-react'
@ -27,7 +26,6 @@ function baseItems(opts = {}) {
{ to: '/planning', label: 'Planung' },
{ to: '/media', label: 'Medien', shortLabel: 'Medien' },
{ to: '/clubs', label: 'Vereine' },
{ to: '/trainer-contexts', label: 'Meine Bereiche', shortLabel: 'Bereiche' },
{ to: '/settings', label: 'Einstellungen', shortLabel: 'Einst.' }
]
return items
@ -43,7 +41,6 @@ export function getMainNavItems(isAdmin, opts = {}) {
Calendar,
Images,
Building2,
Target,
Settings,
]

View File

@ -71,6 +71,9 @@ export default function AdminUsersPage() {
const [addMemberOpen, setAddMemberOpen] = useState(false)
const [newMemberProfileId, setNewMemberProfileId] = useState('')
const [newMemberRoles, setNewMemberRoles] = useState(['trainer'])
const [pwdModal, setPwdModal] = useState(null)
const [pwdNew, setPwdNew] = useState('')
const [pwdNew2, setPwdNew2] = useState('')
const selectableClubs = useMemo(
() => clubSelectOptions(user, clubs, isPlatformAdmin),
@ -233,6 +236,27 @@ export default function AdminUsersPage() {
}
}
const submitPasswordReset = async () => {
if (!pwdModal) return
if (pwdNew.length < 8) {
alert('Mindestens 8 Zeichen.')
return
}
if (pwdNew !== pwdNew2) {
alert('Die beiden Passwörter stimmen nicht überein.')
return
}
try {
await api.managementPasswordReset(pwdModal.profileId, pwdNew)
setPwdModal(null)
setPwdNew('')
setPwdNew2('')
alert('Neues Passwort wurde gesetzt.')
} catch (e) {
alert(e.message || String(e))
}
}
return (
<div className="app-page">
<AdminPageNav clubOrgOnly={clubOrgMode} />
@ -245,11 +269,44 @@ export default function AdminUsersPage() {
Portal-Rollen und andere Vereine sind hier nicht sichtbar.
</p>
) : (
<p style={{ color: 'var(--text2)', maxWidth: '52rem', lineHeight: 1.55, marginBottom: '1.25rem' }}>
Gesamtübersicht aller Konten und Vereinszuordnungen. <strong>Portal-Administrator</strong> und{' '}
<strong>Super-Administrator</strong> steuern den Zugriff auf die Plattform-Administration; alles Weitere
(z.B. Trainer, Vereinsadmin) legst du pro Verein fest. Abonnement/Tier ist derzeit nicht freigeschaltet.
</p>
<>
<p style={{ color: 'var(--text2)', maxWidth: '52rem', lineHeight: 1.55, marginBottom: '0.75rem' }}>
Gesamtübersicht aller Konten und Vereinszuordnungen. <strong>Portal</strong>-Einstellungen steuern nur den
Zugang zur <strong>plattformweiten Administration</strong> (Kataloge, Hierarchie, globale Nutzerliste usw.);
Vereinsarbeit (Trainer, Vereinsadmin&nbsp;) bleiben <strong>Vereinsrollen</strong>. Abonnement/Tier ist
derzeit nicht freigeschaltet.
</p>
<div
className="card"
style={{
marginBottom: '1.25rem',
padding: '0.85rem 1rem',
maxWidth: '52rem',
fontSize: '0.92rem',
lineHeight: 1.5,
color: 'var(--text2)',
}}
>
<strong style={{ color: 'var(--text1)' }}>Die vier Portal-Zugriffsstufen:</strong>
<ul style={{ margin: '0.5rem 0 0', paddingLeft: '1.2rem' }}>
<li>
<strong>Nutzer</strong> Standardnutzer ohne Plattform-Admin-Bereiche.
</li>
<li>
<strong>Portal-Trainer (Legacy)</strong> ältere Kennzeichnung auf Profil-Ebene; organisatorisch ist
Trainer in der Regel eine <strong>Vereinsrolle</strong>.
</li>
<li>
<strong>Portal-Administrator</strong> Zugang zu allen geschützten <code>/admin</code>-Bereichen
(Außer: einige Funktionen nur Superadmin, z.&nbsp;B. bestimmte Medien-/Governance-Aktionen).
</li>
<li>
<strong>Super-Administrator</strong> volle Plattform-Governance (u.&nbsp;a. offizielle Medien,
Superadmin-Rolle vergeben, harte Lifecycle-Aktionen an Medien).
</li>
</ul>
</div>
</>
)}
{clubOrgMode && managedClubIds.length > 1 ? (
@ -303,22 +360,38 @@ export default function AdminUsersPage() {
Rollen: {(m.roles || []).join(', ') || '—'}
</div>
</div>
<button
type="button"
className="btn btn-secondary"
onClick={() =>
setClubEditModal({
clubId: selectedClubId,
clubName: selectedClubLabel,
profileId: m.profile_id,
profileLabel: m.name || m.email,
roles: [...(m.roles || [])],
status: (m.status || 'active').toLowerCase(),
})
}
>
Bearbeiten
</button>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem', alignItems: 'stretch' }}>
<button
type="button"
className="btn btn-secondary"
onClick={() =>
setClubEditModal({
clubId: selectedClubId,
clubName: selectedClubLabel,
profileId: m.profile_id,
profileLabel: m.name || m.email,
roles: [...(m.roles || [])],
status: (m.status || 'active').toLowerCase(),
})
}
>
Bearbeiten
</button>
{m.profile_id !== user?.id ? (
<button
type="button"
className="btn btn-secondary"
onClick={() =>
setPwdModal({
profileId: m.profile_id,
label: m.name || m.email || `Profil #${m.profile_id}`,
})
}
>
Passwort setzen
</button>
) : null}
</div>
</div>
</div>
))
@ -363,6 +436,20 @@ export default function AdminUsersPage() {
))}
</select>
</div>
<button
type="button"
className="btn btn-secondary"
disabled={row.id === user?.id}
title={row.id === user?.id ? 'Eigenes Passwort unter Einstellungen' : undefined}
onClick={() =>
setPwdModal({
profileId: row.id,
label: row.name || row.email || `#${row.id}`,
})
}
>
Passwort setzen
</button>
<button type="button" className="btn btn-secondary" onClick={() => savePortal(row.id)}>
Portal speichern
</button>
@ -668,6 +755,73 @@ export default function AdminUsersPage() {
</div>
</div>
)}
{pwdModal ? (
<div
style={{
position: 'fixed',
inset: 0,
background: 'rgba(0,0,0,0.5)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1250,
padding: '1rem',
}}
>
<div
style={{
background: 'var(--surface)',
borderRadius: '12px',
padding: '1.5rem',
maxWidth: '440px',
width: '100%',
}}
>
<h2 style={{ marginTop: 0 }}>Login-Passwort setzen</h2>
<p style={{ color: 'var(--text2)', fontSize: '0.9rem' }}>{pwdModal.label}</p>
<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.
</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>
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '1rem' }}>
<button type="button" className="btn btn-primary" style={{ flex: 1 }} onClick={submitPasswordReset}>
Übernehmen
</button>
<button
type="button"
className="btn btn-secondary"
onClick={() => {
setPwdModal(null)
setPwdNew('')
setPwdNew2('')
}}
>
Abbrechen
</button>
</div>
</div>
</div>
) : null}
</div>
)
}

View File

@ -139,6 +139,14 @@ export async function updateProfile(profileId, data) {
})
}
/** Neues Login-Passwort für anderes Profil (Super-/Portal- oder Vereinsadmin). */
export async function managementPasswordReset(profileId, newPassword) {
return request(`/api/profiles/${profileId}/management-password-reset`, {
method: 'POST',
body: JSON.stringify({ new_password: newPassword }),
})
}
export async function changePassword(newPassword) {
return request('/api/auth/pin', {
method: 'PUT',
@ -1308,6 +1316,7 @@ export const api = {
listProfiles,
listAdminUsers,
updateProfile,
managementPasswordReset,
changePassword,
verifyEmail,
resendVerification,