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
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:
parent
8404a42b6c
commit
e4cb491d46
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
543
backend/routers/admin_rights.py
Normal file
543
backend/routers/admin_rights.py
Normal 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)
|
||||
21
backend/tests/test_admin_rights.py
Normal file
21
backend/tests/test_admin_rights.py
Normal 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
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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: (
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
747
frontend/src/pages/AdminRightsPage.jsx
Normal file
747
frontend/src/pages/AdminRightsPage.jsx
Normal 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 & 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user