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() {
+ 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 ? ( ++ Keine Zuordnung. +
+ ) : ( +{assignModal.profileLabel}
++ {clubEditModal.profileLabel} → {clubEditModal.clubName} +
+