diff --git a/backend/models.py b/backend/models.py index 65feaee..c2b473a 100644 --- a/backend/models.py +++ b/backend/models.py @@ -28,6 +28,7 @@ class ProfileUpdate(BaseModel): goal_weight: Optional[float] = None goal_bf_pct: Optional[float] = None quality_filter_level: Optional[str] = None # Issue #31: Global quality filter + email: Optional[str] = None # Self-service; leer = entfernen; Änderung setzt Verifikation zurück # ── Tracking Models ─────────────────────────────────────────────────────────── diff --git a/backend/routers/profiles.py b/backend/routers/profiles.py index f2ebe05..b9937ea 100644 --- a/backend/routers/profiles.py +++ b/backend/routers/profiles.py @@ -68,13 +68,62 @@ def get_profile(pid: str, session=Depends(require_auth)): @router.put("/profiles/{pid}") def update_profile(pid: str, p: ProfileUpdate, session=Depends(require_auth)): - """Update profile by ID (admin).""" + """Update profile by ID.""" with get_db() as conn: - data = {k:v for k,v in p.model_dump().items() if v is not None} - data['updated'] = datetime.now().isoformat() cur = get_cursor(conn) - cur.execute(f"UPDATE profiles SET {', '.join(f'{k}=%s' for k in data)} WHERE id=%s", - list(data.values())+[pid]) + cur.execute("SELECT * FROM profiles WHERE id=%s", (pid,)) + row = cur.fetchone() + if not row: + raise HTTPException(404, "Profil nicht gefunden") + rowd = r2d(row) + cur_email_norm = (rowd.get("email") or "").strip().lower() + + patch = p.model_dump(exclude_unset=True) + data = {} + + if "email" in patch: + ev = patch["email"] + if ev is None or (isinstance(ev, str) and ev.strip() == ""): + if rowd.get("email") is not None: + data["email"] = None + data["email_verified"] = False + data["verification_token"] = None + data["verification_expires"] = None + else: + email_norm = ev.strip().lower() + if "@" not in email_norm or len(email_norm) < 5: + raise HTTPException(400, "Ungültige E-Mail-Adresse") + cur.execute( + """ + SELECT id FROM profiles + WHERE email IS NOT NULL AND lower(trim(email)) = %s AND id <> %s + """, + (email_norm, pid), + ) + if cur.fetchone(): + raise HTTPException(409, "E-Mail wird bereits verwendet") + data["email"] = email_norm + if email_norm != cur_email_norm: + data["email_verified"] = False + data["verification_token"] = None + data["verification_expires"] = None + + nullable_keys = {"goal_weight", "goal_bf_pct", "dob"} + for k, v in patch.items(): + if k == "email": + continue + if v is None and k in nullable_keys: + data[k] = None + elif v is not None: + data[k] = v + + if not data: + return get_profile(pid, session) + + data["updated"] = datetime.now().isoformat() + cols = ", ".join(f"{k}=%s" for k in data) + vals = list(data.values()) + [pid] + cur.execute(f"UPDATE profiles SET {cols} WHERE id=%s", vals) return get_profile(pid, session) diff --git a/frontend/src/pages/SettingsPage.jsx b/frontend/src/pages/SettingsPage.jsx index 4def0fc..c40ee7e 100644 --- a/frontend/src/pages/SettingsPage.jsx +++ b/frontend/src/pages/SettingsPage.jsx @@ -1,5 +1,6 @@ import { useState, useEffect } from 'react' -import { Save, Download, Upload, Trash2, Plus, Check, Pencil, X, LogOut, Key, BarChart3 } from 'lucide-react' +import { Save, Download, Upload, Check, LogOut, Key, BarChart3 } from 'lucide-react' +import { Link } from 'react-router-dom' import { useProfile } from '../context/ProfileContext' import { useAuth } from '../context/AuthContext' import { Avatar } from './ProfileSelect' @@ -9,93 +10,15 @@ import UsageBadge from '../components/UsageBadge' 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 ( -
- {title &&
{title}
} - -
- - set('name',e.target.value)} autoFocus/> - -
- -
-
Avatar-Farbe
-
- -
- {COLORS.map(c=>( -
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'}}/> - ))} -
-
-
- -
- - -
-
- - set('dob',e.target.value)}/> - -
-
- - set('height',e.target.value)}/> - cm -
-
Ziele (optional)
-
- - set('goal_weight',e.target.value)} placeholder="–"/> - kg -
-
- - set('goal_bf_pct',e.target.value)} placeholder="–"/> - % -
-
- - -
-
- ) +function dobInputValue(dob) { + if (!dob) return '' + const s = String(dob) + return s.length >= 10 ? s.slice(0, 10) : s } export default function SettingsPage() { const { profiles, activeProfile, setActiveProfile, refreshProfiles } = useProfile() - const { logout, canExport } = useAuth() + const { logout, canExport, isAdmin } = useAuth() const [pinOpen, setPinOpen] = useState(false) const [newPin, setNewPin] = useState('') const [pinMsg, setPinMsg] = useState(null) @@ -129,8 +52,19 @@ export default function SettingsPage() { 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 [form, setForm] = useState({ + name: '', + email: '', + sex: 'm', + dob: '', + height: '', + goal_weight: '', + goal_bf_pct: '', + avatar_color: COLORS[0], + }) + const setF = (k, v) => setForm((f) => ({ ...f, [k]: v })) + + const [profileErr, setProfileErr] = useState(null) const [saved, setSaved] = useState(false) const [importing, setImporting] = useState(false) const [importMsg, setImportMsg] = useState(null) @@ -200,53 +134,78 @@ export default function SettingsPage() { } } + useEffect(() => { + if (!activeProfile) return + const sexRaw = activeProfile.sex || 'm' + setForm({ + name: activeProfile.name || '', + email: activeProfile.email || '', + sex: sexRaw === 'f' ? 'w' : sexRaw, + dob: dobInputValue(activeProfile.dob), + height: activeProfile.height != null ? String(activeProfile.height) : '', + goal_weight: activeProfile.goal_weight != null ? String(activeProfile.goal_weight) : '', + goal_bf_pct: activeProfile.goal_bf_pct != null ? String(activeProfile.goal_bf_pct) : '', + avatar_color: activeProfile.avatar_color || COLORS[0], + }) + setProfileErr(null) + }, [activeProfile?.id]) + const handleQualityFilterChange = async (level) => { - // Issue #31: Update global quality filter await api.updateActiveProfile({ quality_filter_level: level }) await refreshProfiles() const updated = profiles.find(p => p.id === activeProfile?.id) - if (updated) setActiveProfile({...updated, quality_filter_level: level}) + if (updated) setActiveProfile({ ...updated, quality_filter_level: level }) setSaved(true) setTimeout(() => setSaved(false), 2000) } - 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}) + const handleSaveMyProfile = async () => { + if (!activeProfile) return + const name = form.name.trim() + if (!name) { + setProfileErr('Bitte einen Namen eingeben.') + return + } + const h = parseFloat(form.height) + if (!form.height || Number.isNaN(h) || h < 100 || h > 250) { + setProfileErr('Bitte eine gültige Größe (100–250 cm) eingeben.') + return + } + let goal_weight = null + if (form.goal_weight !== '') { + goal_weight = parseFloat(form.goal_weight) + if (Number.isNaN(goal_weight)) { + setProfileErr('Zielgewicht: bitte eine gültige Zahl eingeben oder leer lassen.') + return } } - 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]) + let goal_bf_pct = null + if (form.goal_bf_pct !== '') { + goal_bf_pct = parseFloat(form.goal_bf_pct) + if (Number.isNaN(goal_bf_pct)) { + setProfileErr('Ziel-KF%: bitte eine gültige Zahl eingeben oder leer lassen.') + return + } + } + setProfileErr(null) + try { + const payload = { + name, + sex: form.sex, + dob: form.dob ? form.dob : null, + height: h, + avatar_color: form.avatar_color, + goal_weight, + goal_bf_pct, + email: form.email.trim() === '' ? null : form.email.trim(), + } + await api.updateActiveProfile(payload) + await refreshProfiles() + setSaved(true) + setTimeout(() => setSaved(false), 2000) + } catch (e) { + setProfileErr(e.message || 'Speichern fehlgeschlagen') } - setEditingId(null) } const handleExportPlaceholders = async () => { @@ -270,69 +229,197 @@ export default function SettingsPage() {

Einstellungen

- {/* Profile list */} + {/* Aktives Profil (nur eigenes Profil; weitere Profile nur im Admin) */}
-
Profile ({profiles.length})
- - {profiles.map(p => ( -
-
- -
-
{p.name}
-
- {p.sex==='m'?'Männlich':'Weiblich'} - {p.height ? ` · ${p.height} cm` : ''} - {p.goal_weight ? ` · Ziel: ${p.goal_weight} kg` : ''} -
-
-
- {activeProfile?.id === p.id - ? Aktiv - : - } - - {profiles.length > 1 && ( - - )} -
-
- - {/* Edit form – only shown for THIS profile */} - {editingId === p.id && ( - handleSave(form, p.id)} - onCancel={() => setEditingId(null)} - /> - )} +
+ + Mein Profil +
+

+ Hier bearbeitest du nur das aktive Profil. Zum Anlegen weiterer Profile oder zum + Verwalten anderer Nutzer nutzt du den Admin-Bereich (Zugriff nur als Administrator). +

+ {isAdmin && ( +
+ Admin:{' '} + + Benutzerverwaltung +
- ))} - - {/* New profile */} - {editingId === 'new' ? ( - handleSave(form, 'new')} - onCancel={() => setEditingId(null)} - /> - ) : ( - )} + +
+ + setF('name', e.target.value)} + /> + +
+ +
+ + setF('email', e.target.value)} + /> + +
+ {activeProfile?.email && activeProfile?.email_verified === false && ( +
+ Diese E-Mail ist noch nicht bestätigt. Nach einer Änderung der Adresse ist ggf. erneut eine + Bestätigung nötig. +
+ )} + +
+
Avatar-Farbe
+
+ +
+ {COLORS.map((c) => ( +
setF('avatar_color', c)} + onKeyDown={(e) => e.key === 'Enter' && setF('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', + }} + /> + ))} +
+
+
+ +
+ + +
+
+ + setF('dob', e.target.value)} + /> + +
+
+ + setF('height', e.target.value)} + /> + cm +
+ +
+ Ziele (optional, Legacy) +
+

+ Diese Felder bleiben vorerst erhalten; strategische Ziele verwaltest du unter{' '} + Analyse → Ziele. +

+
+ + setF('goal_weight', e.target.value)} + placeholder="–" + /> + kg +
+
+ + setF('goal_bf_pct', e.target.value)} + placeholder="–" + /> + % +
+ + {profileErr && ( +
+ {profileErr} +
+ )} + +
{/* Auth actions */}