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
|
return out
|
||||||
|
|
||||||
# Register routers
|
# 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(auth.router)
|
||||||
app.include_router(profiles.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(club_creation_requests.router)
|
||||||
app.include_router(admin_users.router)
|
app.include_router(admin_users.router)
|
||||||
app.include_router(admin_user_content.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(me_entitlements.router)
|
||||||
app.include_router(platform_media_storage.router)
|
app.include_router(platform_media_storage.router)
|
||||||
app.include_router(media_assets.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
|
# Shinkan Jinkendo Version Information
|
||||||
|
|
||||||
APP_VERSION = "0.8.195"
|
APP_VERSION = "0.8.197"
|
||||||
BUILD_DATE = "2026-06-07"
|
BUILD_DATE = "2026-06-07"
|
||||||
DB_SCHEMA_VERSION = "20260606083"
|
DB_SCHEMA_VERSION = "20260606083"
|
||||||
|
|
||||||
|
|
@ -18,6 +18,7 @@ MODULE_VERSIONS = {
|
||||||
"admin_users": "1.0.0", # GET /api/admin/users
|
"admin_users": "1.0.0", # GET /api/admin/users
|
||||||
"club_features": "1.5.0", # Kontingent-Bypass via Capability-Grants (probe/consume)
|
"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
|
"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
|
"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)
|
"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)
|
"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 MediaWikiImportPage = lazy(() => import('./pages/MediaWikiImportPage'))
|
||||||
const AdminUsersPage = lazy(() => import('./pages/AdminUsersPage'))
|
const AdminUsersPage = lazy(() => import('./pages/AdminUsersPage'))
|
||||||
const AdminClubCreationRequestsPage = lazy(() => import('./pages/AdminClubCreationRequestsPage'))
|
const AdminClubCreationRequestsPage = lazy(() => import('./pages/AdminClubCreationRequestsPage'))
|
||||||
|
const AdminRightsPage = lazy(() => import('./pages/AdminRightsPage'))
|
||||||
const MediaLibraryPage = lazy(() => import('./pages/MediaLibraryPage'))
|
const MediaLibraryPage = lazy(() => import('./pages/MediaLibraryPage'))
|
||||||
const LegalPage = lazy(() => import('./pages/LegalPage'))
|
const LegalPage = lazy(() => import('./pages/LegalPage'))
|
||||||
const AdminLegalDocumentsPage = lazy(() => import('./pages/AdminLegalDocumentsPage'))
|
const AdminLegalDocumentsPage = lazy(() => import('./pages/AdminLegalDocumentsPage'))
|
||||||
|
|
@ -291,6 +292,15 @@ const appRouter = createBrowserRouter([
|
||||||
</PlatformAdminRoute>
|
</PlatformAdminRoute>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'admin/rights',
|
||||||
|
element: (
|
||||||
|
<PlatformAdminRoute>
|
||||||
|
<AdminRightsPage />
|
||||||
|
</PlatformAdminRoute>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ path: 'admin/membership', element: <Navigate to="/admin/rights" replace /> },
|
||||||
{
|
{
|
||||||
path: 'admin/hierarchy',
|
path: 'admin/hierarchy',
|
||||||
element: (
|
element: (
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { NavLink } from 'react-router-dom'
|
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).
|
* 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/hierarchy', label: 'Hierarchie', icon: TreePine },
|
||||||
{ to: '/admin/users', label: 'Nutzer', icon: Users },
|
{ to: '/admin/users', label: 'Nutzer', icon: Users },
|
||||||
{ to: '/admin/club-creation-requests', label: 'Vereinsgründungen', icon: Building2 },
|
{ 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/user-content', label: 'Nutzer-Inhalte', icon: Activity },
|
||||||
{ to: '/admin/maturity-models', label: 'Fähigkeitsmatrix', icon: Grid3x3 },
|
{ to: '/admin/maturity-models', label: 'Fähigkeitsmatrix', icon: Grid3x3 },
|
||||||
{ to: '/admin/catalogs', label: 'Kataloge', icon: FolderTree },
|
{ 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' })
|
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). */
|
/** Offene Anträge (Vereins-/Plattform-Admin). */
|
||||||
export async function listClubJoinRequests(clubId) {
|
export async function listClubJoinRequests(clubId) {
|
||||||
return request(`/api/clubs/${clubId}/join-requests`)
|
return request(`/api/clubs/${clubId}/join-requests`)
|
||||||
|
|
@ -924,6 +1005,20 @@ export const api = {
|
||||||
listAdminClubCreationRequests,
|
listAdminClubCreationRequests,
|
||||||
approveClubCreationRequest,
|
approveClubCreationRequest,
|
||||||
rejectClubCreationRequest,
|
rejectClubCreationRequest,
|
||||||
|
getAdminRightsCapabilityMatrix,
|
||||||
|
addAdminRightsPortalGrant,
|
||||||
|
deleteAdminRightsPortalGrant,
|
||||||
|
addAdminRightsClubRoleGrant,
|
||||||
|
deleteAdminRightsClubRoleGrant,
|
||||||
|
listAdminRightsQuotaBypass,
|
||||||
|
addAdminRightsQuotaBypassPortal,
|
||||||
|
deleteAdminRightsQuotaBypassPortal,
|
||||||
|
addAdminRightsQuotaBypassProfile,
|
||||||
|
deleteAdminRightsQuotaBypassProfile,
|
||||||
|
getAdminRightsClubPlansMatrix,
|
||||||
|
updateAdminRightsClubPlanLimits,
|
||||||
|
listAdminRightsClubSubscriptions,
|
||||||
|
updateAdminRightsClubSubscription,
|
||||||
listClubJoinRequests,
|
listClubJoinRequests,
|
||||||
acceptClubJoinRequest,
|
acceptClubJoinRequest,
|
||||||
rejectClubJoinRequest,
|
rejectClubJoinRequest,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user