diff --git a/backend/main.py b/backend/main.py
index 666b79e..dbad225 100644
--- a/backend/main.py
+++ b/backend/main.py
@@ -221,7 +221,7 @@ def read_root():
return out
# Register routers
-from routers import auth, profiles, exercises, exercise_progression_graphs, clubs, club_memberships, club_join_requests, club_creation_requests, admin_users, admin_user_content, admin_club_feature_exemptions, me_entitlements, platform_media_storage, media_assets, skills, skill_profiles, training_planning, planning_exercise_suggest, dashboard, training_modules, training_framework_programs, catalogs, maturity_models, matrix_stack_bundle, matrix_editor, import_wiki, import_wiki_admin, legal_documents, content_reports, ai_prompts_admin, ai_skill_retrieval_admin, exercise_enrichment_admin
+from routers import auth, profiles, exercises, exercise_progression_graphs, clubs, club_memberships, club_join_requests, club_creation_requests, admin_users, admin_user_content, admin_rights, me_entitlements, platform_media_storage, media_assets, skills, skill_profiles, training_planning, planning_exercise_suggest, dashboard, training_modules, training_framework_programs, catalogs, maturity_models, matrix_stack_bundle, matrix_editor, import_wiki, import_wiki_admin, legal_documents, content_reports, ai_prompts_admin, ai_skill_retrieval_admin, exercise_enrichment_admin
app.include_router(auth.router)
app.include_router(profiles.router)
@@ -233,7 +233,7 @@ app.include_router(club_join_requests.router)
app.include_router(club_creation_requests.router)
app.include_router(admin_users.router)
app.include_router(admin_user_content.router)
-app.include_router(admin_club_feature_exemptions.router)
+app.include_router(admin_rights.router)
app.include_router(me_entitlements.router)
app.include_router(platform_media_storage.router)
app.include_router(media_assets.router)
diff --git a/backend/routers/admin_club_feature_exemptions.py b/backend/routers/admin_club_feature_exemptions.py
deleted file mode 100644
index dd969f2..0000000
--- a/backend/routers/admin_club_feature_exemptions.py
+++ /dev/null
@@ -1,227 +0,0 @@
-"""
-Superadmin: Kontingent-Bypass über Capability-Grants (portal_role / profile).
-
-Kein separates Exemption-Schema — nutzt portal_role_capability_grants und
-profile_capability_grants mit IDs platform.club_quota.bypass[.feature_id].
-"""
-from typing import Optional
-
-from fastapi import APIRouter, Depends, HTTPException
-from pydantic import BaseModel, Field
-
-from auth import require_auth
-from club_quota_bypass import (
- QUOTA_BYPASS_ALL,
- ensure_quota_bypass_capability,
- list_quota_bypass_grants,
- quota_bypass_capability_id_for_feature,
-)
-from club_tenancy import is_superadmin
-from db import get_db, get_cursor, r2d
-
-router = APIRouter(prefix="/api/admin", tags=["admin_capability_grants"])
-
-
-def _require_superadmin(session: dict) -> None:
- if not is_superadmin(session.get("role")):
- raise HTTPException(status_code=403, detail="Nur Super-Administratoren")
-
-
-def _resolve_capability_id(cur, feature_id: Optional[str]) -> str:
- fid = (feature_id or "").strip() or None
- if not fid:
- return QUOTA_BYPASS_ALL
- cur.execute("SELECT 1 FROM features WHERE id = %s", (fid,))
- if not cur.fetchone():
- raise HTTPException(status_code=400, detail="Unbekanntes Feature")
- return ensure_quota_bypass_capability(cur, fid)
-
-
-class PortalGrantBody(BaseModel):
- portal_role: str = Field(..., min_length=1, max_length=50)
- feature_id: Optional[str] = Field(
- None,
- description="Feature-ID oder leer = alle Vereins-Features (platform.club_quota.bypass)",
- )
-
-
-class ProfileGrantBody(BaseModel):
- feature_id: Optional[str] = Field(None, description="Feature-ID oder leer = alle Features")
- reason: Optional[str] = Field(None, max_length=500)
-
-
-@router.get("/club-feature-exemptions")
-def list_club_feature_exemptions(session: dict = Depends(require_auth)):
- """Übersicht Kontingent-Bypass-Grants (Capability-System)."""
- _require_superadmin(session)
- with get_db() as conn:
- cur = get_cursor(conn)
- return list_quota_bypass_grants(cur)
-
-
-@router.get("/capability-grants/club-quota-bypass")
-def list_quota_bypass_capability_grants(session: dict = Depends(require_auth)):
- """Alias — gleiche Daten wie /club-feature-exemptions."""
- return list_club_feature_exemptions(session)
-
-
-@router.post("/club-feature-exemptions/roles", status_code=201)
-@router.post("/capability-grants/club-quota-bypass/portal-roles", status_code=201)
-def add_portal_quota_bypass_grant(body: PortalGrantBody, session: dict = Depends(require_auth)):
- _require_superadmin(session)
- role = body.portal_role.strip().lower()
-
- with get_db() as conn:
- cur = get_cursor(conn)
- cap_id = _resolve_capability_id(cur, body.feature_id)
- cur.execute(
- """
- SELECT 1 FROM portal_role_capability_grants
- WHERE portal_role = %s AND capability_id = %s
- LIMIT 1
- """,
- (role, cap_id),
- )
- if cur.fetchone():
- raise HTTPException(status_code=409, detail="Grant existiert bereits")
-
- cur.execute(
- """
- INSERT INTO portal_role_capability_grants (portal_role, capability_id)
- VALUES (%s, %s)
- RETURNING portal_role, capability_id
- """,
- (role, cap_id),
- )
- row = cur.fetchone()
- conn.commit()
- out = r2d(row)
- out["capability_id"] = cap_id
- if body.feature_id:
- out["feature_id"] = body.feature_id.strip()
- else:
- out["feature_id"] = None
- return out
-
-
-@router.delete("/club-feature-exemptions/roles/{exemption_id}")
-def delete_legacy_role_exemption(exemption_id: int, session: dict = Depends(require_auth)):
- """Legacy-Pfad: exemption_id = portal_role_capability_grants nicht unterstützt — 410."""
- _require_superadmin(session)
- raise HTTPException(
- status_code=410,
- detail="Bitte DELETE /api/admin/capability-grants/club-quota-bypass/portal-roles nutzen",
- )
-
-
-@router.delete("/capability-grants/club-quota-bypass/portal-roles")
-def delete_portal_quota_bypass_grant(
- portal_role: str,
- capability_id: Optional[str] = None,
- feature_id: Optional[str] = None,
- session: dict = Depends(require_auth),
-):
- _require_superadmin(session)
- role = portal_role.strip().lower()
- cap_id = capability_id
- if not cap_id:
- cap_id = QUOTA_BYPASS_ALL if not (feature_id or "").strip() else quota_bypass_capability_id_for_feature(
- feature_id.strip()
- )
-
- with get_db() as conn:
- cur = get_cursor(conn)
- cur.execute(
- """
- DELETE FROM portal_role_capability_grants
- WHERE portal_role = %s AND capability_id = %s
- RETURNING portal_role, capability_id
- """,
- (role, cap_id),
- )
- if not cur.fetchone():
- raise HTTPException(status_code=404, detail="Grant nicht gefunden")
- conn.commit()
- return {"ok": True}
-
-
-@router.post("/club-feature-exemptions/profiles/{profile_id}", status_code=201)
-@router.post("/capability-grants/club-quota-bypass/profiles/{profile_id}", status_code=201)
-def add_profile_quota_bypass_grant(
- profile_id: int,
- body: ProfileGrantBody,
- session: dict = Depends(require_auth),
-):
- _require_superadmin(session)
- admin_pid = int(session["profile_id"])
-
- with get_db() as conn:
- cur = get_cursor(conn)
- cur.execute("SELECT 1 FROM profiles WHERE id = %s", (profile_id,))
- if not cur.fetchone():
- raise HTTPException(status_code=404, detail="Profil nicht gefunden")
-
- cap_id = _resolve_capability_id(cur, body.feature_id)
- cur.execute(
- """
- SELECT 1 FROM profile_capability_grants
- WHERE profile_id = %s AND capability_id = %s
- LIMIT 1
- """,
- (profile_id, cap_id),
- )
- if cur.fetchone():
- raise HTTPException(status_code=409, detail="Grant existiert bereits")
-
- cur.execute(
- """
- INSERT INTO profile_capability_grants (
- profile_id, capability_id, reason, granted_by_profile_id
- )
- VALUES (%s, %s, %s, %s)
- RETURNING profile_id, capability_id, reason, granted_by_profile_id, created_at
- """,
- (profile_id, cap_id, (body.reason or "").strip() or None, admin_pid),
- )
- row = cur.fetchone()
- conn.commit()
- return r2d(row)
-
-
-@router.delete("/club-feature-exemptions/profiles/{exemption_id}")
-def delete_legacy_profile_exemption(exemption_id: int, session: dict = Depends(require_auth)):
- _require_superadmin(session)
- raise HTTPException(
- status_code=410,
- detail="Bitte DELETE /api/admin/capability-grants/club-quota-bypass/profiles nutzen",
- )
-
-
-@router.delete("/capability-grants/club-quota-bypass/profiles")
-def delete_profile_quota_bypass_grant(
- profile_id: int,
- capability_id: Optional[str] = None,
- feature_id: Optional[str] = None,
- session: dict = Depends(require_auth),
-):
- _require_superadmin(session)
- cap_id = capability_id
- if not cap_id:
- cap_id = QUOTA_BYPASS_ALL if not (feature_id or "").strip() else quota_bypass_capability_id_for_feature(
- feature_id.strip()
- )
-
- with get_db() as conn:
- cur = get_cursor(conn)
- cur.execute(
- """
- DELETE FROM profile_capability_grants
- WHERE profile_id = %s AND capability_id = %s
- RETURNING profile_id, capability_id
- """,
- (profile_id, cap_id),
- )
- if not cur.fetchone():
- raise HTTPException(status_code=404, detail="Grant nicht gefunden")
- conn.commit()
- return {"ok": True}
diff --git a/backend/routers/admin_rights.py b/backend/routers/admin_rights.py
new file mode 100644
index 0000000..585df13
--- /dev/null
+++ b/backend/routers/admin_rights.py
@@ -0,0 +1,543 @@
+"""
+Superadmin: Rollen & Rechte — Capability-Grants, Kontingent-Bypass, Vereins-Kontingente.
+
+Ein Router für das Rechtesystem (M6). Kein paralleles Exemption-Schema.
+"""
+from typing import Dict, List, Optional
+
+from fastapi import APIRouter, Depends, HTTPException, Query
+from pydantic import BaseModel, Field
+
+from auth import require_auth
+from club_quota_bypass import (
+ QUOTA_BYPASS_ALL,
+ ensure_quota_bypass_capability,
+ list_quota_bypass_grants,
+ quota_bypass_capability_id_for_feature,
+)
+from club_tenancy import is_superadmin
+from db import get_db, get_cursor, r2d
+
+router = APIRouter(prefix="/api/admin/rights", tags=["admin_rights"])
+
+PORTAL_ROLES = ("user", "trainer", "admin", "superadmin")
+CLUB_ROLES = ("club_admin", "trainer", "division_lead", "content_editor")
+
+
+def _require_superadmin(session: dict) -> None:
+ if not is_superadmin(session.get("role")):
+ raise HTTPException(status_code=403, detail="Nur Super-Administratoren")
+
+
+def _resolve_quota_bypass_capability_id(cur, feature_id: Optional[str]) -> str:
+ fid = (feature_id or "").strip() or None
+ if not fid:
+ return QUOTA_BYPASS_ALL
+ cur.execute("SELECT 1 FROM features WHERE id = %s", (fid,))
+ if not cur.fetchone():
+ raise HTTPException(status_code=400, detail="Unbekanntes Feature")
+ return ensure_quota_bypass_capability(cur, fid)
+
+
+class PlanLimitItem(BaseModel):
+ feature_id: str
+ limit_value: Optional[int] = Field(
+ None,
+ description="NULL = unbegrenzt; 0 = deaktiviert (boolean/count)",
+ )
+
+
+class PlanLimitsBody(BaseModel):
+ limits: List[PlanLimitItem]
+
+
+class ClubSubscriptionBody(BaseModel):
+ plan_id: str
+ status: str = Field(default="active", pattern="^(active|trial|past_due|cancelled)$")
+
+
+class PortalCapabilityGrantBody(BaseModel):
+ portal_role: str = Field(..., min_length=1, max_length=50)
+ capability_id: str = Field(..., min_length=1)
+
+
+class ClubRoleCapabilityGrantBody(BaseModel):
+ role_code: str = Field(..., min_length=1, max_length=50)
+ capability_id: str = Field(..., min_length=1)
+
+
+class QuotaBypassPortalBody(BaseModel):
+ portal_role: str = Field(..., min_length=1, max_length=50)
+ feature_id: Optional[str] = Field(
+ None,
+ description="Feature-ID oder leer = alle Vereins-Features (platform.club_quota.bypass)",
+ )
+
+
+class QuotaBypassProfileBody(BaseModel):
+ feature_id: Optional[str] = Field(None, description="Feature-ID oder leer = alle Features")
+ reason: Optional[str] = Field(None, max_length=500)
+
+
+# ── Capability-Matrix (Rollen → Fähigkeiten) ─────────────────────────────────
+
+@router.get("/capability-matrix")
+def get_capability_matrix(session: dict = Depends(require_auth)):
+ _require_superadmin(session)
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ cur.execute(
+ """
+ SELECT id, name, domain, min_account_state, linked_feature_id
+ FROM capabilities
+ WHERE active = true
+ ORDER BY domain, id
+ """
+ )
+ capabilities = [r2d(r) for r in cur.fetchall()]
+
+ cur.execute(
+ """
+ SELECT portal_role, capability_id
+ FROM portal_role_capability_grants
+ ORDER BY portal_role, capability_id
+ """
+ )
+ portal_grants = [r2d(r) for r in cur.fetchall()]
+
+ cur.execute(
+ """
+ SELECT role_code, capability_id
+ FROM club_role_capability_grants
+ ORDER BY role_code, capability_id
+ """
+ )
+ club_role_grants = [r2d(r) for r in cur.fetchall()]
+
+ return {
+ "portal_roles": list(PORTAL_ROLES),
+ "club_roles": list(CLUB_ROLES),
+ "capabilities": capabilities,
+ "portal_grants": portal_grants,
+ "club_role_grants": club_role_grants,
+ }
+
+
+@router.post("/capability-grants/portal-roles", status_code=201)
+def add_portal_capability_grant(body: PortalCapabilityGrantBody, session: dict = Depends(require_auth)):
+ _require_superadmin(session)
+ role = body.portal_role.strip().lower()
+ cap_id = body.capability_id.strip()
+
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ cur.execute("SELECT domain FROM capabilities WHERE id = %s AND active = true", (cap_id,))
+ cap = cur.fetchone()
+ if not cap:
+ raise HTTPException(status_code=400, detail="Unbekannte Capability")
+ domain = (cap.get("domain") or "").lower()
+ if domain not in ("platform", "quota_bypass") and not cap_id.startswith("platform."):
+ raise HTTPException(
+ status_code=400,
+ detail="Portal-Grants nur für domain=platform oder quota_bypass",
+ )
+
+ cur.execute(
+ """
+ INSERT INTO portal_role_capability_grants (portal_role, capability_id)
+ VALUES (%s, %s)
+ ON CONFLICT DO NOTHING
+ RETURNING portal_role, capability_id
+ """,
+ (role, cap_id),
+ )
+ row = cur.fetchone()
+ if not row:
+ raise HTTPException(status_code=409, detail="Grant existiert bereits")
+ conn.commit()
+ return r2d(row)
+
+
+@router.delete("/capability-grants/portal-roles")
+def delete_portal_capability_grant(
+ portal_role: str = Query(...),
+ capability_id: str = Query(...),
+ session: dict = Depends(require_auth),
+):
+ _require_superadmin(session)
+ role = portal_role.strip().lower()
+ cap_id = capability_id.strip()
+
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ cur.execute(
+ """
+ DELETE FROM portal_role_capability_grants
+ WHERE portal_role = %s AND capability_id = %s
+ RETURNING portal_role, capability_id
+ """,
+ (role, cap_id),
+ )
+ if not cur.fetchone():
+ raise HTTPException(status_code=404, detail="Grant nicht gefunden")
+ conn.commit()
+ return {"ok": True}
+
+
+@router.post("/capability-grants/club-roles", status_code=201)
+def add_club_role_capability_grant(
+ body: ClubRoleCapabilityGrantBody,
+ session: dict = Depends(require_auth),
+):
+ _require_superadmin(session)
+ role = body.role_code.strip().lower()
+ cap_id = body.capability_id.strip()
+
+ if role not in CLUB_ROLES:
+ raise HTTPException(status_code=400, detail="Unbekannte Vereinsrolle")
+
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ cur.execute(
+ """
+ SELECT domain FROM capabilities
+ WHERE id = %s AND active = true AND domain NOT IN ('platform', 'quota_bypass')
+ """,
+ (cap_id,),
+ )
+ if not cur.fetchone():
+ raise HTTPException(status_code=400, detail="Capability nicht für Vereinsrollen")
+
+ cur.execute(
+ """
+ INSERT INTO club_role_capability_grants (role_code, capability_id)
+ VALUES (%s, %s)
+ ON CONFLICT DO NOTHING
+ RETURNING role_code, capability_id
+ """,
+ (role, cap_id),
+ )
+ row = cur.fetchone()
+ if not row:
+ raise HTTPException(status_code=409, detail="Grant existiert bereits")
+ conn.commit()
+ return r2d(row)
+
+
+@router.delete("/capability-grants/club-roles")
+def delete_club_role_capability_grant(
+ role_code: str = Query(...),
+ capability_id: str = Query(...),
+ session: dict = Depends(require_auth),
+):
+ _require_superadmin(session)
+ role = role_code.strip().lower()
+ cap_id = capability_id.strip()
+
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ cur.execute(
+ """
+ DELETE FROM club_role_capability_grants
+ WHERE role_code = %s AND capability_id = %s
+ RETURNING role_code, capability_id
+ """,
+ (role, cap_id),
+ )
+ if not cur.fetchone():
+ raise HTTPException(status_code=404, detail="Grant nicht gefunden")
+ conn.commit()
+ return {"ok": True}
+
+
+# ── Kontingent-Bypass (Capability-Grants) ───────────────────────────────────
+
+@router.get("/quota-bypass")
+def list_quota_bypass(session: dict = Depends(require_auth)):
+ _require_superadmin(session)
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ return list_quota_bypass_grants(cur)
+
+
+@router.post("/quota-bypass/portal-roles", status_code=201)
+def add_quota_bypass_portal_grant(body: QuotaBypassPortalBody, session: dict = Depends(require_auth)):
+ _require_superadmin(session)
+ role = body.portal_role.strip().lower()
+
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ cap_id = _resolve_quota_bypass_capability_id(cur, body.feature_id)
+ cur.execute(
+ """
+ SELECT 1 FROM portal_role_capability_grants
+ WHERE portal_role = %s AND capability_id = %s
+ LIMIT 1
+ """,
+ (role, cap_id),
+ )
+ if cur.fetchone():
+ raise HTTPException(status_code=409, detail="Grant existiert bereits")
+
+ cur.execute(
+ """
+ INSERT INTO portal_role_capability_grants (portal_role, capability_id)
+ VALUES (%s, %s)
+ RETURNING portal_role, capability_id
+ """,
+ (role, cap_id),
+ )
+ row = cur.fetchone()
+ conn.commit()
+ out = r2d(row)
+ out["capability_id"] = cap_id
+ out["feature_id"] = (body.feature_id or "").strip() or None
+ return out
+
+
+@router.delete("/quota-bypass/portal-roles")
+def delete_quota_bypass_portal_grant(
+ portal_role: str = Query(...),
+ capability_id: Optional[str] = Query(None),
+ feature_id: Optional[str] = Query(None),
+ session: dict = Depends(require_auth),
+):
+ _require_superadmin(session)
+ role = portal_role.strip().lower()
+ cap_id = capability_id
+ if not cap_id:
+ cap_id = (
+ QUOTA_BYPASS_ALL
+ if not (feature_id or "").strip()
+ else quota_bypass_capability_id_for_feature(feature_id.strip())
+ )
+
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ cur.execute(
+ """
+ DELETE FROM portal_role_capability_grants
+ WHERE portal_role = %s AND capability_id = %s
+ RETURNING portal_role, capability_id
+ """,
+ (role, cap_id),
+ )
+ if not cur.fetchone():
+ raise HTTPException(status_code=404, detail="Grant nicht gefunden")
+ conn.commit()
+ return {"ok": True}
+
+
+@router.post("/quota-bypass/profiles/{profile_id}", status_code=201)
+def add_quota_bypass_profile_grant(
+ profile_id: int,
+ body: QuotaBypassProfileBody,
+ session: dict = Depends(require_auth),
+):
+ _require_superadmin(session)
+ admin_pid = int(session["profile_id"])
+
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ cur.execute("SELECT 1 FROM profiles WHERE id = %s", (profile_id,))
+ if not cur.fetchone():
+ raise HTTPException(status_code=404, detail="Profil nicht gefunden")
+
+ cap_id = _resolve_quota_bypass_capability_id(cur, body.feature_id)
+ cur.execute(
+ """
+ SELECT 1 FROM profile_capability_grants
+ WHERE profile_id = %s AND capability_id = %s
+ LIMIT 1
+ """,
+ (profile_id, cap_id),
+ )
+ if cur.fetchone():
+ raise HTTPException(status_code=409, detail="Grant existiert bereits")
+
+ cur.execute(
+ """
+ INSERT INTO profile_capability_grants (
+ profile_id, capability_id, reason, granted_by_profile_id
+ )
+ VALUES (%s, %s, %s, %s)
+ RETURNING profile_id, capability_id, reason, granted_by_profile_id, created_at
+ """,
+ (profile_id, cap_id, (body.reason or "").strip() or None, admin_pid),
+ )
+ row = cur.fetchone()
+ conn.commit()
+ return r2d(row)
+
+
+@router.delete("/quota-bypass/profiles")
+def delete_quota_bypass_profile_grant(
+ profile_id: int = Query(...),
+ capability_id: Optional[str] = Query(None),
+ feature_id: Optional[str] = Query(None),
+ session: dict = Depends(require_auth),
+):
+ _require_superadmin(session)
+ cap_id = capability_id
+ if not cap_id:
+ cap_id = (
+ QUOTA_BYPASS_ALL
+ if not (feature_id or "").strip()
+ else quota_bypass_capability_id_for_feature(feature_id.strip())
+ )
+
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ cur.execute(
+ """
+ DELETE FROM profile_capability_grants
+ WHERE profile_id = %s AND capability_id = %s
+ RETURNING profile_id, capability_id
+ """,
+ (profile_id, cap_id),
+ )
+ if not cur.fetchone():
+ raise HTTPException(status_code=404, detail="Grant nicht gefunden")
+ conn.commit()
+ return {"ok": True}
+
+
+# ── Vereins-Kontingente (Pläne & Zuordnung) ─────────────────────────────────
+
+@router.get("/club-plans/matrix")
+def get_club_plans_matrix(session: dict = Depends(require_auth)):
+ """Aktive Vereinspläne, club-scoped Features und Limit-Matrix."""
+ _require_superadmin(session)
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ cur.execute(
+ """
+ SELECT id, name, description, sort_order, active
+ FROM club_plans
+ WHERE active = true
+ ORDER BY sort_order, id
+ """
+ )
+ plans = [r2d(r) for r in cur.fetchall()]
+
+ cur.execute(
+ """
+ SELECT id, name, description, category, limit_type, reset_period, default_limit
+ FROM features
+ WHERE app = 'shinkan' AND active = true AND enforcement_subject = 'club'
+ ORDER BY category, id
+ """
+ )
+ features = [r2d(r) for r in cur.fetchall()]
+
+ cur.execute(
+ """
+ SELECT plan_id, feature_id, limit_value
+ FROM club_plan_limits
+ WHERE plan_id IN (SELECT id FROM club_plans WHERE active = true)
+ """
+ )
+ limits: Dict[str, Dict[str, Optional[int]]] = {}
+ for row in cur.fetchall():
+ pid = row["plan_id"]
+ fid = row["feature_id"]
+ limits.setdefault(pid, {})[fid] = row.get("limit_value")
+
+ return {"plans": plans, "features": features, "limits": limits}
+
+
+@router.put("/club-plans/{plan_id}/limits")
+def update_club_plan_limits(
+ plan_id: str,
+ body: PlanLimitsBody,
+ session: dict = Depends(require_auth),
+):
+ _require_superadmin(session)
+ plan_id = plan_id.strip()
+
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ cur.execute("SELECT 1 FROM club_plans WHERE id = %s AND active = true", (plan_id,))
+ if not cur.fetchone():
+ raise HTTPException(status_code=404, detail="Plan nicht gefunden")
+
+ for item in body.limits:
+ fid = item.feature_id.strip()
+ cur.execute(
+ "SELECT 1 FROM features WHERE id = %s AND app = 'shinkan'",
+ (fid,),
+ )
+ if not cur.fetchone():
+ raise HTTPException(status_code=400, detail=f"Unbekanntes Feature: {fid}")
+
+ cur.execute(
+ """
+ INSERT INTO club_plan_limits (plan_id, feature_id, limit_value)
+ VALUES (%s, %s, %s)
+ ON CONFLICT (plan_id, feature_id)
+ DO UPDATE SET limit_value = EXCLUDED.limit_value
+ """,
+ (plan_id, fid, item.limit_value),
+ )
+ conn.commit()
+
+ return {"ok": True, "plan_id": plan_id, "updated": len(body.limits)}
+
+
+@router.get("/club-subscriptions")
+def list_club_subscriptions(session: dict = Depends(require_auth)):
+ _require_superadmin(session)
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ cur.execute(
+ """
+ SELECT c.id AS club_id, c.name AS club_name,
+ cs.plan_id, cs.status, cs.started_at, cs.ends_at
+ FROM clubs c
+ LEFT JOIN club_subscriptions cs ON cs.club_id = c.id
+ ORDER BY lower(c.name), c.id
+ """
+ )
+ rows = []
+ for r in cur.fetchall():
+ d = r2d(r)
+ if not d.get("plan_id"):
+ d["plan_id"] = "free"
+ d["status"] = "active"
+ rows.append(d)
+ return rows
+
+
+@router.put("/clubs/{club_id}/subscription")
+def update_club_subscription(
+ club_id: int,
+ body: ClubSubscriptionBody,
+ session: dict = Depends(require_auth),
+):
+ _require_superadmin(session)
+ plan_id = body.plan_id.strip()
+ status = body.status.strip().lower()
+
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ cur.execute("SELECT 1 FROM clubs WHERE id = %s", (club_id,))
+ if not cur.fetchone():
+ raise HTTPException(status_code=404, detail="Verein nicht gefunden")
+
+ cur.execute("SELECT 1 FROM club_plans WHERE id = %s AND active = true", (plan_id,))
+ if not cur.fetchone():
+ raise HTTPException(status_code=400, detail="Unbekannter Plan")
+
+ cur.execute(
+ """
+ INSERT INTO club_subscriptions (club_id, plan_id, status)
+ VALUES (%s, %s, %s)
+ ON CONFLICT (club_id)
+ DO UPDATE SET plan_id = EXCLUDED.plan_id, status = EXCLUDED.status, updated_at = NOW()
+ RETURNING club_id, plan_id, status
+ """,
+ (club_id, plan_id, status),
+ )
+ row = cur.fetchone()
+ conn.commit()
+ return r2d(row)
diff --git a/backend/tests/test_admin_rights.py b/backend/tests/test_admin_rights.py
new file mode 100644
index 0000000..190c93c
--- /dev/null
+++ b/backend/tests/test_admin_rights.py
@@ -0,0 +1,21 @@
+"""M6: Admin-Rollen/Rechte-API — Zugriffskontrolle."""
+import pytest
+from fastapi import HTTPException
+
+from routers.admin_rights import get_capability_matrix, _require_superadmin
+
+
+def test_require_superadmin_denies_admin():
+ with pytest.raises(HTTPException) as exc:
+ _require_superadmin({"role": "admin"})
+ assert exc.value.status_code == 403
+
+
+def test_require_superadmin_allows():
+ _require_superadmin({"role": "superadmin"})
+
+
+def test_get_capability_matrix_requires_superadmin():
+ with pytest.raises(HTTPException) as exc:
+ get_capability_matrix(session={"role": "trainer"})
+ assert exc.value.status_code == 403
diff --git a/backend/version.py b/backend/version.py
index d6f17c6..aa915a9 100644
--- a/backend/version.py
+++ b/backend/version.py
@@ -1,6 +1,6 @@
# Shinkan Jinkendo Version Information
-APP_VERSION = "0.8.195"
+APP_VERSION = "0.8.197"
BUILD_DATE = "2026-06-07"
DB_SCHEMA_VERSION = "20260606083"
@@ -18,6 +18,7 @@ MODULE_VERSIONS = {
"admin_users": "1.0.0", # GET /api/admin/users
"club_features": "1.5.0", # Kontingent-Bypass via Capability-Grants (probe/consume)
"club_quota_bypass": "1.0.0", # platform.club_quota.bypass* + Admin-Grants-API
+ "admin_rights": "1.0.0", # M6: Rollen/Rechte — Capabilities, Bypass, Vereins-Kontingente
"entitlements": "1.2.0", # capability_quota_bypass in Feature-Map für /me/entitlements
"platform_media_storage": "1.0.0", # GET/PUT /api/admin/platform-media-storage (Superadmin-Pfad unter MEDIA_ROOT)
"media_rights": "1.3.1", # acting_profile_id in write_audit_log_entry auf Optional[int] (P-13 anonyme Meldungen)
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index b308aed..acb42d4 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -56,6 +56,7 @@ const TrainerContextsPage = lazy(() => import('./pages/TrainerContextsPage'))
const MediaWikiImportPage = lazy(() => import('./pages/MediaWikiImportPage'))
const AdminUsersPage = lazy(() => import('./pages/AdminUsersPage'))
const AdminClubCreationRequestsPage = lazy(() => import('./pages/AdminClubCreationRequestsPage'))
+const AdminRightsPage = lazy(() => import('./pages/AdminRightsPage'))
const MediaLibraryPage = lazy(() => import('./pages/MediaLibraryPage'))
const LegalPage = lazy(() => import('./pages/LegalPage'))
const AdminLegalDocumentsPage = lazy(() => import('./pages/AdminLegalDocumentsPage'))
@@ -291,6 +292,15 @@ const appRouter = createBrowserRouter([
),
},
+ {
+ path: 'admin/rights',
+ element: (
+
+ Rollen → Fähigkeiten (Capabilities): Wer darf welche Funktion nutzen?
+
+ Kontingente: Wie viel darf ein Verein verbrauchen (an Fähigkeiten gekoppelt
+ über linked_feature_id)?
+
+ Vereinspläne bündeln nur Kontingent-Werte — sie ersetzen keine Berechtigungen.
+
+ {error} +
+ ) : null} + + {loading ? ( ++ Laden… +
+ ) : null} + + {!loading && tab === 'portal' && capMatrix ? ( +
+ Plattform-Funktionen (domain=platform). Jede Funktion im Produkt soll sich
+ hier anmelden und bei Anzeige und Ausführung prüfen.
+
| Fähigkeit | + {(capMatrix.portal_roles || []).map((r) => ( ++ {PORTAL_ROLE_LABEL[r] || r} + | + ))} +
|---|---|
+ {cap.id}
+ {cap.name}
+ {cap.linked_feature_id ? (
+
+ Kontingent: {cap.linked_feature_id}
+
+ ) : null}
+ |
+ {(capMatrix.portal_roles || []).map((role) => {
+ const on = portalGrantSet.has(`${role}::${cap.id}`)
+ return (
+ + togglePortalGrant(role, cap.id, on)} + /> + | + ) + })} +
+ Vereinsrollen → Fähigkeiten. Ohne Grant-Eintrag gilt die Fähigkeit für alle aktiven + Vereinsmitglieder; gesetzte Grants schränken auf die angehakten Rollen ein. +
+| Fähigkeit | + {(capMatrix.club_roles || []).map((r) => ( ++ {CLUB_ROLE_LABEL[r] || r} + | + ))} +
|---|---|
+ {cap.id}
+ {cap.name}
+ {cap.linked_feature_id ? (
+
+ Kontingent: {cap.linked_feature_id}
+
+ ) : null}
+ |
+ {(capMatrix.club_roles || []).map((role) => {
+ const on = clubGrantSet.has(`${role}::${cap.id}`)
+ return (
+ + toggleClubGrant(role, cap.id, on)} + /> + | + ) + })} +
+ Capability platform.club_quota.bypass — umgeht Vereins-Kontingente (z. B.
+ Superadmin, Helpdesk). Kein separates Rechtemodell.
+
+ Kontingent-Bündel pro Plan. Leeres Feld = unbegrenzt. Ersetzt keine Rollen-Grants. +
+| Feature | + {(plansData.plans || []).map((p) => ( +
+ {p.name}
+
+ {p.id}
+
+
+ |
+ ))}
+
|---|---|
|
+ {f.name}
+
+ {f.id} · {formatLimitHint(f)}
+
+ |
+ {(plansData.plans || []).map((p) => (
+ + + setLimitDraft((prev) => ({ + ...prev, + [p.id]: { ...prev[p.id], [f.id]: e.target.value }, + })) + } + /> + | + ))} +
| Verein | +Plan | +Status | +
|---|---|---|
| + {row.club_name || `Verein #${row.club_id}`} + | ++ + | ++ + | +
Keine Vereine.
+ ) : null} +