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}
}
-
-
- Name
- 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'}}/>
- ))}
-
-
-
-
-
- Geschlecht
- set('sex',e.target.value)}>
- Männlich
- Weiblich
-
-
-
- Geburtsdatum
- set('dob',e.target.value)}/>
-
-
-
- Größe
- set('height',e.target.value)}/>
- cm
-
-
Ziele (optional)
-
- Zielgewicht
- set('goal_weight',e.target.value)} placeholder="–"/>
- kg
-
-
- Ziel-KF%
- set('goal_bf_pct',e.target.value)} placeholder="–"/>
- %
-
-
- onSave(form)}>
- Speichern
-
-
- Abbrechen
-
-
-
- )
+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
- :
- Nutzer wechseln
-
- }
-
setEditingId(editingId===p.id ? null : p.id)}>
-
-
- {profiles.length > 1 && (
-
handleDelete(p.id)}>
-
-
- )}
-
-
-
- {/* Edit form – only shown for THIS profile */}
- {editingId === p.id && (
-
handleSave(form, p.id)}
- onCancel={() => setEditingId(null)}
- />
- )}
+
+
+ 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)}
- />
- ) : (
- setEditingId('new')}>
- Neues Profil anlegen
-
)}
+
+
+ Name
+ setF('name', e.target.value)}
+ />
+
+
+
+
+ E-Mail
+ 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',
+ }}
+ />
+ ))}
+
+
+
+
+
+ Geschlecht
+ setF('sex', e.target.value)}>
+ Männlich
+ Weiblich
+ Divers
+
+
+
+ Geburtsdatum
+ setF('dob', e.target.value)}
+ />
+
+
+
+ Größe
+ setF('height', e.target.value)}
+ />
+ cm
+
+
+
+ Ziele (optional, Legacy)
+
+
+ Diese Felder bleiben vorerst erhalten; strategische Ziele verwaltest du unter{' '}
+ Analyse → Ziele.
+
+
+ Zielgewicht
+ setF('goal_weight', e.target.value)}
+ placeholder="–"
+ />
+ kg
+
+
+ Ziel-KF%
+ setF('goal_bf_pct', e.target.value)}
+ placeholder="–"
+ />
+ %
+
+
+ {profileErr && (
+
+ {profileErr}
+
+ )}
+
+
+ Profil speichern
+
{/* Auth actions */}