mitai-jinkendo/frontend/src/pages/SettingsPage.jsx
Lars 3745ebd6cd
All checks were successful
Deploy Development / deploy (push) Successful in 34s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 12s
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>
2026-03-20 12:43:41 +01:00

440 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 { useProfile } from '../context/ProfileContext'
import { useAuth } from '../context/AuthContext'
import { FeatureGate, FeatureBadge } from '../components/FeatureGate'
import { Avatar } from './ProfileSelect'
import { api } from '../utils/api'
import AdminPanel from './AdminPanel'
const COLORS = ['#1D9E75','#378ADD','#D85A30','#EF9F27','#7F77DD','#D4537E','#639922','#888780']
function ProfileForm({ profile, onSave, onCancel, title }) {
const [form, setForm] = useState({
name: profile?.name || '',
sex: profile?.sex || 'm',
dob: profile?.dob || '',
height: profile?.height || '',
goal_weight: profile?.goal_weight || '',
goal_bf_pct: profile?.goal_bf_pct || '',
avatar_color: profile?.avatar_color || COLORS[0],
})
const set = (k,v) => setForm(f=>({...f,[k]:v}))
return (
<div style={{background:'var(--surface2)',borderRadius:10,padding:14,marginTop:8,
border:'1.5px solid var(--accent)'}}>
{title && <div style={{fontWeight:600,fontSize:14,marginBottom:12,color:'var(--accent)'}}>{title}</div>}
<div className="form-row">
<label className="form-label">Name</label>
<input type="text" className="form-input" value={form.name}
onChange={e=>set('name',e.target.value)} autoFocus/>
<span className="form-unit"/>
</div>
<div style={{marginBottom:12}}>
<div style={{fontSize:12,color:'var(--text3)',marginBottom:8}}>Avatar-Farbe</div>
<div style={{display:'flex',gap:8,alignItems:'center'}}>
<Avatar profile={{...form}} size={36}/>
<div style={{display:'flex',gap:6,flexWrap:'wrap'}}>
{COLORS.map(c=>(
<div key={c} onClick={()=>set('avatar_color',c)}
style={{width:26,height:26,borderRadius:'50%',background:c,cursor:'pointer',
border:`3px solid ${form.avatar_color===c?'white':'transparent'}`,
boxShadow:form.avatar_color===c?`0 0 0 2px ${c}`:'none'}}/>
))}
</div>
</div>
</div>
<div className="form-row">
<label className="form-label">Geschlecht</label>
<select className="form-select" value={form.sex} onChange={e=>set('sex',e.target.value)}>
<option value="m">Männlich</option>
<option value="f">Weiblich</option>
</select>
</div>
<div className="form-row">
<label className="form-label">Geburtsdatum</label>
<input type="date" className="form-input" style={{width:140}} value={form.dob||''}
onChange={e=>set('dob',e.target.value)}/>
<span className="form-unit"/>
</div>
<div className="form-row">
<label className="form-label">Größe</label>
<input type="number" className="form-input" min={100} max={250} value={form.height||''}
onChange={e=>set('height',e.target.value)}/>
<span className="form-unit">cm</span>
</div>
<div style={{fontSize:11,fontWeight:600,color:'var(--text3)',textTransform:'uppercase',
letterSpacing:'0.04em',margin:'10px 0 6px'}}>Ziele (optional)</div>
<div className="form-row">
<label className="form-label">Zielgewicht</label>
<input type="number" className="form-input" min={30} max={300} step={0.1}
value={form.goal_weight||''} onChange={e=>set('goal_weight',e.target.value)} placeholder="–"/>
<span className="form-unit">kg</span>
</div>
<div className="form-row">
<label className="form-label">Ziel-KF%</label>
<input type="number" className="form-input" min={3} max={50} step={0.1}
value={form.goal_bf_pct||''} onChange={e=>set('goal_bf_pct',e.target.value)} placeholder="–"/>
<span className="form-unit">%</span>
</div>
<div style={{display:'flex',gap:8,marginTop:12}}>
<button className="btn btn-primary" style={{flex:1}} onClick={()=>onSave(form)}>
<Save size={13}/> Speichern
</button>
<button className="btn btn-secondary" style={{flex:1}} onClick={onCancel}>
<X size={13}/> Abbrechen
</button>
</div>
</div>
)
}
export default function SettingsPage() {
const { profiles, activeProfile, setActiveProfile, refreshProfiles } = useProfile()
const { logout, isAdmin, canExport } = useAuth()
const [adminOpen, setAdminOpen] = useState(false)
const [pinOpen, setPinOpen] = useState(false)
const [newPin, setNewPin] = useState('')
const [pinMsg, setPinMsg] = useState(null)
const handleLogout = async () => {
if (!confirm('Ausloggen?')) return
await logout()
}
const handlePinChange = async () => {
if (newPin.length < 4) return setPinMsg('Mind. 4 Zeichen')
try {
const token = localStorage.getItem('bodytrack_token')||''
const pid = localStorage.getItem('bodytrack_active_profile')||''
const r = await fetch('/api/auth/pin', {
method:'PUT',
headers:{'Content-Type':'application/json','X-Auth-Token':token,'X-Profile-Id':pid},
body: JSON.stringify({pin: newPin})
})
if (!r.ok) throw new Error('Fehler')
setNewPin(''); setPinMsg('✓ PIN geändert')
setTimeout(()=>setPinMsg(null), 2000)
} catch(e) { setPinMsg('Fehler beim Speichern') }
}
// editingId: string ID of profile being edited, or 'new' for new profile, or null
const [editingId, setEditingId] = useState(null)
const [saved, setSaved] = useState(false)
const [importing, setImporting] = useState(false)
const [importMsg, setImportMsg] = useState(null)
const handleImport = async (e) => {
const file = e.target.files?.[0]
if (!file) return
if (!confirm(`Backup "${file.name}" importieren? Vorhandene Einträge werden nicht überschrieben.`)) {
e.target.value = '' // Reset file input
return
}
setImporting(true)
setImportMsg(null)
try {
const formData = new FormData()
formData.append('file', file)
const token = localStorage.getItem('bodytrack_token')||''
const pid = localStorage.getItem('bodytrack_active_profile')||''
const res = await fetch('/api/import/zip', {
method: 'POST',
headers: {
'X-Auth-Token': token,
'X-Profile-Id': pid
},
body: formData
})
const data = await res.json()
if (!res.ok) {
throw new Error(data.detail || 'Import fehlgeschlagen')
}
// Show success message with stats
const stats = data.stats
const lines = []
if (stats.weight > 0) lines.push(`${stats.weight} Gewicht`)
if (stats.circumferences > 0) lines.push(`${stats.circumferences} Umfänge`)
if (stats.caliper > 0) lines.push(`${stats.caliper} Caliper`)
if (stats.nutrition > 0) lines.push(`${stats.nutrition} Ernährung`)
if (stats.activity > 0) lines.push(`${stats.activity} Aktivität`)
if (stats.photos > 0) lines.push(`${stats.photos} Fotos`)
if (stats.insights > 0) lines.push(`${stats.insights} KI-Analysen`)
setImportMsg({
type: 'success',
text: `✓ Import erfolgreich: ${lines.join(', ')}`
})
// Refresh data (in case new entries were added)
await refreshProfiles()
} catch (err) {
setImportMsg({
type: 'error',
text: `✗ ${err.message}`
})
} finally {
setImporting(false)
e.target.value = '' // Reset file input
setTimeout(() => setImportMsg(null), 5000)
}
}
const handleSave = async (form, profileId) => {
const data = {}
if (form.name) data.name = form.name
if (form.sex) data.sex = form.sex
if (form.dob) data.dob = form.dob
if (form.height) data.height = parseFloat(form.height)
if (form.avatar_color) data.avatar_color = form.avatar_color
if (form.goal_weight) data.goal_weight = parseFloat(form.goal_weight)
if (form.goal_bf_pct) data.goal_bf_pct = parseFloat(form.goal_bf_pct)
if (profileId === 'new') {
const p = await api.createProfile({ ...data, name: form.name || 'Neues Profil' })
await refreshProfiles()
// Don't auto-switch – just close the form
} else {
await api.updateProfile(profileId, data)
await refreshProfiles()
// If editing active profile, update it
if (profileId === activeProfile?.id) {
const updated = profiles.find(p => p.id === profileId)
if (updated) setActiveProfile({...updated, ...data})
}
}
setEditingId(null)
setSaved(true)
setTimeout(() => setSaved(false), 2000)
}
const handleDelete = async (id) => {
if (!confirm('Profil und ALLE zugehörigen Daten unwiderruflich löschen?')) return
await api.deleteProfile(id)
await refreshProfiles()
if (activeProfile?.id === id) {
const remaining = profiles.filter(p => p.id !== id)
if (remaining.length) setActiveProfile(remaining[0])
}
setEditingId(null)
}
return (
<div>
<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 */}
<div className="card section-gap">
<div className="card-title">Profile ({profiles.length})</div>
{profiles.map(p => (
<div key={p.id}>
<div style={{display:'flex',alignItems:'center',gap:10,padding:'10px 0',
borderBottom:'1px solid var(--border)'}}>
<Avatar profile={p} size={40}/>
<div style={{flex:1}}>
<div style={{fontSize:14,fontWeight:600}}>{p.name}</div>
<div style={{fontSize:11,color:'var(--text3)'}}>
{p.sex==='m'?'Männlich':'Weiblich'}
{p.height ? ` · ${p.height} cm` : ''}
{p.goal_weight ? ` · Ziel: ${p.goal_weight} kg` : ''}
</div>
</div>
<div style={{display:'flex',gap:6,alignItems:'center'}}>
{activeProfile?.id === p.id
? <span style={{fontSize:11,color:'var(--accent)',fontWeight:600,padding:'3px 8px',
background:'var(--accent-light)',borderRadius:6}}>Aktiv</span>
: <button className="btn btn-secondary" style={{padding:'4px 10px',fontSize:12}}
onClick={handleLogout}>
Nutzer wechseln
</button>
}
<button className="btn btn-secondary" style={{padding:'4px 8px'}}
onClick={()=>setEditingId(editingId===p.id ? null : p.id)}>
<Pencil size={12}/>
</button>
{profiles.length > 1 && (
<button className="btn btn-danger" style={{padding:'4px 8px'}}
onClick={()=>handleDelete(p.id)}>
<Trash2 size={12}/>
</button>
)}
</div>
</div>
{/* Edit form – only shown for THIS profile */}
{editingId === p.id && (
<ProfileForm
profile={p}
onSave={(form) => handleSave(form, p.id)}
onCancel={() => setEditingId(null)}
/>
)}
</div>
))}
{/* New profile */}
{editingId === 'new' ? (
<ProfileForm
title="Neues Profil"
onSave={(form) => handleSave(form, 'new')}
onCancel={() => setEditingId(null)}
/>
) : (
<button className="btn btn-secondary btn-full" style={{marginTop:12}}
onClick={() => setEditingId('new')}>
<Plus size={14}/> Neues Profil anlegen
</button>
)}
</div>
{/* Auth actions */}
<div className="card section-gap">
<div className="card-title">🔐 Konto</div>
<div style={{display:'flex',gap:8,flexDirection:'column'}}>
<button className="btn btn-secondary btn-full"
onClick={()=>setPinOpen(o=>!o)}
style={{display:'flex',alignItems:'center',gap:8,justifyContent:'center'}}>
<Key size={14}/> PIN / Passwort ändern
</button>
{pinOpen && (
<div style={{padding:12,background:'var(--surface2)',borderRadius:8}}>
<div style={{display:'flex',gap:8}}>
<input type="password" className="form-input" placeholder="Neue PIN/Passwort"
value={newPin} onChange={e=>setNewPin(e.target.value)} style={{flex:1}}/>
<button className="btn btn-primary" onClick={handlePinChange}>Setzen</button>
</div>
{pinMsg && <div style={{fontSize:12,color:pinMsg.startsWith('✓')?'var(--accent)':'#D85A30',marginTop:6}}>{pinMsg}</div>}
</div>
)}
<button className="btn btn-secondary btn-full"
onClick={handleLogout}
style={{display:'flex',alignItems:'center',gap:8,justifyContent:'center',color:'var(--warn)'}}>
<LogOut size={14}/> Ausloggen
</button>
</div>
</div>
{/* Admin Panel */}
{isAdmin && (
<div className="card section-gap">
<div style={{display:'flex',alignItems:'center',justifyContent:'space-between'}}>
<div className="card-title" style={{margin:0,display:'flex',alignItems:'center',gap:6}}>
<Shield size={15} color="var(--accent)"/> Admin
</div>
<button className="btn btn-secondary" style={{fontSize:12}}
onClick={()=>setAdminOpen(o=>!o)}>
{adminOpen?'Schließen':'Öffnen'}
</button>
</div>
{adminOpen && <div style={{marginTop:12}}><AdminPanel/></div>}
</div>
)}
{/* Export */}
<FeatureGate feature="data_export" showUpgradePrompt>
<div className="card section-gap">
<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}}>
Exportiert alle Daten von <strong>{activeProfile?.name}</strong>:
Gewicht, Umfänge, Caliper, Ernährung, Aktivität und KI-Auswertungen.
</p>
<div style={{display:'flex',flexDirection:'column',gap:8}}>
<button className="btn btn-primary btn-full"
onClick={()=>api.exportZip()}>
<Download size={14}/> ZIP exportieren
<span style={{fontSize:11,opacity:0.8,marginLeft:6}}>— je eine CSV pro Kategorie</span>
</button>
<button className="btn btn-secondary btn-full"
onClick={()=>api.exportJson()}>
<Download size={14}/> JSON exportieren
<span style={{fontSize:11,opacity:0.8,marginLeft:6}}>— maschinenlesbar, alles in einer Datei</span>
</button>
</div>
<p style={{fontSize:11,color:'var(--text3)',marginTop:8}}>
Der ZIP-Export enthält separate Dateien für Excel und eine lesbare KI-Auswertungsdatei.
</p>
</div>
</FeatureGate>
{/* Import */}
<FeatureGate feature="csv_import" showUpgradePrompt>
<div className="card section-gap">
<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}}>
Importiere einen ZIP-Export zurück in <strong>{activeProfile?.name}</strong>.
Vorhandene Einträge werden nicht überschrieben.
</p>
<div style={{display:'flex',flexDirection:'column',gap:8}}>
<label className="btn btn-primary btn-full"
style={{cursor:importing?'wait':'pointer',opacity:importing?0.6:1}}>
<input type="file" accept=".zip" onChange={handleImport}
disabled={importing}
style={{display:'none'}}/>
{importing ? (
<>Importiere...</>
) : (
<>
<Upload size={14}/> ZIP-Backup importieren
</>
)}
</label>
{importMsg && (
<div style={{
padding:'10px 12px',
background: importMsg.type === 'success' ? '#E1F5EE' : '#FCEBEB',
borderRadius:8,
fontSize:12,
color: importMsg.type === 'success' ? 'var(--accent)' : '#D85A30',
lineHeight:1.4
}}>
{importMsg.text}
</div>
)}
</div>
<p style={{fontSize:11,color:'var(--text3)',marginTop:8}}>
Der Import erkennt automatisch das Format und importiert nur neue Einträge.
</p>
</div>
</FeatureGate>
{saved && (
<div style={{position:'fixed',bottom:80,left:'50%',transform:'translateX(-50%)',
background:'var(--accent)',color:'white',padding:'8px 20px',borderRadius:20,
fontSize:13,fontWeight:600,display:'flex',alignItems:'center',gap:6,zIndex:100}}>
<Check size={14}/> Gespeichert
</div>
)}
</div>
)
}