diff --git a/.claude/docs/technical/CAPABILITY_CATALOG.v1.md b/.claude/docs/technical/CAPABILITY_CATALOG.v1.md index fb4f1bf..0e403d5 100644 --- a/.claude/docs/technical/CAPABILITY_CATALOG.v1.md +++ b/.claude/docs/technical/CAPABILITY_CATALOG.v1.md @@ -1,8 +1,8 @@ # Capability-Katalog Shinkan v1 -**Status:** Konzept (verbindliche Zieldefinition vor Implementierung) +**Status:** Konzept (verbindliche Zieldefinition; M3 teilweise umgesetzt) **Stand:** 2026-06-06 -**Bezüge:** `ACCESS_LAYER_AND_GOVERNANCE_PLAN.md` (Stufe E), `MULTI_TENANCY_RBAC_ARCHITECTURE.md`, `CLUB_MEMBERSHIP_AND_FEATURES.v1.md` +**Bezüge:** `ACCESS_LAYER_AND_GOVERNANCE_PLAN.md` (Stufe E), `MULTI_TENANCY_RBAC_ARCHITECTURE.md`, `CLUB_MEMBERSHIP_AND_FEATURES.v1.md`, **`MEMBERSHIP_RBAC_DECISIONS_2026-06.md`** (Produktentscheidungen) --- @@ -50,7 +50,7 @@ Objektbezogene Feinheiten (nur Ersteller, nur Vereinsadmin des Objekt-Vereins) b |-----------------|-----------|------------------------| | `anonymous` | Keine Session | nur öffentliche Routen (`/login`, Rechtstexte, `clubs/public-directory`) | | `unverified` | Session, `email_verified=false` | `account.resend_verification`, `account.logout` | -| `verified_pending_club` | Verifiziert, keine aktive `club_members` | `club.join_request`, `account.settings` | +| `verified_pending_club` | Verifiziert, keine aktive `club_members` | `club.join_request`, `club.creation_request` (M7), `account.settings` — **kein** Lesezugriff auf Domänen-Inhalte (siehe Entscheidungs-Doc §1.1) | | `active_member` | Mind. eine aktive Vereinsmitgliedschaft | Domänen-Capabilities gemäß Vereinsrolle | | `platform_admin` | `role` ∈ `admin`, `superadmin` | `platform.*` zusätzlich | diff --git a/.claude/docs/technical/CLUB_MEMBERSHIP_AND_FEATURES.v1.md b/.claude/docs/technical/CLUB_MEMBERSHIP_AND_FEATURES.v1.md index ffced68..813cc53 100644 --- a/.claude/docs/technical/CLUB_MEMBERSHIP_AND_FEATURES.v1.md +++ b/.claude/docs/technical/CLUB_MEMBERSHIP_AND_FEATURES.v1.md @@ -1,8 +1,8 @@ # Vereins-Membership & Feature-System Shinkan v1 -**Status:** Konzept (Schema- und Enforcement-Zielbild vor Implementierung) +**Status:** Konzept + M1–M3 teilweise produktiv (siehe Entscheidungs-Doc §2) **Stand:** 2026-06-06 -**Bezüge:** Schwesterprojekt Mitai (`v9c_subscription_system.sql`, `FEATURE_ENFORCEMENT.md`), `CAPABILITY_CATALOG.v1.md`, `MULTI_TENANCY_RBAC_ARCHITECTURE.md` §4.6, `ACCESS_LAYER_AND_GOVERNANCE_PLAN.md` +**Bezüge:** Schwesterprojekt Mitai (`v9c_subscription_system.sql`, `FEATURE_ENFORCEMENT.md`), `CAPABILITY_CATALOG.v1.md`, `MULTI_TENANCY_RBAC_ARCHITECTURE.md` §4.6, `ACCESS_LAYER_AND_GOVERNANCE_PLAN.md`, **`MEMBERSHIP_RBAC_DECISIONS_2026-06.md`** --- @@ -430,6 +430,17 @@ Unabhängig vom Membership-System — **Pflicht** wegen Prod-Vorfälle (`access_ | M7 | `club_creation_requests` | Prozess | | M8 | Stripe / Rechnung | Später | +**Nach Produktentscheidungen 2026-06-06** (Details `MEMBERSHIP_RBAC_DECISIONS_2026-06.md` §4): + +| Phase | Paket | Priorität | +|-------|--------|-----------| +| A | Onboarding-Gates vollständig (`verified_pending_club`) | **Als Nächstes** | +| B | M7 Vereinsgründung beantragen | hoch | +| C | M5 Hard-Block `ai_calls` | danach | +| D | M6 Superadmin-UI | danach | +| E | Systemrolle `co_trainer` + Frontend-Entitlements | v1 Rollen | +| F | Trainer-Member-Budgets (v2) | später | + --- ## 12. Offene Produktentscheidungen @@ -440,7 +451,7 @@ Vor M6 festlegen: 2. **Soft-Limit vs. Hard-Stop:** Warnung bei 80 % oder sofort 403? 3. **Pilotverein:** eigener Plan `pilot` mit hohen Limits? 4. **KI-Fairness:** nur Vereinslimit oder zusätzlich Max pro Trainer/Monat? -5. **Offizielle Inhalte:** für `verified_pending_club` sichtbar oder gesperrt? (Capability-Doc) +5. **Offizielle Inhalte:** für `verified_pending_club` sichtbar oder gesperrt? → **entschieden: gesperrt** (`MEMBERSHIP_RBAC_DECISIONS_2026-06.md` §1.1) 6. **Portal `admin` vs. `superadmin`:** Wer darf Vereine anlegen? (Ziel: nur `superadmin` für Freigabe) --- diff --git a/.claude/docs/technical/MEMBERSHIP_RBAC_DECISIONS_2026-06.md b/.claude/docs/technical/MEMBERSHIP_RBAC_DECISIONS_2026-06.md new file mode 100644 index 0000000..df6d355 --- /dev/null +++ b/.claude/docs/technical/MEMBERSHIP_RBAC_DECISIONS_2026-06.md @@ -0,0 +1,210 @@ +# Membership, RBAC & Kontingente — Produktentscheidungen + +**Status:** Verbindlich (Zielbild & Roadmap-Priorisierung) +**Stand:** 2026-06-06 +**Bezüge:** `CAPABILITY_CATALOG.v1.md`, `CLUB_MEMBERSHIP_AND_FEATURES.v1.md`, `ACCESS_LAYER_AND_GOVERNANCE_PLAN.md` + +Dieses Dokument hält **getroffene Produktentscheidungen** fest (Session 2026-06-06) und ergänzt die v1-Konzept-Specs um Umsetzungsrichtung. Technischer Implementierungsstand: Abschnitt 2. + +--- + +## 1. Getroffene Entscheidungen + +### 1.1 Onboarding: `verified_pending_club` + +Nutzer **ohne aktive Vereinsmitgliedschaft** (E-Mail verifiziert) dürfen **nur**: + +| Erlaubt | Nicht erlaubt (Zielbild) | +|---------|---------------------------| +| Konto / Einstellungen | Übungen, Planung, KI, Medien | +| Vereinsverzeichnis lesen | Vereinsinterne Inhalte (`club`), private Fremdinhalte | +| **Beitrittsantrag** an bestehenden Verein | Vollzugriff auf Bibliothek / offizielle Inhalte (Lesen) — **bewusst gesperrt** bis Mitgliedschaft | +| **Vereinsgründung beantragen** (Prozess M7, Superadmin-Freigabe) | | + +**Kein** „Bibliothek durchstöbern“ für Bewerber — reduziert Datenexposition und vereinfacht UX („erst Verein, dann Arbeit“). + +Technischer Zustand: `account_state = verified_pending_club` (siehe `CAPABILITY_CATALOG.v1.md` §3). + +--- + +### 1.2 Rollenmodell: Risikoarm statt Big-Bang + +**Zielbild (langfristig):** + +- **Fest:** nur `superadmin` (Plattform) als nicht konfigurierbare Systemrolle. +- **Dynamisch konfigurierbar:** alle Vereinsrollen und deren Capability-Bundles (später `club_custom_roles`). +- Optional: `admin` (Plattform) als abgeschwächter Portal-Admin bleibt vorerst bestehen (Ist-Code). + +**Entscheidung v1 (risikoarm):** + +| Maßnahme | Jetzt | Später | +|----------|-------|--------| +| Alte Helfer (`can_plan_in_club`, `if (club_admin)` in JSX) | **Behalten** — weiter produktiv | Schrittweise durch `entitlements` ersetzen | +| Neue Endpoints / Features | Nur über **Capability-IDs** + Audit | — | +| Neue Vereinsrollen | Als **Systemrollen** ergänzen (z. B. `co_trainer`) | Custom Roles UI | +| `club_custom_roles` | **Nicht** in v1 | v2 Epic | + +**Begründung:** Backend und Frontend haben hunderte Verdrahtungen auf `trainer` / `club_admin` / Plattform-Rollen. Parallelbetrieb Capability-System + Legacy-Helfer ist sicherer als einmaliges Aufbrechen. + +**Co-Trainer (geplant als Systemrolle):** weniger Capabilities als `trainer` (z. B. kein `planning.*`, kein `exercises.create`) — Umsetzung nach Onboarding-Gates + Entitlements-Rollout, nicht vorher. + +--- + +### 1.3 Vereins-Kontingente (Membership-Pakete) + +**Jetzt:** Schema und Anzeige vorbereiten; **keine** detaillierte Paket-Logik (z. B. „3 Trainer + 10 Co-Trainer“) implementieren. + +| Vorbereitet (DB/Module) | Bewusst zurückgestellt | +|-------------------------|-------------------------| +| `features`, `club_plans`, `club_subscriptions` | Eigene Feature-IDs `trainer_seats` / `co_trainer_seats` | +| Bestands-Limits (`exercises`, `training_groups`, `ai_calls`, …) | Zählregel „nur planungsberechtigte Mitglieder“ vs. alle Mitglieder | +| `GET /me/entitlements` Feature-Teil | Stripe / Rechnung (M8) | + +**Prinzip:** Neue Kontingent-Typen = neue `features`-Zeile + Plan-Limits + optional Capability-`linked_feature_id` — ohne Schema-Bruch. + +--- + +### 1.4 Trainer-Budget innerhalb Vereins-Kontingent (v2) + +**Anforderung:** Vereins-KI-Kontingent liegt beim Verein; **Vereinsadmin** kann pro Trainer ein **Sub-Budget** vergeben (Fairness, „Kontingent-Fresser“). + +**Entscheidung:** + +- v1: nur **Vereins-Ebene** (`club_plan_limits`, `club_feature_usage`). +- v2: neue Tabellen (Skizze): + +```sql +-- Skizze — noch nicht migriert +club_member_feature_budgets (club_id, profile_id, feature_id, limit_value, …) +club_member_feature_usage (club_id, profile_id, feature_id, usage_count, reset_at, …) +``` + +**Prüf-Kette v2:** Capability → Mitglieds-Budget (falls gesetzt) → Vereins-Kontingent. + +**Fairness-Modell (offen, Tendenz):** harte Sub-Budgets (Modell A) — Trainer darf sein Budget nicht überschreiten, auch wenn Verein noch Rest hat. + +--- + +### 1.5 Enforcement-Phasen (unverändert, bestätigt) + +| Phase | Verhalten | Nutzer sichtbar | +|-------|-----------|-----------------| +| 2 (M2/M3) | JSON-Log, kein Block | Nein (außer Logs) | +| 3 (M4) | `GET /me/entitlements` + Badge | Kontingent-Anzeige | +| 4 (M5+) | HTTP 403 + `increment` | Hard-Block | + +Env-Schalter: `ACCOUNT_GATE_ENFORCE` (Default `1`, Endpoint-Helfer), `ACCOUNT_GATE_API_ENFORCE` (Default `1`, API-Middleware Phase A), `CAPABILITY_ENFORCE` / `CLUB_FEATURE_ENFORCE` (Default `0`). + +--- + +## 2. Implementierungsstand (Ist, Codebase) + +**DB-Schema:** `20260606079` (`backend/version.py`) +**Deploy-Referenz:** Dev mit M2-Logging verifiziert (`club-feature-usage.log`). + +### M1 — Feature-Schema v9c ✅ + +| Deliverable | Status | +|-------------|--------| +| Migration `078_club_features_and_plans.sql` | ✅ | +| Legacy `001` archiviert | ✅ | +| `club_plans`, `club_subscriptions`, Usage-Tabellen | ✅ | +| Seed Features + Pläne (`free`, …) | ✅ | +| `club_features.py`: `check_club_feature_access`, `get_effective_club_plan` | ✅ | +| Backfill Vereine → Plan `free` | ✅ | + +### M2 — Feature-Probe (Log only) ✅ + +| Deliverable | Status | +|-------------|--------| +| `club_feature_logger.py` → `club-feature-usage.log` | ✅ | +| `probe_club_feature_access()` | ✅ | +| Hooks: KI-Endpoints, `POST /exercises`, Medien-Upload, Planungs-KI | ✅ | +| `CLUB_FEATURE_ENFORCE=0` (Default) | ✅ | + +### M3 — Account-Lifecycle + Capability-Grants ⚠️ teilweise + +| Deliverable | Status | Lücke | +|-------------|--------|-------| +| Migration `079_capabilities.sql` + Seed | ✅ | — | +| `account_lifecycle.py`, `resolve_account_state` | ✅ | — | +| `capabilities.py`, `check_capability`, `probe_capability` | ✅ | — | +| `TenantContext.account_state` | ✅ | — | +| `GET /profiles/me` → `account_state`, `club_roles` | ✅ | — | +| Account-Gates auf **Schreib-/KI-Endpoints** | ✅ | Lesepfade für Bewerber noch offen | +| `CAPABILITY_ENFORCE=0` (nur Log) | ✅ | — | +| Onboarding UX: nur Bewerbung/Gründung | ✅ | Phase A: API-Middleware + `/onboarding` + reduzierte Nav | +| `club_creation_requests` (M7) | ❌ | — | +| Custom Roles / Co-Trainer | ❌ | bewusst v2 | +| Legacy-Helfer entfernt | ❌ | bewusst parallel | + +### Bewusst zurückgestellt (Roadmap) + +| ID | Inhalt | +|----|--------| +| M0 | CI-Isolation / Test-DB | +| M5 | Hard-Block Kontingente | +| M6 | Superadmin Admin-UI (Pläne, Capability-Matrix) | +| M7 | Vereinsgründung beantragen | +| M8 | Stripe | + +### Hinweis: M4 im Repo (über M3 hinaus) + +Falls bereits deployed: `GET /api/me/entitlements`, `EntitlementsContext`, `FeatureUsageBadge` — gehört zur **Anzeige-Phase 3**, nicht zum M3-Kern. Siehe `entitlements` Modul v1.0.0. + +--- + +## 3. Architektur-Zielbild (kompakt) + +``` +Request + → require_auth + → account_state (Gate) + → TenantContext + → assert_capability (Rolle / Funktion) + → check_club_feature_access (Vereins-Kontingent) + → [v2] member_feature_budget (Trainer-Budget) + → Governance (Objekt) +``` + +**Drei Achsen:** Account-Lifecycle · Capabilities · Features (Kontingente). Governance bleibt vierte Prüfung. + +--- + +## 4. Empfohlene Roadmap (nach Entscheidungen) + +| Phase | Paket | Warum zuerst | +|-------|--------|--------------| +| **A** | **Onboarding-Gates vollständig** | ✅ umgesetzt (API + Frontend `/onboarding`) | +| **B** | **M7 Vereinsgründung beantragen** | **Als Nächstes** — zweiter Pfad für `verified_pending_club` | +| **C** | **M5 Hard-Block `ai_calls`** | Free-Plan `0` wird real; Badge (M4) liefert Erklärung | +| **D** | **M6 Superadmin-UI** | Pläne + Capability-Matrix ohne SQL | +| **E** | Systemrolle `co_trainer` + Entitlements im Frontend | Entscheidung 1.2 risikoarm | +| **F** | Member-Budgets (v2) | Entscheidung 1.4 | + +M0 parallel, nicht blockierend. + +--- + +## 5. Offene Punkte (vor M6 / v2) + +1. Fairness Modell A/B/C für Trainer-Budget (Tendenz: A). +2. Ob `admin` (Portal) langfristig neben `superadmin` bleibt. +3. Ob offizielle Inhalte für Bewerber **nie** lesbar bleiben (aktuell: ja). + +--- + +## 6. Referenzen + +| Pfad | Inhalt | +|------|--------| +| `CAPABILITY_CATALOG.v1.md` | Capability-IDs, Account-States | +| `CLUB_MEMBERSHIP_AND_FEATURES.v1.md` | Feature-Registry, Kontingente | +| `backend/club_features.py` | Vereins-Features | +| `backend/capabilities.py` | Capability-Auflösung | +| `backend/account_lifecycle.py` | Account-Gates | + +**Changelog** + +- 2026-06-06: Initial — Entscheidungen Onboarding, Rollen-Risiko, Kontingente, Trainer-Budget v2; Ist-Stand M1–M3; Roadmap A–F. +- 2026-06-06: Phase A — `account_onboarding_gate.py`, Frontend `/onboarding`, reduzierte Navigation. diff --git a/backend/account_onboarding_gate.py b/backend/account_onboarding_gate.py new file mode 100644 index 0000000..bbe9e25 --- /dev/null +++ b/backend/account_onboarding_gate.py @@ -0,0 +1,165 @@ +""" +API-Gates für Onboarding (Phase A — MEMBERSHIP_RBAC_DECISIONS_2026-06.md §1.1). + +Blockiert Domänen-APIs für unverified / verified_pending_club vor dem Router. +""" +from __future__ import annotations + +import os +import re +from typing import Optional, Tuple + +from account_lifecycle import resolve_account_state +from club_tenancy import is_platform_admin, memberships_with_roles + +# Öffentlich ohne Session +PUBLIC_API_PREFIXES = ( + "/api/auth/login", + "/api/auth/register", + "/api/auth/forgot-password", + "/api/auth/reset-password", + "/api/auth/verify/", + "/api/legal-documents/", + "/api/clubs/public-directory", + "/api/version", + "/api/health/", + "/health", +) + +# Mit Session, unabhängig vom account_state (Logout, Profil lesen, …) +AUTH_INFRA_PREFIXES = ( + "/api/auth/logout", + "/api/auth/me", + "/api/auth/status", + "/api/auth/pin", + "/api/auth/resend-verification", + "/api/profiles/me", + "/api/me/entitlements", +) + +# Zusätzlich für verified_pending_club (Verein bewerben) +PENDING_CLUB_PREFIXES = ( + "/api/me/club-join-requests", +) + +_PROFILE_MUTATION_RE = re.compile(r"^/api/profiles/(\d+)$") + + +def api_onboarding_gate_enabled() -> bool: + return os.getenv("ACCOUNT_GATE_API_ENFORCE", "1").strip() == "1" + + +def normalize_api_path(path: str) -> str: + p = (path or "").split("?", 1)[0].strip() + if not p.startswith("/"): + p = "/" + p + if len(p) > 1 and p.endswith("/"): + p = p[:-1] + return p + + +def is_public_api_path(path: str) -> bool: + p = normalize_api_path(path) + return any(p == pref or p.startswith(pref) for pref in PUBLIC_API_PREFIXES) + + +def _path_allowed_for_state(path: str, method: str, account_state: str, profile_id: int) -> bool: + p = normalize_api_path(path) + m = (method or "GET").upper() + + for pref in AUTH_INFRA_PREFIXES: + if p == pref or p.startswith(pref + "/"): + return True + + match = _PROFILE_MUTATION_RE.match(p) + if match and m in ("PUT", "PATCH") and int(match.group(1)) == int(profile_id): + return True + + if account_state == "unverified": + return False + + if account_state == "verified_pending_club": + for pref in PENDING_CLUB_PREFIXES: + if p == pref or p.startswith(pref + "/"): + return True + return False + + return True + + +def resolve_account_state_for_token(cur, session_row: dict) -> str: + profile_id = int(session_row["profile_id"]) + role = (session_row.get("role") or "").lower() + cur.execute( + "SELECT COALESCE(email_verified, false) AS email_verified FROM profiles WHERE id = %s", + (profile_id,), + ) + prof = cur.fetchone() + email_verified = bool(prof.get("email_verified")) if prof else False + memberships = memberships_with_roles(cur, profile_id, active_only=True) + has_active = len(memberships) > 0 + return resolve_account_state( + email_verified=email_verified, + global_role=role, + has_active_membership=has_active, + ) + + +def check_api_onboarding_gate( + *, + path: str, + method: str, + profile_id: int, + account_state: str, +) -> Tuple[bool, Optional[str]]: + """ + Returns (allowed, reason). + active_member / platform_admin → immer erlaubt (Domain). + """ + if not api_onboarding_gate_enabled(): + return True, None + + if account_state in ("active_member", "platform_admin"): + return True, None + + if _path_allowed_for_state(path, method, account_state, profile_id): + return True, None + + return False, f"account_state_{account_state}" + + +def evaluate_request_gate(token: Optional[str], path: str, method: str) -> Tuple[bool, Optional[str], Optional[str]]: + """ + Vollständige Prüfung inkl. Session-Lookup. + Returns: allowed, reason, account_state (für Logging) + """ + if not api_onboarding_gate_enabled(): + return True, None, None + + p = normalize_api_path(path) + if not p.startswith("/api/"): + return True, None, None + if is_public_api_path(p): + return True, None, None + if not token: + return True, None, None + + from auth import get_session + from db import get_db, get_cursor + + session = get_session(token) + if not session: + return True, None, None + + profile_id = int(session["profile_id"]) + with get_db() as conn: + cur = get_cursor(conn) + account_state = resolve_account_state_for_token(cur, session) + + allowed, reason = check_api_onboarding_gate( + path=p, + method=method, + profile_id=profile_id, + account_state=account_state, + ) + return allowed, reason, account_state diff --git a/backend/main.py b/backend/main.py index 81884bf..331c067 100644 --- a/backend/main.py +++ b/backend/main.py @@ -87,6 +87,34 @@ app.add_middleware( ) +@app.middleware("http") +async def account_onboarding_api_gate(request: Request, call_next): + """ + Phase A: Domänen-APIs für unverified / verified_pending_club sperren. + Siehe account_onboarding_gate.py und MEMBERSHIP_RBAC_DECISIONS_2026-06.md §1.1 + """ + from account_onboarding_gate import evaluate_request_gate + + token = request.headers.get("x-auth-token") or request.headers.get("X-Auth-Token") + allowed, reason, _state = evaluate_request_gate( + token, + request.url.path, + request.method, + ) + if not allowed: + return JSONResponse( + status_code=403, + content={ + "detail": ( + "Zugriff erst nach E-Mail-Bestätigung und Vereinsmitgliedschaft möglich. " + "Du kannst einen Beitrittsantrag stellen oder dein Konto in den Einstellungen verwalten." + ), + "reason": reason, + }, + ) + return await call_next(request) + + @app.middleware("http") async def add_api_security_headers(request: Request, call_next): """Konsistente Basis-Header auch für rein JSON-Responses (MIME-Sniffing).""" diff --git a/backend/tests/test_account_onboarding_gate.py b/backend/tests/test_account_onboarding_gate.py new file mode 100644 index 0000000..e5c2aa6 --- /dev/null +++ b/backend/tests/test_account_onboarding_gate.py @@ -0,0 +1,58 @@ +"""Tests für account_onboarding_gate.""" +import pytest + +from account_onboarding_gate import ( + check_api_onboarding_gate, + is_public_api_path, + normalize_api_path, +) + + +def test_public_directory_is_public(): + assert is_public_api_path("/api/clubs/public-directory") + + +def test_exercises_blocked_for_pending(): + allowed, reason = check_api_onboarding_gate( + path="/api/exercises", + method="GET", + profile_id=1, + account_state="verified_pending_club", + ) + assert not allowed + assert reason == "account_state_verified_pending_club" + + +def test_join_request_allowed_for_pending(): + allowed, _ = check_api_onboarding_gate( + path="/api/me/club-join-requests", + method="POST", + profile_id=1, + account_state="verified_pending_club", + ) + assert allowed + + +def test_active_member_domain_ok(): + allowed, reason = check_api_onboarding_gate( + path="/api/exercises", + method="GET", + profile_id=1, + account_state="active_member", + ) + assert allowed + assert reason is None + + +def test_profile_self_update_allowed_unverified(): + allowed, _ = check_api_onboarding_gate( + path="/api/profiles/42", + method="PUT", + profile_id=42, + account_state="unverified", + ) + assert allowed + + +def test_normalize_trailing_slash(): + assert normalize_api_path("/api/exercises/") == "/api/exercises" diff --git a/backend/version.py b/backend/version.py index f8d695f..e9d351d 100644 --- a/backend/version.py +++ b/backend/version.py @@ -10,7 +10,7 @@ MODULE_VERSIONS = { "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) + "account_lifecycle": "1.1.0", # Phase A: account_onboarding_gate API-Middleware "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) diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 7d19542..38702a0 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -14,6 +14,7 @@ import { ToastProvider } from './context/ToastContext' import { OrgInboxProvider, useOrgInbox } from './context/OrgInboxContext' import DesktopSidebar from './components/DesktopSidebar' import { getMainNavItems } from './config/appNav' +import { isOnboardingAllowedPath, isOnboardingRestricted } from './utils/accountState' import AdminHomeRedirect from './components/AdminHomeRedirect' import PlatformAdminRoute from './components/PlatformAdminRoute' import ActiveClubSwitcher from './components/ActiveClubSwitcher' @@ -21,6 +22,7 @@ import InactiveMembershipBanner from './components/InactiveMembershipBanner' import './app.css' const LoginPage = lazy(() => import('./pages/LoginPage')) +const OnboardingPage = lazy(() => import('./pages/OnboardingPage')) const VerifyPage = lazy(() => import('./pages/VerifyPage')) const Dashboard = lazy(() => import('./pages/Dashboard')) const AccountSettingsPage = lazy(() => import('./pages/AccountSettingsPage')) @@ -83,9 +85,12 @@ function AppRouteFallback() { } // Bottom Navigation (Mobile) -function Nav({ showAdminNav }) { +function Nav({ showAdminNav, onboardingOnly }) { const { canAccessOrgInbox, inboxCount } = useOrgInbox() - const items = getMainNavItems(showAdminNav, { showInbox: canAccessOrgInbox }) + const items = getMainNavItems(showAdminNav, { + showInbox: canAccessOrgInbox, + onboardingOnly, + }) const loc = useLocation() const navItemActive = (pathname, item, routerIsActive) => { @@ -147,26 +152,37 @@ function ProtectedLayout() { return } - const showAdminNav = computeShowAdminNav(user) + const location = useLocation() + const onboardingOnly = isOnboardingRestricted(user) + if (onboardingOnly && !isOnboardingAllowedPath(location.pathname)) { + return + } + + const showAdminNav = computeShowAdminNav(user) && !onboardingOnly return ( - +
🥋 Shinkan
- + {!onboardingOnly ? : null}
-
@@ -220,6 +236,7 @@ const appRouter = createBrowserRouter([ element: , children: [ { index: true, element: }, + { path: 'onboarding', element: }, { path: 'profile', element: }, { path: 'settings', element: }, { path: 'settings/system', element: }, diff --git a/frontend/src/components/DesktopSidebar.jsx b/frontend/src/components/DesktopSidebar.jsx index f5f582a..b8bcdf9 100644 --- a/frontend/src/components/DesktopSidebar.jsx +++ b/frontend/src/components/DesktopSidebar.jsx @@ -14,12 +14,16 @@ function sidebarLinkActive(pathname, item, routerIsActive) { */ export default function DesktopSidebar({ showAdminNav, + onboardingOnly = false, user, onLogout }) { const loc = useLocation() const { canAccessOrgInbox, inboxCount } = useOrgInbox() - const items = getMainNavItems(showAdminNav, { showInbox: canAccessOrgInbox }) + const items = getMainNavItems(showAdminNav, { + showInbox: canAccessOrgInbox, + onboardingOnly, + }) const tier = user?.tier || '' return ( diff --git a/frontend/src/config/appNav.js b/frontend/src/config/appNav.js index 4a10fee..0c4b13d 100644 --- a/frontend/src/config/appNav.js +++ b/frontend/src/config/appNav.js @@ -31,8 +31,19 @@ function baseItems(opts = {}) { return items } -/** @param {boolean} isAdmin @param {{ showInbox?: boolean }} opts */ +/** Nav für Onboarding (ohne Vereinsmitgliedschaft). */ +export function getOnboardingNavItems() { + return [ + { to: '/onboarding', label: 'Verein', shortLabel: 'Verein', end: true, Icon: Building2 }, + { to: '/settings', label: 'Einstellungen', shortLabel: 'Einst.', Icon: Settings }, + ] +} + +/** @param {boolean} isAdmin @param {{ showInbox?: boolean, onboardingOnly?: boolean }} opts */ export function getMainNavItems(isAdmin, opts = {}) { + if (opts.onboardingOnly) { + return getOnboardingNavItems() + } const showInbox = !!opts.showInbox const icons = [ LayoutDashboard, diff --git a/frontend/src/pages/OnboardingPage.jsx b/frontend/src/pages/OnboardingPage.jsx new file mode 100644 index 0000000..7503209 --- /dev/null +++ b/frontend/src/pages/OnboardingPage.jsx @@ -0,0 +1,208 @@ +import { useEffect, useState } from 'react' +import { Link } from 'react-router-dom' +import api from '../utils/api' +import { useAuth } from '../context/AuthContext' +import EmailVerificationBanner from '../components/EmailVerificationBanner' +import { resolveAccountState } from '../utils/accountState' + +const joinStatusLabel = (s) => + ({ + pending: 'ausstehend', + accepted: 'angenommen', + rejected: 'abgelehnt', + withdrawn: 'zurückgezogen', + })[s] || s + +/** + * Onboarding für Nutzer ohne aktive Vereinsmitgliedschaft (Phase A). + */ +export default function OnboardingPage() { + const { user, checkAuth } = useAuth() + const [publicClubs, setPublicClubs] = useState([]) + const [myJoinRequests, setMyJoinRequests] = useState([]) + const [joinClubId, setJoinClubId] = useState('') + const [joinMessage, setJoinMessage] = useState('') + const [joinBusy, setJoinBusy] = useState(false) + const [error, setError] = useState('') + const [ok, setOk] = useState('') + + const accountState = resolveAccountState(user) + const emailOk = accountState !== 'unverified' + + const refreshJoinRequests = () => { + if (!emailOk) return + api.getMyClubJoinRequests().then(setMyJoinRequests).catch(() => {}) + } + + useEffect(() => { + api.listPublicClubsDirectory().then(setPublicClubs).catch(() => {}) + refreshJoinRequests() + }, [user?.id, emailOk]) + + const memberClubIds = new Set((user?.clubs || []).map((c) => c.id)) + const pendingClubIds = new Set( + myJoinRequests.filter((r) => r.status === 'pending').map((r) => r.club_id) + ) + const joinClubChoices = publicClubs.filter( + (c) => !memberClubIds.has(c.id) && !pendingClubIds.has(c.id) + ) + + const handleJoin = async (e) => { + e.preventDefault() + setError('') + setOk('') + if (!joinClubId) { + setError('Bitte einen Verein auswählen.') + return + } + setJoinBusy(true) + try { + await api.createClubJoinRequest({ + club_id: parseInt(joinClubId, 10), + message: (joinMessage || '').trim() || undefined, + }) + setJoinMessage('') + setJoinClubId('') + refreshJoinRequests() + await checkAuth() + setOk('Antrag gesendet. Der Vereinsadmin kann ihn unter Vereinsverwaltung annehmen.') + } catch (err) { + setError(err.message || 'Antrag fehlgeschlagen.') + } finally { + setJoinBusy(false) + } + } + + return ( +
+

Willkommen bei Shinkan

+

+ Shinkan ist die Trainingsplanungs-Plattform für Vereine. Um Übungen, Planung und Medien zu nutzen, + brauchst du eine Mitgliedschaft in einem Verein — oder du beantragst die Gründung eines neuen Vereins. +

+ + + + {!emailOk ? ( +
+

+ Bitte bestätige zuerst deine E-Mail-Adresse. Danach kannst du einen Beitrittsantrag stellen. +

+
+ ) : ( + <> + {ok ? ( +

+ {ok} +

+ ) : null} + {error ? ( +

+ {error} +

+ ) : null} + +
+

Bestehendem Verein beitreten

+

+ Wähle einen Verein und sende einen Beitrittsantrag. Nach Freigabe durch den Vereinsadmin + stehen dir alle Funktionen zur Verfügung. +

+ + {myJoinRequests.length > 0 ? ( +
+ Meine Anträge +
    + {myJoinRequests.map((r) => ( +
  • + {r.club_name || `Verein #${r.club_id}`} — {joinStatusLabel(r.status)} + {r.status === 'pending' ? ( + <> + {' '} + + + ) : null} +
  • + ))} +
+
+ ) : null} + +
+ + + +