Update Membership RBAC Decisions and Enhance Admin Rights Management
All checks were successful
Deploy Development / deploy (push) Successful in 44s
Test Suite / pytest-backend (push) Successful in 43s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 14s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m13s

- Updated the Membership RBAC Decisions document to reflect the latest implementation status and roadmap, including new features and enhancements.
- Incremented application version to 0.8.200 and updated database schema version to 20260606083.
- Added a new API endpoint to clear capability grants for club roles, improving admin rights management.
- Enhanced the Admin Rights page in the frontend to display enforcement status and feature consumption details for capabilities.
- Improved the user interface for better clarity on rights and capabilities management.
This commit is contained in:
Lars 2026-06-07 15:27:37 +02:00
parent b68185842e
commit 9d52aeab67
7 changed files with 448 additions and 124 deletions

View File

@ -99,8 +99,8 @@ Env-Schalter: `ACCOUNT_GATE_ENFORCE` (Default `1`, Endpoint-Helfer), `ACCOUNT_GA
## 2. Implementierungsstand (Ist, Codebase) ## 2. Implementierungsstand (Ist, Codebase)
**DB-Schema:** `20260606079` (`backend/version.py`) **DB-Schema:** `20260606083` · App **0.8.199** (`backend/version.py`)
**Deploy-Referenz:** Dev mit M2-Logging verifiziert (`club-feature-usage.log`). **Roadmap (detailliert):** `docs/working/RBAC_ENFORCEMENT_ROADMAP.md`
### M1 — Feature-Schema v9c ✅ ### M1 — Feature-Schema v9c ✅
@ -120,6 +120,7 @@ Env-Schalter: `ACCOUNT_GATE_ENFORCE` (Default `1`, Endpoint-Helfer), `ACCOUNT_GA
| `club_feature_logger.py``club-feature-usage.log` | ✅ | | `club_feature_logger.py``club-feature-usage.log` | ✅ |
| `probe_club_feature_access()` | ✅ | | `probe_club_feature_access()` | ✅ |
| Hooks: KI-Endpoints, `POST /exercises`, Medien-Upload, Planungs-KI | ✅ | | Hooks: KI-Endpoints, `POST /exercises`, Medien-Upload, Planungs-KI | ✅ |
| Consume-Standard + `feature_usage` in Response (`ai_calls`) | ✅ |
| `CLUB_FEATURE_ENFORCE=0` (Default) | ✅ | | `CLUB_FEATURE_ENFORCE=0` (Default) | ✅ |
### M3 — Account-Lifecycle + Capability-Grants ⚠️ teilweise ### M3 — Account-Lifecycle + Capability-Grants ⚠️ teilweise
@ -134,23 +135,44 @@ Env-Schalter: `ACCOUNT_GATE_ENFORCE` (Default `1`, Endpoint-Helfer), `ACCOUNT_GA
| Account-Gates auf **Schreib-/KI-Endpoints** | ✅ | Lesepfade für Bewerber noch offen | | Account-Gates auf **Schreib-/KI-Endpoints** | ✅ | Lesepfade für Bewerber noch offen |
| `CAPABILITY_ENFORCE=0` (nur Log) | ✅ | — | | `CAPABILITY_ENFORCE=0` (nur Log) | ✅ | — |
| Onboarding UX: nur Bewerbung/Gründung | ✅ | Phase A: API-Middleware + `/onboarding` + reduzierte Nav | | Onboarding UX: nur Bewerbung/Gründung | ✅ | Phase A: API-Middleware + `/onboarding` + reduzierte Nav |
| `club_creation_requests` (M7) | ❌ | — | | `club_creation_requests` (M7) | ✅ Basis | Capabilities + Admin-Freigabe |
| Quota-Bypass via Capability-Grants (083) | ✅ | kein paralleles Exemption-Schema |
| Custom Roles / Co-Trainer | ❌ | bewusst v2 | | Custom Roles / Co-Trainer | ❌ | bewusst v2 |
| Legacy-Helfer entfernt | ❌ | bewusst parallel | | Legacy-Helfer entfernt | ❌ | bewusst parallel |
### Bewusst zurückgestellt (Roadmap) ### M4 — Anzeige ✅ teilweise
| Deliverable | Status |
|-------------|--------|
| `GET /api/me/entitlements` | ✅ |
| `EntitlementsContext`, `hasCapability()` | ✅ (UI nutzt noch kaum) |
| `FeatureUsageBadge` | ✅ nur KI im Übungsformular |
| `featureUsageSync` in `request()` | ✅ |
### M5 — Hard-Block + vollständiger Verbrauch ⚠️
| Deliverable | Status |
|-------------|--------|
| `consume_club_feature_with_usage` Standard | ✅ `ai_calls` |
| `CLUB_FEATURE_ENFORCE=1` produktiv | ❌ Default 0 |
| Consume `exercises`, `exercise_media`, … | ❌ |
### M6 — Admin UI Rollen & Rechte ⚠️
| Deliverable | Status |
|-------------|--------|
| `/admin/rights` Capability-Matrix (Portal + Verein) | ✅ |
| Klartext zuerst, Enforcement-Badge | ✅ 2026-06-07 |
| Kontingent-Bypass + Vereinspläne (Seed) | ✅ |
| Neue Pläne / Rollen anlegen (CRUD) | ❌ |
### Bewusst zurückgestellt
| ID | Inhalt | | ID | Inhalt |
|----|--------| |----|--------|
| M0 | CI-Isolation / Test-DB | | M0 | CI-Isolation / Test-DB |
| M5 | Hard-Block Kontingente |
| M6 | Superadmin Admin-UI (Pläne, Capability-Matrix) |
| M7 | Vereinsgründung beantragen |
| M8 | Stripe | | M8 | Stripe |
| v2 | Trainer-Budgets, Custom Roles |
### Hinweis: M4 im Repo (über M3 hinaus)
Falls bereits deployed: `GET /api/me/entitlements`, `EntitlementsContext`, `FeatureUsageBadge` — gehört zur **Anzeige-Phase 3**, nicht zum M3-Kern. Siehe `entitlements` Modul v1.0.0.
--- ---
@ -178,9 +200,9 @@ Request
| **A** | **Onboarding-Gates vollständig** | ✅ umgesetzt (API + Frontend `/onboarding`) | | **A** | **Onboarding-Gates vollständig** | ✅ umgesetzt (API + Frontend `/onboarding`) |
| **B** | **M7 Vereinsgründung beantragen** | **Als Nächstes** — zweiter Pfad für `verified_pending_club` | | **B** | **M7 Vereinsgründung beantragen** | **Als Nächstes** — zweiter Pfad für `verified_pending_club` |
| **C** | **M5 Hard-Block `ai_calls`** | Free-Plan `0` wird real; Badge (M4) liefert Erklärung | | **C** | **M5 Hard-Block `ai_calls`** | Free-Plan `0` wird real; Badge (M4) liefert Erklärung |
| **D** | **M6 Superadmin-UI** | Pläne + Capability-Matrix ohne SQL | | **D** | **M6 voll** | Pläne-CRUD, Rollen-CRUD | ⚠️ Matrix da |
| **E** | Systemrolle `co_trainer` + Entitlements im Frontend | Entscheidung 1.2 risikoarm | | **E** | Entitlements im Frontend (`hasCapability`) | Entscheidung 1.2 risikoarm |
| **F** | Member-Budgets (v2) | Entscheidung 1.4 | | **F** | `co_trainer` + Member-Budgets (v2) | Entscheidung 1.4 |
M0 parallel, nicht blockierend. M0 parallel, nicht blockierend.
@ -204,7 +226,14 @@ M0 parallel, nicht blockierend.
| `backend/capabilities.py` | Capability-Auflösung | | `backend/capabilities.py` | Capability-Auflösung |
| `backend/account_lifecycle.py` | Account-Gates | | `backend/account_lifecycle.py` | Account-Gates |
## 7. Superadmin im Verein (FAQ)
Siehe **`docs/working/RBAC_ENFORCEMENT_ROADMAP.md` §4**: Plattform-Admin (`admin`, `superadmin`) erhält **Capability-Bypass** für Vereins-Funktionen ohne `club_admin`-Mitgliedschaft. Mandant über aktiven Verein wählen; Kontingente via Bypass. Einzelne Legacy-Pfade (z.B. Löschen `visibility=club`) sind noch nicht vereinheitlicht — Ziel Phase 3.
---
**Changelog** **Changelog**
- 2026-06-06: Initial — Entscheidungen Onboarding, Rollen-Risiko, Kontingente, Trainer-Budget v2; Ist-Stand M1M3; Roadmap AF. - 2026-06-06: Initial — Entscheidungen Onboarding, Rollen-Risiko, Kontingente, Trainer-Budget v2; Ist-Stand M1M3; Roadmap AF.
- 2026-06-06: Phase A — `account_onboarding_gate.py`, Frontend `/onboarding`, reduzierte Navigation. - 2026-06-06: Phase A — `account_onboarding_gate.py`, Frontend `/onboarding`, reduzierte Navigation.
- 2026-06-07: M4M6 Ist-Stand, Roadmap-Verweis, Superadmin-FAQ; Admin-Matrix UX + Enforcement-Audit.

View File

@ -0,0 +1,94 @@
"""
Audit: Welche Capabilities sind an Endpoints angebunden?
Für Admin-Matrix (Rollen & Rechte) und Roadmap bei neuem probe_capability hier eintragen.
"""
from __future__ import annotations
from typing import Any, Dict
# Endpoints rufen probe_capability auf (Log; Block nur bei CAPABILITY_ENFORCE=1)
WIRED_PROBE = frozenset(
{
"exercises.ai.suggest",
"exercises.ai.regenerate",
"exercises.create",
"exercises.media.upload",
"planning.ai.suggest",
"planning.ai.progression_path",
"club.creation_request.read_own",
"club.creation_request.create",
"club.creation_request.withdraw",
"platform.club_creation.approve",
}
)
# Kontingent-Verbrauch nach Erfolg (consume_club_feature_with_usage)
FEATURE_CONSUME_WIRED = frozenset(
{
"ai_calls",
}
)
def enforcement_status_for_capability(capability_id: str) -> Dict[str, Any]:
"""
Anzeige-Status für Superadmin-Matrix.
level: probe | legacy | platform | open | none
"""
cid = (capability_id or "").strip()
if cid in WIRED_PROBE:
return {
"level": "probe",
"label": "API vorbereitet (Log)",
"detail": "probe_capability am Endpoint; Hard-Block erst mit CAPABILITY_ENFORCE=1",
"implemented": True,
}
if cid.startswith("platform."):
if cid == "platform.admin.access":
return {
"level": "platform",
"label": "Plattform (Router-Guard)",
"detail": "RequireAdmin / Superadmin-Checks",
"implemented": True,
}
if cid in WIRED_PROBE:
pass
return {
"level": "platform",
"label": "Plattform (teilweise)",
"detail": "Meist Router-Guard; Capability-Probe nur wo eingetragen",
"implemented": cid in WIRED_PROBE,
}
if cid.startswith("club."):
return {
"level": "open",
"label": "Onboarding",
"detail": "Account-State / eigene Flows",
"implemented": cid in WIRED_PROBE,
}
# Vereins-Capabilities ohne Probe: Legacy club_tenancy (can_plan_in_club, has_club_role, …)
return {
"level": "legacy",
"label": "Nur Legacy-Rollen",
"detail": "Noch kein probe_capability — prüft can_plan_in_club / club_admin im Code",
"implemented": False,
}
def feature_consume_status(feature_id: str) -> Dict[str, Any]:
fid = (feature_id or "").strip()
if fid in FEATURE_CONSUME_WIRED:
return {
"level": "consume",
"label": "Verbrauch aktiv",
"detail": "consume_club_feature_with_usage + feature_usage in Response",
"implemented": True,
}
return {
"level": "inventory",
"label": "Bestand / Probe",
"detail": "Probe oder Live-Zählung; kein Consume nach Aktion",
"implemented": False,
}

View File

@ -15,6 +15,10 @@ from club_quota_bypass import (
list_quota_bypass_grants, list_quota_bypass_grants,
quota_bypass_capability_id_for_feature, quota_bypass_capability_id_for_feature,
) )
from capability_enforcement_audit import (
enforcement_status_for_capability,
feature_consume_status,
)
from club_tenancy import is_superadmin from club_tenancy import is_superadmin
from db import get_db, get_cursor, r2d from db import get_db, get_cursor, r2d
@ -94,7 +98,13 @@ def get_capability_matrix(session: dict = Depends(require_auth)):
ORDER BY domain, id ORDER BY domain, id
""" """
) )
capabilities = [r2d(r) for r in cur.fetchall()] capabilities = []
for row in cur.fetchall():
cap = r2d(row)
cap["enforcement"] = enforcement_status_for_capability(cap.get("id"))
if cap.get("linked_feature_id"):
cap["feature_consume"] = feature_consume_status(cap["linked_feature_id"])
capabilities.append(cap)
cur.execute( cur.execute(
""" """
@ -224,6 +234,27 @@ def add_club_role_capability_grant(
return r2d(row) return r2d(row)
@router.delete("/capability-grants/club-roles/by-capability")
def clear_club_capability_grants(
capability_id: str = Query(...),
session: dict = Depends(require_auth),
):
"""Alle Rollen-Grants einer Capability entfernen → wieder offen für alle Mitglieder."""
_require_superadmin(session)
cap_id = capability_id.strip()
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"""
DELETE FROM club_role_capability_grants
WHERE capability_id = %s
""",
(cap_id,),
)
conn.commit()
return {"ok": True, "capability_id": cap_id}
@router.delete("/capability-grants/club-roles") @router.delete("/capability-grants/club-roles")
def delete_club_role_capability_grant( def delete_club_role_capability_grant(
role_code: str = Query(...), role_code: str = Query(...),

View File

@ -1,6 +1,6 @@
# Shinkan Jinkendo Version Information # Shinkan Jinkendo Version Information
APP_VERSION = "0.8.199" APP_VERSION = "0.8.200"
BUILD_DATE = "2026-06-07" BUILD_DATE = "2026-06-07"
DB_SCHEMA_VERSION = "20260606083" DB_SCHEMA_VERSION = "20260606083"
@ -16,9 +16,10 @@ MODULE_VERSIONS = {
"club_join_requests": "1.0.1", # Depends(get_tenant_context) "club_join_requests": "1.0.1", # Depends(get_tenant_context)
"club_creation_requests": "1.0.1", # superseded wenn freigegebener Verein gelöscht "club_creation_requests": "1.0.1", # superseded wenn freigegebener Verein gelöscht
"admin_users": "1.0.0", # GET /api/admin/users "admin_users": "1.0.0", # GET /api/admin/users
"club_features": "1.6.0", # Standard consume_club_feature_with_usage + merge_feature_usage_into_response "club_features": "1.6.0",
"admin_rights": "1.1.0", # Matrix UX, Enforcement-Audit, clear club grants by capability
"capability_enforcement_audit": "1.0.0",
"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)

View File

@ -0,0 +1,156 @@
# RBAC, Kontingente & Enforcement — Roadmap
**Stand:** 2026-06-07 · App **0.8.199** · Schema **20260606083**
**Bezüge:** `MEMBERSHIP_RBAC_DECISIONS_2026-06.md`, `CAPABILITY_CATALOG.v1.md`, `CLUB_MEMBERSHIP_AND_FEATURES.v1.md`
Diese Roadmap bündelt **was fertig ist**, **was als Standard gilt** und **was noch fehlt** — ohne Insellösungen pro Feature.
---
## 1. Architektur-Standard (verbindlich)
### Request-Kette (Ziel)
```
Auth → Account-State → TenantContext
→ probe_capability (Recht)
→ probe_club_feature_access (Kontingent)
→ Governance (Objekt)
→ Business-Logik
→ consume_club_feature_with_usage + merge_feature_usage_into_response
```
### Frontend-Standard
- `GET /api/me/entitlements` = einzige Quelle für Rechte + Kontingente in der UI
- `request()` synchronisiert `feature_usage` aus API-Responses automatisch (`featureUsageSync.js`)
- Keine parallelen `if (club_admin)` für **Sicherheit** (UX-Fallback nur übergangsweise)
### Admin
- **Rollen & Rechte** (`/admin/rights`): Matrix mit Klartext zuerst, technische ID darunter
- Umsetzungsstand pro Recht: `capability_enforcement_audit.py` → Feld `enforcement` in der Matrix
---
## 2. Ist-Stand nach Meilenstein
| Meilenstein | Inhalt | Status |
|-------------|--------|--------|
| **M1** | Feature-Schema, Pläne, Seeds | ✅ |
| **M2** | Feature-Probe + JSON-Log | ✅ |
| **M3** | Capabilities, Account-Lifecycle, Tenant | ✅ (Legacy parallel) |
| **M4** | `/me/entitlements`, Badge (KI) | ✅ teilweise |
| **M5** | Hard-Block + vollständiger Consume | ⚠️ nur `ai_calls` consume; Enforce **aus** |
| **M6** | Admin UI Rollen & Rechte | ⚠️ Matrix + Kontingente; kein Plan-/Rollen-CRUD |
| **M7** | Vereinsgründung beantragen | ✅ Basis + Capabilities |
| **M8** | Stripe | ❌ |
| **Sync** | `feature_usage` + `request()` | ✅ |
---
## 3. Roadmap (empfohlene Reihenfolge)
### Phase 1 — Durchsetzung sichtbar machen (kurz)
| # | Paket | Lieferumfang | Aufwand |
|---|--------|--------------|---------|
| 1.1 | **Admin-Matrix UX** | Alle Rechte, Haken-Matrix, Umsetzungs-Badge | ✅ 2026-06-07 |
| 1.2 | **Doku-Sync** | Diese Roadmap, `MEMBERSHIP_RBAC` §2 | ✅ |
| 1.3 | **Audit pflegen** | Bei jedem `probe_capability``capability_enforcement_audit.py` | laufend |
### Phase 2 — Kontingente vollständig (M5)
| # | Paket | Lieferumfang |
|---|--------|--------------|
| 2.1 | **Consume erweitern** | `exercises`, `exercise_media` nach Standard-Helfer |
| 2.2 | **Badges** | `FeatureUsageBadge` an Create/Upload, nicht nur KI |
| 2.3 | **Dev: Enforce** | `CLUB_FEATURE_ENFORCE=1` auf Dev, Free `ai_calls=0` testen |
| 2.4 | **Prod-Rollout** | Enforce schrittweise; Kommunikation an Vereine |
### Phase 3 — Capabilities an alle Endpoints (C3C4)
| # | Paket | Lieferumfang |
|---|--------|--------------|
| 3.1 | **Endpoint-Audit** | `ACCESS_LAYER_ENDPOINT_AUDIT.md` — jeder Schreib-Pfad |
| 3.2 | **probe_capability** | `exercises.update/delete`, `planning.*`, `org.*`, Medien-Bibliothek, … |
| 3.3 | **CAPABILITY_ENFORCE=1** | Nach Audit auf Dev, dann Prod |
| 3.4 | **Legacy abbauen** | `can_plan_in_club` nur noch als Fallback, dokumentiert |
### Phase 4 — Frontend auf Entitlements (Phase E)
| # | Paket | Lieferumfang |
|---|--------|--------------|
| 4.1 | **Navigation** | Menüpunkte aus `hasCapability()` |
| 4.2 | **Buttons** | KI, Anlegen, Löschen, Planung — aus Entitlements |
| 4.3 | **Rollen-Labels** | Anzeige `club_roles` statt technischer IDs |
### Phase 5 — Admin & Produkt (M6 voll)
| # | Paket | Lieferumfang |
|---|--------|--------------|
| 5.1 | **Pläne-CRUD** | Neue Vereinspläne anlegen, nicht nur Seed |
| 5.2 | **Systemrolle Co-Trainer** | Seed + Matrix |
| 5.3 | **Trainer-Budgets** | v2 — `club_member_feature_budgets` |
### Phase 6 — Abrechnung (M8)
Stripe / Rechnung — bewusst nach funktionierendem Enforce.
---
## 4. Superadmin & Vereinsrechte (Entscheidung)
**Kurz: Superadmin braucht keine Vereinsrolle `club_admin` für die meiste Arbeit.**
| Ebene | Verhalten |
|-------|-----------|
| **Capabilities (neu)** | `admin` und `superadmin` = `platform_admin_bypass` für **alle Vereins-Capabilities** (`capabilities.py`) — unabhängig von `club_member_roles` |
| **Legacy-Helfer** | `can_plan_in_club`, `can_manage_club_org``True` für Plattform-Admin ohne Mitgliedschaft |
| **Mandant** | Aktiver Verein über `X-Active-Club-Id` / `active_club_id`**keine** Mitgliedschaft nötig für Plattform-Admin |
| **Kontingente** | Superadmin: Quota-Bypass (Capability-Grant); zählt nicht gegen Vereins-Kontingent |
| **Ausnahmen Legacy** | Einzelne Pfade prüfen noch **nur** `has_club_role(…, 'club_admin')` ohne Plattform-Bypass — z.B. Löschen von `visibility=club`-Übungen. → Phase 3 bereinigen |
**Empfehlung:** Superadmin **nicht** zwingend als `club_admin` in jeden Verein eintragen. Optional Mitgliedschaft nur für realistische Audit-Tests oder Vereinsorga-Simulation. Produktiv: Mandant per Club-Switcher wählen.
`admin` (Portal-Admin): gleicher Capability-Bypass für Vereins-Funktionen; Portal-Capabilities nur mit explizitem Grant in der Matrix.
---
## 5. Vereinsrollen-Matrix — Semantik (Admin-UI)
| Zustand | Bedeutung | UI |
|---------|-----------|-----|
| **Keine Grants** in DB | Alle aktiven Mitglieder (wenn `min_account_state` reicht) | Zellen zeigen „alle“ |
| **Mindestens ein Grant** | Nur angehakte Rollen | Checkboxen |
| **„Alle Mitglieder“** | Löscht alle Grants der Zeile | Zurück zum offenen Zustand |
Das ersetzt das frühere Formular „Vereinsrollen-Grant hinzufügen“, das nur bereits eingeschränkte Rechte sichtbar machte.
---
## 6. Offene Lücken (Checkliste)
- [ ] `CAPABILITY_ENFORCE=1` in Produktion
- [ ] `CLUB_FEATURE_ENFORCE=1` in Produktion
- [ ] Consume für alle Features mit Verbrauch (nicht nur `ai_calls`)
- [ ] `probe_capability` auf >90% der Schreib-Endpoints
- [ ] Frontend ohne Legacy-Rollen-Guards
- [ ] Multipart-Uploads an `featureUsageSync` anbinden
- [ ] Legacy-Löschpfade mit Plattform-Bypass harmonisieren
- [ ] `HANDOVER.md` / `PROJECT_STATUS` Versionsstand aktualisieren
---
## 7. Referenzen
| Datei | Zweck |
|-------|--------|
| `backend/capability_enforcement_audit.py` | Matrix-Badges „angebunden / Legacy“ |
| `backend/club_features.py` | Consume-Standard |
| `frontend/src/utils/featureUsageSync.js` | Entitlements-Sync |
| `frontend/src/pages/AdminRightsPage.jsx` | Konfiguration |
**Changelog**
- 2026-06-07: Initial nach Session Rollen/Kontingente — Standard, Roadmap Phasen 16, Superadmin-Klärung, Matrix-Semantik.

View File

@ -45,6 +45,48 @@ function formatLimitHint(feature) {
return '' return ''
} }
function EnforcementBadge({ enforcement, featureConsume }) {
if (!enforcement) return null
const tone =
enforcement.implemented
? 'var(--accent-dark)'
: enforcement.level === 'legacy'
? 'var(--danger)'
: 'var(--text3)'
return (
<div style={{ marginTop: '4px', fontSize: '0.68rem', lineHeight: 1.35 }}>
<span style={{ color: tone }} title={enforcement.detail}>
{enforcement.implemented ? '● ' : '○ '}
{enforcement.label}
</span>
{featureConsume ? (
<div style={{ color: featureConsume.implemented ? 'var(--accent-dark)' : 'var(--text3)' }}>
Kontingent: {featureConsume.label}
</div>
) : null}
</div>
)
}
function CapabilityNameCell({ cap }) {
return (
<td style={{ padding: '6px', verticalAlign: 'top' }}>
<div style={{ fontWeight: 500, color: 'var(--text1)' }}>{cap.name || cap.id}</div>
<code style={{ fontSize: '0.68rem', color: 'var(--text3)' }}>{cap.id}</code>
{cap.linked_feature_id ? (
<div style={{ color: 'var(--text3)', fontSize: '0.68rem' }}>
Kontingent-ID: {cap.linked_feature_id}
</div>
) : null}
<EnforcementBadge enforcement={cap.enforcement} featureConsume={cap.feature_consume} />
</td>
)
}
function clubGrantsForCapability(capMatrix, capabilityId) {
return (capMatrix?.club_role_grants || []).filter((g) => g.capability_id === capabilityId)
}
/** /**
* Superadmin: Rollen Fähigkeiten (Capabilities) und Vereins-Kontingente konfigurieren. * Superadmin: Rollen Fähigkeiten (Capabilities) und Vereins-Kontingente konfigurieren.
*/ */
@ -63,7 +105,6 @@ export default function AdminRightsPage() {
const [capMatrix, setCapMatrix] = useState(null) const [capMatrix, setCapMatrix] = useState(null)
const [bypassData, setBypassData] = 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 [newBypassPortal, setNewBypassPortal] = useState({ portal_role: 'helpdesk', feature_id: '' })
const [newBypassProfile, setNewBypassProfile] = useState({ const [newBypassProfile, setNewBypassProfile] = useState({
profile_id: '', profile_id: '',
@ -222,6 +263,19 @@ export default function AdminRightsPage() {
} }
} }
const openClubCapabilityForAllMembers = async (capabilityId) => {
setBusy(true)
setError('')
try {
await api.clearAdminRightsClubCapabilityGrants(capabilityId)
await loadCapMatrix()
} catch (e) {
setError(e.message || String(e))
} finally {
setBusy(false)
}
}
const submitBypassPortal = async (e) => { const submitBypassPortal = async (e) => {
e.preventDefault() e.preventDefault()
setBusy(true) setBusy(true)
@ -268,13 +322,15 @@ export default function AdminRightsPage() {
<div className="page-padding app-page"> <div className="page-padding app-page">
<AdminPageNav /> <AdminPageNav />
<h1 style={{ marginTop: '1rem', fontSize: '1.35rem' }}>Rollen &amp; Rechte</h1> <h1 style={{ marginTop: '1rem', fontSize: '1.35rem' }}>Rollen &amp; Rechte</h1>
<p style={{ color: 'var(--text2)', maxWidth: '48rem', lineHeight: 1.55 }}> <p style={{ color: 'var(--text2)', maxWidth: '52rem', lineHeight: 1.55 }}>
<strong>Rollen Fähigkeiten (Capabilities):</strong> Wer darf welche Funktion nutzen? <strong>Rechte:</strong> Wer darf welche Funktion nutzen? Haken = Grant für diese Rolle.
<br /> <br />
<strong>Kontingente:</strong> Wie viel darf ein Verein verbrauchen (an Fähigkeiten gekoppelt <strong>Kontingente:</strong> Wie viel darf ein Verein verbrauchen (an Rechten gekoppelt).
über <code>linked_feature_id</code>)?
<br /> <br />
Vereinspläne bündeln nur Kontingent-Werte sie ersetzen keine Berechtigungen. <span style={{ fontSize: '0.85rem' }}>
= an API angebunden · = nur Legacy oder noch nicht durchgesetzt. Roadmap:{' '}
<code>docs/working/RBAC_ENFORCEMENT_ROADMAP.md</code>
</span>
</p> </p>
<div <div
@ -310,13 +366,13 @@ export default function AdminRightsPage() {
{!loading && tab === 'portal' && capMatrix ? ( {!loading && tab === 'portal' && capMatrix ? (
<div className="card" style={{ marginTop: '1rem', overflowX: 'auto' }}> <div className="card" style={{ marginTop: '1rem', overflowX: 'auto' }}>
<p style={{ fontSize: '0.875rem', color: 'var(--text2)', margin: '0 0 12px' }}> <p style={{ fontSize: '0.875rem', color: 'var(--text2)', margin: '0 0 12px' }}>
Plattform-Funktionen (<code>domain=platform</code>). Jede Funktion im Produkt soll sich Plattform-Funktionen Anzeige primär nach Klartext. Technische ID und
hier anmelden und bei Anzeige und Ausführung prüfen. Umsetzungsstand darunter.
</p> </p>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.8rem' }}> <table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.8rem' }}>
<thead> <thead>
<tr> <tr>
<th style={{ textAlign: 'left', padding: '6px', minWidth: '200px' }}>Fähigkeit</th> <th style={{ textAlign: 'left', padding: '6px', minWidth: '220px' }}>Recht</th>
{(capMatrix.portal_roles || []).map((r) => ( {(capMatrix.portal_roles || []).map((r) => (
<th key={r} style={{ textAlign: 'center', padding: '6px' }}> <th key={r} style={{ textAlign: 'center', padding: '6px' }}>
{PORTAL_ROLE_LABEL[r] || r} {PORTAL_ROLE_LABEL[r] || r}
@ -327,15 +383,7 @@ export default function AdminRightsPage() {
<tbody> <tbody>
{portalCapabilities.map((cap) => ( {portalCapabilities.map((cap) => (
<tr key={cap.id} style={{ borderTop: '1px solid var(--border)' }}> <tr key={cap.id} style={{ borderTop: '1px solid var(--border)' }}>
<td style={{ padding: '6px' }}> <CapabilityNameCell cap={cap} />
<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) => { {(capMatrix.portal_roles || []).map((role) => {
const on = portalGrantSet.has(`${role}::${cap.id}`) const on = portalGrantSet.has(`${role}::${cap.id}`)
return ( return (
@ -361,118 +409,75 @@ export default function AdminRightsPage() {
<div style={{ marginTop: '1rem', display: 'flex', flexDirection: 'column', gap: '1rem' }}> <div style={{ marginTop: '1rem', display: 'flex', flexDirection: 'column', gap: '1rem' }}>
<div className="card" style={{ overflowX: 'auto' }}> <div className="card" style={{ overflowX: 'auto' }}>
<p style={{ fontSize: '0.875rem', color: 'var(--text2)', margin: '0 0 12px' }}> <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 Vereinsrollen: Alle Rechte in der Matrix. Haken = diese Rolle hat das Recht.
Vereinsmitglieder; gesetzte Grants schränken auf die angehakten Rollen ein. Zeile mit <em>alle</em> = noch nicht rollenbeschränkt (gilt für jedes aktive Mitglied).
Erster Klick auf <em>alle</em> schränkt auf die gewählte Rolle ein; Alle Mitglieder
hebt die Einschränkung wieder auf.
</p> </p>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.75rem' }}> <table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.75rem' }}>
<thead> <thead>
<tr> <tr>
<th style={{ textAlign: 'left', padding: '6px', minWidth: '180px' }}>Fähigkeit</th> <th style={{ textAlign: 'left', padding: '6px', minWidth: '220px' }}>Recht</th>
{(capMatrix.club_roles || []).map((r) => ( {(capMatrix.club_roles || []).map((r) => (
<th key={r} style={{ textAlign: 'center', padding: '6px' }}> <th key={r} style={{ textAlign: 'center', padding: '6px' }}>
{CLUB_ROLE_LABEL[r] || r} {CLUB_ROLE_LABEL[r] || r}
</th> </th>
))} ))}
<th style={{ textAlign: 'left', padding: '6px', minWidth: '100px' }}>Freigabe</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{clubScopedCapabilities {clubScopedCapabilities.map((cap) => {
.filter((cap) => const restricted = clubGrantsForCapability(capMatrix, cap.id).length > 0
(capMatrix.club_role_grants || []).some((g) => g.capability_id === cap.id), return (
)
.map((cap) => (
<tr key={cap.id} style={{ borderTop: '1px solid var(--border)' }}> <tr key={cap.id} style={{ borderTop: '1px solid var(--border)' }}>
<td style={{ padding: '6px' }}> <CapabilityNameCell cap={cap} />
<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) => { {(capMatrix.club_roles || []).map((role) => {
const on = clubGrantSet.has(`${role}::${cap.id}`) const on = clubGrantSet.has(`${role}::${cap.id}`)
return ( return (
<td key={role} style={{ textAlign: 'center', padding: '6px' }}> <td key={role} style={{ textAlign: 'center', padding: '6px' }}>
{restricted ? (
<input <input
type="checkbox" type="checkbox"
checked={on} checked={on}
disabled={busy} disabled={busy}
aria-label={`${cap.name} für ${CLUB_ROLE_LABEL[role] || role}`}
onChange={() => toggleClubGrant(role, cap.id, on)} onChange={() => toggleClubGrant(role, cap.id, on)}
/> />
) : (
<button
type="button"
className="btn btn-secondary"
disabled={busy}
title={`Standard: alle Mitglieder. Klick = nur ${CLUB_ROLE_LABEL[role] || role}`}
style={{ fontSize: '0.7rem', padding: '2px 6px' }}
onClick={() => toggleClubGrant(role, cap.id, false)}
>
alle
</button>
)}
</td> </td>
) )
})} })}
<td style={{ padding: '6px', whiteSpace: 'nowrap' }}>
{restricted ? (
<button
type="button"
className="btn btn-secondary"
disabled={busy}
style={{ fontSize: '0.68rem', padding: '2px 6px' }}
onClick={() => openClubCapabilityForAllMembers(cap.id)}
>
Alle Mitglieder
</button>
) : null}
</td>
</tr> </tr>
))} )
})}
</tbody> </tbody>
</table> </table>
</div> </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> </div>
) : null} ) : null}

View File

@ -290,6 +290,13 @@ export async function deleteAdminRightsClubRoleGrant(roleCode, capabilityId) {
return request(`/api/admin/rights/capability-grants/club-roles?${q}`, { method: 'DELETE' }) return request(`/api/admin/rights/capability-grants/club-roles?${q}`, { method: 'DELETE' })
} }
export async function clearAdminRightsClubCapabilityGrants(capabilityId) {
const q = new URLSearchParams({ capability_id: capabilityId })
return request(`/api/admin/rights/capability-grants/club-roles/by-capability?${q}`, {
method: 'DELETE',
})
}
export async function listAdminRightsQuotaBypass() { export async function listAdminRightsQuotaBypass() {
return request('/api/admin/rights/quota-bypass') return request('/api/admin/rights/quota-bypass')
} }
@ -1010,6 +1017,7 @@ export const api = {
deleteAdminRightsPortalGrant, deleteAdminRightsPortalGrant,
addAdminRightsClubRoleGrant, addAdminRightsClubRoleGrant,
deleteAdminRightsClubRoleGrant, deleteAdminRightsClubRoleGrant,
clearAdminRightsClubCapabilityGrants,
listAdminRightsQuotaBypass, listAdminRightsQuotaBypass,
addAdminRightsQuotaBypassPortal, addAdminRightsQuotaBypassPortal,
deleteAdminRightsQuotaBypassPortal, deleteAdminRightsQuotaBypassPortal,