diff --git a/backend/routers/club_memberships.py b/backend/routers/club_memberships.py index f88cf1d..9eb6385 100644 --- a/backend/routers/club_memberships.py +++ b/backend/routers/club_memberships.py @@ -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), ) diff --git a/backend/routers/media_assets.py b/backend/routers/media_assets.py index d33aa1d..756580d 100644 --- a/backend/routers/media_assets.py +++ b/backend/routers/media_assets.py @@ -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 diff --git a/backend/routers/profiles.py b/backend/routers/profiles.py index daa2dda..51f704d 100644 --- a/backend/routers/profiles.py +++ b/backend/routers/profiles.py @@ -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).""" diff --git a/frontend/src/config/appNav.js b/frontend/src/config/appNav.js index 5c5ea48..4a10fee 100644 --- a/frontend/src/config/appNav.js +++ b/frontend/src/config/appNav.js @@ -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, ] diff --git a/frontend/src/pages/AdminUsersPage.jsx b/frontend/src/pages/AdminUsersPage.jsx index 3a2580f..d03139a 100644 --- a/frontend/src/pages/AdminUsersPage.jsx +++ b/frontend/src/pages/AdminUsersPage.jsx @@ -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 (
- Gesamtübersicht aller Konten und Vereinszuordnungen. Portal-Administrator und{' '} - Super-Administrator steuern den Zugriff auf die Plattform-Administration; alles Weitere - (z. B. Trainer, Vereinsadmin) legst du pro Verein fest. Abonnement/Tier ist derzeit nicht freigeschaltet. -
+ <> ++ Gesamtübersicht aller Konten und Vereinszuordnungen. Portal-Einstellungen steuern nur den + Zugang zur plattformweiten Administration (Kataloge, Hierarchie, globale Nutzerliste usw.); + Vereinsarbeit (Trainer, Vereinsadmin …) bleiben Vereinsrollen. Abonnement/Tier ist + derzeit nicht freigeschaltet. +
+/admin-Bereichen
+ (Außer: einige Funktionen nur Superadmin, z. B. bestimmte Medien-/Governance-Aktionen).
+ {pwdModal.label}
++ Mindestens 8 Zeichen. Das Passwort liegt dem Nutzer nicht automatisch vor — gib es sicher weiter. +
+