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.
This commit is contained in:
parent
0f08e8df58
commit
9afcd762d0
|
|
@ -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,),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
41
backend/routers/admin_users.py
Normal file
41
backend/routers/admin_users.py
Normal 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
|
||||
|
|
@ -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() == ""):
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 />} />
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
448
frontend/src/pages/AdminUsersPage.jsx
Normal file
448
frontend/src/pages/AdminUsersPage.jsx
Normal 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 & 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
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user