Refactor Admin Rights Management and Update Versioning
Some checks failed
Deploy Development / deploy (push) Successful in 44s
Test Suite / pytest-backend (push) Failing after 1s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 13s
Test Suite / k6 /health Baseline (push) Successful in 34s
Test Suite / playwright-tests (push) Has been cancelled

- Replaced the admin club feature exemptions router with a new admin rights router to streamline capability management.
- Added new API endpoints for managing admin rights, including capability grants and quota bypass for portal roles and profiles.
- Updated the frontend to include navigation and lazy loading for the new Admin Rights page.
- Incremented application version to 0.8.197 to reflect these changes and enhancements.
This commit is contained in:
Lars 2026-06-07 09:21:59 +02:00
parent 8404a42b6c
commit e4cb491d46
9 changed files with 1422 additions and 231 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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([
</PlatformAdminRoute>
),
},
{
path: 'admin/rights',
element: (
<PlatformAdminRoute>
<AdminRightsPage />
</PlatformAdminRoute>
),
},
{ path: 'admin/membership', element: <Navigate to="/admin/rights" replace /> },
{
path: 'admin/hierarchy',
element: (

View File

@ -1,5 +1,5 @@
import { NavLink } from 'react-router-dom'
import { TreePine, FolderTree, Download, Grid3x3, Users, Scale, Sparkles, Wand2, Activity, Building2 } from 'lucide-react'
import { TreePine, FolderTree, Download, Grid3x3, Users, Scale, Sparkles, Wand2, Activity, Building2, Shield } from 'lucide-react'
/**
* Admin-Seiten-Navigation (horizontal) nur für Super-Admins (globaler Portal-Mandant).
@ -9,6 +9,7 @@ export default function AdminPageNav() {
{ to: '/admin/hierarchy', label: 'Hierarchie', icon: TreePine },
{ to: '/admin/users', label: 'Nutzer', icon: Users },
{ to: '/admin/club-creation-requests', label: 'Vereinsgründungen', icon: Building2 },
{ to: '/admin/rights', label: 'Rollen & Rechte', icon: Shield },
{ to: '/admin/user-content', label: 'Nutzer-Inhalte', icon: Activity },
{ to: '/admin/maturity-models', label: 'Fähigkeitsmatrix', icon: Grid3x3 },
{ to: '/admin/catalogs', label: 'Kataloge', icon: FolderTree },

View File

@ -0,0 +1,747 @@
import { useCallback, useEffect, useMemo, 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 TABS = [
{ id: 'portal', label: 'Portal-Rollen' },
{ id: 'club_roles', label: 'Vereinsrollen' },
{ id: 'bypass', label: 'Kontingent-Bypass' },
{ id: 'quotas', label: 'Vereins-Kontingente' },
]
const PORTAL_ROLE_LABEL = {
user: 'Nutzer',
trainer: 'Portal-Trainer',
admin: 'Portal-Admin',
superadmin: 'Superadmin',
}
const CLUB_ROLE_LABEL = {
club_admin: 'Vereinsadmin',
trainer: 'Trainer',
division_lead: 'Spartenleitung',
content_editor: 'Inhalte',
}
function limitInputValue(v) {
if (v === null || v === undefined) return ''
return String(v)
}
function parseLimitInput(raw, limitType) {
const s = String(raw ?? '').trim()
if (s === '' || s === '∞') return null
const n = parseInt(s, 10)
if (Number.isNaN(n)) return limitType === 'boolean' ? 0 : null
return n
}
function formatLimitHint(feature) {
if (feature.limit_type === 'boolean') return '0 = aus, 1 = an'
if (feature.reset_period === 'monthly') return 'pro Monat'
if (feature.reset_period === 'never') return 'Bestand'
return ''
}
/**
* Superadmin: Rollen Fähigkeiten (Capabilities) und Vereins-Kontingente konfigurieren.
*/
export default function AdminRightsPage() {
const { user } = useAuth()
const isSuperadmin = user?.role === 'superadmin'
const [tab, setTab] = useState('portal')
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [busy, setBusy] = useState(false)
const [plansData, setPlansData] = useState({ plans: [], features: [], limits: {} })
const [limitDraft, setLimitDraft] = useState({})
const [clubSubs, setClubSubs] = useState([])
const [capMatrix, setCapMatrix] = useState(null)
const [bypassData, setBypassData] = useState(null)
const [newClubGrant, setNewClubGrant] = useState({ role_code: 'trainer', capability_id: '' })
const [newBypassPortal, setNewBypassPortal] = useState({ portal_role: 'helpdesk', feature_id: '' })
const [newBypassProfile, setNewBypassProfile] = useState({
profile_id: '',
feature_id: '',
reason: '',
})
const loadPlans = useCallback(async () => {
const data = await api.getAdminRightsClubPlansMatrix()
setPlansData(data)
const draft = {}
for (const plan of data.plans || []) {
draft[plan.id] = {}
for (const f of data.features || []) {
draft[plan.id][f.id] = limitInputValue(data.limits?.[plan.id]?.[f.id])
}
}
setLimitDraft(draft)
}, [])
const loadClubs = useCallback(async () => {
const rows = await api.listAdminRightsClubSubscriptions()
setClubSubs(Array.isArray(rows) ? rows : [])
}, [])
const loadCapMatrix = useCallback(async () => {
setCapMatrix(await api.getAdminRightsCapabilityMatrix())
}, [])
const loadBypass = useCallback(async () => {
setBypassData(await api.listAdminRightsQuotaBypass())
}, [])
const reloadTab = useCallback(async () => {
setError('')
if (tab === 'portal' || tab === 'club_roles') await loadCapMatrix()
else if (tab === 'bypass') await loadBypass()
else if (tab === 'quotas') await Promise.all([loadPlans(), loadClubs()])
}, [tab, loadPlans, loadClubs, loadCapMatrix, loadBypass])
useEffect(() => {
if (!isSuperadmin) return
let cancelled = false
;(async () => {
setLoading(true)
try {
await reloadTab()
} catch (e) {
if (!cancelled) setError(e.message || String(e))
} finally {
if (!cancelled) setLoading(false)
}
})()
return () => {
cancelled = true
}
}, [isSuperadmin, reloadTab])
const portalCapabilities = useMemo(() => {
if (!capMatrix?.capabilities) return []
return capMatrix.capabilities.filter(
(c) => c.domain === 'platform' || String(c.id).startsWith('platform.'),
)
}, [capMatrix])
const clubScopedCapabilities = useMemo(() => {
if (!capMatrix?.capabilities) return []
return capMatrix.capabilities.filter(
(c) =>
c.domain !== 'platform' &&
c.domain !== 'quota_bypass' &&
c.domain !== 'account' &&
c.domain !== 'club',
)
}, [capMatrix])
const portalGrantSet = useMemo(() => {
const s = new Set()
for (const g of capMatrix?.portal_grants || []) {
s.add(`${g.portal_role}::${g.capability_id}`)
}
return s
}, [capMatrix])
const clubGrantSet = useMemo(() => {
const s = new Set()
for (const g of capMatrix?.club_role_grants || []) {
s.add(`${g.role_code}::${g.capability_id}`)
}
return s
}, [capMatrix])
if (!isSuperadmin) return <Navigate to="/" replace />
const savePlanLimits = async (planId) => {
setBusy(true)
setError('')
try {
const limits = (plansData.features || []).map((f) => ({
feature_id: f.id,
limit_value: parseLimitInput(limitDraft[planId]?.[f.id], f.limit_type),
}))
await api.updateAdminRightsClubPlanLimits(planId, limits)
await loadPlans()
} catch (e) {
setError(e.message || String(e))
} finally {
setBusy(false)
}
}
const saveClubPlan = async (clubId, planId, status) => {
setBusy(true)
setError('')
try {
await api.updateAdminRightsClubSubscription(clubId, { plan_id: planId, status })
await loadClubs()
} catch (e) {
setError(e.message || String(e))
} finally {
setBusy(false)
}
}
const togglePortalGrant = async (portalRole, capabilityId, hasGrant) => {
setBusy(true)
setError('')
try {
if (hasGrant) {
await api.deleteAdminRightsPortalGrant(portalRole, capabilityId)
} else {
await api.addAdminRightsPortalGrant(portalRole, capabilityId)
}
await loadCapMatrix()
} catch (e) {
setError(e.message || String(e))
} finally {
setBusy(false)
}
}
const toggleClubGrant = async (roleCode, capabilityId, hasGrant) => {
setBusy(true)
setError('')
try {
if (hasGrant) {
await api.deleteAdminRightsClubRoleGrant(roleCode, capabilityId)
} else {
await api.addAdminRightsClubRoleGrant(roleCode, capabilityId)
}
await loadCapMatrix()
} catch (e) {
setError(e.message || String(e))
} finally {
setBusy(false)
}
}
const submitBypassPortal = async (e) => {
e.preventDefault()
setBusy(true)
setError('')
try {
await api.addAdminRightsQuotaBypassPortal(
newBypassPortal.portal_role.trim(),
newBypassPortal.feature_id.trim() || null,
)
setNewBypassPortal({ portal_role: 'helpdesk', feature_id: '' })
await loadBypass()
} catch (err) {
setError(err.message || String(err))
} finally {
setBusy(false)
}
}
const submitBypassProfile = async (e) => {
e.preventDefault()
const pid = parseInt(newBypassProfile.profile_id, 10)
if (!pid) {
setError('Profil-ID erforderlich')
return
}
setBusy(true)
setError('')
try {
await api.addAdminRightsQuotaBypassProfile(
pid,
newBypassProfile.feature_id.trim() || null,
newBypassProfile.reason.trim() || null,
)
setNewBypassProfile({ profile_id: '', feature_id: '', reason: '' })
await loadBypass()
} catch (err) {
setError(err.message || String(err))
} finally {
setBusy(false)
}
}
return (
<div className="page-padding app-page">
<AdminPageNav />
<h1 style={{ marginTop: '1rem', fontSize: '1.35rem' }}>Rollen &amp; Rechte</h1>
<p style={{ color: 'var(--text2)', maxWidth: '48rem', lineHeight: 1.55 }}>
<strong>Rollen Fähigkeiten (Capabilities):</strong> Wer darf welche Funktion nutzen?
<br />
<strong>Kontingente:</strong> Wie viel darf ein Verein verbrauchen (an Fähigkeiten gekoppelt
über <code>linked_feature_id</code>)?
<br />
Vereinspläne bündeln nur Kontingent-Werte sie ersetzen keine Berechtigungen.
</p>
<div
style={{ display: 'flex', flexWrap: 'wrap', gap: '0.35rem', marginTop: '1rem' }}
role="tablist"
>
{TABS.map((t) => (
<button
key={t.id}
type="button"
role="tab"
aria-selected={tab === t.id}
className={`btn ${tab === t.id ? 'btn-primary' : 'btn-secondary'}`}
onClick={() => setTab(t.id)}
>
{t.label}
</button>
))}
</div>
{error ? (
<p role="alert" style={{ color: 'var(--danger)', marginTop: '0.75rem' }}>
{error}
</p>
) : null}
{loading ? (
<p className="spinner" style={{ marginTop: '1rem' }}>
Laden
</p>
) : null}
{!loading && tab === 'portal' && capMatrix ? (
<div className="card" style={{ marginTop: '1rem', overflowX: 'auto' }}>
<p style={{ fontSize: '0.875rem', color: 'var(--text2)', margin: '0 0 12px' }}>
Plattform-Funktionen (<code>domain=platform</code>). Jede Funktion im Produkt soll sich
hier anmelden und bei Anzeige und Ausführung prüfen.
</p>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.8rem' }}>
<thead>
<tr>
<th style={{ textAlign: 'left', padding: '6px', minWidth: '200px' }}>Fähigkeit</th>
{(capMatrix.portal_roles || []).map((r) => (
<th key={r} style={{ textAlign: 'center', padding: '6px' }}>
{PORTAL_ROLE_LABEL[r] || r}
</th>
))}
</tr>
</thead>
<tbody>
{portalCapabilities.map((cap) => (
<tr key={cap.id} style={{ borderTop: '1px solid var(--border)' }}>
<td style={{ padding: '6px' }}>
<code style={{ fontSize: '0.75rem' }}>{cap.id}</code>
<div style={{ color: 'var(--text3)' }}>{cap.name}</div>
{cap.linked_feature_id ? (
<div style={{ color: 'var(--text3)', fontSize: '0.7rem' }}>
Kontingent: {cap.linked_feature_id}
</div>
) : null}
</td>
{(capMatrix.portal_roles || []).map((role) => {
const on = portalGrantSet.has(`${role}::${cap.id}`)
return (
<td key={role} style={{ textAlign: 'center', padding: '6px' }}>
<input
type="checkbox"
checked={on}
disabled={busy}
aria-label={`${cap.id} für ${role}`}
onChange={() => togglePortalGrant(role, cap.id, on)}
/>
</td>
)
})}
</tr>
))}
</tbody>
</table>
</div>
) : null}
{!loading && tab === 'club_roles' && capMatrix ? (
<div style={{ marginTop: '1rem', display: 'flex', flexDirection: 'column', gap: '1rem' }}>
<div className="card" style={{ overflowX: 'auto' }}>
<p style={{ fontSize: '0.875rem', color: 'var(--text2)', margin: '0 0 12px' }}>
Vereinsrollen Fähigkeiten. Ohne Grant-Eintrag gilt die Fähigkeit für alle aktiven
Vereinsmitglieder; gesetzte Grants schränken auf die angehakten Rollen ein.
</p>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.75rem' }}>
<thead>
<tr>
<th style={{ textAlign: 'left', padding: '6px', minWidth: '180px' }}>Fähigkeit</th>
{(capMatrix.club_roles || []).map((r) => (
<th key={r} style={{ textAlign: 'center', padding: '6px' }}>
{CLUB_ROLE_LABEL[r] || r}
</th>
))}
</tr>
</thead>
<tbody>
{clubScopedCapabilities
.filter((cap) =>
(capMatrix.club_role_grants || []).some((g) => g.capability_id === cap.id),
)
.map((cap) => (
<tr key={cap.id} style={{ borderTop: '1px solid var(--border)' }}>
<td style={{ padding: '6px' }}>
<code>{cap.id}</code>
<div style={{ color: 'var(--text3)' }}>{cap.name}</div>
{cap.linked_feature_id ? (
<div style={{ color: 'var(--text3)', fontSize: '0.7rem' }}>
Kontingent: {cap.linked_feature_id}
</div>
) : null}
</td>
{(capMatrix.club_roles || []).map((role) => {
const on = clubGrantSet.has(`${role}::${cap.id}`)
return (
<td key={role} style={{ textAlign: 'center', padding: '6px' }}>
<input
type="checkbox"
checked={on}
disabled={busy}
onChange={() => toggleClubGrant(role, cap.id, on)}
/>
</td>
)
})}
</tr>
))}
</tbody>
</table>
</div>
<form
className="card"
onSubmit={async (e) => {
e.preventDefault()
if (!newClubGrant.capability_id) return
setBusy(true)
setError('')
try {
await api.addAdminRightsClubRoleGrant(
newClubGrant.role_code,
newClubGrant.capability_id,
)
setNewClubGrant((p) => ({ ...p, capability_id: '' }))
await loadCapMatrix()
} catch (err) {
setError(err.message || String(err))
} finally {
setBusy(false)
}
}}
>
<h2 style={{ fontSize: '1rem', margin: '0 0 0.75rem' }}>Vereinsrollen-Grant hinzufügen</h2>
<div className="form-row" style={{ display: 'flex', flexWrap: 'wrap', gap: '0.75rem' }}>
<label style={{ flex: '1 1 140px' }}>
<span className="form-label">Rolle</span>
<select
className="form-input"
value={newClubGrant.role_code}
onChange={(e) =>
setNewClubGrant((p) => ({ ...p, role_code: e.target.value }))
}
>
{(capMatrix.club_roles || []).map((r) => (
<option key={r} value={r}>
{CLUB_ROLE_LABEL[r] || r}
</option>
))}
</select>
</label>
<label style={{ flex: '2 1 240px' }}>
<span className="form-label">Fähigkeit</span>
<select
className="form-input"
value={newClubGrant.capability_id}
onChange={(e) =>
setNewClubGrant((p) => ({ ...p, capability_id: e.target.value }))
}
>
<option value=""> wählen </option>
{clubScopedCapabilities.map((c) => (
<option key={c.id} value={c.id}>
{c.id} {c.name}
</option>
))}
</select>
</label>
<div style={{ alignSelf: 'flex-end' }}>
<button type="submit" className="btn btn-primary" disabled={busy}>
Hinzufügen
</button>
</div>
</div>
</form>
</div>
) : null}
{!loading && tab === 'bypass' && bypassData ? (
<div style={{ marginTop: '1rem', display: 'flex', flexDirection: 'column', gap: '1rem' }}>
<p style={{ fontSize: '0.875rem', color: 'var(--text2)', margin: 0 }}>
Capability <code>platform.club_quota.bypass</code> umgeht Vereins-Kontingente (z.B.
Superadmin, Helpdesk). Kein separates Rechtemodell.
</p>
<div className="card">
<h2 style={{ fontSize: '1rem', margin: '0 0 0.75rem' }}>Portal-Rollen</h2>
<ul style={{ margin: 0, paddingLeft: '1.25rem', fontSize: '0.875rem' }}>
{(bypassData.portal_role_grants || []).map((g) => (
<li key={`${g.portal_role}-${g.capability_id}`} style={{ marginBottom: '6px' }}>
<strong>{g.portal_role}</strong> {g.capability_id}
{g.linked_feature_id ? ` (${g.linked_feature_id})` : ' (alle Features)'}
<button
type="button"
className="btn btn-secondary"
style={{ marginLeft: '8px', fontSize: '0.75rem', padding: '2px 8px' }}
disabled={busy}
onClick={async () => {
setBusy(true)
try {
await api.deleteAdminRightsQuotaBypassPortal(
g.portal_role,
g.linked_feature_id || null,
)
await loadBypass()
} catch (err) {
setError(err.message || String(err))
} finally {
setBusy(false)
}
}}
>
Entfernen
</button>
</li>
))}
</ul>
<form onSubmit={submitBypassPortal} style={{ marginTop: '12px' }}>
<div className="form-row" style={{ display: 'flex', flexWrap: 'wrap', gap: '0.75rem' }}>
<label style={{ flex: '1 1 120px' }}>
<span className="form-label">Portal-Rolle</span>
<input
className="form-input"
value={newBypassPortal.portal_role}
onChange={(e) =>
setNewBypassPortal((p) => ({ ...p, portal_role: e.target.value }))
}
placeholder="helpdesk"
/>
</label>
<label style={{ flex: '1 1 160px' }}>
<span className="form-label">Feature (leer = alle)</span>
<input
className="form-input"
value={newBypassPortal.feature_id}
onChange={(e) =>
setNewBypassPortal((p) => ({ ...p, feature_id: e.target.value }))
}
placeholder="ai_calls"
/>
</label>
<div style={{ alignSelf: 'flex-end' }}>
<button type="submit" className="btn btn-primary" disabled={busy}>
Grant anlegen
</button>
</div>
</div>
</form>
</div>
<div className="card">
<h2 style={{ fontSize: '1rem', margin: '0 0 0.75rem' }}>Einzelprofile</h2>
<ul style={{ margin: 0, paddingLeft: '1.25rem', fontSize: '0.875rem' }}>
{(bypassData.profile_grants || []).map((g) => (
<li key={`${g.profile_id}-${g.capability_id}`} style={{ marginBottom: '6px' }}>
Profil #{g.profile_id} {g.profile_name ? `(${g.profile_name})` : ''} {' '}
{g.capability_id}
{g.reason ? `${g.reason}` : ''}
<button
type="button"
className="btn btn-secondary"
style={{ marginLeft: '8px', fontSize: '0.75rem', padding: '2px 8px' }}
disabled={busy}
onClick={async () => {
setBusy(true)
try {
await api.deleteAdminRightsQuotaBypassProfile(
g.profile_id,
g.linked_feature_id || null,
)
await loadBypass()
} catch (err) {
setError(err.message || String(err))
} finally {
setBusy(false)
}
}}
>
Entfernen
</button>
</li>
))}
</ul>
<form onSubmit={submitBypassProfile} style={{ marginTop: '12px' }}>
<div className="form-row" style={{ display: 'flex', flexWrap: 'wrap', gap: '0.75rem' }}>
<label style={{ flex: '0 1 100px' }}>
<span className="form-label">Profil-ID</span>
<input
className="form-input"
value={newBypassProfile.profile_id}
onChange={(e) =>
setNewBypassProfile((p) => ({ ...p, profile_id: e.target.value }))
}
/>
</label>
<label style={{ flex: '1 1 120px' }}>
<span className="form-label">Feature (leer = alle)</span>
<input
className="form-input"
value={newBypassProfile.feature_id}
onChange={(e) =>
setNewBypassProfile((p) => ({ ...p, feature_id: e.target.value }))
}
/>
</label>
<label style={{ flex: '2 1 200px' }}>
<span className="form-label">Grund (optional)</span>
<input
className="form-input"
value={newBypassProfile.reason}
onChange={(e) =>
setNewBypassProfile((p) => ({ ...p, reason: e.target.value }))
}
/>
</label>
<div style={{ alignSelf: 'flex-end' }}>
<button type="submit" className="btn btn-primary" disabled={busy}>
Grant anlegen
</button>
</div>
</div>
</form>
</div>
</div>
) : null}
{!loading && tab === 'quotas' ? (
<div style={{ marginTop: '1rem', display: 'flex', flexDirection: 'column', gap: '1rem' }}>
<div className="card" style={{ overflowX: 'auto' }}>
<h2 style={{ fontSize: '1rem', margin: '0 0 0.75rem' }}>Plan-Limits</h2>
<p style={{ fontSize: '0.875rem', color: 'var(--text2)', margin: '0 0 12px' }}>
Kontingent-Bündel pro Plan. Leeres Feld = unbegrenzt. Ersetzt keine Rollen-Grants.
</p>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.875rem' }}>
<thead>
<tr>
<th style={{ textAlign: 'left', padding: '8px', minWidth: '140px' }}>Feature</th>
{(plansData.plans || []).map((p) => (
<th key={p.id} style={{ textAlign: 'center', padding: '8px', minWidth: '100px' }}>
<div>{p.name}</div>
<div style={{ fontWeight: 400, color: 'var(--text3)', fontSize: '0.75rem' }}>
{p.id}
</div>
<button
type="button"
className="btn btn-secondary"
style={{ marginTop: '4px', fontSize: '0.75rem', padding: '4px 8px' }}
disabled={busy}
onClick={() => savePlanLimits(p.id)}
>
Speichern
</button>
</th>
))}
</tr>
</thead>
<tbody>
{(plansData.features || []).map((f) => (
<tr key={f.id} style={{ borderTop: '1px solid var(--border)' }}>
<td style={{ padding: '8px' }}>
<strong>{f.name}</strong>
<div style={{ color: 'var(--text3)', fontSize: '0.75rem' }}>
{f.id} · {formatLimitHint(f)}
</div>
</td>
{(plansData.plans || []).map((p) => (
<td key={p.id} style={{ padding: '8px', textAlign: 'center' }}>
<input
className="form-input"
style={{ width: '72px', textAlign: 'center' }}
placeholder="∞"
value={limitDraft[p.id]?.[f.id] ?? ''}
disabled={busy}
onChange={(e) =>
setLimitDraft((prev) => ({
...prev,
[p.id]: { ...prev[p.id], [f.id]: e.target.value },
}))
}
/>
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
<div className="card" style={{ overflowX: 'auto' }}>
<h2 style={{ fontSize: '1rem', margin: '0 0 0.75rem' }}>Verein Plan</h2>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.875rem' }}>
<thead>
<tr>
<th style={{ textAlign: 'left', padding: '8px' }}>Verein</th>
<th style={{ textAlign: 'left', padding: '8px' }}>Plan</th>
<th style={{ textAlign: 'left', padding: '8px' }}>Status</th>
</tr>
</thead>
<tbody>
{clubSubs.map((row) => (
<tr key={row.club_id} style={{ borderTop: '1px solid var(--border)' }}>
<td style={{ padding: '8px' }}>
{row.club_name || `Verein #${row.club_id}`}
</td>
<td style={{ padding: '8px' }}>
<select
className="form-input"
value={row.plan_id || 'free'}
disabled={busy}
onChange={(e) =>
saveClubPlan(row.club_id, e.target.value, row.status || 'active')
}
>
{(plansData.plans || []).map((p) => (
<option key={p.id} value={p.id}>
{p.name} ({p.id})
</option>
))}
</select>
</td>
<td style={{ padding: '8px' }}>
<select
className="form-input"
value={row.status || 'active'}
disabled={busy}
onChange={(e) =>
saveClubPlan(row.club_id, row.plan_id || 'free', e.target.value)
}
>
<option value="active">aktiv</option>
<option value="trial">Test</option>
<option value="past_due">überfällig</option>
<option value="cancelled">gekündigt</option>
</select>
</td>
</tr>
))}
</tbody>
</table>
{clubSubs.length === 0 ? (
<p style={{ padding: '12px', color: 'var(--text2)', margin: 0 }}>Keine Vereine.</p>
) : null}
</div>
</div>
) : null}
</div>
)
}

View File

@ -261,6 +261,87 @@ export async function rejectClubCreationRequest(requestId) {
return request(`/api/admin/club-creation-requests/${requestId}/reject`, { method: 'POST' })
}
/** M6: Rollen & Rechte — Capabilities, Kontingent-Bypass, Vereins-Kontingente (Superadmin). */
export async function getAdminRightsCapabilityMatrix() {
return request('/api/admin/rights/capability-matrix')
}
export async function addAdminRightsPortalGrant(portalRole, capabilityId) {
return request('/api/admin/rights/capability-grants/portal-roles', {
method: 'POST',
body: JSON.stringify({ portal_role: portalRole, capability_id: capabilityId }),
})
}
export async function deleteAdminRightsPortalGrant(portalRole, capabilityId) {
const q = new URLSearchParams({ portal_role: portalRole, capability_id: capabilityId })
return request(`/api/admin/rights/capability-grants/portal-roles?${q}`, { method: 'DELETE' })
}
export async function addAdminRightsClubRoleGrant(roleCode, capabilityId) {
return request('/api/admin/rights/capability-grants/club-roles', {
method: 'POST',
body: JSON.stringify({ role_code: roleCode, capability_id: capabilityId }),
})
}
export async function deleteAdminRightsClubRoleGrant(roleCode, capabilityId) {
const q = new URLSearchParams({ role_code: roleCode, capability_id: capabilityId })
return request(`/api/admin/rights/capability-grants/club-roles?${q}`, { method: 'DELETE' })
}
export async function listAdminRightsQuotaBypass() {
return request('/api/admin/rights/quota-bypass')
}
export async function addAdminRightsQuotaBypassPortal(portalRole, featureId = null) {
return request('/api/admin/rights/quota-bypass/portal-roles', {
method: 'POST',
body: JSON.stringify({ portal_role: portalRole, feature_id: featureId }),
})
}
export async function deleteAdminRightsQuotaBypassPortal(portalRole, featureId = null) {
const q = new URLSearchParams({ portal_role: portalRole })
if (featureId) q.set('feature_id', featureId)
return request(`/api/admin/rights/quota-bypass/portal-roles?${q}`, { method: 'DELETE' })
}
export async function addAdminRightsQuotaBypassProfile(profileId, featureId = null, reason = null) {
return request(`/api/admin/rights/quota-bypass/profiles/${profileId}`, {
method: 'POST',
body: JSON.stringify({ feature_id: featureId, reason }),
})
}
export async function deleteAdminRightsQuotaBypassProfile(profileId, featureId = null) {
const q = new URLSearchParams({ profile_id: String(profileId) })
if (featureId) q.set('feature_id', featureId)
return request(`/api/admin/rights/quota-bypass/profiles?${q}`, { method: 'DELETE' })
}
export async function getAdminRightsClubPlansMatrix() {
return request('/api/admin/rights/club-plans/matrix')
}
export async function updateAdminRightsClubPlanLimits(planId, limits) {
return request(`/api/admin/rights/club-plans/${encodeURIComponent(planId)}/limits`, {
method: 'PUT',
body: JSON.stringify({ limits }),
})
}
export async function listAdminRightsClubSubscriptions() {
return request('/api/admin/rights/club-subscriptions')
}
export async function updateAdminRightsClubSubscription(clubId, payload) {
return request(`/api/admin/rights/clubs/${clubId}/subscription`, {
method: 'PUT',
body: JSON.stringify(payload),
})
}
/** Offene Anträge (Vereins-/Plattform-Admin). */
export async function listClubJoinRequests(clubId) {
return request(`/api/clubs/${clubId}/join-requests`)
@ -924,6 +1005,20 @@ export const api = {
listAdminClubCreationRequests,
approveClubCreationRequest,
rejectClubCreationRequest,
getAdminRightsCapabilityMatrix,
addAdminRightsPortalGrant,
deleteAdminRightsPortalGrant,
addAdminRightsClubRoleGrant,
deleteAdminRightsClubRoleGrant,
listAdminRightsQuotaBypass,
addAdminRightsQuotaBypassPortal,
deleteAdminRightsQuotaBypassPortal,
addAdminRightsQuotaBypassProfile,
deleteAdminRightsQuotaBypassProfile,
getAdminRightsClubPlansMatrix,
updateAdminRightsClubPlanLimits,
listAdminRightsClubSubscriptions,
updateAdminRightsClubSubscription,
listClubJoinRequests,
acceptClubJoinRequest,
rejectClubJoinRequest,