feat: enhance API and profile management with environment configurations
All checks were successful
Deploy Development / deploy (push) Successful in 34s
Test Suite / pytest-backend (push) Successful in 23s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 7s
Test Suite / playwright-tests (push) Successful in 24s
All checks were successful
Deploy Development / deploy (push) Successful in 34s
Test Suite / pytest-backend (push) Successful in 23s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 7s
Test Suite / playwright-tests (push) Successful in 24s
- Added functions to determine production environment and OpenAPI exposure settings, improving API documentation control. - Updated FastAPI initialization to conditionally set OpenAPI and documentation URLs based on environment variables. - Refactored health check response to limit detail exposure in production environments, enhancing security. - Streamlined profile management by removing legacy ID retrieval and ensuring session-based profile access for security improvements.
This commit is contained in:
parent
18fa4de055
commit
c2d9eac151
|
|
@ -29,6 +29,12 @@ Letzte Änderung: 2026-05-06 — MULTI_TENANCY §3 Gap-Analyse aktualisiert; Aud
|
|||
|
||||
---
|
||||
|
||||
### Changelog (Fortführung)
|
||||
|
||||
- **2026-05-07:** Legacy `GET/PUT /api/profile` auf Session-Profil gehärtet; OpenAPI/Health-Ready Produktionsdefaults; Security-Release-Tests + CI-Schritt `security_release_checks.py` — siehe `PRODUCTION_READINESS_AUDIT_2026-05.md`.
|
||||
|
||||
---
|
||||
|
||||
### Hinweis `GET /training-units`
|
||||
|
||||
Kein impliziter Filter nach `effective_club_id` (Multi-Verein-Kalender); bei Bedarf `club_id` Query setzen.
|
||||
|
|
|
|||
82
.claude/docs/working/PRODUCTION_READINESS_AUDIT_2026-05.md
Normal file
82
.claude/docs/working/PRODUCTION_READINESS_AUDIT_2026-05.md
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
# Produktionsreife: Audit-Ergebnis & Umsetzungsplan (Stand 2026-05-07)
|
||||
|
||||
**Zweck:** Einheitliche Referenz gegen **Drift** zwischen Sicherheits-/Betriebsanforderungen und Code.
|
||||
**Bezug:** Zugriffsschicht `.claude/docs/technical/ACCESS_LAYER_AND_GOVERNANCE_PLAN.md`, Cursor-Regel `.cursor/rules/access-layer.mdc`.
|
||||
|
||||
---
|
||||
|
||||
## Teil A — Audit-Ergebnis (Kurzfassung)
|
||||
|
||||
### A.1 Kritisch / Hoch (behoben oder geplant)
|
||||
|
||||
| ID | Befund | Status (2026-05-07) |
|
||||
|----|--------|----------------------|
|
||||
| SEC-01 | `GET /api/profile` (Legacy): ohne Header wurde das **erste Profil der DB** geliefert → IDOR / Datenleck | **Behoben:** Profil immer aus Session |
|
||||
| SEC-02 | OpenAPI `/docs`, `/redoc`, `/openapi.json` in Produktion exponiert | **Behoben:** bei `ENVIRONMENT=production` aus; Override `PUBLIC_OPENAPI=1` |
|
||||
| SEC-03 | `/api/health/ready` mit Tabellendetails / Migrationszähler öffentlich | **Behoben:** in Prod nur kompakte Antwort; Detail via `HEALTH_READY_PUBLIC_DETAIL=1` |
|
||||
| SEC-04 | Statische Auslieferung `/media` ohne Auth (Link-Leak) | **Geplant** (Phase 2): geschützter Download oder signierte URLs |
|
||||
| OPS-01 | PostgreSQL-Hostport in `docker-compose.yml` | **Dokumentiert:** nur intern binden / Firewall |
|
||||
| OPS-02 | Fehlende Security-Header (CSP, X-Frame-Options, …) | **Teilweise:** nginx-Basis-Header ergänzt |
|
||||
|
||||
### A.2 Mittel / Konsistenz
|
||||
|
||||
| ID | Befund | Status |
|
||||
|----|--------|--------|
|
||||
| CON-01 | Frontend: direktes `fetch('/api/profiles')` ohne zentralen Client | **Behoben** (AdminCatalogsPage → `api.listProfiles`) |
|
||||
| CON-02 | `ProfileContext.jsx` (unused): falsches Token-Feld / `session` | **Behoben** (`user` + `getCurrentProfile` / `listProfiles`) |
|
||||
| CON-03 | Sparten (`division`) vs. Zielbild ACCESS_LAYER | **Offen** (Roadmap Stufe D) |
|
||||
|
||||
### A.3 Niedrig
|
||||
|
||||
| ID | Befund | Status |
|
||||
|----|--------|--------|
|
||||
| MISC-01 | `auth.py` Debug-`print` beim Import | **Behoben** (entfernt) |
|
||||
| MISC-02 | `delete_profile`: DELETE auf Mitai-Tabellen schlägt fehl, wenn Tabelle fehlt | **Behoben:** nur löschen, wenn Tabelle existiert |
|
||||
|
||||
---
|
||||
|
||||
## Teil B — Priorisierter Umsetzungsplan
|
||||
|
||||
### Phase 1 — Sofort (Beta-Blocker) ✅ umgesetzt in Repo-Stand
|
||||
|
||||
1. Legacy-Endpunkte `/api/profile` GET/PUT: **nur Session-`profile_id`**, kein „erstes Profil“-Fallback.
|
||||
2. OpenAPI in Produktion deaktivieren; opt-in für Notfall-Debugging.
|
||||
3. Readiness-Endpoint in Prod entschärfen; operatives Detail per Env.
|
||||
4. Automatisierte **Security-Release-Tests** (`tests/test_security_release.py`) + Script `scripts/security_release_checks.py` in CI.
|
||||
5. Profil-Löschung: optionale Tabellen nur bei Existenz.
|
||||
6. Frontend: Admin-Kataloge Profilliste über `api.js`.
|
||||
|
||||
### Phase 2 — Kurzfristig (vor breitem Go-Live)
|
||||
|
||||
1. **Medien:** Zugriff an Übungs-/Governance-Rechte koppeln (kein anonymes `/media/...` für sensible Objekte).
|
||||
2. **Mitgliederverzeichnis:** E-Mail-Sichtbarkeit rollenbasierend oder Richtlinie/DSE.
|
||||
3. **DB-Port:** Prod-Compose ohne öffentliche DB-Port-Publikation oder strikte Bindung.
|
||||
4. `GET /api/maturity-models/{id}`: Leserechte mit Admin/Matrix-Politik abstimmen.
|
||||
|
||||
### Phase 3 — Mittelfristig
|
||||
|
||||
1. CSP und restliche Header fein abstimmen (PWA, eingebettete Medien).
|
||||
2. `ACCESS_LAYER_ENDPOINT_AUDIT.md` bei Änderungen an Tenant/Governance aktualisieren (Arbeitsdisziplin).
|
||||
3. Division-/Sparten-Durchsetzung laut ARCHITEKTUR-Roadmap.
|
||||
|
||||
---
|
||||
|
||||
## Teil C — CI / Qualitätssicherung
|
||||
|
||||
- **ACCESS_LAYER_STRICT:** `scripts/check_access_layer_hints.py` (unverändert Pflicht).
|
||||
- **Security-Release:** `python scripts/security_release_checks.py` (pytest-Modul `test_security_release`).
|
||||
- **Regression Profil:** `tests/test_profiles_read_access.py` (Legacy-Route).
|
||||
|
||||
---
|
||||
|
||||
## Teil D — Umgebungsvariablen (Referenz)
|
||||
|
||||
| Variable | Bedeutung |
|
||||
|----------|-----------|
|
||||
| `ENVIRONMENT` | `production` / `prod` → OpenAPI aus, Health-Ready kompakt |
|
||||
| `PUBLIC_OPENAPI` | `1` / `true` → OpenAPI trotz Prod einschalten (nur Debugging) |
|
||||
| `HEALTH_READY_PUBLIC_DETAIL` | `1` / `true` → volle Ready-JSON inkl. Tabellenliste in Prod |
|
||||
|
||||
---
|
||||
|
||||
**Pflege:** Nach jeder relevanten Änderung **Status-Spalte** und ggf. **Phase** aktualisieren; bei neuem Audit Datum im Titel anpassen oder Abschnitt „Changelog“ ergänzen.
|
||||
|
|
@ -38,6 +38,7 @@ jobs:
|
|||
pip install -r /app/requirements-dev.txt &&
|
||||
cd /app &&
|
||||
ACCESS_LAYER_STRICT=1 python scripts/check_access_layer_hints.py &&
|
||||
python scripts/security_release_checks.py &&
|
||||
ACCESS_LAYER_INTEGRATION=1 SKIP_DB_MIGRATE=1 python -m pytest tests -m 'not slow' -ra -vv --tb=short
|
||||
"
|
||||
|
||||
|
|
|
|||
|
|
@ -200,6 +200,10 @@ ALLOWED_ORIGINS=https://shinkan.jinkendo.de
|
|||
MEDIA_DIR=/app/media
|
||||
```
|
||||
|
||||
## Produktions-/Sicherheitsaudit (Drift vermeiden)
|
||||
|
||||
Aktuelle Befunde und Umsetzungsstände: `.claude/docs/working/PRODUCTION_READINESS_AUDIT_2026-05.md` (Fortlaufend pflegen.)
|
||||
|
||||
## Kritische Regeln für Claude Code
|
||||
|
||||
### Must-Do:
|
||||
|
|
|
|||
|
|
@ -13,8 +13,6 @@ import bcrypt
|
|||
|
||||
from db import get_db, get_cursor
|
||||
|
||||
print("[AUTH.PY] Module loaded - require_auth_flexible will be defined")
|
||||
|
||||
|
||||
def hash_pin(pin: str) -> str:
|
||||
"""Hash password with bcrypt. Falls back gracefully from legacy SHA256."""
|
||||
|
|
|
|||
|
|
@ -18,6 +18,20 @@ from slowapi.errors import RateLimitExceeded
|
|||
|
||||
from version import APP_VERSION, BUILD_DATE, DB_SCHEMA_VERSION, MODULE_VERSIONS
|
||||
|
||||
|
||||
def _is_production_environment() -> bool:
|
||||
return os.getenv("ENVIRONMENT", "development").strip().lower() in ("production", "prod")
|
||||
|
||||
|
||||
def _public_openapi_enabled() -> bool:
|
||||
return os.getenv("PUBLIC_OPENAPI", "").strip().lower() in ("1", "true", "yes")
|
||||
|
||||
|
||||
def _health_ready_public_detail_enabled() -> bool:
|
||||
"""In Prod standardmäßig keine Tabellen-/Migrations-Details (Information Disclosure)."""
|
||||
return os.getenv("HEALTH_READY_PUBLIC_DETAIL", "").strip().lower() in ("1", "true", "yes")
|
||||
|
||||
|
||||
# Run database migrations before API start — halbes Schema ist schlimmer als kein Start
|
||||
# Lokal ohne DB / nur Tests: SKIP_DB_MIGRATE=1
|
||||
if os.getenv("SKIP_DB_MIGRATE", "").strip().lower() in ("1", "true", "yes"):
|
||||
|
|
@ -39,11 +53,20 @@ else:
|
|||
|
||||
from routers.auth import limiter as auth_rate_limiter
|
||||
|
||||
# OpenAPI: in Produktion standardmäßig aus (Schema nicht öffentlich). Notfall: PUBLIC_OPENAPI=1
|
||||
_expose_docs = (not _is_production_environment()) or _public_openapi_enabled()
|
||||
_openapi_url = "/openapi.json" if _expose_docs else None
|
||||
_docs_url = "/docs" if _expose_docs else None
|
||||
_redoc_url = "/redoc" if _expose_docs else None
|
||||
|
||||
# Initialize FastAPI app
|
||||
app = FastAPI(
|
||||
title="Shinkan Jinkendo API",
|
||||
description="Trainer- und Vereinsplattform für Kampfsport-Trainingsplanung",
|
||||
version=APP_VERSION
|
||||
version=APP_VERSION,
|
||||
openapi_url=_openapi_url,
|
||||
docs_url=_docs_url,
|
||||
redoc_url=_redoc_url,
|
||||
)
|
||||
|
||||
# SlowAPI: Rate Limits auf /api/auth/* (Decorator in routers/auth.py)
|
||||
|
|
@ -132,7 +155,7 @@ def health_ready():
|
|||
migration_count = 0
|
||||
|
||||
complete = bool(err is None and all(tables.get(t) for t in REQUIRED))
|
||||
return {
|
||||
body = {
|
||||
"status": "ready" if complete else "degraded",
|
||||
"database": err is None,
|
||||
"detail": err,
|
||||
|
|
@ -140,18 +163,27 @@ def health_ready():
|
|||
"tables": tables,
|
||||
"schema_migrations_count": migration_count,
|
||||
}
|
||||
if _is_production_environment() and not _health_ready_public_detail_enabled():
|
||||
return {
|
||||
"status": body["status"],
|
||||
"database": body["database"],
|
||||
"schema_complete": body["schema_complete"],
|
||||
}
|
||||
return body
|
||||
|
||||
|
||||
# Root Endpoint
|
||||
@app.get("/")
|
||||
def read_root():
|
||||
"""Root endpoint - API info"""
|
||||
return {
|
||||
out = {
|
||||
"app": "Shinkan Jinkendo API",
|
||||
"version": APP_VERSION,
|
||||
"docs": "/docs",
|
||||
"health": "/health"
|
||||
"health": "/health",
|
||||
}
|
||||
if _expose_docs:
|
||||
out["docs"] = "/docs"
|
||||
return out
|
||||
|
||||
# Register routers
|
||||
from routers import auth, profiles, exercises, exercise_progression_graphs, clubs, club_memberships, club_join_requests, admin_users, skills, training_planning, training_framework_programs, catalogs, maturity_models, matrix_stack_bundle, import_wiki, import_wiki_admin
|
||||
|
|
|
|||
|
|
@ -22,19 +22,6 @@ router = APIRouter(prefix="/api", tags=["profiles"])
|
|||
_ALLOWED_PORTAL_ROLES = frozenset({"user", "trainer", "admin", "superadmin"})
|
||||
|
||||
|
||||
# ── Helper ────────────────────────────────────────────────────────────────────
|
||||
def get_pid(x_profile_id: Optional[str] = Header(default=None)) -> str:
|
||||
"""Get profile_id - from header for legacy endpoints."""
|
||||
if x_profile_id:
|
||||
return x_profile_id
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cur.execute("SELECT id FROM profiles ORDER BY created LIMIT 1")
|
||||
row = cur.fetchone()
|
||||
if row: return row['id']
|
||||
raise HTTPException(400, "Kein Profil gefunden")
|
||||
|
||||
|
||||
# ── Current User Profile ──────────────────────────────────────────────────────
|
||||
@router.get("/profiles/me")
|
||||
def get_current_profile(
|
||||
|
|
@ -310,26 +297,45 @@ def delete_profile(pid: str, session=Depends(require_auth)):
|
|||
cur.execute("SELECT COUNT(*) as count FROM profiles")
|
||||
count = cur.fetchone()['count']
|
||||
if count <= 1: raise HTTPException(400, "Letztes Profil kann nicht gelöscht werden")
|
||||
for table in ['weight_log','circumference_log','caliper_log','nutrition_log','activity_log','ai_insights']:
|
||||
cur.execute(f"DELETE FROM {table} WHERE profile_id=%s", (pid,))
|
||||
# Mitai-Überbleibsel: nur löschen, wenn die Tabelle im Schema existiert (Shinkan-DB ohne diese Tabellen).
|
||||
_optional_mitai_tables = (
|
||||
"weight_log",
|
||||
"circumference_log",
|
||||
"caliper_log",
|
||||
"nutrition_log",
|
||||
"activity_log",
|
||||
"ai_insights",
|
||||
)
|
||||
for table in _optional_mitai_tables:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM information_schema.tables
|
||||
WHERE table_schema = 'public' AND table_name = %s
|
||||
) AS t_exists
|
||||
""",
|
||||
(table,),
|
||||
)
|
||||
ex = cur.fetchone()
|
||||
if ex and next(iter(ex.values())):
|
||||
cur.execute(f"DELETE FROM {table} WHERE profile_id=%s", (pid,))
|
||||
cur.execute("DELETE FROM profiles WHERE id=%s", (pid,))
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ── Current User Profile ──────────────────────────────────────────────────────
|
||||
@router.get("/profile")
|
||||
def get_active_profile(x_profile_id: Optional[str] = Header(default=None), session: dict = Depends(require_auth)):
|
||||
"""Legacy endpoint – returns active profile."""
|
||||
pid = get_pid(x_profile_id)
|
||||
def get_active_profile(session: dict = Depends(require_auth)):
|
||||
"""Legacy-Alias für das eingeloggte Profil — immer Session, kein X-Profile-Id (SECURITY: kein IDOR)."""
|
||||
pid = str(session["profile_id"])
|
||||
return profile_document(pid)
|
||||
|
||||
|
||||
@router.put("/profile")
|
||||
def update_active_profile(
|
||||
p: ProfileUpdate,
|
||||
x_profile_id: Optional[str] = Header(default=None),
|
||||
tenant: TenantContext = Depends(get_tenant_context),
|
||||
):
|
||||
"""Update current user's profile."""
|
||||
pid = get_pid(x_profile_id)
|
||||
"""Profil des eingeloggten Nutzers aktualisieren — dieselbe Quelle wie GET /profile."""
|
||||
pid = str(tenant.profile_id)
|
||||
return _run_profile_update(pid, p, tenant)
|
||||
|
|
|
|||
32
backend/scripts/security_release_checks.py
Normal file
32
backend/scripts/security_release_checks.py
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
CI-Sicherheitschecks: schlanke pytest-Sammlung ohne Integrations-DB.
|
||||
|
||||
Repo-Root ist egal — arbeitet relativ zu diesem Script (backend/).
|
||||
|
||||
Usage (aus backend/):
|
||||
python scripts/security_release_checks.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def main() -> int:
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
test_file = root / "tests" / "test_security_release.py"
|
||||
cmd = [
|
||||
sys.executable,
|
||||
"-m",
|
||||
"pytest",
|
||||
str(test_file),
|
||||
"-v",
|
||||
"--tb=short",
|
||||
]
|
||||
return subprocess.run(cmd, cwd=str(root)).returncode
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
116
backend/tests/test_security_release.py
Normal file
116
backend/tests/test_security_release.py
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
"""
|
||||
Sicherheits-Regression für Release-Konfiguration (OpenAPI, Health-Ready, Legacy-Routen).
|
||||
|
||||
Läuft ohne PostgreSQL, wo möglich (TestClient + Subprocess mit frischem Import).
|
||||
CI: scripts/security_release_checks.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
os.environ.setdefault("SKIP_DB_MIGRATE", "1")
|
||||
|
||||
from auth import require_auth
|
||||
from main import app
|
||||
|
||||
BACKEND_ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client() -> TestClient:
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clear_auth_override():
|
||||
yield
|
||||
app.dependency_overrides.pop(require_auth, None)
|
||||
|
||||
|
||||
def test_legacy_get_profile_uses_session_only(client: TestClient) -> None:
|
||||
"""SEC-01: /api/profile darf nur das Session-Profil liefern."""
|
||||
|
||||
def auth_user() -> dict:
|
||||
return {"profile_id": 901, "role": "trainer"}
|
||||
|
||||
app.dependency_overrides[require_auth] = auth_user
|
||||
with patch("routers.profiles.profile_document") as pd_mock:
|
||||
pd_mock.return_value = {"id": 901, "name": "T"}
|
||||
r = client.get("/api/profile", headers={"X-Auth-Token": "dummy"})
|
||||
assert r.status_code == 200
|
||||
pd_mock.assert_called_once_with("901")
|
||||
|
||||
|
||||
def _run_fresh_import_int(code: str, env: dict) -> subprocess.CompletedProcess:
|
||||
merged = {**os.environ, **env, "SKIP_DB_MIGRATE": "1"}
|
||||
return subprocess.run(
|
||||
[sys.executable, "-c", code],
|
||||
cwd=str(BACKEND_ROOT),
|
||||
env=merged,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"extra_env, expect_openapi",
|
||||
[
|
||||
({"ENVIRONMENT": "development"}, True),
|
||||
({"ENVIRONMENT": "production"}, False),
|
||||
({"ENVIRONMENT": "production", "PUBLIC_OPENAPI": "true"}, True),
|
||||
],
|
||||
)
|
||||
def test_openapi_urls_match_environment(extra_env: dict, expect_openapi: bool) -> None:
|
||||
snippet = f"""
|
||||
from main import app
|
||||
assert (app.openapi_url is not None) == {expect_openapi}
|
||||
assert (app.docs_url is not None) == {expect_openapi}
|
||||
"""
|
||||
proc = _run_fresh_import_int(snippet, extra_env)
|
||||
assert proc.returncode == 0, proc.stderr + proc.stdout
|
||||
|
||||
|
||||
def test_health_ready_minimal_body_in_production() -> None:
|
||||
snippet = """
|
||||
from fastapi.testclient import TestClient
|
||||
from main import app
|
||||
c = TestClient(app)
|
||||
r = c.get("/api/health/ready")
|
||||
r.raise_for_status()
|
||||
j = r.json()
|
||||
assert "tables" not in j
|
||||
assert "detail" not in j
|
||||
assert "schema_migrations_count" not in j
|
||||
assert "status" in j and "database" in j and "schema_complete" in j
|
||||
"""
|
||||
proc = _run_fresh_import_int(
|
||||
snippet,
|
||||
{"ENVIRONMENT": "production"},
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr + proc.stdout
|
||||
|
||||
|
||||
def test_health_ready_full_detail_when_flag_in_production() -> None:
|
||||
snippet = """
|
||||
from fastapi.testclient import TestClient
|
||||
from main import app
|
||||
c = TestClient(app)
|
||||
r = c.get("/api/health/ready")
|
||||
r.raise_for_status()
|
||||
j = r.json()
|
||||
assert "tables" in j
|
||||
assert "schema_migrations_count" in j
|
||||
"""
|
||||
proc = _run_fresh_import_int(
|
||||
snippet,
|
||||
{"ENVIRONMENT": "production", "HEALTH_READY_PUBLIC_DETAIL": "1"},
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr + proc.stdout
|
||||
|
|
@ -4,6 +4,11 @@ server {
|
|||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
|
||||
|
||||
# Docker-Embedded DNS: Hostname »backend« bei Container-Neustarts neu auflösen
|
||||
# — verringert sporadische 502, wenn sich nur die Backend-Container-IP geändert hat.
|
||||
resolver 127.0.0.11 valid=10s ipv6=off;
|
||||
|
|
|
|||
|
|
@ -1,58 +1,70 @@
|
|||
import { createContext, useContext, useState, useEffect } from 'react'
|
||||
import { useAuth } from './AuthContext'
|
||||
import { getCurrentProfile, listProfiles } from '../utils/api'
|
||||
|
||||
const ProfileContext = createContext(null)
|
||||
|
||||
export function ProfileProvider({ children }) {
|
||||
const { session } = useAuth()
|
||||
const [profiles, setProfiles] = useState([])
|
||||
const { user, isAuthenticated } = useAuth()
|
||||
const [profiles, setProfiles] = useState([])
|
||||
const [activeProfile, setActiveProfileState] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
const loadProfiles = async () => {
|
||||
const loadProfiles = async (authUser) => {
|
||||
try {
|
||||
const token = localStorage.getItem('bodytrack_token') || ''
|
||||
const res = await fetch('/api/profiles', {
|
||||
headers: { 'X-Auth-Token': token }
|
||||
})
|
||||
if (!res.ok) return []
|
||||
return await res.json()
|
||||
} catch(e) { return [] }
|
||||
if (!authUser?.id) return []
|
||||
const admin = authUser.role === 'admin' || authUser.role === 'superadmin'
|
||||
if (admin) {
|
||||
try {
|
||||
return await listProfiles()
|
||||
} catch {
|
||||
const me = await getCurrentProfile()
|
||||
return me ? [me] : []
|
||||
}
|
||||
}
|
||||
const me = await getCurrentProfile()
|
||||
return me ? [me] : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
// Re-load whenever session changes (login/logout/switch)
|
||||
useEffect(() => {
|
||||
if (!session) {
|
||||
if (!isAuthenticated || !user?.id) {
|
||||
setActiveProfileState(null)
|
||||
setProfiles([])
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
setLoading(true)
|
||||
loadProfiles().then(data => {
|
||||
setProfiles(data)
|
||||
// Always use the profile_id from the session token – not localStorage
|
||||
const match = data.find(p => p.id === session.profile_id)
|
||||
setActiveProfileState(match || data[0] || null)
|
||||
loadProfiles(user).then((data) => {
|
||||
const rows = Array.isArray(data) ? data : []
|
||||
setProfiles(rows)
|
||||
const uid = user.id
|
||||
const match = rows.find((p) => String(p.id) === String(uid))
|
||||
setActiveProfileState(match || rows[0] || null)
|
||||
setLoading(false)
|
||||
})
|
||||
}, [session?.profile_id]) // re-runs when profile changes
|
||||
}, [isAuthenticated, user?.id, user?.role])
|
||||
|
||||
const setActiveProfile = (profile) => {
|
||||
setActiveProfileState(profile)
|
||||
localStorage.setItem('bodytrack_active_profile', profile.id)
|
||||
localStorage.setItem('shinkan_active_profile', String(profile.id))
|
||||
}
|
||||
|
||||
const refreshProfiles = () => loadProfiles().then(data => {
|
||||
setProfiles(data)
|
||||
if (activeProfile) {
|
||||
const updated = data.find(p => p.id === activeProfile.id)
|
||||
if (updated) setActiveProfileState(updated)
|
||||
}
|
||||
})
|
||||
const refreshProfiles = () =>
|
||||
loadProfiles(user).then((data) => {
|
||||
setProfiles(Array.isArray(data) ? data : [])
|
||||
if (activeProfile) {
|
||||
const updated = data.find((p) => String(p.id) === String(activeProfile.id))
|
||||
if (updated) setActiveProfileState(updated)
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<ProfileContext.Provider value={{ profiles, activeProfile, setActiveProfile, refreshProfiles, loading }}>
|
||||
<ProfileContext.Provider
|
||||
value={{ profiles, activeProfile, setActiveProfile, refreshProfiles, loading }}
|
||||
>
|
||||
{children}
|
||||
</ProfileContext.Provider>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -92,7 +92,7 @@ export default function AdminCatalogsPage() {
|
|||
} else if (activeTab === 'trainer-assignments') {
|
||||
const [assignments, profs, areas] = await Promise.all([
|
||||
api.listTrainerFocusAreas(),
|
||||
fetch('/api/profiles').then(r => r.json()),
|
||||
api.listProfiles(),
|
||||
api.listFocusAreas()
|
||||
])
|
||||
setTrainerAssignments(assignments)
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user