Update Capability Catalog and Club Membership Documentation
Some checks failed
Deploy Development / deploy (push) Successful in 49s
Test Suite / pytest-backend (push) Failing after 0s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 15s
Test Suite / playwright-tests (push) Has been cancelled
Test Suite / k6 /health Baseline (push) Has been cancelled
Some checks failed
Deploy Development / deploy (push) Successful in 49s
Test Suite / pytest-backend (push) Failing after 0s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 15s
Test Suite / playwright-tests (push) Has been cancelled
Test Suite / k6 /health Baseline (push) Has been cancelled
- Revised the status in the Capability Catalog to reflect partial implementation (M3). - Added a new reference to `MEMBERSHIP_RBAC_DECISIONS_2026-06.md` in both the Capability Catalog and Club Membership documentation. - Enhanced the Club Membership documentation with details on product decisions and onboarding phases. - Implemented middleware in the backend to restrict access for unverified users and those pending club membership. - Updated versioning in `version.py` to reflect changes in account lifecycle management.
This commit is contained in:
parent
30dc30c7aa
commit
a2f60d3f46
|
|
@ -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 |
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
---
|
||||
|
|
|
|||
210
.claude/docs/technical/MEMBERSHIP_RBAC_DECISIONS_2026-06.md
Normal file
210
.claude/docs/technical/MEMBERSHIP_RBAC_DECISIONS_2026-06.md
Normal file
|
|
@ -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.
|
||||
165
backend/account_onboarding_gate.py
Normal file
165
backend/account_onboarding_gate.py
Normal file
|
|
@ -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
|
||||
|
|
@ -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)."""
|
||||
|
|
|
|||
58
backend/tests/test_account_onboarding_gate.py
Normal file
58
backend/tests/test_account_onboarding_gate.py
Normal file
|
|
@ -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"
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 <Navigate to="/login" replace />
|
||||
}
|
||||
|
||||
const showAdminNav = computeShowAdminNav(user)
|
||||
const location = useLocation()
|
||||
const onboardingOnly = isOnboardingRestricted(user)
|
||||
if (onboardingOnly && !isOnboardingAllowedPath(location.pathname)) {
|
||||
return <Navigate to="/onboarding" replace />
|
||||
}
|
||||
|
||||
const showAdminNav = computeShowAdminNav(user) && !onboardingOnly
|
||||
|
||||
return (
|
||||
<OrgInboxProvider user={user}>
|
||||
<FormEditorActionsProvider>
|
||||
<DesktopSidebar showAdminNav={showAdminNav} user={user} onLogout={handleLogout} />
|
||||
<DesktopSidebar
|
||||
showAdminNav={showAdminNav}
|
||||
onboardingOnly={onboardingOnly}
|
||||
user={user}
|
||||
onLogout={handleLogout}
|
||||
/>
|
||||
<div className="app-shell">
|
||||
<div className="app-shell__column">
|
||||
<div className="app-header app-header--mobile app-header--mobile-stack">
|
||||
<div className="app-header-mobile__top">
|
||||
<div className="app-logo">🥋 Shinkan</div>
|
||||
</div>
|
||||
<ActiveClubSwitcher variant="mobile" />
|
||||
{!onboardingOnly ? <ActiveClubSwitcher variant="mobile" /> : null}
|
||||
</div>
|
||||
<div className="app-main">
|
||||
<InactiveMembershipBanner />
|
||||
<Outlet />
|
||||
</div>
|
||||
<FormEditorBottomSlot>
|
||||
<Nav showAdminNav={showAdminNav} />
|
||||
<Nav showAdminNav={showAdminNav} onboardingOnly={onboardingOnly} />
|
||||
</FormEditorBottomSlot>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -220,6 +236,7 @@ const appRouter = createBrowserRouter([
|
|||
element: <ProtectedLayout />,
|
||||
children: [
|
||||
{ index: true, element: <Dashboard /> },
|
||||
{ path: 'onboarding', element: <OnboardingPage /> },
|
||||
{ path: 'profile', element: <Navigate to="/settings" replace /> },
|
||||
{ path: 'settings', element: <AccountSettingsPage /> },
|
||||
{ path: 'settings/system', element: <SettingsSystemInfoPage /> },
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
208
frontend/src/pages/OnboardingPage.jsx
Normal file
208
frontend/src/pages/OnboardingPage.jsx
Normal file
|
|
@ -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 (
|
||||
<div className="page-padding app-page" style={{ padding: '1rem', maxWidth: '40rem' }}>
|
||||
<h1 style={{ marginTop: 0, fontSize: '1.5rem' }}>Willkommen bei Shinkan</h1>
|
||||
<p style={{ color: 'var(--text2)', lineHeight: 1.5, marginBottom: '1.25rem' }}>
|
||||
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.
|
||||
</p>
|
||||
|
||||
<EmailVerificationBanner profile={user} />
|
||||
|
||||
{!emailOk ? (
|
||||
<div className="card">
|
||||
<p style={{ margin: 0, color: 'var(--text2)', lineHeight: 1.5 }}>
|
||||
Bitte bestätige zuerst deine E-Mail-Adresse. Danach kannst du einen Beitrittsantrag stellen.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{ok ? (
|
||||
<p role="status" style={{ color: 'var(--accent-dark)', marginBottom: '1rem' }}>
|
||||
{ok}
|
||||
</p>
|
||||
) : null}
|
||||
{error ? (
|
||||
<p role="alert" style={{ color: 'var(--danger)', marginBottom: '1rem' }}>
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="card" style={{ marginBottom: '1rem' }}>
|
||||
<h2 style={{ margin: '0 0 0.75rem', fontSize: '1.1rem' }}>Bestehendem Verein beitreten</h2>
|
||||
<p style={{ color: 'var(--text2)', fontSize: '0.875rem', marginBottom: '1rem', lineHeight: 1.5 }}>
|
||||
Wähle einen Verein und sende einen Beitrittsantrag. Nach Freigabe durch den Vereinsadmin
|
||||
stehen dir alle Funktionen zur Verfügung.
|
||||
</p>
|
||||
|
||||
{myJoinRequests.length > 0 ? (
|
||||
<div style={{ marginBottom: '1rem' }}>
|
||||
<strong style={{ fontSize: '0.9rem' }}>Meine Anträge</strong>
|
||||
<ul
|
||||
style={{
|
||||
margin: '0.5rem 0 0',
|
||||
paddingLeft: '1.25rem',
|
||||
color: 'var(--text2)',
|
||||
fontSize: '0.9rem',
|
||||
}}
|
||||
>
|
||||
{myJoinRequests.map((r) => (
|
||||
<li key={r.id} style={{ marginBottom: '0.35rem' }}>
|
||||
{r.club_name || `Verein #${r.club_id}`} — {joinStatusLabel(r.status)}
|
||||
{r.status === 'pending' ? (
|
||||
<>
|
||||
{' '}
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
style={{ fontSize: '0.75rem', padding: '0.15rem 0.45rem' }}
|
||||
onClick={async () => {
|
||||
if (!confirm('Antrag wirklich zurückziehen?')) return
|
||||
try {
|
||||
await api.withdrawClubJoinRequest(r.id)
|
||||
refreshJoinRequests()
|
||||
} catch (err) {
|
||||
setError(err.message || 'Zurückziehen fehlgeschlagen.')
|
||||
}
|
||||
}}
|
||||
>
|
||||
zurückziehen
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<form onSubmit={handleJoin}>
|
||||
<label className="form-label" htmlFor="onb-join-club">
|
||||
Verein
|
||||
</label>
|
||||
<select
|
||||
id="onb-join-club"
|
||||
className="form-input"
|
||||
value={joinClubId}
|
||||
onChange={(e) => setJoinClubId(e.target.value)}
|
||||
>
|
||||
<option value="">—</option>
|
||||
{joinClubChoices.map((c) => (
|
||||
<option key={c.id} value={String(c.id)}>
|
||||
{c.name}
|
||||
{c.abbreviation ? ` (${c.abbreviation})` : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<label className="form-label" htmlFor="onb-join-msg" style={{ marginTop: '0.75rem' }}>
|
||||
Nachricht (optional)
|
||||
</label>
|
||||
<textarea
|
||||
id="onb-join-msg"
|
||||
className="form-input"
|
||||
rows={2}
|
||||
value={joinMessage}
|
||||
onChange={(e) => setJoinMessage(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-primary"
|
||||
disabled={joinBusy}
|
||||
style={{ marginTop: '0.85rem' }}
|
||||
>
|
||||
{joinBusy ? 'Senden…' : 'Beitritt beantragen'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<h2 style={{ margin: '0 0 0.75rem', fontSize: '1.1rem' }}>Neuen Verein gründen</h2>
|
||||
<p style={{ color: 'var(--text2)', fontSize: '0.875rem', margin: 0, lineHeight: 1.5 }}>
|
||||
Die Beantragung einer neuen Vereinsgründung wird als Nächstes freigeschaltet (Freigabe durch
|
||||
den Plattform-Administrator). Bis dahin wende dich an{' '}
|
||||
<a href="mailto:support@jinkendo.de">support@jinkendo.de</a> oder tritt einem bestehenden Verein bei.
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<p style={{ marginTop: '1.25rem', fontSize: '0.875rem' }}>
|
||||
<Link to="/settings">Einstellungen</Link> (Passwort, Profil)
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
38
frontend/src/utils/accountState.js
Normal file
38
frontend/src/utils/accountState.js
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
/**
|
||||
* Account-Lifecycle-Helfer (CAPABILITY_CATALOG §3, Phase A Onboarding).
|
||||
*/
|
||||
|
||||
export function resolveAccountState(user) {
|
||||
if (!user) return 'anonymous'
|
||||
if (user.account_state) return user.account_state
|
||||
const clubs = user.clubs || []
|
||||
const hasActive = clubs.some(
|
||||
(c) => String(c.membership_status || 'active').toLowerCase() === 'active'
|
||||
)
|
||||
if (hasActive) return 'active_member'
|
||||
const verified =
|
||||
user.email_verified === true ||
|
||||
user.email_verified === 't' ||
|
||||
user.email_verified === 1 ||
|
||||
user.email_verified === 'true'
|
||||
if (!verified) return 'unverified'
|
||||
return 'verified_pending_club'
|
||||
}
|
||||
|
||||
export function isPlatformAccountState(state) {
|
||||
return state === 'platform_admin'
|
||||
}
|
||||
|
||||
/** Eingeschränkter Modus: noch kein aktiver Vereinszugang bzw. E-Mail offen. */
|
||||
export function isOnboardingRestricted(user) {
|
||||
const state = resolveAccountState(user)
|
||||
if (isPlatformAccountState(state)) return false
|
||||
return state === 'verified_pending_club' || state === 'unverified'
|
||||
}
|
||||
|
||||
const ONBOARDING_PATHS = ['/onboarding', '/settings', '/settings/legal', '/settings/system']
|
||||
|
||||
export function isOnboardingAllowedPath(pathname) {
|
||||
const p = pathname || ''
|
||||
return ONBOARDING_PATHS.some((base) => p === base || p.startsWith(`${base}/`))
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user