Enhance Tenant Context and Access Control Features
Some checks failed
Deploy Development / deploy (push) Successful in 43s
Test Suite / pytest-backend (push) Failing after 0s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 14s
Test Suite / k6 /health Baseline (push) Failing after 4m0s
Test Suite / playwright-tests (push) Failing after 3m41s

- Introduced `email_verified` and `account_state` attributes in the `TenantContext` to improve user state management.
- Updated the `resolve_tenant_context` function to dynamically fetch `email_verified` status from the database and determine `account_state` based on user roles and memberships.
- Implemented `assert_min_account_state` checks across various endpoints to enforce access control based on user account status.
- Incremented version to 1.1.0 in version.py to reflect these enhancements in tenant context management and access control.
This commit is contained in:
Lars 2026-06-06 21:10:52 +02:00
parent 7cfbca40bb
commit 30dc30c7aa
21 changed files with 1124 additions and 22 deletions

View File

@ -0,0 +1,77 @@
"""
Account-Lifecycle (CAPABILITY_CATALOG.v1.md §3, M3 C0).
Zustände: unverified verified_pending_club active_member; platform_admin separat.
"""
from __future__ import annotations
import os
from typing import TYPE_CHECKING, Optional
from fastapi import HTTPException
from club_tenancy import is_platform_admin
if TYPE_CHECKING:
from tenant_context import TenantContext
_ACCOUNT_STATE_RANK = {
"unverified": 1,
"verified_pending_club": 2,
"active_member": 3,
"platform_admin": 4,
}
def resolve_account_state(
*,
email_verified: bool,
global_role: str,
has_active_membership: bool,
) -> str:
"""Ermittelt account_state für ein Profil."""
if is_platform_admin(global_role):
return "platform_admin"
if not email_verified:
return "unverified"
if not has_active_membership:
return "verified_pending_club"
return "active_member"
def account_state_satisfies(current: str, required: str) -> bool:
"""True wenn current mindestens required ist."""
cur = _ACCOUNT_STATE_RANK.get(current, 0)
req = _ACCOUNT_STATE_RANK.get(required, 99)
if current == "platform_admin":
return True
return cur >= req
def account_gate_enforcement_enabled() -> bool:
"""Account-Gates aktiv (Default an — nur wenige Endpoints in M3)."""
return os.getenv("ACCOUNT_GATE_ENFORCE", "1").strip() == "1"
def assert_min_account_state(
tenant: "TenantContext",
min_state: str,
*,
endpoint: Optional[str] = None,
) -> None:
"""
Prüft Mindest-Account-Status. Wirft 403 wenn ACCOUNT_GATE_ENFORCE=1 (Default).
"""
current = getattr(tenant, "account_state", "active_member")
ok = account_state_satisfies(current, min_state)
if ok:
return
if not account_gate_enforcement_enabled():
return
detail = (
f"Account-Status „{current}“ reicht nicht für diese Aktion "
f"(erforderlich: {min_state})."
)
if endpoint:
detail = f"{detail} ({endpoint})"
raise HTTPException(status_code=403, detail=detail)

232
backend/capabilities.py Normal file
View File

@ -0,0 +1,232 @@
"""
Capability-Auflösung (CAPABILITY_CATALOG.v1.md, M3 C1).
Phase 2: probe_capability JSON-Log, kein Block (CAPABILITY_ENFORCE=0).
Phase 3+: CAPABILITY_ENFORCE=1 HTTP 403 bei fehlender Capability.
"""
from __future__ import annotations
import os
from typing import Any, Dict, List, Optional, TYPE_CHECKING
from fastapi import HTTPException
from account_lifecycle import account_state_satisfies
from club_tenancy import is_platform_admin
from db import get_db, get_cursor
if TYPE_CHECKING:
from tenant_context import TenantContext
def capability_enforcement_enabled() -> bool:
return os.getenv("CAPABILITY_ENFORCE", "0").strip() == "1"
def club_roles_in_club(tenant: "TenantContext", club_id: Optional[int]) -> List[str]:
if club_id is None:
return []
for m in tenant.memberships or []:
if int(m.get("id") or 0) == int(club_id):
roles = m.get("roles") or []
if hasattr(roles, "tolist"):
roles = roles.tolist()
return list(roles)
return []
def check_capability(
cur,
tenant: "TenantContext",
capability_id: str,
*,
club_id: Optional[int] = None,
) -> Dict[str, Any]:
"""
Prüft eine Capability für Tenant + optionalen Vereinskontext.
Returns: allowed, reason, account_state, club_roles, linked_feature_id
"""
account_state = getattr(tenant, "account_state", "active_member")
eff_club = club_id if club_id is not None else tenant.effective_club_id
club_roles = club_roles_in_club(tenant, eff_club) if eff_club is not None else []
cur.execute(
"""
SELECT id, min_account_state, linked_feature_id, active, domain
FROM capabilities
WHERE id = %s
""",
(capability_id,),
)
cap = cur.fetchone()
if not cap or not cap.get("active"):
return {
"allowed": False,
"reason": "capability_not_found",
"account_state": account_state,
"club_roles": club_roles,
"linked_feature_id": None,
}
min_state = cap.get("min_account_state") or "active_member"
if not account_state_satisfies(account_state, min_state):
return {
"allowed": False,
"reason": "account_state_insufficient",
"account_state": account_state,
"club_roles": club_roles,
"linked_feature_id": cap.get("linked_feature_id"),
}
domain = (cap.get("domain") or "").strip().lower()
# Plattform-Capabilities
if domain == "platform" or capability_id.startswith("platform."):
role_lc = (tenant.global_role or "").lower()
if not is_platform_admin(role_lc):
return {
"allowed": False,
"reason": "portal_role_required",
"account_state": account_state,
"club_roles": club_roles,
"linked_feature_id": cap.get("linked_feature_id"),
}
cur.execute(
"""
SELECT 1 FROM portal_role_capability_grants
WHERE portal_role = %s AND capability_id = %s
LIMIT 1
""",
(role_lc, capability_id),
)
if not cur.fetchone():
return {
"allowed": False,
"reason": "portal_capability_denied",
"account_state": account_state,
"club_roles": club_roles,
"linked_feature_id": cap.get("linked_feature_id"),
}
return {
"allowed": True,
"reason": "portal_granted",
"account_state": account_state,
"club_roles": club_roles,
"linked_feature_id": cap.get("linked_feature_id"),
}
# Plattform-Admin-Bypass für Mandanten-Funktionen (Audit-Pflicht, s. Katalog §9)
if is_platform_admin(tenant.global_role):
return {
"allowed": True,
"reason": "platform_admin_bypass",
"account_state": account_state,
"club_roles": club_roles,
"linked_feature_id": cap.get("linked_feature_id"),
}
# Vereins-Capabilities: aktive Mitgliedschaft im Zielverein
if min_state == "active_member":
if eff_club is None:
return {
"allowed": False,
"reason": "no_club_context",
"account_state": account_state,
"club_roles": club_roles,
"linked_feature_id": cap.get("linked_feature_id"),
}
if eff_club not in tenant.club_ids:
return {
"allowed": False,
"reason": "not_club_member",
"account_state": account_state,
"club_roles": club_roles,
"linked_feature_id": cap.get("linked_feature_id"),
}
cur.execute(
"""
SELECT role_code FROM club_role_capability_grants
WHERE capability_id = %s
""",
(capability_id,),
)
required_roles = [r["role_code"] for r in cur.fetchall()]
if required_roles:
if not any(r in required_roles for r in club_roles):
return {
"allowed": False,
"reason": "club_role_denied",
"account_state": account_state,
"club_roles": club_roles,
"linked_feature_id": cap.get("linked_feature_id"),
}
elif min_state == "active_member" and eff_club is not None:
# Offene Capability für alle aktiven Mitglieder — Mitgliedschaft reicht
pass
return {
"allowed": True,
"reason": "granted",
"account_state": account_state,
"club_roles": club_roles,
"linked_feature_id": cap.get("linked_feature_id"),
}
def resolve_capabilities_map(
cur,
tenant: "TenantContext",
*,
club_id: Optional[int] = None,
) -> Dict[str, bool]:
"""Alle aktiven Capabilities → bool (für späteres /me/entitlements)."""
cur.execute("SELECT id FROM capabilities WHERE active = true ORDER BY id")
ids = [r["id"] for r in cur.fetchall()]
out: Dict[str, bool] = {}
for cid in ids:
res = check_capability(cur, tenant, cid, club_id=club_id)
out[cid] = bool(res.get("allowed"))
return out
def probe_capability(
tenant: "TenantContext",
capability_id: str,
*,
action: str,
club_id: Optional[int] = None,
endpoint: Optional[str] = None,
conn=None,
) -> Dict[str, Any]:
"""Phase 2: Capability prüfen + JSON-Log; blockiert nur bei CAPABILITY_ENFORCE=1."""
from capability_logger import log_capability_check
def _run(c):
cur = get_cursor(c)
result = check_capability(cur, tenant, capability_id, club_id=club_id)
log_capability_check(
club_id=club_id if club_id is not None else tenant.effective_club_id,
profile_id=tenant.profile_id,
capability_id=capability_id,
action=action,
result=result,
endpoint=endpoint,
phase="enforce" if capability_enforcement_enabled() else "probe",
)
if capability_enforcement_enabled() and not result.get("allowed"):
raise HTTPException(
status_code=403,
detail=(
f"Keine Berechtigung für {capability_id} "
f"({result.get('reason', 'denied')})."
),
)
return result
if conn is not None:
return _run(conn)
with get_db() as c:
return _run(c)

