ROLLBACK: complete removal of broken feature enforcement system
All checks were successful
Deploy Development / deploy (push) Successful in 32s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 12s

Reverts all feature enforcement changes (commits 3745ebd, cbad50a, cd4d912, 8415509)
to restore original working functionality.

Issues caused by feature enforcement implementation:
- Export buttons disappeared and never reappeared
- KI analysis counter not incrementing
- New analyses not saving
- Pipeline appearing twice
- Many core features broken

Restored files to working state before enforcement implementation (commit 0210844):
- Backend: auth.py, insights.py, exportdata.py, importdata.py, nutrition.py, activity.py
- Frontend: Analysis.jsx, SettingsPage.jsx, api.js
- Removed: FeatureGate.jsx, useFeatureAccess.js

The original simple AI limit system (ai_enabled, ai_limit_day) is now active again.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Lars 2026-03-20 15:19:56 +01:00
parent 8415509f4c
commit 4fcde4abfb
11 changed files with 125 additions and 376 deletions

View File

@ -318,9 +318,8 @@ def increment_feature_usage(profile_id: str, feature_id: str) -> None:
ON CONFLICT (profile_id, feature_id) ON CONFLICT (profile_id, feature_id)
DO UPDATE SET DO UPDATE SET
usage_count = user_feature_usage.usage_count + 1, usage_count = user_feature_usage.usage_count + 1,
reset_at = %s,
updated = CURRENT_TIMESTAMP updated = CURRENT_TIMESTAMP
""", (profile_id, feature_id, next_reset, next_reset)) """, (profile_id, feature_id, next_reset))
conn.commit() conn.commit()

View File

@ -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, check_feature_access, increment_feature_usage from auth import require_auth
from models import ActivityEntry from models import ActivityEntry
from routers.profiles import get_pid from routers.profiles import get_pid
@ -94,17 +94,6 @@ 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')
@ -145,8 +134,4 @@ 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"}

View File

@ -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, check_feature_access, increment_feature_usage from auth import require_auth
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,15 +30,13 @@ 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 feature access (v9c feature system) # Check export permission
access = check_feature_access(pid, 'data_export') with get_db() as conn:
if not access['allowed']: cur = get_cursor(conn)
if access['reason'] == 'feature_disabled': cur.execute("SELECT export_enabled FROM profiles WHERE id=%s", (pid,))
raise HTTPException(403, "Export ist für dein Tier nicht verfügbar") prof = cur.fetchone()
elif access['reason'] == 'limit_exceeded': if not prof or not prof['export_enabled']:
raise HTTPException(429, f"Monatliches Export-Limit erreicht ({access['limit']} Exporte)") raise HTTPException(403, "Export ist für dieses Profil deaktiviert")
else:
raise HTTPException(403, f"Zugriff verweigert: {access['reason']}")
# Build CSV # Build CSV
output = io.StringIO() output = io.StringIO()
@ -76,10 +74,6 @@ 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",
@ -92,15 +86,13 @@ 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 feature access (v9c feature system) # Check export permission
access = check_feature_access(pid, 'data_export') with get_db() as conn:
if not access['allowed']: cur = get_cursor(conn)
if access['reason'] == 'feature_disabled': cur.execute("SELECT export_enabled FROM profiles WHERE id=%s", (pid,))
raise HTTPException(403, "Export ist für dein Tier nicht verfügbar") prof = cur.fetchone()
elif access['reason'] == 'limit_exceeded': if not prof or not prof['export_enabled']:
raise HTTPException(429, f"Monatliches Export-Limit erreicht ({access['limit']} Exporte)") raise HTTPException(403, "Export ist für dieses Profil deaktiviert")
else:
raise HTTPException(403, f"Zugriff verweigert: {access['reason']}")
# Collect all data # Collect all data
data = {} data = {}
@ -134,10 +126,6 @@ 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",
@ -150,21 +138,13 @@ 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 feature access (v9c feature system) # Check export permission & get profile
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):
@ -317,10 +297,6 @@ 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",

View File

@ -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, check_feature_access, increment_feature_usage from auth import require_auth
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,16 +41,6 @@ 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)
@ -264,9 +254,6 @@ 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",

View File

@ -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, check_feature_access, increment_feature_usage from auth import require_auth, require_admin
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,16 +251,7 @@ 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:
@ -310,8 +301,7 @@ 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))
# Increment usage counter (v9c feature system) inc_ai_usage(pid)
increment_feature_usage(pid, 'ai_calls')
return {"scope": slug, "content": content} return {"scope": slug, "content": content}
@ -319,16 +309,7 @@ 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)
@ -457,8 +438,7 @@ 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))
# Increment pipeline usage counter (v9c feature system) inc_ai_usage(pid)
increment_feature_usage(pid, 'ai_pipeline')
return {"scope": "gesamt", "content": final_content, "stage1": stage1_results} return {"scope": "gesamt", "content": final_content, "stage1": stage1_results}

View File

@ -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, check_feature_access, increment_feature_usage from auth import require_auth
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,17 +30,6 @@ 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')
@ -76,10 +65,6 @@ 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}}

View File

@ -1,80 +0,0 @@
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>
)
}

View File

@ -1,67 +0,0 @@
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 }
}

View File

@ -2,8 +2,6 @@ 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'
@ -229,14 +227,10 @@ 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.
@ -253,6 +247,7 @@ 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)',
@ -261,7 +256,6 @@ export default function Analysis() {
</div> </div>
)} )}
</div> </div>
</FeatureGate>
)} )}
{!canUseAI && ( {!canUseAI && (
@ -277,11 +271,9 @@ export default function Analysis() {
</div> </div>
</div> </div>
)} )}
<FeatureGate feature="ai_calls"> {canUseAI && <p style={{fontSize:13,color:'var(--text2)',marginBottom:14,lineHeight:1.6}}>
<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:
<FeatureBadge feature="ai_calls" /> </p>}
</p>
{activePrompts.map(p => { {activePrompts.map(p => {
// Show latest existing insight for this prompt // Show latest existing insight for this prompt
@ -317,7 +309,6 @@ 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>
)} )}

View File

@ -1,9 +1,7 @@
import { useState } from 'react' import { useState } from 'react'
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'
@ -236,16 +234,6 @@ export default function SettingsPage() {
<div> <div>
<h1 className="page-title">Einstellungen</h1> <h1 className="page-title">Einstellungen</h1>
{/* Subscription */}
<div className="card section-gap">
<div className="card-title">Mein Abo</div>
<Link to="/subscription" style={{textDecoration:'none'}}>
<button className="btn btn-secondary btn-full">
👑 Abo-Status, Limits & Coupon einlösen
</button>
</Link>
</div>
{/* Profile list */} {/* Profile list */}
<div className="card section-gap"> <div className="card section-gap">
<div className="card-title">Profile ({profiles.length})</div> <div className="card-title">Profile ({profiles.length})</div>
@ -355,17 +343,20 @@ 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" style={{display:'flex',alignItems:'center',gap:8}}> <div className="card-title">Daten exportieren</div>
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
@ -376,25 +367,29 @@ 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" style={{display:'flex',alignItems:'center',gap:8}}> <div className="card-title">Backup importieren</div>
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}
@ -420,12 +415,13 @@ 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%)',

View File

@ -180,7 +180,4 @@ 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`),
} }