From e4cb491d465f92e1ba9c781b3021405bb79d8428 Mon Sep 17 00:00:00 2001 From: Lars Date: Sun, 7 Jun 2026 09:21:59 +0200 Subject: [PATCH] Refactor Admin Rights Management and Update Versioning - 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. --- backend/main.py | 4 +- .../routers/admin_club_feature_exemptions.py | 227 ------ backend/routers/admin_rights.py | 543 +++++++++++++ backend/tests/test_admin_rights.py | 21 + backend/version.py | 3 +- frontend/src/App.jsx | 10 + frontend/src/components/AdminPageNav.jsx | 3 +- frontend/src/pages/AdminRightsPage.jsx | 747 ++++++++++++++++++ frontend/src/utils/api.js | 95 +++ 9 files changed, 1422 insertions(+), 231 deletions(-) delete mode 100644 backend/routers/admin_club_feature_exemptions.py create mode 100644 backend/routers/admin_rights.py create mode 100644 backend/tests/test_admin_rights.py create mode 100644 frontend/src/pages/AdminRightsPage.jsx 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: ( + + + + ), + }, + { path: 'admin/membership', element: }, { path: 'admin/hierarchy', element: ( diff --git a/frontend/src/components/AdminPageNav.jsx b/frontend/src/components/AdminPageNav.jsx index dafba90..27e8d32 100644 --- a/frontend/src/components/AdminPageNav.jsx +++ b/frontend/src/components/AdminPageNav.jsx @@ -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 }, diff --git a/frontend/src/pages/AdminRightsPage.jsx b/frontend/src/pages/AdminRightsPage.jsx new file mode 100644 index 0000000..375343c --- /dev/null +++ b/frontend/src/pages/AdminRightsPage.jsx @@ -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 + + 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 ( +
+ +

Rollen & Rechte

+

+ 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. +

+ +
+ {TABS.map((t) => ( + + ))} +
+ + {error ? ( +

+ {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. +

+ + + + + {(capMatrix.portal_roles || []).map((r) => ( + + ))} + + + + {portalCapabilities.map((cap) => ( + + + {(capMatrix.portal_roles || []).map((role) => { + const on = portalGrantSet.has(`${role}::${cap.id}`) + return ( + + ) + })} + + ))} + +
Fähigkeit + {PORTAL_ROLE_LABEL[r] || r} +
+ {cap.id} +
{cap.name}
+ {cap.linked_feature_id ? ( +
+ Kontingent: {cap.linked_feature_id} +
+ ) : null} +
+ togglePortalGrant(role, cap.id, on)} + /> +
+
+ ) : null} + + {!loading && tab === 'club_roles' && capMatrix ? ( +
+
+

+ Vereinsrollen → Fähigkeiten. Ohne Grant-Eintrag gilt die Fähigkeit für alle aktiven + Vereinsmitglieder; gesetzte Grants schränken auf die angehakten Rollen ein. +

+ + + + + {(capMatrix.club_roles || []).map((r) => ( + + ))} + + + + {clubScopedCapabilities + .filter((cap) => + (capMatrix.club_role_grants || []).some((g) => g.capability_id === cap.id), + ) + .map((cap) => ( + + + {(capMatrix.club_roles || []).map((role) => { + const on = clubGrantSet.has(`${role}::${cap.id}`) + return ( + + ) + })} + + ))} + +
Fähigkeit + {CLUB_ROLE_LABEL[r] || r} +
+ {cap.id} +
{cap.name}
+ {cap.linked_feature_id ? ( +
+ Kontingent: {cap.linked_feature_id} +
+ ) : null} +
+ toggleClubGrant(role, cap.id, on)} + /> +
+
+ +
{ + 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) + } + }} + > +

Vereinsrollen-Grant hinzufügen

+
+ + +
+ +
+
+
+
+ ) : null} + + {!loading && tab === 'bypass' && bypassData ? ( +
+

+ Capability platform.club_quota.bypass — umgeht Vereins-Kontingente (z. B. + Superadmin, Helpdesk). Kein separates Rechtemodell. +

+
+

Portal-Rollen

+
    + {(bypassData.portal_role_grants || []).map((g) => ( +
  • + {g.portal_role} → {g.capability_id} + {g.linked_feature_id ? ` (${g.linked_feature_id})` : ' (alle Features)'} + +
  • + ))} +
+
+
+ + +
+ +
+
+
+
+ +
+

Einzelprofile

+
    + {(bypassData.profile_grants || []).map((g) => ( +
  • + Profil #{g.profile_id} {g.profile_name ? `(${g.profile_name})` : ''} →{' '} + {g.capability_id} + {g.reason ? ` — ${g.reason}` : ''} + +
  • + ))} +
+
+
+ + + +
+ +
+
+
+
+
+ ) : null} + + {!loading && tab === 'quotas' ? ( +
+
+

Plan-Limits

+

+ Kontingent-Bündel pro Plan. Leeres Feld = unbegrenzt. Ersetzt keine Rollen-Grants. +

+ + + + + {(plansData.plans || []).map((p) => ( + + ))} + + + + {(plansData.features || []).map((f) => ( + + + {(plansData.plans || []).map((p) => ( + + ))} + + ))} + +
Feature +
{p.name}
+
+ {p.id} +
+ +
+ {f.name} +
+ {f.id} · {formatLimitHint(f)} +
+
+ + setLimitDraft((prev) => ({ + ...prev, + [p.id]: { ...prev[p.id], [f.id]: e.target.value }, + })) + } + /> +
+
+ +
+

Verein → Plan

+ + + + + + + + + + {clubSubs.map((row) => ( + + + + + + ))} + +
VereinPlanStatus
+ {row.club_name || `Verein #${row.club_id}`} + + + + +
+ {clubSubs.length === 0 ? ( +

Keine Vereine.

+ ) : null} +
+
+ ) : null} +
+ ) +} diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js index 6f1a801..77d98af 100644 --- a/frontend/src/utils/api.js +++ b/frontend/src/utils/api.js @@ -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,