View File

@ -0,0 +1,64 @@
"""
JSON-Log für Capability-Checks (M3 Phase 2 analog club_feature_logger).
"""
from __future__ import annotations
import json
import logging
import os
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, Optional
def _log_dir() -> Path:
custom = (os.getenv("CAPABILITY_LOG_DIR") or "").strip()
if custom:
return Path(custom)
return Path("/app/logs")
capability_logger = logging.getLogger("shinkan.capability_usage")
capability_logger.setLevel(logging.INFO)
capability_logger.propagate = False
if not capability_logger.handlers:
log_dir = _log_dir()
try:
log_dir.mkdir(parents=True, exist_ok=True)
log_file = log_dir / "capability-usage.log"
file_handler = logging.FileHandler(log_file, encoding="utf-8")
file_handler.setLevel(logging.INFO)
file_handler.setFormatter(logging.Formatter("%(message)s"))
capability_logger.addHandler(file_handler)
except OSError:
stream_handler = logging.StreamHandler()
stream_handler.setFormatter(logging.Formatter("[capability-usage] %(message)s"))
capability_logger.addHandler(stream_handler)
def log_capability_check(
*,
club_id: Optional[int],
profile_id: Optional[int],
capability_id: str,
action: str,
result: Dict[str, Any],
endpoint: Optional[str] = None,
phase: str = "probe",
) -> None:
entry = {
"timestamp": datetime.now(timezone.utc).isoformat(),
"club_id": club_id,
"profile_id": profile_id,
"capability": capability_id,
"action": action,
"endpoint": endpoint,
"phase": phase,
"allowed": result.get("allowed", True),
"reason": result.get("reason", "unknown"),
"account_state": result.get("account_state"),
"club_roles": result.get("club_roles"),
"enforcement": os.getenv("CAPABILITY_ENFORCE", "0") == "1",
}
capability_logger.info(json.dumps(entry, ensure_ascii=False))

View File

