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

- 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:
Lars 2026-06-07 05:57:13 +02:00
parent 30dc30c7aa
commit a2f60d3f46
12 changed files with 765 additions and 15 deletions

View File

@ -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 |

View File

@ -1,8 +1,8 @@
# Vereins-Membership & Feature-System Shinkan v1
**Status:** Konzept (Schema- und Enforcement-Zielbild vor Implementierung)
**Status:** Konzept + M1M3 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)
---

View 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 M1M3; Roadmap AF.
- 2026-06-06: Phase A — `account_onboarding_gate.py`, Frontend `/onboarding`, reduzierte Navigation.

View 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

View File

@ -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)."""

View 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"

View File

@ -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)

View File

@ -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 /> },

View File

@ -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 (

View File

@ -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,

View 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>
)
}

View 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}/`))
}