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
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:
parent
c46f5f99be
commit
f54372d7b5
|
|
@ -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),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)."""
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -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 …) 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. B. bestimmte Medien-/Governance-Aktionen).
|
||||
</li>
|
||||
<li>
|
||||
<strong>Super-Administrator</strong> — volle Plattform-Governance (u. 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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user