diff --git a/backend/account_lifecycle.py b/backend/account_lifecycle.py new file mode 100644 index 0000000..00bfcf2 --- /dev/null +++ b/backend/account_lifecycle.py @@ -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) diff --git a/backend/capabilities.py b/backend/capabilities.py new file mode 100644 index 0000000..83d6f2a --- /dev/null +++ b/backend/capabilities.py @@ -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) diff --git a/backend/capability_logger.py b/backend/capability_logger.py new file mode 100644 index 0000000..ccc905d --- /dev/null +++ b/backend/capability_logger.py @@ -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)) diff --git a/backend/club_features.py b/backend/club_features.py index 0bda5df..bc1ef25 100644 --- a/backend/club_features.py +++ b/backend/club_features.py @@ -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, + } diff --git a/backend/entitlements.py b/backend/entitlements.py new file mode 100644 index 0000000..60cced8 --- /dev/null +++ b/backend/entitlements.py @@ -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, + } diff --git a/backend/main.py b/backend/main.py index 93b1e36..81884bf 100644 --- a/backend/main.py +++ b/backend/main.py @@ -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) diff --git a/backend/migrations/079_capabilities.sql b/backend/migrations/079_capabilities.sql new file mode 100644 index 0000000..4c6ae24 --- /dev/null +++ b/backend/migrations/079_capabilities.sql @@ -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; diff --git a/backend/routers/exercises.py b/backend/routers/exercises.py index 879296e..5b63e66 100644 --- a/backend/routers/exercises.py +++ b/backend/routers/exercises.py @@ -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", diff --git a/backend/routers/me_entitlements.py b/backend/routers/me_entitlements.py new file mode 100644 index 0000000..90dd4a9 --- /dev/null +++ b/backend/routers/me_entitlements.py @@ -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) diff --git a/backend/routers/planning_exercise_suggest.py b/backend/routers/planning_exercise_suggest.py index 229219e..9bcba18 100644 --- a/backend/routers/planning_exercise_suggest.py +++ b/backend/routers/planning_exercise_suggest.py @@ -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", diff --git a/backend/routers/profiles.py b/backend/routers/profiles.py index c554b3e..ad4c950 100644 --- a/backend/routers/profiles.py +++ b/backend/routers/profiles.py @@ -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 diff --git a/backend/tenant_context.py b/backend/tenant_context.py index fe76393..175221e 100644 --- a/backend/tenant_context.py +++ b/backend/tenant_context.py @@ -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, ) diff --git a/backend/tests/test_access_layer.py b/backend/tests/test_access_layer.py index d74fe7a..915c0ee 100644 --- a/backend/tests/test_access_layer.py +++ b/backend/tests/test_access_layer.py @@ -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 diff --git a/backend/tests/test_account_capabilities.py b/backend/tests/test_account_capabilities.py new file mode 100644 index 0000000..6e3d92b --- /dev/null +++ b/backend/tests/test_account_capabilities.py @@ -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) == [] diff --git a/backend/tests/test_entitlements.py b/backend/tests/test_entitlements.py new file mode 100644 index 0000000..cc50509 --- /dev/null +++ b/backend/tests/test_entitlements.py @@ -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 diff --git a/backend/version.py b/backend/version.py index bab4dec..f8d695f 100644 --- a/backend/version.py +++ b/backend/version.py @@ -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) diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index ba38f7f..7d19542 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -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 ( - - }> - - - + + + }> + + + + ) } diff --git a/frontend/src/components/FeatureUsageBadge.jsx b/frontend/src/components/FeatureUsageBadge.jsx new file mode 100644 index 0000000..9bc7a09 --- /dev/null +++ b/frontend/src/components/FeatureUsageBadge.jsx @@ -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 ( + + {label}: … + + ) + } + + 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 ( + + {label}: {used}/{limit} + {remaining != null ? ` (${remaining} übrig)` : ''} + + ) +} diff --git a/frontend/src/components/exercises/ExerciseFormPageRoot.jsx b/frontend/src/components/exercises/ExerciseFormPageRoot.jsx index 528f280..3733bd2 100644 --- a/frontend/src/components/exercises/ExerciseFormPageRoot.jsx +++ b/frontend/src/components/exercises/ExerciseFormPageRoot.jsx @@ -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,15 +1654,18 @@ function ExerciseFormPageRoot() { - +
+ + +
{ + 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 ( + {children} + ) +} + +export function useEntitlements() { + const ctx = useContext(EntitlementsContext) + if (!ctx) { + throw new Error('useEntitlements must be used within EntitlementsProvider') + } + return ctx +} diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js index 57ed2bb..7294ba9 100644 --- a/frontend/src/utils/api.js +++ b/frontend/src/utils/api.js @@ -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,