feat: enhance admin user management and profile updates
Some checks failed
Deploy Development / deploy (push) Successful in 35s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 6s
Test Suite / playwright-tests (push) Failing after 40s

- 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.
This commit is contained in:
Lars 2026-05-05 21:05:52 +02:00
parent 0f08e8df58
commit 9afcd762d0
11 changed files with 574 additions and 11 deletions

View File

@ -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,),

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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() == ""):

View File

@ -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",

View File

@ -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() {
<Route path="planning/run/:unitId" element={<TrainingUnitRunPage />} />
<Route path="planning" element={<TrainingPlanningPage />} />
<Route path="admin" element={<Navigate to="/admin/hierarchy" replace />} />
<Route path="admin/users" element={<AdminUsersPage />} />
<Route path="admin/hierarchy" element={<AdminHierarchyPage />} />
<Route path="admin/maturity-models" element={<AdminMaturityModelsPage />} />
<Route path="admin/catalogs" element={<AdminCatalogsPage />} />

View File

@ -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 }

View File

@ -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 <Navigate to="/" replace />
}
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 (
<div className="app-page">
<AdminPageNav />
<h1 style={{ marginTop: 0 }}>Portal-Nutzer &amp; Vereine</h1>
<p style={{ color: 'var(--text2)', maxWidth: '52rem', lineHeight: 1.55, marginBottom: '1.25rem' }}>
Alle Konten mit Vereinszuordnungen. Hier kannst du die <strong>Portal-Rolle</strong> (Zugriff auf
Admin-Funktionen) und das <strong>Tier</strong> setzen sowie Nutzer explizit einem Verein mit Rollen
zuordnen.
</p>
{loading ? (
<p style={{ color: 'var(--text2)' }}>Laden</p>
) : error ? (
<div className="card" style={{ borderColor: 'var(--danger)', color: 'var(--danger)' }}>
{error}
</div>
) : (
<div style={{ display: 'grid', gap: '1rem' }}>
{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 (
<div key={row.id} className="card">
<div style={{ display: 'flex', justifyContent: 'space-between', flexWrap: 'wrap', gap: '0.75rem' }}>
<div>
<strong style={{ fontSize: '1.05rem' }}>
{row.name || '—'} <span style={{ color: 'var(--text2)', fontWeight: 400 }}>#{row.id}</span>
</strong>
<div style={{ fontSize: '0.875rem', color: 'var(--text2)' }}>{row.email || '—'}</div>
<div style={{ fontSize: '0.78rem', color: 'var(--text3)', marginTop: '0.35rem' }}>
Verifiziert: {row.email_verified ? 'ja' : 'nein'}
</div>
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem', alignItems: 'flex-end' }}>
<div>
<label className="form-label" style={{ fontSize: '0.75rem' }}>
Portal-Rolle
</label>
<select
className="form-input"
style={{ minWidth: '140px' }}
value={(portalDraft[row.id]?.role || row.role || 'user').toLowerCase()}
onChange={(e) =>
setPortalDraft((prev) => ({
...prev,
[row.id]: { ...prev[row.id], role: e.target.value, tier: prev[row.id]?.tier ?? row.tier },
}))
}
>
{portalRoleChoices.map((r) => (
<option key={r} value={r}>
{ROLE_LABEL[r] || r}
</option>
))}
</select>
</div>
<div>
<label className="form-label" style={{ fontSize: '0.75rem' }}>
Tier
</label>
<select
className="form-input"
style={{ minWidth: '120px' }}
value={tierValue}
onChange={(e) =>
setPortalDraft((prev) => ({
...prev,
[row.id]: {
...prev[row.id],
tier: e.target.value,
role: prev[row.id]?.role ?? row.role,
},
}))
}
>
{tierChoices.map((t) => (
<option key={t} value={t}>
{t}
</option>
))}
</select>
</div>
<button type="button" className="btn btn-secondary" onClick={() => savePortal(row.id)}>
Portal speichern
</button>
<button
type="button"
className="btn btn-primary"
disabled={!clubs.length}
title={!clubs.length ? 'Zuerst einen Verein anlegen' : undefined}
onClick={() => {
if (!clubs.length) return
setAssignRoles(['trainer'])
setAssignModal({
profileId: row.id,
profileLabel: row.name || row.email || `#${row.id}`,
clubId: clubs[0]?.id ?? '',
})
}}
>
Verein zuweisen
</button>
</div>
</div>
<div style={{ marginTop: '1rem', paddingTop: '0.75rem', borderTop: '1px solid var(--border)' }}>
<strong style={{ fontSize: '0.85rem' }}>Vereinsmitgliedschaften</strong>
{!row.clubs?.length ? (
<p style={{ color: 'var(--text2)', fontSize: '0.875rem', margin: '0.35rem 0 0' }}>
Keine Zuordnung.
</p>
) : (
<ul style={{ margin: '0.5rem 0 0', paddingLeft: '1.2rem', fontSize: '0.9rem' }}>
{row.clubs.map((c) => (
<li key={c.id} style={{ marginBottom: '0.35rem' }}>
<strong>{c.name}</strong>
{c.abbreviation ? ` (${c.abbreviation})` : ''} {' '}
{(c.roles || []).join(', ') || '—'}
{c.membership_status === 'inactive' ? (
<span style={{ color: 'var(--text3)', fontSize: '0.8rem' }}> (inaktiv)</span>
) : null}{' '}
<button
type="button"
style={{
marginLeft: '0.35rem',
fontSize: '0.75rem',
padding: '0.12rem 0.45rem',
borderRadius: '6px',
border: '1px solid var(--border)',
background: 'var(--surface2)',
cursor: 'pointer',
}}
onClick={() =>
setClubEditModal({
clubId: c.id,
clubName: c.name,
profileId: row.id,
profileLabel: row.name || row.email,
roles: [...(c.roles || [])],
status: (c.membership_status || 'active').toLowerCase(),
})
}
>
bearbeiten
</button>
</li>
))}
</ul>
)}
</div>
</div>
)
})}
</div>
)}
{assignModal && (
<div
style={{
position: 'fixed',
inset: 0,
background: 'rgba(0,0,0,0.5)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1200,
padding: '1rem',
}}
>
<div
style={{
background: 'var(--surface)',
borderRadius: '12px',
padding: '1.5rem',
maxWidth: '440px',
width: '100%',
}}
>
<h2 style={{ marginTop: 0 }}>Verein zuweisen</h2>
<p style={{ color: 'var(--text2)', fontSize: '0.9rem' }}>{assignModal.profileLabel}</p>
<div className="form-row">
<label className="form-label">Verein</label>
<select
className="form-input"
value={assignModal.clubId === '' ? '' : String(assignModal.clubId)}
onChange={(e) =>
setAssignModal((prev) =>
prev ? { ...prev, clubId: e.target.value ? parseInt(e.target.value, 10) : '' } : prev
)
}
>
{clubs.map((c) => (
<option key={c.id} value={String(c.id)}>
{c.name}
</option>
))}
</select>
</div>
<div className="form-row">
<span className="form-label">Rollen im Verein</span>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem' }}>
{CLUB_ROLE_OPTIONS.map((opt) => (
<label key={opt.code} style={{ display: 'flex', alignItems: 'center', gap: '0.35rem' }}>
<input
type="checkbox"
checked={assignRoles.includes(opt.code)}
onChange={() => {
setAssignRoles((prev) => {
const s = new Set(prev)
if (s.has(opt.code)) s.delete(opt.code)
else s.add(opt.code)
const out = Array.from(s)
return out.length ? out : ['trainer']
})
}}
/>
{opt.label}
</label>
))}
</div>
</div>
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '1rem' }}>
<button type="button" className="btn btn-primary" style={{ flex: 1 }} onClick={submitAssignClub}>
Zuweisen
</button>
<button
type="button"
className="btn btn-secondary"
onClick={() => setAssignModal(null)}
>
Abbrechen
</button>
</div>
</div>
</div>
)}
{clubEditModal && (
<div
style={{
position: 'fixed',
inset: 0,
background: 'rgba(0,0,0,0.5)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1200,
padding: '1rem',
}}
>
<div
style={{
background: 'var(--surface)',
borderRadius: '12px',
padding: '1.5rem',
maxWidth: '440px',
width: '100%',
}}
>
<h2 style={{ marginTop: 0 }}>Vereinsmitgliedschaft</h2>
<p style={{ color: 'var(--text2)', fontSize: '0.9rem' }}>
{clubEditModal.profileLabel} {clubEditModal.clubName}
</p>
<div className="form-row">
<label className="form-label">Status</label>
<select
className="form-input"
value={clubEditModal.status}
onChange={(e) =>
setClubEditModal((prev) => (prev ? { ...prev, status: e.target.value } : prev))
}
>
<option value="active">aktiv</option>
<option value="inactive">inaktiv</option>
</select>
</div>
<div className="form-row">
<span className="form-label">Rollen</span>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem' }}>
{CLUB_ROLE_OPTIONS.map((opt) => (
<label key={opt.code} style={{ display: 'flex', alignItems: 'center', gap: '0.35rem' }}>
<input
type="checkbox"
checked={clubEditModal.roles.includes(opt.code)}
onChange={() => {
setClubEditModal((prev) => {
if (!prev) return prev
const s = new Set(prev.roles)
if (s.has(opt.code)) s.delete(opt.code)
else s.add(opt.code)
let roles = Array.from(s)
if (!roles.length) roles = ['trainer']
return { ...prev, roles }
})
}}
/>
{opt.label}
</label>
))}
</div>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem', marginTop: '1rem' }}>
<button type="button" className="btn btn-primary" onClick={saveClubMembership}>
Speichern
</button>
<button
type="button"
className="btn"
style={{ background: 'var(--danger)', color: '#fff', border: 'none' }}
onClick={removeClubMembership}
>
Aus Verein entfernen
</button>
<button type="button" className="btn btn-secondary" onClick={() => setClubEditModal(null)}>
Schließen
</button>
</div>
</div>
</div>
)}
</div>
)
}
export default AdminUsersPage

View File

@ -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,

View File

@ -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 = {