feat: implement v9c feature enforcement system
Backend: - Add feature access checks to insights, export, import endpoints - Enforce ai_calls, ai_pipeline, data_export, csv_import limits - Return HTTP 403 (disabled) or 429 (limit exceeded) Frontend: - Create useFeatureAccess hook for feature checking - Create FeatureGate/FeatureBadge components - Gate KI-Analysen in Analysis page - Gate Export/Import in Settings page - Show usage counters (e.g. "3/10") Docs: - Update CLAUDE.md with implementation status Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
0210844522
commit
3745ebd6cd
81
CLAUDE.md
81
CLAUDE.md
|
|
@ -99,11 +99,11 @@ mitai-jinkendo/
|
||||||
### Was in v9c kommt: Subscription & Coupon Management System
|
### Was in v9c kommt: Subscription & Coupon Management System
|
||||||
**Phase 1 (DB-Schema): ✅ DONE**
|
**Phase 1 (DB-Schema): ✅ DONE**
|
||||||
**Phase 2 (Backend API): ✅ DONE**
|
**Phase 2 (Backend API): ✅ DONE**
|
||||||
**Phase 3 (Frontend UI): ⚡ MOSTLY DONE** (Kern-Features komplett, Self-Registration offen)
|
**Phase 3 (Frontend UI): ✅ DONE** (Feature Enforcement komplett, Self-Registration offen)
|
||||||
|
|
||||||
**Core Features (Backend):**
|
**Core Features (Backend):**
|
||||||
- ✅ DB-Schema (11 neue Tabellen, Feature-Registry Pattern)
|
- ✅ DB-Schema (11 neue Tabellen, Feature-Registry Pattern)
|
||||||
- ⚠️ Feature-Access Middleware (existiert, aber wird NICHT in Endpoints aufgerufen - siehe KRITISCH unten!)
|
- ✅ **Feature-Access Enforcement** - Limits werden jetzt korrekt durchgesetzt!
|
||||||
- ✅ Flexibles Tier-System (free/basic/premium/selfhosted) - Admin-editierbar via API
|
- ✅ Flexibles Tier-System (free/basic/premium/selfhosted) - Admin-editierbar via API
|
||||||
- ✅ **Coupon-System** (3 Typen: single_use, period, wellpass)
|
- ✅ **Coupon-System** (3 Typen: single_use, period, wellpass)
|
||||||
- ✅ Coupon-Stacking-Logik (Pause + Resume bei Wellpass-Override)
|
- ✅ Coupon-Stacking-Logik (Pause + Resume bei Wellpass-Override)
|
||||||
|
|
@ -120,47 +120,60 @@ mitai-jinkendo/
|
||||||
- ✅ **AdminCouponsPage** - Coupon-Manager (CRUD, 3 Typen, auto-generate codes, redemption history)
|
- ✅ **AdminCouponsPage** - Coupon-Manager (CRUD, 3 Typen, auto-generate codes, redemption history)
|
||||||
- ✅ **AdminUserRestrictionsPage** - User-Override-System (effektive Werte, auto-remove redundant overrides)
|
- ✅ **AdminUserRestrictionsPage** - User-Override-System (effektive Werte, auto-remove redundant overrides)
|
||||||
- ✅ **SubscriptionPage** - User Subscription-Info + Coupon-Einlösung (tier badge, limits, usage progress bars)
|
- ✅ **SubscriptionPage** - User Subscription-Info + Coupon-Einlösung (tier badge, limits, usage progress bars)
|
||||||
|
- ✅ **Feature Enforcement (März 2026)** - Backend + Frontend Feature Gates komplett implementiert
|
||||||
- ✅ Alle Routes in App.jsx registriert
|
- ✅ Alle Routes in App.jsx registriert
|
||||||
- 🔲 Selbst-Registrierung mit E-Mail-Verifizierung (Pflicht)
|
- 🔲 Selbst-Registrierung mit E-Mail-Verifizierung (Pflicht)
|
||||||
- 🔲 Trial-System UI (Countdown-Banner, auto-start nach E-Mail-Verifikation)
|
- 🔲 Trial-System UI (Countdown-Banner, auto-start nach E-Mail-Verifikation)
|
||||||
- 🔲 App-Settings Admin-Panel (globale Konfiguration: trial_days, allow_registration, etc.)
|
- 🔲 App-Settings Admin-Panel (globale Konfiguration: trial_days, allow_registration, etc.)
|
||||||
|
|
||||||
**⚠️ KRITISCH: Feature-Enforcement fehlt noch! (März 2026)**
|
**✅ Feature Enforcement Implementation (20. März 2026):**
|
||||||
|
|
||||||
**Problem:** Admin-UI zum Konfigurieren existiert, aber die eigentliche Prüfung/Durchsetzung fehlt!
|
**Backend - Feature Checks in Endpoints:**
|
||||||
- User kann Limits überschreiten (KI-Analysen, Export, etc.)
|
- ✅ **routers/insights.py** - KI-Analysen geschützt
|
||||||
- Deaktivierte Features sind trotzdem nutzbar
|
- `analyze_with_prompt()` - prüft 'ai_calls' feature, wirft HTTP 403/429 bei Limit
|
||||||
- Feature-Middleware existiert aber wird NICHT aufgerufen
|
- `run_pipeline()` - prüft 'ai_pipeline' feature
|
||||||
|
- Beide inkrementieren usage nach erfolgreicher Ausführung
|
||||||
|
- ✅ **routers/exportdata.py** - Alle Export-Endpoints geschützt (CSV/JSON/ZIP)
|
||||||
|
- Prüft 'data_export' feature, wirft HTTP 403 bei deaktiviert/429 bei Limit
|
||||||
|
- Inkrementiert usage nach Export
|
||||||
|
- ✅ **routers/importdata.py** - ZIP-Import geschützt
|
||||||
|
- Prüft 'csv_import' feature vor Import
|
||||||
|
- ✅ **routers/nutrition.py** - FDDB CSV-Import geschützt
|
||||||
|
- Prüft 'csv_import' feature
|
||||||
|
- ✅ **routers/activity.py** - Apple Health CSV-Import geschützt
|
||||||
|
- Prüft 'csv_import' feature
|
||||||
|
|
||||||
**Backend TODO (KRITISCH):**
|
**Frontend - Feature Gate System:**
|
||||||
- 🔲 **insights.py** - Feature-Checks für KI-Analysen einbauen
|
- ✅ **hooks/useFeatureAccess.js** - Custom Hook für Feature-Access-Prüfung
|
||||||
```python
|
- Ruft `/api/features/{slug}/check-access` auf
|
||||||
@router.post('/run/{slug}')
|
- Liefert: canUse, limit, used, remaining, reason, loading, error
|
||||||
def run_analysis(slug: str, session = Depends(require_auth)):
|
- Optimistic default (canUse=true) für bessere UX
|
||||||
profile_id = session['profile_id']
|
- ✅ **components/FeatureGate.jsx** - Reusable Feature Gate Component
|
||||||
# TODO: check_feature_access(profile_id, 'ai_calls', action='use')
|
- `<FeatureGate>` - versteckt children wenn Feature nicht verfügbar
|
||||||
# TODO: increment_feature_usage(profile_id, 'ai_calls')
|
- `<FeatureBadge>` - zeigt Usage-Counter (z.B. "3/10") mit Farb-Codierung
|
||||||
```
|
- Upgrade-Prompt optional anzeigbar
|
||||||
- 🔲 **exportdata.py** - Feature-Check für Export (CSV/JSON/ZIP)
|
- ✅ **pages/Analysis.jsx** - KI-Analysen Feature-Gates
|
||||||
- 🔲 **importdata.py** - Feature-Check für Import
|
- Pipeline-Button wrapped in `<FeatureGate feature="ai_pipeline">`
|
||||||
- 🔲 **nutrition.py** - Feature-Check für FDDB-Import
|
- Einzelanalysen wrapped in `<FeatureGate feature="ai_calls">`
|
||||||
- 🔲 **activity.py** - Feature-Check für Apple Health Import
|
- Usage-Counter in beiden Sektionen
|
||||||
- 🔲 **photos.py** - Feature-Check für Progress-Fotos
|
- ✅ **pages/SettingsPage.jsx** - Export/Import Feature-Gates
|
||||||
- 🔲 **weight.py, circumference.py, caliper.py** - Entry-Limits prüfen
|
- Export-Buttons wrapped in `<FeatureGate feature="data_export">`
|
||||||
|
- Import-Button wrapped in `<FeatureGate feature="csv_import">`
|
||||||
|
- Usage-Counter bei beiden
|
||||||
|
- ✅ **utils/api.js** - API-Funktion hinzugefügt
|
||||||
|
- `checkFeatureAccess(featureSlug)` für Frontend-Checks
|
||||||
|
|
||||||
**Frontend TODO (wichtig für UX):**
|
**Verhalten:**
|
||||||
- 🔲 `useFeatureAccess()` Hook implementieren
|
- Backend wirft HTTP 403 "Feature nicht verfügbar" bei deaktiviertem Feature
|
||||||
```javascript
|
- Backend wirft HTTP 429 "Limit erreicht" bei überschrittenem Limit
|
||||||
const { canUse, remaining, limit } = useFeatureAccess('ai_calls')
|
- Frontend blendet Feature aus oder zeigt Upgrade-Prompt
|
||||||
```
|
- Usage-Counter zeigt aktuelle Nutzung (z.B. "5/10 AI-Calls")
|
||||||
- 🔲 `<FeatureGate feature="...">` Komponente erstellen
|
- Farb-Codierung: Grün (0-70%), Orange (70-90%), Rot (90-100%)
|
||||||
- 🔲 Feature-Gates in Analysis-Seite (KI-Button ausblenden wenn limit=0)
|
|
||||||
- 🔲 Feature-Gates in Settings (Export-Buttons)
|
|
||||||
- 🔲 Feature-Gates in Import-Funktionen
|
|
||||||
- 🔲 Limit-Anzeige ("3/10 KI-Analysen verbleibend")
|
|
||||||
- 🔲 Upgrade-Prompt bei Limit erreicht
|
|
||||||
|
|
||||||
**Geschätzte Arbeit:** 2-3 Stunden (Backend 60%, Frontend 40%)
|
**Noch NICHT geschützt (optional für v9d):**
|
||||||
|
- 🔲 **photos.py** - Feature-Check für Progress-Fotos Upload
|
||||||
|
- 🔲 **weight.py, circumference.py, caliper.py** - Entry-Limits prüfen (verhindert neue Einträge wenn Limit erreicht)
|
||||||
|
- 🔲 **nutrition.py, activity.py** - Entry-Limits prüfen (analog zu weight)
|
||||||
|
|
||||||
**E-Mail Templates (v9c):**
|
**E-Mail Templates (v9c):**
|
||||||
- 🔲 Registrierung + E-Mail-Verifizierung
|
- 🔲 Registrierung + E-Mail-Verifizierung
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ from typing import Optional
|
||||||
from fastapi import APIRouter, HTTPException, UploadFile, File, Header, Depends
|
from fastapi import APIRouter, HTTPException, UploadFile, File, Header, Depends
|
||||||
|
|
||||||
from db import get_db, get_cursor, r2d
|
from db import get_db, get_cursor, r2d
|
||||||
from auth import require_auth
|
from auth import require_auth, check_feature_access, increment_feature_usage
|
||||||
from models import ActivityEntry
|
from models import ActivityEntry
|
||||||
from routers.profiles import get_pid
|
from routers.profiles import get_pid
|
||||||
|
|
||||||
|
|
@ -94,6 +94,17 @@ def activity_stats(x_profile_id: Optional[str]=Header(default=None), session: di
|
||||||
async def import_activity_csv(file: UploadFile=File(...), x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
|
async def import_activity_csv(file: UploadFile=File(...), x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
|
||||||
"""Import Apple Health workout CSV."""
|
"""Import Apple Health workout CSV."""
|
||||||
pid = get_pid(x_profile_id)
|
pid = get_pid(x_profile_id)
|
||||||
|
|
||||||
|
# Check feature access (v9c feature system)
|
||||||
|
access = check_feature_access(pid, 'csv_import')
|
||||||
|
if not access['allowed']:
|
||||||
|
if access['reason'] == 'feature_disabled':
|
||||||
|
raise HTTPException(403, "CSV-Import ist für dein Tier nicht verfügbar")
|
||||||
|
elif access['reason'] == 'limit_exceeded':
|
||||||
|
raise HTTPException(429, f"Monatliches Import-Limit erreicht ({access['limit']} Imports)")
|
||||||
|
else:
|
||||||
|
raise HTTPException(403, f"Zugriff verweigert: {access['reason']}")
|
||||||
|
|
||||||
raw = await file.read()
|
raw = await file.read()
|
||||||
try: text = raw.decode('utf-8')
|
try: text = raw.decode('utf-8')
|
||||||
except: text = raw.decode('latin-1')
|
except: text = raw.decode('latin-1')
|
||||||
|
|
@ -134,4 +145,8 @@ async def import_activity_csv(file: UploadFile=File(...), x_profile_id: Optional
|
||||||
tf(row.get('Distanz (km)',''))))
|
tf(row.get('Distanz (km)',''))))
|
||||||
inserted+=1
|
inserted+=1
|
||||||
except: skipped+=1
|
except: skipped+=1
|
||||||
|
|
||||||
|
# Increment import usage counter (v9c feature system)
|
||||||
|
increment_feature_usage(pid, 'csv_import')
|
||||||
|
|
||||||
return {"inserted":inserted,"skipped":skipped,"message":f"{inserted} Trainings importiert"}
|
return {"inserted":inserted,"skipped":skipped,"message":f"{inserted} Trainings importiert"}
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ from fastapi import APIRouter, HTTPException, Header, Depends
|
||||||
from fastapi.responses import StreamingResponse, Response
|
from fastapi.responses import StreamingResponse, Response
|
||||||
|
|
||||||
from db import get_db, get_cursor, r2d
|
from db import get_db, get_cursor, r2d
|
||||||
from auth import require_auth
|
from auth import require_auth, check_feature_access, increment_feature_usage
|
||||||
from routers.profiles import get_pid
|
from routers.profiles import get_pid
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/export", tags=["export"])
|
router = APIRouter(prefix="/api/export", tags=["export"])
|
||||||
|
|
@ -30,13 +30,15 @@ def export_csv(x_profile_id: Optional[str]=Header(default=None), session: dict=D
|
||||||
"""Export all data as CSV."""
|
"""Export all data as CSV."""
|
||||||
pid = get_pid(x_profile_id)
|
pid = get_pid(x_profile_id)
|
||||||
|
|
||||||
# Check export permission
|
# Check feature access (v9c feature system)
|
||||||
with get_db() as conn:
|
access = check_feature_access(pid, 'data_export')
|
||||||
cur = get_cursor(conn)
|
if not access['allowed']:
|
||||||
cur.execute("SELECT export_enabled FROM profiles WHERE id=%s", (pid,))
|
if access['reason'] == 'feature_disabled':
|
||||||
prof = cur.fetchone()
|
raise HTTPException(403, "Export ist für dein Tier nicht verfügbar")
|
||||||
if not prof or not prof['export_enabled']:
|
elif access['reason'] == 'limit_exceeded':
|
||||||
raise HTTPException(403, "Export ist für dieses Profil deaktiviert")
|
raise HTTPException(429, f"Monatliches Export-Limit erreicht ({access['limit']} Exporte)")
|
||||||
|
else:
|
||||||
|
raise HTTPException(403, f"Zugriff verweigert: {access['reason']}")
|
||||||
|
|
||||||
# Build CSV
|
# Build CSV
|
||||||
output = io.StringIO()
|
output = io.StringIO()
|
||||||
|
|
@ -74,6 +76,10 @@ def export_csv(x_profile_id: Optional[str]=Header(default=None), session: dict=D
|
||||||
writer.writerow(["Training", r['date'], r['activity_type'], f"{float(r['duration_min'])}min {float(r['kcal_active'])}kcal"])
|
writer.writerow(["Training", r['date'], r['activity_type'], f"{float(r['duration_min'])}min {float(r['kcal_active'])}kcal"])
|
||||||
|
|
||||||
output.seek(0)
|
output.seek(0)
|
||||||
|
|
||||||
|
# Increment export usage counter (v9c feature system)
|
||||||
|
increment_feature_usage(pid, 'data_export')
|
||||||
|
|
||||||
return StreamingResponse(
|
return StreamingResponse(
|
||||||
iter([output.getvalue()]),
|
iter([output.getvalue()]),
|
||||||
media_type="text/csv",
|
media_type="text/csv",
|
||||||
|
|
@ -86,13 +92,15 @@ def export_json(x_profile_id: Optional[str]=Header(default=None), session: dict=
|
||||||
"""Export all data as JSON."""
|
"""Export all data as JSON."""
|
||||||
pid = get_pid(x_profile_id)
|
pid = get_pid(x_profile_id)
|
||||||
|
|
||||||
# Check export permission
|
# Check feature access (v9c feature system)
|
||||||
with get_db() as conn:
|
access = check_feature_access(pid, 'data_export')
|
||||||
cur = get_cursor(conn)
|
if not access['allowed']:
|
||||||
cur.execute("SELECT export_enabled FROM profiles WHERE id=%s", (pid,))
|
if access['reason'] == 'feature_disabled':
|
||||||
prof = cur.fetchone()
|
raise HTTPException(403, "Export ist für dein Tier nicht verfügbar")
|
||||||
if not prof or not prof['export_enabled']:
|
elif access['reason'] == 'limit_exceeded':
|
||||||
raise HTTPException(403, "Export ist für dieses Profil deaktiviert")
|
raise HTTPException(429, f"Monatliches Export-Limit erreicht ({access['limit']} Exporte)")
|
||||||
|
else:
|
||||||
|
raise HTTPException(403, f"Zugriff verweigert: {access['reason']}")
|
||||||
|
|
||||||
# Collect all data
|
# Collect all data
|
||||||
data = {}
|
data = {}
|
||||||
|
|
@ -126,6 +134,10 @@ def export_json(x_profile_id: Optional[str]=Header(default=None), session: dict=
|
||||||
return str(obj)
|
return str(obj)
|
||||||
|
|
||||||
json_str = json.dumps(data, indent=2, default=decimal_handler)
|
json_str = json.dumps(data, indent=2, default=decimal_handler)
|
||||||
|
|
||||||
|
# Increment export usage counter (v9c feature system)
|
||||||
|
increment_feature_usage(pid, 'data_export')
|
||||||
|
|
||||||
return Response(
|
return Response(
|
||||||
content=json_str,
|
content=json_str,
|
||||||
media_type="application/json",
|
media_type="application/json",
|
||||||
|
|
@ -138,13 +150,21 @@ def export_zip(x_profile_id: Optional[str]=Header(default=None), session: dict=D
|
||||||
"""Export all data as ZIP (CSV + JSON + photos) per specification."""
|
"""Export all data as ZIP (CSV + JSON + photos) per specification."""
|
||||||
pid = get_pid(x_profile_id)
|
pid = get_pid(x_profile_id)
|
||||||
|
|
||||||
# Check export permission & get profile
|
# Check feature access (v9c feature system)
|
||||||
|
access = check_feature_access(pid, 'data_export')
|
||||||
|
if not access['allowed']:
|
||||||
|
if access['reason'] == 'feature_disabled':
|
||||||
|
raise HTTPException(403, "Export ist für dein Tier nicht verfügbar")
|
||||||
|
elif access['reason'] == 'limit_exceeded':
|
||||||
|
raise HTTPException(429, f"Monatliches Export-Limit erreicht ({access['limit']} Exporte)")
|
||||||
|
else:
|
||||||
|
raise HTTPException(403, f"Zugriff verweigert: {access['reason']}")
|
||||||
|
|
||||||
|
# Get profile
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
cur = get_cursor(conn)
|
cur = get_cursor(conn)
|
||||||
cur.execute("SELECT * FROM profiles WHERE id=%s", (pid,))
|
cur.execute("SELECT * FROM profiles WHERE id=%s", (pid,))
|
||||||
prof = r2d(cur.fetchone())
|
prof = r2d(cur.fetchone())
|
||||||
if not prof or not prof.get('export_enabled'):
|
|
||||||
raise HTTPException(403, "Export ist für dieses Profil deaktiviert")
|
|
||||||
|
|
||||||
# Helper: CSV writer with UTF-8 BOM + semicolon
|
# Helper: CSV writer with UTF-8 BOM + semicolon
|
||||||
def write_csv(zf, filename, rows, columns):
|
def write_csv(zf, filename, rows, columns):
|
||||||
|
|
@ -297,6 +317,10 @@ Datumsformat: YYYY-MM-DD
|
||||||
|
|
||||||
zip_buffer.seek(0)
|
zip_buffer.seek(0)
|
||||||
filename = f"mitai-export-{profile_name.replace(' ','-')}-{export_date}.zip"
|
filename = f"mitai-export-{profile_name.replace(' ','-')}-{export_date}.zip"
|
||||||
|
|
||||||
|
# Increment export usage counter (v9c feature system)
|
||||||
|
increment_feature_usage(pid, 'data_export')
|
||||||
|
|
||||||
return StreamingResponse(
|
return StreamingResponse(
|
||||||
iter([zip_buffer.getvalue()]),
|
iter([zip_buffer.getvalue()]),
|
||||||
media_type="application/zip",
|
media_type="application/zip",
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ from datetime import datetime
|
||||||
from fastapi import APIRouter, HTTPException, UploadFile, File, Header, Depends
|
from fastapi import APIRouter, HTTPException, UploadFile, File, Header, Depends
|
||||||
|
|
||||||
from db import get_db, get_cursor
|
from db import get_db, get_cursor
|
||||||
from auth import require_auth
|
from auth import require_auth, check_feature_access, increment_feature_usage
|
||||||
from routers.profiles import get_pid
|
from routers.profiles import get_pid
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/import", tags=["import"])
|
router = APIRouter(prefix="/api/import", tags=["import"])
|
||||||
|
|
@ -41,6 +41,16 @@ async def import_zip(
|
||||||
"""
|
"""
|
||||||
pid = get_pid(x_profile_id)
|
pid = get_pid(x_profile_id)
|
||||||
|
|
||||||
|
# Check feature access (v9c feature system)
|
||||||
|
access = check_feature_access(pid, 'csv_import')
|
||||||
|
if not access['allowed']:
|
||||||
|
if access['reason'] == 'feature_disabled':
|
||||||
|
raise HTTPException(403, "Import ist für dein Tier nicht verfügbar")
|
||||||
|
elif access['reason'] == 'limit_exceeded':
|
||||||
|
raise HTTPException(429, f"Monatliches Import-Limit erreicht ({access['limit']} Imports)")
|
||||||
|
else:
|
||||||
|
raise HTTPException(403, f"Zugriff verweigert: {access['reason']}")
|
||||||
|
|
||||||
# Read uploaded file
|
# Read uploaded file
|
||||||
content = await file.read()
|
content = await file.read()
|
||||||
zip_buffer = io.BytesIO(content)
|
zip_buffer = io.BytesIO(content)
|
||||||
|
|
@ -254,6 +264,9 @@ async def import_zip(
|
||||||
conn.rollback()
|
conn.rollback()
|
||||||
raise HTTPException(500, f"Import fehlgeschlagen: {str(e)}")
|
raise HTTPException(500, f"Import fehlgeschlagen: {str(e)}")
|
||||||
|
|
||||||
|
# Increment import usage counter (v9c feature system)
|
||||||
|
increment_feature_usage(pid, 'csv_import')
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"ok": True,
|
"ok": True,
|
||||||
"message": "Import erfolgreich",
|
"message": "Import erfolgreich",
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ from datetime import datetime
|
||||||
from fastapi import APIRouter, HTTPException, Header, Depends
|
from fastapi import APIRouter, HTTPException, Header, Depends
|
||||||
|
|
||||||
from db import get_db, get_cursor, r2d
|
from db import get_db, get_cursor, r2d
|
||||||
from auth import require_auth, require_admin
|
from auth import require_auth, require_admin, check_feature_access, increment_feature_usage
|
||||||
from routers.profiles import get_pid
|
from routers.profiles import get_pid
|
||||||
|
|
||||||
router = APIRouter(prefix="/api", tags=["insights"])
|
router = APIRouter(prefix="/api", tags=["insights"])
|
||||||
|
|
@ -251,7 +251,16 @@ def delete_ai_insight(scope: str, x_profile_id: Optional[str]=Header(default=Non
|
||||||
async def analyze_with_prompt(slug: str, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
|
async def analyze_with_prompt(slug: str, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
|
||||||
"""Run AI analysis with specified prompt template."""
|
"""Run AI analysis with specified prompt template."""
|
||||||
pid = get_pid(x_profile_id)
|
pid = get_pid(x_profile_id)
|
||||||
check_ai_limit(pid)
|
|
||||||
|
# Check feature access (v9c feature system)
|
||||||
|
access = check_feature_access(pid, 'ai_calls')
|
||||||
|
if not access['allowed']:
|
||||||
|
if access['reason'] == 'feature_disabled':
|
||||||
|
raise HTTPException(403, "KI-Analysen sind für dein Tier nicht verfügbar")
|
||||||
|
elif access['reason'] == 'limit_exceeded':
|
||||||
|
raise HTTPException(429, f"Monatliches KI-Limit erreicht ({access['limit']} Analysen)")
|
||||||
|
else:
|
||||||
|
raise HTTPException(403, f"Zugriff verweigert: {access['reason']}")
|
||||||
|
|
||||||
# Get prompt template
|
# Get prompt template
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
|
|
@ -301,7 +310,8 @@ async def analyze_with_prompt(slug: str, x_profile_id: Optional[str]=Header(defa
|
||||||
cur.execute("INSERT INTO ai_insights (id, profile_id, scope, content, created) VALUES (%s,%s,%s,%s,CURRENT_TIMESTAMP)",
|
cur.execute("INSERT INTO ai_insights (id, profile_id, scope, content, created) VALUES (%s,%s,%s,%s,CURRENT_TIMESTAMP)",
|
||||||
(str(uuid.uuid4()), pid, slug, content))
|
(str(uuid.uuid4()), pid, slug, content))
|
||||||
|
|
||||||
inc_ai_usage(pid)
|
# Increment usage counter (v9c feature system)
|
||||||
|
increment_feature_usage(pid, 'ai_calls')
|
||||||
return {"scope": slug, "content": content}
|
return {"scope": slug, "content": content}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -309,7 +319,16 @@ async def analyze_with_prompt(slug: str, x_profile_id: Optional[str]=Header(defa
|
||||||
async def analyze_pipeline(x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
|
async def analyze_pipeline(x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
|
||||||
"""Run 3-stage pipeline analysis."""
|
"""Run 3-stage pipeline analysis."""
|
||||||
pid = get_pid(x_profile_id)
|
pid = get_pid(x_profile_id)
|
||||||
check_ai_limit(pid)
|
|
||||||
|
# Check feature access for pipeline (v9c feature system)
|
||||||
|
access = check_feature_access(pid, 'ai_pipeline')
|
||||||
|
if not access['allowed']:
|
||||||
|
if access['reason'] == 'feature_disabled':
|
||||||
|
raise HTTPException(403, "KI-Pipeline ist für dein Tier nicht verfügbar")
|
||||||
|
elif access['reason'] == 'limit_exceeded':
|
||||||
|
raise HTTPException(429, f"Monatliches Pipeline-Limit erreicht ({access['limit']} Pipelines)")
|
||||||
|
else:
|
||||||
|
raise HTTPException(403, f"Zugriff verweigert: {access['reason']}")
|
||||||
|
|
||||||
data = _get_profile_data(pid)
|
data = _get_profile_data(pid)
|
||||||
vars = _prepare_template_vars(data)
|
vars = _prepare_template_vars(data)
|
||||||
|
|
@ -438,7 +457,8 @@ async def analyze_pipeline(x_profile_id: Optional[str]=Header(default=None), ses
|
||||||
cur.execute("INSERT INTO ai_insights (id, profile_id, scope, content, created) VALUES (%s,%s,'gesamt',%s,CURRENT_TIMESTAMP)",
|
cur.execute("INSERT INTO ai_insights (id, profile_id, scope, content, created) VALUES (%s,%s,'gesamt',%s,CURRENT_TIMESTAMP)",
|
||||||
(str(uuid.uuid4()), pid, final_content))
|
(str(uuid.uuid4()), pid, final_content))
|
||||||
|
|
||||||
inc_ai_usage(pid)
|
# Increment pipeline usage counter (v9c feature system)
|
||||||
|
increment_feature_usage(pid, 'ai_pipeline')
|
||||||
return {"scope": "gesamt", "content": final_content, "stage1": stage1_results}
|
return {"scope": "gesamt", "content": final_content, "stage1": stage1_results}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ from datetime import datetime
|
||||||
from fastapi import APIRouter, HTTPException, UploadFile, File, Header, Depends
|
from fastapi import APIRouter, HTTPException, UploadFile, File, Header, Depends
|
||||||
|
|
||||||
from db import get_db, get_cursor, r2d
|
from db import get_db, get_cursor, r2d
|
||||||
from auth import require_auth
|
from auth import require_auth, check_feature_access, increment_feature_usage
|
||||||
from routers.profiles import get_pid
|
from routers.profiles import get_pid
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/nutrition", tags=["nutrition"])
|
router = APIRouter(prefix="/api/nutrition", tags=["nutrition"])
|
||||||
|
|
@ -30,6 +30,17 @@ def _pf(s):
|
||||||
async def import_nutrition_csv(file: UploadFile=File(...), x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
|
async def import_nutrition_csv(file: UploadFile=File(...), x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
|
||||||
"""Import FDDB nutrition CSV."""
|
"""Import FDDB nutrition CSV."""
|
||||||
pid = get_pid(x_profile_id)
|
pid = get_pid(x_profile_id)
|
||||||
|
|
||||||
|
# Check feature access (v9c feature system)
|
||||||
|
access = check_feature_access(pid, 'csv_import')
|
||||||
|
if not access['allowed']:
|
||||||
|
if access['reason'] == 'feature_disabled':
|
||||||
|
raise HTTPException(403, "CSV-Import ist für dein Tier nicht verfügbar")
|
||||||
|
elif access['reason'] == 'limit_exceeded':
|
||||||
|
raise HTTPException(429, f"Monatliches Import-Limit erreicht ({access['limit']} Imports)")
|
||||||
|
else:
|
||||||
|
raise HTTPException(403, f"Zugriff verweigert: {access['reason']}")
|
||||||
|
|
||||||
raw = await file.read()
|
raw = await file.read()
|
||||||
try: text = raw.decode('utf-8')
|
try: text = raw.decode('utf-8')
|
||||||
except: text = raw.decode('latin-1')
|
except: text = raw.decode('latin-1')
|
||||||
|
|
@ -65,6 +76,10 @@ async def import_nutrition_csv(file: UploadFile=File(...), x_profile_id: Optiona
|
||||||
cur.execute("INSERT INTO nutrition_log (id,profile_id,date,kcal,protein_g,fat_g,carbs_g,source,created) VALUES (%s,%s,%s,%s,%s,%s,%s,'csv',CURRENT_TIMESTAMP)",
|
cur.execute("INSERT INTO nutrition_log (id,profile_id,date,kcal,protein_g,fat_g,carbs_g,source,created) VALUES (%s,%s,%s,%s,%s,%s,%s,'csv',CURRENT_TIMESTAMP)",
|
||||||
(str(uuid.uuid4()),pid,iso,kcal,prot,fat,carbs))
|
(str(uuid.uuid4()),pid,iso,kcal,prot,fat,carbs))
|
||||||
inserted+=1
|
inserted+=1
|
||||||
|
|
||||||
|
# Increment import usage counter (v9c feature system)
|
||||||
|
increment_feature_usage(pid, 'csv_import')
|
||||||
|
|
||||||
return {"rows_parsed":count,"days_imported":inserted,
|
return {"rows_parsed":count,"days_imported":inserted,
|
||||||
"date_range":{"from":min(days) if days else None,"to":max(days) if days else None}}
|
"date_range":{"from":min(days) if days else None,"to":max(days) if days else None}}
|
||||||
|
|
||||||
|
|
|
||||||
6766
frontend/package-lock.json
generated
Normal file
6766
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
80
frontend/src/components/FeatureGate.jsx
Normal file
80
frontend/src/components/FeatureGate.jsx
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
import { useFeatureAccess } from '../hooks/useFeatureAccess'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Feature Gate Component
|
||||||
|
*
|
||||||
|
* Hides children if feature is not available.
|
||||||
|
* Optionally shows upgrade prompt or message.
|
||||||
|
*
|
||||||
|
* @param {string} feature - Feature slug (e.g. 'ai_calls', 'data_export')
|
||||||
|
* @param {ReactNode} children - Content to gate
|
||||||
|
* @param {ReactNode} fallback - Optional fallback when not allowed
|
||||||
|
* @param {boolean} showUpgradePrompt - Show upgrade message instead of hiding
|
||||||
|
*/
|
||||||
|
export function FeatureGate({ feature, children, fallback = null, showUpgradePrompt = false }) {
|
||||||
|
const { canUse, reason, loading, limit, remaining } = useFeatureAccess(feature)
|
||||||
|
|
||||||
|
// While loading, show children optimistically (better UX)
|
||||||
|
if (loading) return <>{children}</>
|
||||||
|
|
||||||
|
// If allowed, render children
|
||||||
|
if (canUse) return <>{children}</>
|
||||||
|
|
||||||
|
// Not allowed
|
||||||
|
if (showUpgradePrompt) {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
padding: 16,
|
||||||
|
background: 'var(--accent-light)',
|
||||||
|
borderRadius: 8,
|
||||||
|
textAlign: 'center',
|
||||||
|
color: 'var(--accent-dark)'
|
||||||
|
}}>
|
||||||
|
<div style={{ fontSize: 14, fontWeight: 600, marginBottom: 4 }}>
|
||||||
|
Feature nicht verfügbar
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 12 }}>
|
||||||
|
{reason === 'feature_disabled' && 'Dieses Feature ist in deinem Tier nicht enthalten.'}
|
||||||
|
{reason === 'limit_exceeded' && `Limit erreicht (${limit}). Upgrade für mehr Zugriff.`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide completely
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Feature Badge - Shows usage counter
|
||||||
|
*
|
||||||
|
* @param {string} feature - Feature slug
|
||||||
|
* @param {string} label - Label text (optional)
|
||||||
|
*/
|
||||||
|
export function FeatureBadge({ feature, label }) {
|
||||||
|
const { limit, used, remaining, loading } = useFeatureAccess(feature)
|
||||||
|
|
||||||
|
if (loading) return null
|
||||||
|
if (limit === null) return null // Unlimited
|
||||||
|
|
||||||
|
const percentage = limit > 0 ? (used / limit) * 100 : 0
|
||||||
|
const color = percentage > 90 ? 'var(--danger)' : percentage > 70 ? '#FFA726' : 'var(--accent)'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 6,
|
||||||
|
padding: '4px 10px',
|
||||||
|
background: 'var(--surface2)',
|
||||||
|
borderRadius: 12,
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: 'var(--text3)'
|
||||||
|
}}>
|
||||||
|
{label && <span>{label}:</span>}
|
||||||
|
<span style={{ color }}>{used}/{limit}</span>
|
||||||
|
{remaining !== null && remaining === 0 && <span style={{ color: 'var(--danger)' }}>⚠️</span>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
67
frontend/src/hooks/useFeatureAccess.js
Normal file
67
frontend/src/hooks/useFeatureAccess.js
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { api } from '../utils/api'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to check feature access and usage limits
|
||||||
|
*
|
||||||
|
* @param {string} featureSlug - Feature ID (e.g. 'ai_calls', 'data_export')
|
||||||
|
* @returns {{
|
||||||
|
* canUse: boolean,
|
||||||
|
* limit: number|null,
|
||||||
|
* used: number,
|
||||||
|
* remaining: number|null,
|
||||||
|
* reason: string,
|
||||||
|
* loading: boolean,
|
||||||
|
* error: string,
|
||||||
|
* refresh: function
|
||||||
|
* }}
|
||||||
|
*/
|
||||||
|
export function useFeatureAccess(featureSlug) {
|
||||||
|
const [state, setState] = useState({
|
||||||
|
canUse: true, // Optimistic default
|
||||||
|
limit: null,
|
||||||
|
used: 0,
|
||||||
|
remaining: null,
|
||||||
|
reason: 'unknown',
|
||||||
|
loading: true,
|
||||||
|
error: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const refresh = async () => {
|
||||||
|
if (!featureSlug) {
|
||||||
|
setState(prev => ({ ...prev, loading: false }))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setState(prev => ({ ...prev, loading: true, error: '' }))
|
||||||
|
const access = await api.checkFeatureAccess(featureSlug)
|
||||||
|
setState({
|
||||||
|
canUse: access.allowed,
|
||||||
|
limit: access.limit,
|
||||||
|
used: access.used,
|
||||||
|
remaining: access.remaining,
|
||||||
|
reason: access.reason,
|
||||||
|
loading: false,
|
||||||
|
error: ''
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`Feature access check failed for ${featureSlug}:`, e)
|
||||||
|
setState({
|
||||||
|
canUse: false,
|
||||||
|
limit: null,
|
||||||
|
used: 0,
|
||||||
|
remaining: null,
|
||||||
|
reason: 'error',
|
||||||
|
loading: false,
|
||||||
|
error: e.message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
refresh()
|
||||||
|
}, [featureSlug])
|
||||||
|
|
||||||
|
return { ...state, refresh }
|
||||||
|
}
|
||||||
|
|
@ -2,6 +2,8 @@ import { useState, useEffect } from 'react'
|
||||||
import { Brain, Pencil, Trash2, ChevronDown, ChevronUp, Check, X } from 'lucide-react'
|
import { Brain, Pencil, Trash2, ChevronDown, ChevronUp, Check, X } from 'lucide-react'
|
||||||
import { api } from '../utils/api'
|
import { api } from '../utils/api'
|
||||||
import { useAuth } from '../context/AuthContext'
|
import { useAuth } from '../context/AuthContext'
|
||||||
|
import { FeatureGate, FeatureBadge } from '../components/FeatureGate'
|
||||||
|
import { useFeatureAccess } from '../hooks/useFeatureAccess'
|
||||||
import Markdown from '../utils/Markdown'
|
import Markdown from '../utils/Markdown'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
import 'dayjs/locale/de'
|
import 'dayjs/locale/de'
|
||||||
|
|
@ -227,10 +229,14 @@ export default function Analysis() {
|
||||||
|
|
||||||
{/* Pipeline button - only if all sub-prompts are active */}
|
{/* Pipeline button - only if all sub-prompts are active */}
|
||||||
{pipelineAvailable && (
|
{pipelineAvailable && (
|
||||||
|
<FeatureGate feature="ai_pipeline" showUpgradePrompt>
|
||||||
<div className="card" style={{marginBottom:16,borderColor:'var(--accent)',borderWidth:2}}>
|
<div className="card" style={{marginBottom:16,borderColor:'var(--accent)',borderWidth:2}}>
|
||||||
<div style={{display:'flex',alignItems:'flex-start',gap:12}}>
|
<div style={{display:'flex',alignItems:'flex-start',gap:12}}>
|
||||||
<div style={{flex:1}}>
|
<div style={{flex:1}}>
|
||||||
|
<div style={{display:'flex',alignItems:'center',gap:8}}>
|
||||||
<div style={{fontWeight:700,fontSize:15,color:'var(--accent)'}}>🔬 Mehrstufige Gesamtanalyse</div>
|
<div style={{fontWeight:700,fontSize:15,color:'var(--accent)'}}>🔬 Mehrstufige Gesamtanalyse</div>
|
||||||
|
<FeatureBadge feature="ai_pipeline" />
|
||||||
|
</div>
|
||||||
<div style={{fontSize:12,color:'var(--text2)',marginTop:3,lineHeight:1.5}}>
|
<div style={{fontSize:12,color:'var(--text2)',marginTop:3,lineHeight:1.5}}>
|
||||||
3 spezialisierte KI-Calls parallel (Körper + Ernährung + Aktivität),
|
3 spezialisierte KI-Calls parallel (Körper + Ernährung + Aktivität),
|
||||||
dann Synthese + Zielabgleich. Detaillierteste Auswertung.
|
dann Synthese + Zielabgleich. Detaillierteste Auswertung.
|
||||||
|
|
@ -247,7 +253,6 @@ export default function Analysis() {
|
||||||
? <><div className="spinner" style={{width:13,height:13}}/> Läuft…</>
|
? <><div className="spinner" style={{width:13,height:13}}/> Läuft…</>
|
||||||
: <><Brain size={13}/> Starten</>}
|
: <><Brain size={13}/> Starten</>}
|
||||||
</button>
|
</button>
|
||||||
{!canUseAI && <div style={{fontSize:11,color:'#D85A30',marginTop:4}}>🔒 KI nicht freigeschaltet</div>}
|
|
||||||
</div>
|
</div>
|
||||||
{pipelineLoading && (
|
{pipelineLoading && (
|
||||||
<div style={{marginTop:10,padding:'8px 12px',background:'var(--accent-light)',
|
<div style={{marginTop:10,padding:'8px 12px',background:'var(--accent-light)',
|
||||||
|
|
@ -256,6 +261,7 @@ export default function Analysis() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</FeatureGate>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!canUseAI && (
|
{!canUseAI && (
|
||||||
|
|
@ -271,9 +277,11 @@ export default function Analysis() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{canUseAI && <p style={{fontSize:13,color:'var(--text2)',marginBottom:14,lineHeight:1.6}}>
|
<FeatureGate feature="ai_calls">
|
||||||
|
<p style={{fontSize:13,color:'var(--text2)',marginBottom:14,lineHeight:1.6,display:'flex',alignItems:'center',gap:8}}>
|
||||||
Oder wähle eine Einzelanalyse:
|
Oder wähle eine Einzelanalyse:
|
||||||
</p>}
|
<FeatureBadge feature="ai_calls" />
|
||||||
|
</p>
|
||||||
|
|
||||||
{activePrompts.map(p => {
|
{activePrompts.map(p => {
|
||||||
// Show latest existing insight for this prompt
|
// Show latest existing insight for this prompt
|
||||||
|
|
@ -309,6 +317,7 @@ export default function Analysis() {
|
||||||
{activePrompts.length===0 && (
|
{activePrompts.length===0 && (
|
||||||
<div className="empty-state"><p>Keine aktiven Prompts. Aktiviere im Tab "Prompts".</p></div>
|
<div className="empty-state"><p>Keine aktiven Prompts. Aktiviere im Tab "Prompts".</p></div>
|
||||||
)}
|
)}
|
||||||
|
</FeatureGate>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { Link } from 'react-router-dom'
|
||||||
import { Save, Download, Upload, Trash2, Plus, Check, Pencil, X, LogOut, Shield, Key } from 'lucide-react'
|
import { Save, Download, Upload, Trash2, Plus, Check, Pencil, X, LogOut, Shield, Key } from 'lucide-react'
|
||||||
import { useProfile } from '../context/ProfileContext'
|
import { useProfile } from '../context/ProfileContext'
|
||||||
import { useAuth } from '../context/AuthContext'
|
import { useAuth } from '../context/AuthContext'
|
||||||
|
import { FeatureGate, FeatureBadge } from '../components/FeatureGate'
|
||||||
import { Avatar } from './ProfileSelect'
|
import { Avatar } from './ProfileSelect'
|
||||||
import { api } from '../utils/api'
|
import { api } from '../utils/api'
|
||||||
import AdminPanel from './AdminPanel'
|
import AdminPanel from './AdminPanel'
|
||||||
|
|
@ -354,20 +355,17 @@ export default function SettingsPage() {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Export */}
|
{/* Export */}
|
||||||
|
<FeatureGate feature="data_export" showUpgradePrompt>
|
||||||
<div className="card section-gap">
|
<div className="card section-gap">
|
||||||
<div className="card-title">Daten exportieren</div>
|
<div className="card-title" style={{display:'flex',alignItems:'center',gap:8}}>
|
||||||
|
Daten exportieren
|
||||||
|
<FeatureBadge feature="data_export" label="Exporte" />
|
||||||
|
</div>
|
||||||
<p style={{fontSize:13,color:'var(--text2)',marginBottom:12,lineHeight:1.6}}>
|
<p style={{fontSize:13,color:'var(--text2)',marginBottom:12,lineHeight:1.6}}>
|
||||||
Exportiert alle Daten von <strong>{activeProfile?.name}</strong>:
|
Exportiert alle Daten von <strong>{activeProfile?.name}</strong>:
|
||||||
Gewicht, Umfänge, Caliper, Ernährung, Aktivität und KI-Auswertungen.
|
Gewicht, Umfänge, Caliper, Ernährung, Aktivität und KI-Auswertungen.
|
||||||
</p>
|
</p>
|
||||||
<div style={{display:'flex',flexDirection:'column',gap:8}}>
|
<div style={{display:'flex',flexDirection:'column',gap:8}}>
|
||||||
{!canExport && (
|
|
||||||
<div style={{padding:'10px 12px',background:'#FCEBEB',borderRadius:8,
|
|
||||||
fontSize:13,color:'#D85A30',marginBottom:8}}>
|
|
||||||
🔒 Export ist für dein Profil nicht freigeschaltet. Bitte den Admin kontaktieren.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{canExport && <>
|
|
||||||
<button className="btn btn-primary btn-full"
|
<button className="btn btn-primary btn-full"
|
||||||
onClick={()=>api.exportZip()}>
|
onClick={()=>api.exportZip()}>
|
||||||
<Download size={14}/> ZIP exportieren
|
<Download size={14}/> ZIP exportieren
|
||||||
|
|
@ -378,29 +376,25 @@ export default function SettingsPage() {
|
||||||
<Download size={14}/> JSON exportieren
|
<Download size={14}/> JSON exportieren
|
||||||
<span style={{fontSize:11,opacity:0.8,marginLeft:6}}>— maschinenlesbar, alles in einer Datei</span>
|
<span style={{fontSize:11,opacity:0.8,marginLeft:6}}>— maschinenlesbar, alles in einer Datei</span>
|
||||||
</button>
|
</button>
|
||||||
</>}
|
|
||||||
</div>
|
</div>
|
||||||
<p style={{fontSize:11,color:'var(--text3)',marginTop:8}}>
|
<p style={{fontSize:11,color:'var(--text3)',marginTop:8}}>
|
||||||
Der ZIP-Export enthält separate Dateien für Excel und eine lesbare KI-Auswertungsdatei.
|
Der ZIP-Export enthält separate Dateien für Excel und eine lesbare KI-Auswertungsdatei.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
</FeatureGate>
|
||||||
|
|
||||||
{/* Import */}
|
{/* Import */}
|
||||||
|
<FeatureGate feature="csv_import" showUpgradePrompt>
|
||||||
<div className="card section-gap">
|
<div className="card section-gap">
|
||||||
<div className="card-title">Backup importieren</div>
|
<div className="card-title" style={{display:'flex',alignItems:'center',gap:8}}>
|
||||||
|
Backup importieren
|
||||||
|
<FeatureBadge feature="csv_import" label="Imports" />
|
||||||
|
</div>
|
||||||
<p style={{fontSize:13,color:'var(--text2)',marginBottom:12,lineHeight:1.6}}>
|
<p style={{fontSize:13,color:'var(--text2)',marginBottom:12,lineHeight:1.6}}>
|
||||||
Importiere einen ZIP-Export zurück in <strong>{activeProfile?.name}</strong>.
|
Importiere einen ZIP-Export zurück in <strong>{activeProfile?.name}</strong>.
|
||||||
Vorhandene Einträge werden nicht überschrieben.
|
Vorhandene Einträge werden nicht überschrieben.
|
||||||
</p>
|
</p>
|
||||||
<div style={{display:'flex',flexDirection:'column',gap:8}}>
|
<div style={{display:'flex',flexDirection:'column',gap:8}}>
|
||||||
{!canExport && (
|
|
||||||
<div style={{padding:'10px 12px',background:'#FCEBEB',borderRadius:8,
|
|
||||||
fontSize:13,color:'#D85A30',marginBottom:8}}>
|
|
||||||
🔒 Import ist für dein Profil nicht freigeschaltet. Bitte den Admin kontaktieren.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{canExport && (
|
|
||||||
<>
|
|
||||||
<label className="btn btn-primary btn-full"
|
<label className="btn btn-primary btn-full"
|
||||||
style={{cursor:importing?'wait':'pointer',opacity:importing?0.6:1}}>
|
style={{cursor:importing?'wait':'pointer',opacity:importing?0.6:1}}>
|
||||||
<input type="file" accept=".zip" onChange={handleImport}
|
<input type="file" accept=".zip" onChange={handleImport}
|
||||||
|
|
@ -426,13 +420,12 @@ export default function SettingsPage() {
|
||||||
{importMsg.text}
|
{importMsg.text}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<p style={{fontSize:11,color:'var(--text3)',marginTop:8}}>
|
<p style={{fontSize:11,color:'var(--text3)',marginTop:8}}>
|
||||||
Der Import erkennt automatisch das Format und importiert nur neue Einträge.
|
Der Import erkennt automatisch das Format und importiert nur neue Einträge.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
</FeatureGate>
|
||||||
|
|
||||||
{saved && (
|
{saved && (
|
||||||
<div style={{position:'fixed',bottom:80,left:'50%',transform:'translateX(-50%)',
|
<div style={{position:'fixed',bottom:80,left:'50%',transform:'translateX(-50%)',
|
||||||
|
|
|
||||||
|
|
@ -180,4 +180,7 @@ export const api = {
|
||||||
createAccessGrant: (d) => req('/access-grants',json(d)),
|
createAccessGrant: (d) => req('/access-grants',json(d)),
|
||||||
updateAccessGrant: (id,d) => req(`/access-grants/${id}`,jput(d)),
|
updateAccessGrant: (id,d) => req(`/access-grants/${id}`,jput(d)),
|
||||||
revokeAccessGrant: (id) => req(`/access-grants/${id}`,{method:'DELETE'}),
|
revokeAccessGrant: (id) => req(`/access-grants/${id}`,{method:'DELETE'}),
|
||||||
|
|
||||||
|
// Feature Access (v9c)
|
||||||
|
checkFeatureAccess: (featureSlug) => req(`/features/${featureSlug}/check-access`),
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user