From 9afcd762d03b14530276cbb48534e379a1d88cdf Mon Sep 17 00:00:00 2001 From: Lars Date: Tue, 5 May 2026 21:05:52 +0200 Subject: [PATCH] feat: enhance admin user management and profile updates - Added role and tier fields to the ProfileUpdate model, allowing for better user role management. - Implemented new API endpoint for listing admin users, accessible only to portal admins. - Updated profile retrieval and update logic to handle role and tier changes, enforcing permissions for modifications. - Enhanced frontend navigation and routing to include the new admin users page, improving admin interface usability. - Bumped application version to 0.8.19 and updated changelog to reflect these changes. --- backend/club_tenancy.py | 10 +- backend/main.py | 4 +- backend/models.py | 5 + backend/routers/admin_users.py | 41 +++ backend/routers/profiles.py | 51 ++- backend/version.py | 13 +- frontend/src/App.jsx | 2 + frontend/src/components/AdminPageNav.jsx | 3 +- frontend/src/pages/AdminUsersPage.jsx | 448 +++++++++++++++++++++++ frontend/src/utils/api.js | 6 + frontend/src/version.js | 2 +- 11 files changed, 574 insertions(+), 11 deletions(-) create mode 100644 backend/routers/admin_users.py create mode 100644 frontend/src/pages/AdminUsersPage.jsx diff --git a/backend/club_tenancy.py b/backend/club_tenancy.py index ff731f5..178f515 100644 --- a/backend/club_tenancy.py +++ b/backend/club_tenancy.py @@ -68,10 +68,12 @@ def can_plan_in_club(cur, profile_id: int, club_id: int, global_role: Optional[s ) -def memberships_with_roles(cur, profile_id: int) -> List[Dict[str, Any]]: +def memberships_with_roles(cur, profile_id: int, active_only: bool = True) -> List[Dict[str, Any]]: + status_filter = "AND cm.status = 'active'" if active_only else "" cur.execute( - """ + f""" SELECT c.id, c.name, c.abbreviation, c.status, + cm.status AS membership_status, COALESCE( ARRAY_AGG(DISTINCT r.role_code) FILTER (WHERE r.role_code IS NOT NULL), ARRAY[]::varchar[] @@ -79,8 +81,8 @@ def memberships_with_roles(cur, profile_id: int) -> List[Dict[str, Any]]: FROM club_members cm INNER JOIN clubs c ON c.id = cm.club_id LEFT JOIN club_member_roles r ON r.club_member_id = cm.id - WHERE cm.profile_id = %s AND cm.status = 'active' - GROUP BY c.id, c.name, c.abbreviation, c.status + WHERE cm.profile_id = %s {status_filter} + GROUP BY c.id, c.name, c.abbreviation, c.status, cm.status ORDER BY c.name """, (profile_id,), diff --git a/backend/main.py b/backend/main.py index c8c90b5..9adf33f 100644 --- a/backend/main.py +++ b/backend/main.py @@ -154,14 +154,14 @@ def read_root(): } # Register routers -from routers import auth, profiles, exercises, exercise_progression_graphs, clubs, club_memberships, club_join_requests, skills, training_planning, training_framework_programs, catalogs, maturity_models, matrix_stack_bundle, import_wiki, import_wiki_admin +from routers import auth, profiles, exercises, exercise_progression_graphs, clubs, club_memberships, club_join_requests, admin_users, skills, training_planning, training_framework_programs, catalogs, maturity_models, matrix_stack_bundle, import_wiki, import_wiki_admin app.include_router(auth.router) app.include_router(profiles.router) app.include_router(exercises.router) app.include_router(exercise_progression_graphs.router) app.include_router(clubs.router) -app.include_router(club_join_requests.router) +app.include_router(admin_users.router) app.include_router(skills.router) app.include_router(training_planning.router) app.include_router(training_framework_programs.router) diff --git a/backend/models.py b/backend/models.py index ecae0d7..881bfa6 100644 --- a/backend/models.py +++ b/backend/models.py @@ -36,6 +36,11 @@ class ProfileUpdate(BaseModel): name: Optional[str] = None email: Optional[str] = None active_club_id: Optional[int] = None + role: Optional[str] = Field( + default=None, + description="Portal-Rolle: user, trainer, admin, superadmin (nur Plattform-Admin)", + ) + tier: Optional[str] = Field(default=None, max_length=50) class ProfileResponse(BaseModel): id: int diff --git a/backend/routers/admin_users.py b/backend/routers/admin_users.py new file mode 100644 index 0000000..2d32f41 --- /dev/null +++ b/backend/routers/admin_users.py @@ -0,0 +1,41 @@ +""" +Plattform-Admin: Übersicht aller Nutzer inkl. Vereinsmitgliedschaften (ohne Passwort-Hashes). +""" +from typing import Any, Dict, List + +from fastapi import APIRouter, Depends, HTTPException + +from auth import require_auth +from club_tenancy import is_platform_admin, memberships_with_roles +from db import get_db, get_cursor, r2d + +router = APIRouter(prefix="/api/admin", tags=["admin_users"]) + +_SAFE_PROFILE_COLS = """ + id, name, email, role, tier, email_verified, active_club_id, + created_at, updated_at, auth_type +""" + + +@router.get("/users") +def list_platform_users(session: dict = Depends(require_auth)): + """Alle Profile mit Vereinen/Rollen — nur Portal-Admin (admin oder superadmin).""" + role = (session.get("role") or "").lower() + if not is_platform_admin(role): + raise HTTPException(status_code=403, detail="Nur Portal-Administratoren") + + with get_db() as conn: + cur = get_cursor(conn) + cur.execute( + f""" + SELECT {_SAFE_PROFILE_COLS.strip()} + FROM profiles + ORDER BY COALESCE(lower(trim(email)), ''), id + """ + ) + rows: List[Dict[str, Any]] = [] + for r in cur.fetchall(): + d = r2d(r) + d["clubs"] = memberships_with_roles(cur, int(d["id"]), active_only=False) + rows.append(d) + return rows diff --git a/backend/routers/profiles.py b/backend/routers/profiles.py index 035d42c..af0c8af 100644 --- a/backend/routers/profiles.py +++ b/backend/routers/profiles.py @@ -16,6 +16,8 @@ from models import ProfileCreate, ProfileUpdate router = APIRouter(prefix="/api", tags=["profiles"]) +_ALLOWED_PORTAL_ROLES = frozenset({"user", "trainer", "admin", "superadmin"}) + # ── Helper ──────────────────────────────────────────────────────────────────── def get_pid(x_profile_id: Optional[str] = Header(default=None)) -> str: @@ -89,7 +91,9 @@ def get_profile(pid: str, session=Depends(require_auth)): cur.execute("SELECT * FROM profiles WHERE id=%s", (pid,)) row = cur.fetchone() if not row: raise HTTPException(404, "Profil nicht gefunden") - return r2d(row) + d = r2d(row) + d.pop("pin_hash", None) + return d @router.put("/profiles/{pid}") @@ -112,6 +116,51 @@ def update_profile(pid: str, p: ProfileUpdate, session=Depends(require_auth)): patch = p.model_dump(exclude_unset=True) data = {} + if "role" in patch or "tier" in patch: + if not is_platform_admin(role): + raise HTTPException( + status_code=403, + detail="Nur Portal-Admins dürfen Rolle oder Tier ändern", + ) + + if "role" in patch: + new_role = (patch["role"] or "").strip().lower() + if new_role not in _ALLOWED_PORTAL_ROLES: + raise HTTPException( + status_code=400, + detail=f"Ungültige Portal-Rolle. Erlaubt: {', '.join(sorted(_ALLOWED_PORTAL_ROLES))}", + ) + if new_role == "superadmin" and role != "superadmin": + raise HTTPException( + status_code=403, + detail="Nur Super-Admins dürfen die Rolle Super-Admin vergeben", + ) + old_r = (rowd.get("role") or "user").strip().lower() + cur.execute( + """ + SELECT COUNT(*)::int AS c FROM profiles + WHERE lower(trim(role)) IN ('admin','superadmin') + """ + ) + admin_cnt = int(cur.fetchone()["c"]) + if old_r in ("admin", "superadmin") and new_role not in ("admin", "superadmin"): + if admin_cnt <= 1: + raise HTTPException( + status_code=400, + detail="Der letzte Portal-Administrator kann nicht zurückgestuft werden", + ) + data["role"] = new_role + del patch["role"] + + if "tier" in patch: + tv = patch["tier"] + if tv is None: + data["tier"] = "free" + else: + ts = str(tv).strip() + data["tier"] = (ts or "free")[:50] + del patch["tier"] + if "email" in patch: ev = patch["email"] if ev is None or (isinstance(ev, str) and ev.strip() == ""): diff --git a/backend/version.py b/backend/version.py index c075971..40b1e45 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,15 +1,16 @@ # Shinkan Jinkendo Version Information -APP_VERSION = "0.8.18" +APP_VERSION = "0.8.19" BUILD_DATE = "2026-05-05" DB_SCHEMA_VERSION = "20260505040" MODULE_VERSIONS = { "auth": "1.1.0", # Registrierung: optional requested_club_id → Beitrittsantrag - "profiles": "1.2.0", # GET /profiles nur Plattform-Admin; pin_hash aus Liste entfernt + "profiles": "1.3.0", # PUT role/tier (Portal-Admin); GET /profiles; pin_hash aus Liste entfernt "clubs": "0.4.0", # public-directory, members/directory; Vereins-GUI verwendet Endpoints "club_memberships": "1.0.0", "club_join_requests": "1.0.0", + "admin_users": "1.0.0", # GET /api/admin/users "groups": "0.1.0", "skills": "0.1.0", "methods": "0.1.0", @@ -25,6 +26,14 @@ MODULE_VERSIONS = { } CHANGELOG = [ + { + "version": "0.8.19", + "date": "2026-05-05", + "changes": [ + "Portal-Admin: GET /api/admin/users (alle Nutzer + Vereine); PUT /profiles/{id} mit role/tier (Super-Admin nur durch Super-Admin); Mitgliedschaft inaktiv in Übersicht", + "GUI Admin → Nutzer: Portal-Rolle/Tier, Verein zuweisen, Vereinsrollen bearbeiten", + ], + }, { "version": "0.8.18", "date": "2026-05-05", diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 069dd33..951ebf7 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -30,6 +30,7 @@ import AdminHierarchyPage from './pages/AdminHierarchyPage' import AdminMaturityModelsPage from './pages/AdminMaturityModelsPage' import TrainerContextsPage from './pages/TrainerContextsPage' import MediaWikiImportPage from './pages/MediaWikiImportPage' +import AdminUsersPage from './pages/AdminUsersPage' import './app.css' // Bottom Navigation (Mobile) @@ -166,6 +167,7 @@ function AppRoutes() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/components/AdminPageNav.jsx b/frontend/src/components/AdminPageNav.jsx index 9677611..4ad7a50 100644 --- a/frontend/src/components/AdminPageNav.jsx +++ b/frontend/src/components/AdminPageNav.jsx @@ -1,5 +1,5 @@ import { NavLink, useLocation } from 'react-router-dom' -import { TreePine, FolderTree, Download, Grid3x3 } from 'lucide-react' +import { TreePine, FolderTree, Download, Grid3x3, Users } from 'lucide-react' /** * Admin-Seiten-Navigation (horizontal) @@ -10,6 +10,7 @@ export default function AdminPageNav() { const pages = [ { to: '/admin/hierarchy', label: 'Hierarchie', icon: TreePine }, + { to: '/admin/users', label: 'Nutzer', icon: Users }, { to: '/admin/maturity-models', label: 'Fähigkeitsmatrix', icon: Grid3x3 }, { to: '/admin/catalogs', label: 'Kataloge', icon: FolderTree }, { to: '/admin/mediawiki-import', label: 'Wiki-Import', icon: Download } diff --git a/frontend/src/pages/AdminUsersPage.jsx b/frontend/src/pages/AdminUsersPage.jsx new file mode 100644 index 0000000..68902ac --- /dev/null +++ b/frontend/src/pages/AdminUsersPage.jsx @@ -0,0 +1,448 @@ +import { useEffect, useState } from 'react' +import { Navigate } from 'react-router-dom' +import { useAuth } from '../context/AuthContext' +import api from '../utils/api' +import AdminPageNav from '../components/AdminPageNav' + +const CLUB_ROLE_OPTIONS = [ + { code: 'club_admin', label: 'Vereinsadmin' }, + { code: 'trainer', label: 'Trainer' }, + { code: 'division_lead', label: 'Spartenleitung' }, + { code: 'content_editor', label: 'Inhalte bearbeiten' }, +] + +const TIER_OPTIONS = ['free', 'premium', 'pro', 'enterprise'] + +const ROLE_LABEL = { + user: 'Nutzer', + trainer: 'Trainer', + admin: 'Portal-Admin', + superadmin: 'Super-Admin', +} + +function AdminUsersPage() { + const { user } = useAuth() + const isSuper = user?.role === 'superadmin' + const isPlatformAdmin = user?.role === 'admin' || user?.role === 'superadmin' + const portalRoleChoices = isSuper + ? ['user', 'trainer', 'admin', 'superadmin'] + : ['user', 'trainer', 'admin'] + + const [users, setUsers] = useState([]) + const [clubs, setClubs] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState('') + const [portalDraft, setPortalDraft] = useState({}) + const [assignModal, setAssignModal] = useState(null) + const [assignRoles, setAssignRoles] = useState(['trainer']) + const [clubEditModal, setClubEditModal] = useState(null) + + const load = async () => { + setError('') + try { + const [u, c] = await Promise.all([api.listAdminUsers(), api.listClubs()]) + setUsers(u) + setClubs(c) + const d = {} + for (const row of u) { + d[row.id] = { + role: (row.role || 'user').toLowerCase(), + tier: row.tier || 'free', + } + } + setPortalDraft(d) + } catch (e) { + setError(e.message || String(e)) + } finally { + setLoading(false) + } + } + + useEffect(() => { + if (!isPlatformAdmin) return + load() + }, [isPlatformAdmin]) + + if (!isPlatformAdmin) { + return + } + + const savePortal = async (profileId) => { + const dr = portalDraft[profileId] + if (!dr) return + try { + await api.updateProfile(profileId, { role: dr.role, tier: dr.tier }) + await load() + } catch (e) { + alert(e.message || String(e)) + } + } + + const submitAssignClub = async () => { + if (!assignModal) return + const clubId = assignModal.clubId + const profileId = assignModal.profileId + if (!clubId || !assignRoles.length) { + alert('Verein und mindestens eine Rolle wählen.') + return + } + try { + await api.addClubMember(clubId, { profile_id: profileId, roles: assignRoles }) + setAssignModal(null) + setAssignRoles(['trainer']) + await load() + } catch (e) { + alert(e.message || String(e)) + } + } + + const saveClubMembership = async () => { + if (!clubEditModal) return + const { clubId, profileId, roles, status } = clubEditModal + try { + await api.updateClubMember(clubId, profileId, { roles, status }) + setClubEditModal(null) + await load() + } catch (e) { + alert(e.message || String(e)) + } + } + + const removeClubMembership = async () => { + if (!clubEditModal) return + if (!confirm('Mitgliedschaft in diesem Verein wirklich entfernen?')) return + try { + await api.removeClubMember(clubEditModal.clubId, clubEditModal.profileId) + setClubEditModal(null) + await load() + } catch (e) { + alert(e.message || String(e)) + } + } + + return ( +
+ +

Portal-Nutzer & Vereine

+

+ Alle Konten mit Vereinszuordnungen. Hier kannst du die Portal-Rolle (Zugriff auf + Admin-Funktionen) und das Tier setzen sowie Nutzer explizit einem Verein mit Rollen + zuordnen. +

+ + {loading ? ( +

Laden…

+ ) : error ? ( +
+ {error} +
+ ) : ( +
+ {users.map((row) => { + const tierValue = portalDraft[row.id]?.tier ?? row.tier ?? 'free' + const tierChoices = [...TIER_OPTIONS] + if (tierValue && !tierChoices.includes(tierValue)) tierChoices.unshift(tierValue) + return ( +
+
+
+ + {row.name || '—'} #{row.id} + +
{row.email || '—'}
+
+ Verifiziert: {row.email_verified ? 'ja' : 'nein'} +
+
+
+
+ + +
+
+ + +
+ + +
+
+ +
+ Vereinsmitgliedschaften + {!row.clubs?.length ? ( +

+ Keine Zuordnung. +

+ ) : ( +
    + {row.clubs.map((c) => ( +
  • + {c.name} + {c.abbreviation ? ` (${c.abbreviation})` : ''} —{' '} + {(c.roles || []).join(', ') || '—'} + {c.membership_status === 'inactive' ? ( + (inaktiv) + ) : null}{' '} + +
  • + ))} +
+ )} +
+
+ ) + })} +
+ )} + + {assignModal && ( +
+
+

Verein zuweisen

+

{assignModal.profileLabel}

+
+ + +
+
+ Rollen im Verein +
+ {CLUB_ROLE_OPTIONS.map((opt) => ( + + ))} +
+
+
+ + +
+
+
+ )} + + {clubEditModal && ( +
+
+

Vereinsmitgliedschaft

+

+ {clubEditModal.profileLabel} → {clubEditModal.clubName} +

+
+ + +
+
+ Rollen +
+ {CLUB_ROLE_OPTIONS.map((opt) => ( + + ))} +
+
+
+ + + +
+
+
+ )} +
+ ) +} + +export default AdminUsersPage diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js index 149b97a..09b54d8 100644 --- a/frontend/src/utils/api.js +++ b/frontend/src/utils/api.js @@ -115,6 +115,11 @@ export async function listProfiles() { return request('/api/profiles') } +/** Alle Nutzer inkl. Vereinsmitgliedschaften — nur Portal-Admin (UI: Admin → Nutzer). */ +export async function listAdminUsers() { + return request('/api/admin/users') +} + export async function updateProfile(profileId, data) { return request(`/api/profiles/${profileId}`, { method: 'PUT', @@ -1098,6 +1103,7 @@ export const api = { logout, getCurrentProfile, listProfiles, + listAdminUsers, updateProfile, changePassword, verifyEmail, diff --git a/frontend/src/version.js b/frontend/src/version.js index f243c18..b01802e 100644 --- a/frontend/src/version.js +++ b/frontend/src/version.js @@ -1,6 +1,6 @@ // Shinkan Jinkendo Frontend Version -export const APP_VERSION = "0.8.18" +export const APP_VERSION = "0.8.19" export const BUILD_DATE = "2026-05-05" export const PAGE_VERSIONS = {