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
|
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
|
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
|
ORDER BY p.name NULLS LAST, p.email
|
||||||
""",
|
""",
|
||||||
(club_id,),
|
(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
|
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
|
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),
|
(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."""
|
"""Sichtbare aktive Einträge: Plattform-Admin alles; sonst official + eigene private + Verein als Mitglied."""
|
||||||
sql = """(
|
sql = """(
|
||||||
%s
|
%s
|
||||||
|
|
|
||||||
|
|
@ -11,9 +11,17 @@ from fastapi import APIRouter, HTTPException, Header, Depends
|
||||||
|
|
||||||
from psycopg2.extras import Json
|
from psycopg2.extras import Json
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
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
|
||||||
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 tenant_context import resolve_tenant_context, TenantContext, get_tenant_context
|
||||||
from models import ProfileCreate, ProfileUpdate
|
from models import ProfileCreate, ProfileUpdate
|
||||||
|
|
||||||
|
|
@ -22,6 +30,40 @@ router = APIRouter(prefix="/api", tags=["profiles"])
|
||||||
_ALLOWED_PORTAL_ROLES = frozenset({"user", "trainer", "admin", "superadmin"})
|
_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 ──────────────────────────────────────────────────────
|
# ── Current User Profile ──────────────────────────────────────────────────────
|
||||||
@router.get("/profiles/me")
|
@router.get("/profiles/me")
|
||||||
def get_current_profile(
|
def get_current_profile(
|
||||||
|
|
@ -136,6 +178,31 @@ def profile_document(pid: str) -> dict:
|
||||||
return d
|
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}")
|
@router.get("/profiles/{pid}")
|
||||||
def get_profile(pid: str, session=Depends(require_auth)):
|
def get_profile(pid: str, session=Depends(require_auth)):
|
||||||
"""Profil nach ID — nur eigenes Profil oder Plattform-Admin (wie PUT)."""
|
"""Profil nach ID — nur eigenes Profil oder Plattform-Admin (wie PUT)."""
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@ import {
|
||||||
Building2,
|
Building2,
|
||||||
Settings,
|
Settings,
|
||||||
Shield,
|
Shield,
|
||||||
Target,
|
|
||||||
Inbox
|
Inbox
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
|
||||||
|
|
@ -27,7 +26,6 @@ function baseItems(opts = {}) {
|
||||||
{ to: '/planning', label: 'Planung' },
|
{ to: '/planning', label: 'Planung' },
|
||||||
{ to: '/media', label: 'Medien', shortLabel: 'Medien' },
|
{ to: '/media', label: 'Medien', shortLabel: 'Medien' },
|
||||||
{ to: '/clubs', label: 'Vereine' },
|
{ to: '/clubs', label: 'Vereine' },
|
||||||
{ to: '/trainer-contexts', label: 'Meine Bereiche', shortLabel: 'Bereiche' },
|
|
||||||
{ to: '/settings', label: 'Einstellungen', shortLabel: 'Einst.' }
|
{ to: '/settings', label: 'Einstellungen', shortLabel: 'Einst.' }
|
||||||
]
|
]
|
||||||
return items
|
return items
|
||||||
|
|
@ -43,7 +41,6 @@ export function getMainNavItems(isAdmin, opts = {}) {
|
||||||
Calendar,
|
Calendar,
|
||||||
Images,
|
Images,
|
||||||
Building2,
|
Building2,
|
||||||
Target,
|
|
||||||
Settings,
|
Settings,
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,9 @@ export default function AdminUsersPage() {
|
||||||
const [addMemberOpen, setAddMemberOpen] = useState(false)
|
const [addMemberOpen, setAddMemberOpen] = useState(false)
|
||||||
const [newMemberProfileId, setNewMemberProfileId] = useState('')
|
const [newMemberProfileId, setNewMemberProfileId] = useState('')
|
||||||
const [newMemberRoles, setNewMemberRoles] = useState(['trainer'])
|
const [newMemberRoles, setNewMemberRoles] = useState(['trainer'])
|
||||||
|
const [pwdModal, setPwdModal] = useState(null)
|
||||||
|
const [pwdNew, setPwdNew] = useState('')
|
||||||
|
const [pwdNew2, setPwdNew2] = useState('')
|
||||||
|
|
||||||
const selectableClubs = useMemo(
|
const selectableClubs = useMemo(
|
||||||
() => clubSelectOptions(user, clubs, isPlatformAdmin),
|
() => 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 (
|
return (
|
||||||
<div className="app-page">
|
<div className="app-page">
|
||||||
<AdminPageNav clubOrgOnly={clubOrgMode} />
|
<AdminPageNav clubOrgOnly={clubOrgMode} />
|
||||||
|
|
@ -245,11 +269,44 @@ export default function AdminUsersPage() {
|
||||||
Portal-Rollen und andere Vereine sind hier nicht sichtbar.
|
Portal-Rollen und andere Vereine sind hier nicht sichtbar.
|
||||||
</p>
|
</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{' '}
|
<p style={{ color: 'var(--text2)', maxWidth: '52rem', lineHeight: 1.55, marginBottom: '0.75rem' }}>
|
||||||
<strong>Super-Administrator</strong> steuern den Zugriff auf die Plattform-Administration; alles Weitere
|
Gesamtübersicht aller Konten und Vereinszuordnungen. <strong>Portal</strong>-Einstellungen steuern nur den
|
||||||
(z. B. Trainer, Vereinsadmin) legst du pro Verein fest. Abonnement/Tier ist derzeit nicht freigeschaltet.
|
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>
|
</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 ? (
|
{clubOrgMode && managedClubIds.length > 1 ? (
|
||||||
|
|
@ -303,6 +360,7 @@ export default function AdminUsersPage() {
|
||||||
Rollen: {(m.roles || []).join(', ') || '—'}
|
Rollen: {(m.roles || []).join(', ') || '—'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem', alignItems: 'stretch' }}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-secondary"
|
className="btn btn-secondary"
|
||||||
|
|
@ -319,6 +377,21 @@ export default function AdminUsersPage() {
|
||||||
>
|
>
|
||||||
Bearbeiten
|
Bearbeiten
|
||||||
</button>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
|
|
@ -363,6 +436,20 @@ export default function AdminUsersPage() {
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</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)}>
|
<button type="button" className="btn btn-secondary" onClick={() => savePortal(row.id)}>
|
||||||
Portal speichern
|
Portal speichern
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -668,6 +755,73 @@ export default function AdminUsersPage() {
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</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) {
|
export async function changePassword(newPassword) {
|
||||||
return request('/api/auth/pin', {
|
return request('/api/auth/pin', {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
|
|
@ -1308,6 +1316,7 @@ export const api = {
|
||||||
listProfiles,
|
listProfiles,
|
||||||
listAdminUsers,
|
listAdminUsers,
|
||||||
updateProfile,
|
updateProfile,
|
||||||
|
managementPasswordReset,
|
||||||
changePassword,
|
changePassword,
|
||||||
verifyEmail,
|
verifyEmail,
|
||||||
resendVerification,
|
resendVerification,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user