@ -446,8 +446,9 @@ def increment_club_feature_usage(
_run(c)
def list_club_entitlements(cur, club_id: int) -> Dict[str, Any]:
"""Alle aktiven Shinkan-Features mit effektivem Limit und Verbrauch (für API/UI)."""
def list_club_entitlements(cur, club_id: int, *, conn=None) -> Dict[str, Any]:
"""Alle aktiven Shinkan-Features mit effektivem Limit und Verbrauch (Liste, intern)."""
db_conn = conn if conn is not None else cur.connection
plan_id = get_effective_club_plan(cur, club_id)
cur.execute(
"""
@ -461,7 +462,7 @@ def list_club_entitlements(cur, club_id: int) -> Dict[str, Any]:
features_out = []
for row in rows:
fid = row["id"]
access = _check_club_impl(club_id, fid, cur.connection)
access = _check_club_impl(club_id, fid, db_conn)
features_out.append(
{
"id": fid,
@ -474,6 +475,32 @@ def list_club_entitlements(cur, club_id: int) -> Dict[str, Any]:
"used": access.get("used"),
"remaining": access.get("remaining"),
"reason": access.get("reason"),
"reset_at": access.get("reset_at"),
}
)
return {"club_id": club_id, "plan_id": plan_id, "features": features_out}
def club_features_map(cur, club_id: int, *, conn=None) -> Dict[str, Any]:
"""Feature-Kontingente als Dict feature_id → Zustand (für /me/entitlements)."""
raw = list_club_entitlements(cur, club_id, conn=conn)
features_dict: Dict[str, Any] = {}
for row in raw.get("features") or []:
fid = row["id"]
features_dict[fid] = {
"name": row.get("name"),
"category": row.get("category"),
"limit_type": row.get("limit_type"),
"reset_period": row.get("reset_period"),
"allowed": row.get("allowed"),
"limit": row.get("limit"),
"used": row.get("used"),
"remaining": row.get("remaining"),
"reason": row.get("reason"),
"reset_at": row.get("reset_at"),
}
return {
"club_id": raw.get("club_id"),
"plan_id": raw.get("plan_id"),
"features": features_dict,
}

89
backend/entitlements.py Normal file
View File

@ -0,0 +1,89 @@
"""
Zusammenstellung effektiver Rechte für GET /api/me/entitlements (M4).
Spez: CAPABILITY_CATALOG.v1.md §7.1, CLUB_MEMBERSHIP_AND_FEATURES.v1.md §8.1
"""
from __future__ import annotations
from datetime import datetime
from typing import Any, Dict, Optional, TYPE_CHECKING
from fastapi import HTTPException
from capabilities import club_roles_in_club, resolve_capabilities_map
from club_features import club_features_map
from club_tenancy import is_platform_admin
from tenant_context import _club_exists
if TYPE_CHECKING:
from tenant_context import TenantContext
def _serialize_reset_at(value: Any) -> Optional[str]:
if value is None:
return None
if isinstance(value, datetime):
if value.tzinfo is None:
return value.replace(tzinfo=None).isoformat() + "Z"
return value.isoformat()
return str(value)
def _resolve_target_club_id(
cur,
tenant: "TenantContext",
club_id: Optional[int],
) -> Optional[int]:
"""Effektiver Verein für Entitlements (Query > Tenant)."""
target = int(club_id) if club_id is not None else tenant.effective_club_id
if target is None:
return None
if is_platform_admin(tenant.global_role):
if not _club_exists(cur, target):
raise HTTPException(status_code=400, detail="Verein nicht gefunden")
return target
if target not in tenant.club_ids:
raise HTTPException(status_code=403, detail="Keine Mitgliedschaft in diesem Verein")
return target
def build_me_entitlements(
cur,
tenant: "TenantContext",
*,
club_id: Optional[int] = None,
) -> Dict[str, Any]:
"""
Kombiniert Account-Status, Capabilities und Feature-Kontingente.
"""
target_club = _resolve_target_club_id(cur, tenant, club_id)
club_roles = club_roles_in_club(tenant, target_club) if target_club is not None else []
capabilities = resolve_capabilities_map(cur, tenant, club_id=target_club)
features: Dict[str, Any] = {}
plan_id = None
if target_club is not None:
raw = club_features_map(cur, target_club)
plan_id = raw.get("plan_id")
for fid, row in (raw.get("features") or {}).items():
features[fid] = {
"allowed": row.get("allowed"),
"used": row.get("used"),
"limit": row.get("limit"),
"remaining": row.get("remaining"),
"reset_at": _serialize_reset_at(row.get("reset_at")),
"reason": row.get("reason"),
}
return {
"account_state": tenant.account_state,
"portal_role": tenant.global_role,
"club_id": target_club,
"plan_id": plan_id,
"club_roles": club_roles,
"capabilities": capabilities,
"features": features,
}

View File

@ -193,7 +193,7 @@ def read_root():
return out
# Register routers
from routers import auth, profiles, exercises, exercise_progression_graphs, clubs, club_memberships, club_join_requests, admin_users, admin_user_content, 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, admin_users, admin_user_content, me_entitlements, platform_media_storage, media_assets, skills, skill_profiles, training_planning, planning_exercise_suggest, dashboard, training_modules, training_framework_programs, catalogs, maturity_models, matrix_stack_bundle, matrix_editor, import_wiki, import_wiki_admin, legal_documents, content_reports, ai_prompts_admin, ai_skill_retrieval_admin, exercise_enrichment_admin
app.include_router(auth.router)
app.include_router(profiles.router)
@ -204,6 +204,7 @@ app.include_router(club_memberships.router)
app.include_router(club_join_requests.router)
app.include_router(admin_users.router)
app.include_router(admin_user_content.router)
app.include_router(me_entitlements.router)
app.include_router(platform_media_storage.router)
app.include_router(media_assets.router)
app.include_router(media_assets.admin_rights_router)

View File

@ -0,0 +1,211 @@
-- Migration 079: Capability-Registry + Rollen-Grants (M3 / CAPABILITY_CATALOG.v1.md C1)
-- Account-Gates und Enforcement in Python (account_lifecycle.py, capabilities.py).
CREATE TABLE IF NOT EXISTS capabilities (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT,
domain TEXT NOT NULL,
min_account_state TEXT NOT NULL DEFAULT 'active_member'
CHECK (min_account_state IN (
'unverified', 'verified_pending_club', 'active_member', 'platform_admin'
)),
linked_feature_id TEXT REFERENCES features(id) ON DELETE SET NULL,
active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_capabilities_domain ON capabilities(domain) WHERE active = true;
CREATE TABLE IF NOT EXISTS club_role_capability_grants (
role_code TEXT NOT NULL,
capability_id TEXT NOT NULL REFERENCES capabilities(id) ON DELETE CASCADE,
PRIMARY KEY (role_code, capability_id)
);
CREATE INDEX IF NOT EXISTS idx_club_role_cap_grants_cap ON club_role_capability_grants(capability_id);
CREATE TABLE IF NOT EXISTS portal_role_capability_grants (
portal_role TEXT NOT NULL,
capability_id TEXT NOT NULL REFERENCES capabilities(id) ON DELETE CASCADE,
PRIMARY KEY (portal_role, capability_id)
);
-- ── Seed: Capabilities (v1 Katalog §5) ───────────────────────────────────────
INSERT INTO capabilities (id, name, domain, min_account_state, linked_feature_id) VALUES
('account.settings.read', 'Einstellungen lesen', 'account', 'unverified', NULL),
('account.settings.update', 'Einstellungen ändern', 'account', 'unverified', NULL),
('account.password.change', 'Passwort ändern', 'account', 'unverified', NULL),
('account.resend_verification', 'Verifizierung erneut senden', 'account', 'unverified', NULL),
('club.directory.read', 'Vereinsverzeichnis', 'club', 'verified_pending_club', NULL),
('club.join_request.create', 'Vereinsbeitritt beantragen', 'club', 'verified_pending_club', NULL),
('club.join_request.withdraw', 'Beitrittsantrag zurückziehen', 'club', 'verified_pending_club', NULL),
('club.join_request.read_own', 'Eigene Beitrittsanträge', 'club', 'verified_pending_club', NULL),
('org.club.read', 'Vereine lesen', 'org', 'active_member', NULL),
('org.club.create', 'Verein anlegen', 'org', 'platform_admin', NULL),
('org.club.update', 'Verein bearbeiten', 'org', 'active_member', NULL),
('org.club.delete', 'Verein löschen', 'org', 'platform_admin', NULL),
('org.structure.manage', 'Vereinsstruktur verwalten', 'org', 'active_member', 'training_groups'),
('org.members.read', 'Mitgliederliste', 'org', 'active_member', NULL),
('org.members.manage', 'Mitglieder verwalten', 'org', 'active_member', 'active_members'),
('org.members.directory', 'Mitglieder-Verzeichnis', 'org', 'active_member', NULL),
('org.join_request.review', 'Beitrittsanträge prüfen', 'org', 'active_member', NULL),
('org.inbox.read', 'Posteingang', 'org', 'active_member', NULL),
('exercises.read', 'Übungen lesen', 'exercises', 'active_member', NULL),
('exercises.create', 'Übung anlegen', 'exercises', 'active_member', 'exercises'),
('exercises.update', 'Übung bearbeiten', 'exercises', 'active_member', NULL),
('exercises.delete', 'Übung löschen', 'exercises', 'active_member', NULL),
('exercises.bulk_metadata', 'Übungen Stapel-Metadaten', 'exercises', 'active_member', NULL),
('exercises.ai.suggest', 'KI-Vorschlag Übung', 'exercises', 'active_member', 'ai_calls'),
('exercises.ai.regenerate', 'KI neu generieren', 'exercises', 'active_member', 'ai_calls'),
('exercises.media.read', 'Übungsmedien lesen', 'exercises', 'active_member', NULL),
('exercises.media.upload', 'Übungsmedien hochladen', 'exercises', 'active_member', 'exercise_media'),
('exercises.variants.manage', 'Übungsvarianten', 'exercises', 'active_member', NULL),
('media.library.read', 'Medienbibliothek lesen', 'media', 'active_member', NULL),
('media.library.upload', 'Medienbibliothek Upload', 'media', 'active_member', 'exercise_media'),
('media.library.update', 'Medienbibliothek bearbeiten', 'media', 'active_member', NULL),
('media.library.lifecycle', 'Medien-Lifecycle', 'media', 'active_member', NULL),
('media.rights.declare', 'Medienrechte erklären', 'media', 'active_member', NULL),
('media.admin.rights_review', 'Medienrechte Review (Plattform)', 'media', 'platform_admin', NULL),
('modules.read', 'Trainingsmodule lesen', 'modules', 'active_member', NULL),
('modules.create', 'Trainingsmodul anlegen', 'modules', 'active_member', 'training_programs'),
('modules.update', 'Trainingsmodul bearbeiten', 'modules', 'active_member', NULL),
('modules.delete', 'Trainingsmodul löschen', 'modules', 'active_member', NULL),
('framework.read', 'Rahmenprogramme lesen', 'framework', 'active_member', NULL),
('framework.create', 'Rahmenprogramm anlegen', 'framework', 'active_member', 'training_programs'),
('framework.update', 'Rahmenprogramm bearbeiten', 'framework', 'active_member', NULL),
('framework.delete', 'Rahmenprogramm löschen', 'framework', 'active_member', NULL),
('plan_templates.read', 'Planungsvorlagen lesen', 'planning', 'active_member', NULL),
('plan_templates.manage', 'Planungsvorlagen verwalten', 'planning', 'active_member', NULL),
('progression.read', 'Progressionspfade lesen', 'progression', 'active_member', NULL),
('progression.manage', 'Progressionspfade verwalten', 'progression', 'active_member', NULL),
('planning.calendar.read', 'Planungskalender lesen', 'planning', 'active_member', NULL),
('planning.units.create', 'Trainingseinheit anlegen', 'planning', 'active_member', 'training_units'),
('planning.units.update', 'Trainingseinheit bearbeiten', 'planning', 'active_member', NULL),
('planning.units.delete', 'Trainingseinheit löschen', 'planning', 'active_member', NULL),
('planning.units.run', 'Training durchführen', 'planning', 'active_member', NULL),
('planning.coach.execute', 'Coach ausführen', 'planning', 'active_member', NULL),
('planning.ai.suggest', 'Planungs-KI Suggest', 'planning', 'active_member', 'ai_calls'),
('planning.ai.progression_path', 'Planungs-KI Progressionspfad', 'planning', 'active_member', 'ai_calls'),
('skills.catalog.read', 'Fähigkeitenkatalog', 'skills', 'active_member', NULL),
('skills.discovery.read', 'Fähigkeiten-Discovery', 'skills', 'active_member', NULL),
('skill_profiles.read', 'Skill-Profile lesen', 'skills', 'active_member', NULL),
('governance.content_report.create', 'Inhalt melden', 'governance', 'active_member', NULL),
('governance.content_report.review', 'Meldungen prüfen', 'governance', 'active_member', NULL),
('platform.admin.access', 'Plattform-Admin-Bereich', 'platform', 'platform_admin', NULL),
('platform.users.manage', 'Nutzer verwalten', 'platform', 'platform_admin', NULL),
('platform.catalogs.manage', 'Kataloge verwalten', 'platform', 'platform_admin', NULL),
('platform.maturity_models.manage', 'Reifegradmodelle', 'platform', 'platform_admin', NULL),
('platform.wiki_import.execute', 'Wiki-Import', 'platform', 'platform_admin', 'wiki_import'),
('platform.ai_prompts.manage', 'KI-Prompts verwalten', 'platform', 'platform_admin', NULL),
('platform.exercise_enrichment.execute', 'Übungs-Anreicherung KI', 'platform', 'platform_admin', 'ai_calls'),
('platform.user_content.moderate', 'Nutzer-Inhalte moderieren', 'platform', 'platform_admin', NULL),
('platform.legal_documents.manage', 'Rechtstexte verwalten', 'platform', 'platform_admin', NULL),
('platform.media_storage.manage', 'Medienspeicher verwalten', 'platform', 'platform_admin', NULL),
('platform.club_creation.approve', 'Vereinsgründung freigeben', 'platform', 'platform_admin', NULL)
ON CONFLICT (id) DO NOTHING;
-- ── Vereinsrollen-Grants (§6 — nur eingeschränkte Capabilities) ─────────────
-- Konvention: keine Grant-Zeile = alle aktiven Mitglieder (min_account_state reicht).
INSERT INTO club_role_capability_grants (role_code, capability_id)
SELECT r.role_code, c.cap_id
FROM (VALUES
('club_admin', 'org.structure.manage'),
('division_lead', 'org.structure.manage'),
('club_admin', 'org.members.manage'),
('club_admin', 'org.join_request.review'),
('club_admin', 'org.inbox.read'),
('club_admin', 'exercises.create'),
('trainer', 'exercises.create'),
('content_editor', 'exercises.create'),
('division_lead', 'exercises.create'),
('club_admin', 'exercises.update'),
('trainer', 'exercises.update'),
('content_editor', 'exercises.update'),
('division_lead', 'exercises.update'),
('club_admin', 'exercises.delete'),
('club_admin', 'exercises.bulk_metadata'),
('content_editor', 'exercises.bulk_metadata'),
('club_admin', 'exercises.ai.suggest'),
('trainer', 'exercises.ai.suggest'),
('content_editor', 'exercises.ai.suggest'),
('division_lead', 'exercises.ai.suggest'),
('club_admin', 'exercises.ai.regenerate'),
('trainer', 'exercises.ai.regenerate'),
('content_editor', 'exercises.ai.regenerate'),
('division_lead', 'exercises.ai.regenerate'),
('club_admin', 'exercises.media.upload'),
('trainer', 'exercises.media.upload'),
('content_editor', 'exercises.media.upload'),
('club_admin', 'exercises.variants.manage'),
('trainer', 'exercises.variants.manage'),
('content_editor', 'exercises.variants.manage'),
('club_admin', 'media.library.upload'),
('trainer', 'media.library.upload'),
('content_editor', 'media.library.upload'),
('club_admin', 'media.library.update'),
('trainer', 'media.library.update'),
('content_editor', 'media.library.update'),
('club_admin', 'media.library.lifecycle'),
('trainer', 'media.library.lifecycle'),
('club_admin', 'media.rights.declare'),
('trainer', 'media.rights.declare'),
('club_admin', 'modules.create'),
('trainer', 'modules.create'),
('content_editor', 'modules.create'),
('club_admin', 'modules.update'),
('trainer', 'modules.update'),
('content_editor', 'modules.update'),
('club_admin', 'modules.delete'),
('club_admin', 'framework.create'),
('trainer', 'framework.create'),
('club_admin', 'framework.update'),
('trainer', 'framework.update'),
('club_admin', 'framework.delete'),
('club_admin', 'plan_templates.manage'),
('trainer', 'plan_templates.manage'),
('club_admin', 'progression.manage'),
('trainer', 'progression.manage'),
('content_editor', 'progression.manage'),
('club_admin', 'planning.units.create'),
('trainer', 'planning.units.create'),
('division_lead', 'planning.units.create'),
('club_admin', 'planning.units.update'),
('trainer', 'planning.units.update'),
('division_lead', 'planning.units.update'),
('club_admin', 'planning.units.delete'),
('trainer', 'planning.units.delete'),
('club_admin', 'planning.units.run'),
('trainer', 'planning.units.run'),
('division_lead', 'planning.units.run'),
('club_admin', 'planning.coach.execute'),
('trainer', 'planning.coach.execute'),
('club_admin', 'planning.ai.suggest'),
('trainer', 'planning.ai.suggest'),
('division_lead', 'planning.ai.suggest'),
('club_admin', 'planning.ai.progression_path'),
('trainer', 'planning.ai.progression_path'),
('division_lead', 'planning.ai.progression_path'),
('club_admin', 'skills.discovery.read'),
('trainer', 'skills.discovery.read'),
('content_editor', 'skills.discovery.read'),
('club_admin', 'governance.content_report.review')
) AS r(role_code, cap_id)
JOIN capabilities c ON c.id = r.cap_id
ON CONFLICT DO NOTHING;
-- org.club.update: club_admin (zusätzlich zu platform_admin via Bypass)
INSERT INTO club_role_capability_grants (role_code, capability_id)
VALUES ('club_admin', 'org.club.update')
ON CONFLICT DO NOTHING;
-- ── Portal-Rollen ───────────────────────────────────────────────────────────
INSERT INTO portal_role_capability_grants (portal_role, capability_id)
SELECT 'admin', id FROM capabilities WHERE id = 'platform.admin.access'
ON CONFLICT DO NOTHING;
INSERT INTO portal_role_capability_grants (portal_role, capability_id)
SELECT 'superadmin', id FROM capabilities WHERE domain = 'platform'
ON CONFLICT DO NOTHING;

View File

@ -37,6 +37,8 @@ from media_rights import assert_rights_for_exercise_link, validate_rights_declar
from media_legal_hold import assert_not_under_legal_hold
from ai_prompt_context import ExerciseFormAiFocusRow, ExerciseFormAiPromptContext
from ai_prompt_job import run_exercise_form_ai_suggestion
from account_lifecycle import assert_min_account_state
from capabilities import probe_capability
from club_features import probe_club_feature_access, resolve_club_id_for_probe
from exercise_rich_text import (
@ -2318,6 +2320,14 @@ def exercise_ai_suggest_endpoint(
KI-Vorschlaege (Kurzfassung und/oder Skill-Zuordnung) ohne Speichern.
OPENROUTER_API_KEY erforderlich.
"""
assert_min_account_state(tenant, "active_member", endpoint="POST /exercises/ai/suggest")
probe_capability(
tenant,
"exercises.ai.suggest",
action="suggest",
club_id=resolve_club_id_for_probe(tenant),
endpoint="POST /exercises/ai/suggest",
)
probe_club_feature_access(
feature_id="ai_calls",
action="suggest",
@ -2344,6 +2354,14 @@ def exercise_ai_regenerate_endpoint(
tenant: TenantContext = Depends(get_tenant_context),
):
"""Neu-Anfrage KI fuer eine gespeicherte Uebung; schreibendes Ergebnis nur im Frontend (PUT)."""
assert_min_account_state(tenant, "active_member", endpoint="POST /exercises/{id}/ai/regenerate")
probe_capability(
tenant,
"exercises.ai.regenerate",
action="regenerate",
club_id=resolve_club_id_for_probe(tenant),
endpoint="POST /exercises/{id}/ai/regenerate",
)
probe_club_feature_access(
feature_id="ai_calls",
action="regenerate",
@ -2436,6 +2454,14 @@ def create_exercise(
club_id = tenant.effective_club_id
if club_id is not None:
assert_min_account_state(tenant, "active_member", endpoint="POST /exercises")
probe_capability(
tenant,
"exercises.create",
action="create",
club_id=int(club_id),
endpoint="POST /exercises",
)
probe_club_feature_access(
feature_id="exercises",
action="create",
@ -3245,6 +3271,15 @@ async def upload_exercise_media(
ex_club = cur.fetchone()
media_club_id = ex_club.get("club_id") if ex_club else None
if media_club_id is not None:
assert_min_account_state(tenant, "active_member", endpoint="POST /exercises/{id}/media")
probe_capability(
tenant,
"exercises.media.upload",
action="upload",
club_id=int(media_club_id),
endpoint="POST /exercises/{id}/media",
conn=conn,
)
probe_club_feature_access(
feature_id="exercise_media",
action="upload",

View File

@ -0,0 +1,27 @@
"""
GET /api/me/entitlements effektive Capabilities + Feature-Kontingente (M4).
"""
from typing import Optional
from fastapi import APIRouter, Depends, Query
from db import get_db, get_cursor
from entitlements import build_me_entitlements
from tenant_context import TenantContext, get_tenant_context
router = APIRouter(prefix="/api", tags=["entitlements"])
@router.get("/me/entitlements")
def get_me_entitlements(
tenant: TenantContext = Depends(get_tenant_context),
club_id: Optional[int] = Query(default=None, ge=1, description="Verein (Default: effective_club_id)"),
):
"""
Effektive Rechte für Frontend: Account-Status, Capabilities, Feature-Limits.
Spez: CAPABILITY_CATALOG.v1.md §7.1
"""
with get_db() as conn:
cur = get_cursor(conn)
return build_me_entitlements(cur, tenant, club_id=club_id)

View File

@ -7,6 +7,8 @@ from db import get_db, get_cursor
from tenant_context import TenantContext, get_tenant_context
from planning_exercise_suggest import PlanningExerciseSuggestRequest, suggest_planning_exercises
from planning_exercise_path_builder import ProgressionPathSuggestRequest, suggest_progression_path
from account_lifecycle import assert_min_account_state
from capabilities import probe_capability
from club_features import probe_club_feature_access, resolve_club_id_for_probe
router = APIRouter(prefix="/api/planning", tags=["planning_exercise_suggest"])
@ -18,6 +20,14 @@ def post_planning_exercise_suggest(
tenant: TenantContext = Depends(get_tenant_context),
):
if body.include_llm_intent or body.include_llm_rank:
assert_min_account_state(tenant, "active_member", endpoint="POST /planning/exercise-suggest")
probe_capability(
tenant,
"planning.ai.suggest",
action="planning_suggest",
club_id=resolve_club_id_for_probe(tenant),
endpoint="POST /planning/exercise-suggest",
)
probe_club_feature_access(
feature_id="ai_calls",
action="planning_suggest",
@ -40,6 +50,16 @@ def post_progression_path_suggest(
or body.include_llm_path_qa
or body.include_ai_gap_fill
):
assert_min_account_state(
tenant, "active_member", endpoint="POST /planning/progression-path-suggest"
)
probe_capability(
tenant,
"planning.ai.progression_path",
action="progression_path_suggest",
club_id=resolve_club_id_for_probe(tenant),
endpoint="POST /planning/progression-path-suggest",
)
probe_club_feature_access(
feature_id="ai_calls",
action="progression_path_suggest",

View File

@ -22,6 +22,7 @@ from club_tenancy import (
is_superadmin,
memberships_with_roles,
)
from capabilities import club_roles_in_club
from tenant_context import resolve_tenant_context, TenantContext, get_tenant_context
from models import ProfileCreate, ProfileUpdate
@ -118,6 +119,11 @@ def get_current_profile(
invalid_header_policy="ignore",
)
data["effective_club_id"] = tenant.effective_club_id
data["account_state"] = tenant.account_state
if tenant.effective_club_id is not None:
data["club_roles"] = club_roles_in_club(tenant, tenant.effective_club_id)
else:
data["club_roles"] = []
return data

View File

@ -12,6 +12,7 @@ from typing import Any, Dict, List, Optional
from fastapi import Depends, Header, HTTPException
from auth import require_auth, require_auth_flexible
from account_lifecycle import resolve_account_state
from club_tenancy import is_platform_admin, memberships_with_roles
from db import get_db, get_cursor
@ -142,6 +143,8 @@ class TenantContext:
effective_club_id: Optional[int]
club_ids: frozenset[int]
memberships: List[Dict[str, Any]]
email_verified: bool = True
account_state: str = "active_member"
def resolve_tenant_context(
@ -153,6 +156,7 @@ def resolve_tenant_context(
memberships: Optional[List[Dict[str, Any]]] = None,
stored_active_club_id: Optional[int] = None,
invalid_header_policy: str = "reject",
email_verified: Optional[bool] = None,
) -> TenantContext:
"""
Mitgliedschaften: wenn nicht übergeben, lädt ``active_only=True`` aus der DB.
@ -176,6 +180,21 @@ def resolve_tenant_context(
club_ids = frozenset(int(r["id"]) for r in membership_rows if r.get("id") is not None)
if email_verified is None:
cur.execute(
"SELECT COALESCE(email_verified, false) AS email_verified FROM profiles WHERE id = %s",
(profile_id,),
)
prof_row = cur.fetchone()
email_verified = bool(prof_row.get("email_verified")) if prof_row else False
else:
email_verified = bool(email_verified)
account_state = resolve_account_state(
email_verified=email_verified,
global_role=role_lc,
has_active_membership=len(club_ids) > 0,
)
if is_platform_admin(role_lc):
if header_cid is not None:
if not _club_exists(cur, header_cid):
@ -194,6 +213,8 @@ def resolve_tenant_context(
effective_club_id=effective,
club_ids=club_ids,
memberships=membership_rows,
email_verified=email_verified,
account_state=account_state,
)
chosen_header = header_cid
@ -222,6 +243,8 @@ def resolve_tenant_context(
effective_club_id=effective,
club_ids=club_ids,
memberships=membership_rows,
email_verified=email_verified,
account_state=account_state,
)

View File

@ -92,6 +92,7 @@ def test_resolve_platform_admin_uses_stored_club_without_header(monkeypatch):
header_raw=None,
memberships=[{"id": 10}],
stored_active_club_id=99,
email_verified=True,
)
assert ctx.effective_club_id == 99
@ -110,6 +111,7 @@ def test_resolve_platform_admin_header_overrides_stored(monkeypatch):
header_raw="5",
memberships=[{"id": 10}],
stored_active_club_id=99,
email_verified=True,
)
assert ctx.effective_club_id == 5
@ -124,6 +126,7 @@ def test_resolve_platform_admin_no_header_stored_invalid(monkeypatch):
header_raw=None,
memberships=[{"id": 1}],
stored_active_club_id=123,
email_verified=True,
)
assert ctx.effective_club_id is None
@ -142,6 +145,7 @@ def test_resolve_trainer_club_ids_excludes_inactive_memberships():
],
stored_active_club_id=None,
invalid_header_policy="ignore",
email_verified=True,
)
assert ctx.club_ids == frozenset({20})
assert ctx.effective_club_id == 20
@ -157,6 +161,7 @@ def test_resolve_all_memberships_inactive_no_effective_club():
memberships=[{"id": 10, "membership_status": "inactive"}],
stored_active_club_id=10,
invalid_header_policy="ignore",
email_verified=True,
)
assert ctx.club_ids == frozenset()
assert ctx.effective_club_id is None

View File

@ -0,0 +1,86 @@
"""Unit-Tests für Account-Lifecycle und Capability-Helfer (ohne DB)."""
import pytest
from fastapi import HTTPException
from account_lifecycle import (
account_state_satisfies,
assert_min_account_state,
resolve_account_state,
)
from capabilities import club_roles_in_club
from tenant_context import TenantContext
def test_resolve_account_state_platform_admin():
assert (
resolve_account_state(email_verified=False, global_role="superadmin", has_active_membership=False)
== "platform_admin"
)
def test_resolve_account_state_unverified():
assert (
resolve_account_state(email_verified=False, global_role="trainer", has_active_membership=True)
== "unverified"
)
def test_resolve_account_state_pending_club():
assert (
resolve_account_state(email_verified=True, global_role="user", has_active_membership=False)
== "verified_pending_club"
)
def test_resolve_account_state_active_member():
assert (
resolve_account_state(email_verified=True, global_role="trainer", has_active_membership=True)
== "active_member"
)
def test_account_state_satisfies():
assert account_state_satisfies("active_member", "active_member")
assert account_state_satisfies("active_member", "verified_pending_club")
assert not account_state_satisfies("verified_pending_club", "active_member")
assert account_state_satisfies("platform_admin", "active_member")
def test_assert_min_account_state_blocks(monkeypatch):
monkeypatch.setenv("ACCOUNT_GATE_ENFORCE", "1")
tenant = TenantContext(
profile_id=1,
global_role="user",
effective_club_id=None,
club_ids=frozenset(),
memberships=[],
account_state="verified_pending_club",
)
with pytest.raises(HTTPException) as exc:
assert_min_account_state(tenant, "active_member")
assert exc.value.status_code == 403
def test_assert_min_account_state_off(monkeypatch):
monkeypatch.setenv("ACCOUNT_GATE_ENFORCE", "0")
tenant = TenantContext(
profile_id=1,
global_role="user",
effective_club_id=None,
club_ids=frozenset(),
memberships=[],
account_state="verified_pending_club",
)
assert_min_account_state(tenant, "active_member")
def test_club_roles_in_club():
tenant = TenantContext(
profile_id=1,
global_role="trainer",
effective_club_id=5,
club_ids=frozenset({5}),
memberships=[{"id": 5, "roles": ["trainer", "club_admin"]}],
)
assert club_roles_in_club(tenant, 5) == ["trainer", "club_admin"]
assert club_roles_in_club(tenant, 99) == []

View File

@ -0,0 +1,75 @@
"""Tests für GET /me/entitlements Zusammenstellung."""
from datetime import datetime, timezone
from entitlements import _serialize_reset_at, build_me_entitlements
from tenant_context import TenantContext
def test_serialize_reset_at():
dt = datetime(2026, 7, 1, tzinfo=timezone.utc)
assert _serialize_reset_at(dt) == "2026-07-01T00:00:00+00:00"
assert _serialize_reset_at(None) is None
def test_build_me_entitlements_no_club(monkeypatch):
monkeypatch.setattr(
"entitlements.resolve_capabilities_map",
lambda cur, tenant, club_id=None: {"exercises.read": False},
)
tenant = TenantContext(
profile_id=1,
global_role="user",
effective_club_id=None,
club_ids=frozenset(),
memberships=[],
account_state="verified_pending_club",
)
out = build_me_entitlements(object(), tenant)
assert out["account_state"] == "verified_pending_club"
assert out["club_id"] is None
assert out["features"] == {}
assert out["capabilities"]["exercises.read"] is False
def test_build_me_entitlements_with_club(monkeypatch):
monkeypatch.setattr(
"entitlements.resolve_capabilities_map",
lambda cur, tenant, club_id=None: {
"exercises.read": True,
"exercises.ai.suggest": True,
},
)
monkeypatch.setattr(
"entitlements.club_features_map",
lambda cur, club_id, conn=None: {
"plan_id": "free",
"club_id": club_id,
"features": {
"ai_calls": {
"allowed": False,
"used": 0,
"limit": 0,
"remaining": 0,
"reason": "feature_disabled",
"reset_at": None,
}
},
},
)
monkeypatch.setattr("entitlements._club_exists", lambda cur, cid: True)
tenant = TenantContext(
profile_id=3,
global_role="trainer",
effective_club_id=1,
club_ids=frozenset({1}),
memberships=[{"id": 1, "roles": ["trainer"]}],
account_state="active_member",
)
out = build_me_entitlements(object(), tenant, club_id=1)
assert out["club_id"] == 1
assert out["plan_id"] == "free"
assert out["club_roles"] == ["trainer"]
assert out["features"]["ai_calls"]["limit"] == 0
assert out["capabilities"]["exercises.ai.suggest"] is True

View File

@ -2,18 +2,21 @@
APP_VERSION = "0.8.190"
BUILD_DATE = "2026-05-23"
DB_SCHEMA_VERSION = "20260606078"
DB_SCHEMA_VERSION = "20260606079"
MODULE_VERSIONS = {
"legal_documents": "1.4.0", # Admin: Live-Vorschau pro Abschnitt + modale Vollvorschau (Editor + Dokumentenliste)
"auth": "1.2.3", # P-05b: reset-password min_length=8 via Pydantic PasswordResetConfirm
"profiles": "1.8.0", # training_planning_prefs JSONB (Planungs-UI); Patch via ProfileUpdate + Json(), Migration 055
"tenant_context": "1.0.5", # Plattform-Admin: effective_club ohne Header aus Profil active_club_id wenn Verein existiert
"profiles": "1.8.1", # GET /profiles/me: account_state + club_roles
"tenant_context": "1.1.0", # M3: account_state + email_verified im TenantContext
"capabilities": "1.0.1", # resolve_capabilities_map für /me/entitlements
"account_lifecycle": "1.0.0", # resolve_account_state + assert_min_account_state (ACCOUNT_GATE_ENFORCE)
"clubs": "0.4.1", # Alle geschützten Endpoints Depends(get_tenant_context); profile_id/role aus TenantContext
"club_memberships": "1.0.1", # Depends(get_tenant_context)
"club_join_requests": "1.0.1", # Depends(get_tenant_context)
"admin_users": "1.0.0", # GET /api/admin/users
"club_features": "1.1.0", # M2: probe_club_feature_access + JSON-Log (Phase 2, non-blocking)
"club_features": "1.2.0", # M4: club_features_map für /me/entitlements
"entitlements": "1.0.0", # GET /api/me/entitlements — capabilities + features
"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_assets": "1.18.1", # P-13: open_report_count in Listendaten (fuer Admins)

View File

@ -8,6 +8,7 @@ import {
Outlet,
} from 'react-router-dom'
import { AuthProvider, useAuth } from './context/AuthContext'
import { EntitlementsProvider } from './context/EntitlementsContext'
import { FormEditorActionsProvider, FormEditorBottomSlot } from './context/FormEditorActionsContext'
import { ToastProvider } from './context/ToastContext'
import { OrgInboxProvider, useOrgInbox } from './context/OrgInboxContext'
@ -345,11 +346,13 @@ const appRouter = createBrowserRouter([
function App() {
return (
<AuthProvider>
<EntitlementsProvider>
<ToastProvider>
<Suspense fallback={<AppRouteFallback />}>
<RouterProvider router={appRouter} />
</Suspense>
</ToastProvider>
</EntitlementsProvider>
</AuthProvider>
)
}

View File

@ -0,0 +1,40 @@
import { useEntitlements } from '../context/EntitlementsContext'
/**
* Zeigt Vereins-Kontingent für ein Feature (M4 UsageBadge).
* Unbegrenzt (limit null) nichts rendern.
*/
export default function FeatureUsageBadge({ featureId = 'ai_calls', label = 'KI-Kontingent' }) {
const { entitlements, loading, getFeature } = useEntitlements()
const feat = getFeature(featureId)
if (loading && !feat) {
return (
<span className="feature-usage-badge muted" style={{ fontSize: '0.8rem' }}>
{label}:
</span>
)
}
if (!feat) return null
const { used = 0, limit, remaining, allowed } = feat
if (limit == null) return null
const tone = !allowed || remaining === 0 ? 'var(--danger)' : 'var(--text2)'
return (
<span
className="feature-usage-badge"
style={{ fontSize: '0.8rem', color: tone }}
title={
entitlements?.plan_id
? `Plan: ${entitlements.plan_id}`
: undefined
}
>
{label}: {used}/{limit}
{remaining != null ? ` (${remaining} übrig)` : ''}
</span>
)
}

View File

@ -19,6 +19,7 @@ import { stripHtmlToText } from '../../utils/htmlUtils'
import ExerciseCatalogAssocEditor from './ExerciseCatalogAssocEditor'
import ExerciseSkillsEditor from './ExerciseSkillsEditor'
import { useAuth } from '../../context/AuthContext'
import FeatureUsageBadge from '../FeatureUsageBadge'
import { useToast } from '../../context/ToastContext'
import {
activeClubMemberships,
@ -1653,6 +1654,8 @@ function ExerciseFormPageRoot() {
<label className="form-label" style={{ marginBottom: 0 }}>
Kurzbeschreibung
</label>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', flexWrap: 'wrap' }}>
<FeatureUsageBadge featureId="ai_calls" />
<button
type="button"
className="btn btn-secondary"
@ -1663,6 +1666,7 @@ function ExerciseFormPageRoot() {
KI: Kurzfassung
</button>
</div>
</div>
<RichTextEditor
value={formData.summary}
onChange={(html) => updateFormField('summary', html)}

View File

@ -0,0 +1,64 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'
import { getMeEntitlements } from '../utils/api'
import { useAuth } from './AuthContext'
const EntitlementsContext = createContext(null)
export function EntitlementsProvider({ children }) {
const { user, isAuthenticated, loading: authLoading } = useAuth()
const [entitlements, setEntitlements] = useState(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState(null)
const clubId = user?.effective_club_id ?? null
const refreshEntitlements = useCallback(async () => {
if (!isAuthenticated) {
setEntitlements(null)
setError(null)
return null
}
setLoading(true)
setError(null)
try {
const data = await getMeEntitlements(clubId)
setEntitlements(data)
return data
} catch (e) {
setEntitlements(null)
setError(e?.message || String(e))
return null
} finally {
setLoading(false)
}
}, [isAuthenticated, clubId])
useEffect(() => {
if (authLoading) return
refreshEntitlements()
}, [authLoading, refreshEntitlements])
const value = useMemo(
() => ({
entitlements,
loading,
error,
refreshEntitlements,
hasCapability: (capId) => Boolean(entitlements?.capabilities?.[capId]),
getFeature: (featureId) => entitlements?.features?.[featureId] ?? null,
}),
[entitlements, loading, error, refreshEntitlements],
)
return (
<EntitlementsContext.Provider value={value}>{children}</EntitlementsContext.Provider>
)
}
export function useEntitlements() {
const ctx = useContext(EntitlementsContext)
if (!ctx) {
throw new Error('useEntitlements must be used within EntitlementsProvider')
}
return ctx
}

View File

@ -40,6 +40,15 @@ export async function getCurrentProfile() {
return request('/api/profiles/me')
}
/** Effektive Capabilities + Feature-Kontingente (M4). */
export async function getMeEntitlements(clubId = null) {
const q =
clubId != null && clubId !== ''
? `?club_id=${encodeURIComponent(String(clubId))}`
: ''
return request(`/api/me/entitlements${q}`)
}
/** Liste aller Profile nur für Plattform-Admins (Vereinsanlage). */
export async function listProfiles() {
return request('/api/profiles')
@ -850,6 +859,7 @@ export const api = {
register,
logout,
getCurrentProfile,
getMeEntitlements,
listProfiles,
listAdminUsers,
getAdminUserContentMeta,