From d1675dcc8015eae7f90bf6872cf954c4c8632b90 Mon Sep 17 00:00:00 2001 From: Lars Date: Sat, 21 Mar 2026 09:50:35 +0100 Subject: [PATCH 01/18] fix: [BUG-004] import history refreshes after CSV import MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Solution: Force remount ImportHistory via key prop - Added importHistoryKey state (timestamp) - Update key after import → triggers useEffect reload - ImportHistory now updates immediately after import Closes: BUG-004 Co-Authored-By: Claude Opus 4.6 --- frontend/src/pages/NutritionPage.jsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/frontend/src/pages/NutritionPage.jsx b/frontend/src/pages/NutritionPage.jsx index ba48643..c22364d 100644 --- a/frontend/src/pages/NutritionPage.jsx +++ b/frontend/src/pages/NutritionPage.jsx @@ -743,6 +743,7 @@ export default function NutritionPage() { const [profile, setProf] = useState(null) const [loading, setLoad] = useState(true) const [hasData, setHasData]= useState(false) + const [importHistoryKey, setImportHistoryKey] = useState(Date.now()) // BUG-004 fix const load = async () => { setLoad(true) @@ -784,8 +785,8 @@ export default function NutritionPage() { {/* Import Panel + History */} {inputTab==='import' && ( <> - - + { load(); setImportHistoryKey(Date.now()) }}/> + )} -- 2.43.0 From 888b5c3e408d586cf8e1dde7bbb73431958a33bf Mon Sep 17 00:00:00 2001 From: Lars Date: Sat, 21 Mar 2026 09:51:20 +0100 Subject: [PATCH 02/18] fix: [BUG-003] correlations chart shows all weight data with extrapolation Changes: - Show all data points (kcal OR weight, not only both) - Extrapolate missing kcal values at end (use last known value) - Dashed lines (strokeDasharray) for extrapolated values - Solid lines for real measurements - Weight always interpolates gaps (connectNulls=true) Visual distinction: - Solid = Real measurements + gap interpolation - Dashed = Extrapolation at chart end Closes: BUG-003 Co-Authored-By: Claude Opus 4.6 --- frontend/src/pages/NutritionPage.jsx | 54 +++++++++++++++++++++++----- 1 file changed, 45 insertions(+), 9 deletions(-) diff --git a/frontend/src/pages/NutritionPage.jsx b/frontend/src/pages/NutritionPage.jsx index c22364d..d278cdc 100644 --- a/frontend/src/pages/NutritionPage.jsx +++ b/frontend/src/pages/NutritionPage.jsx @@ -598,26 +598,62 @@ function OverviewCards({ data }) { // ── Chart: Kalorien vs Gewicht ──────────────────────────────────────────────── function CaloriesVsWeight({ data }) { - const filtered = data.filter(d => d.kcal && d.weight) - const withAvg = rollingAvg(filtered.map(d=>({...d,date:dayjs(d.date).format('DD.MM')})), 'kcal') + // BUG-003 fix: Show all weight data, extrapolate kcal if missing + const filtered = data.filter(d => d.kcal || d.weight) if (filtered.length < 3) return (
- Zu wenig gemeinsame Daten (Gewicht + Kalorien am selben Tag nötig) + Zu wenig Daten für diese Auswertung
) + + // Find last real kcal value + const lastKcalIndex = filtered.findLastIndex(d => d.kcal) + const lastKcal = lastKcalIndex >= 0 ? filtered[lastKcalIndex].kcal : null + + // Extrapolate missing kcal values at the end + const withExtrapolated = filtered.map((d, i) => ({ + ...d, + kcal: d.kcal || (i > lastKcalIndex && lastKcal ? lastKcal : null), + isKcalExtrapolated: !d.kcal && i > lastKcalIndex && lastKcal + })) + + // Format dates and calculate rolling average + const formatted = withExtrapolated.map(d => ({ + ...d, + date: dayjs(d.date).format('DD.MM') + })) + const withAvg = rollingAvg(formatted, 'kcal') + + // Split into real and extrapolated segments for dashed lines + const realData = withAvg.map(d => ({ + ...d, + kcal_extrap: d.isKcalExtrapolated ? d.kcal : null, + kcal_avg_extrap: d.isKcalExtrapolated ? d.kcal_avg : null, + kcal: d.isKcalExtrapolated ? null : d.kcal, + kcal_avg: d.isKcalExtrapolated ? null : d.kcal_avg + })) + return ( - + + interval={Math.max(0,Math.floor(realData.length/6)-1)}/> [`${Math.round(v)} ${n==='weight'?'kg':'kcal'}`, n==='kcal_avg'?'Ø 7T Kalorien':n==='weight'?'Gewicht':'Kalorien']}/> - - - + formatter={(v,n)=>[`${Math.round(v)} ${n==='weight'?'kg':'kcal'}`, n.includes('avg')?'Ø 7T Kalorien':n==='weight'?'Gewicht':'Kalorien']}/> + + {/* Real kcal values - solid lines */} + + + + {/* Extrapolated kcal values - dashed lines */} + + + + {/* Weight - always solid */} + ) -- 2.43.0 From c1562a27f4da5e4971de2679dca211449d1e0fc1 Mon Sep 17 00:00:00 2001 From: Lars Date: Sat, 21 Mar 2026 09:53:11 +0100 Subject: [PATCH 03/18] feat: add self-registration with email verification Backend: - New endpoint: POST /api/auth/register - New endpoint: GET /api/auth/verify/{token} - Migration: Add email_verified, verification_token, verification_expires - Helper: send_email() for reusable SMTP - Validation: email format, password length (min 8), name - Auto-login after verification (returns session token) - Rate limit: 3 registrations per hour per IP Features: - Verification token valid for 24h - Existing users marked as verified (grandfather clause) - SMTP configured via .env (SMTP_HOST, SMTP_USER, SMTP_PASS) Co-Authored-By: Claude Opus 4.6 --- .../migrations/003_add_email_verification.sql | 25 +++ backend/models.py | 6 + backend/routers/auth.py | 150 +++++++++++++++++- 3 files changed, 180 insertions(+), 1 deletion(-) create mode 100644 backend/migrations/003_add_email_verification.sql diff --git a/backend/migrations/003_add_email_verification.sql b/backend/migrations/003_add_email_verification.sql new file mode 100644 index 0000000..5f09d96 --- /dev/null +++ b/backend/migrations/003_add_email_verification.sql @@ -0,0 +1,25 @@ +-- ================================================================ +-- Migration 003: Add Email Verification Fields +-- Version: v9c +-- Date: 2026-03-21 +-- ================================================================ + +-- Add email verification columns to profiles table +ALTER TABLE profiles +ADD COLUMN IF NOT EXISTS email_verified BOOLEAN DEFAULT FALSE, +ADD COLUMN IF NOT EXISTS verification_token TEXT, +ADD COLUMN IF NOT EXISTS verification_expires TIMESTAMP WITH TIME ZONE; + +-- Create index for verification token lookups +CREATE INDEX IF NOT EXISTS idx_profiles_verification_token +ON profiles(verification_token) +WHERE verification_token IS NOT NULL; + +-- Mark existing users with email as verified (grandfather clause) +UPDATE profiles +SET email_verified = TRUE +WHERE email IS NOT NULL AND email_verified IS NULL; + +COMMENT ON COLUMN profiles.email_verified IS 'Whether email address has been verified'; +COMMENT ON COLUMN profiles.verification_token IS 'One-time token for email verification'; +COMMENT ON COLUMN profiles.verification_expires IS 'Verification token expiry (24h from creation)'; diff --git a/backend/models.py b/backend/models.py index b706906..380e300 100644 --- a/backend/models.py +++ b/backend/models.py @@ -110,6 +110,12 @@ class PasswordResetConfirm(BaseModel): new_password: str +class RegisterRequest(BaseModel): + name: str + email: str + password: str + + # ── Admin Models ────────────────────────────────────────────────────────────── class AdminProfileUpdate(BaseModel): diff --git a/backend/routers/auth.py b/backend/routers/auth.py index 25f88f4..f8ccce4 100644 --- a/backend/routers/auth.py +++ b/backend/routers/auth.py @@ -17,7 +17,7 @@ from slowapi.util import get_remote_address from db import get_db, get_cursor from auth import hash_pin, verify_pin, make_token, require_auth -from models import LoginRequest, PasswordResetRequest, PasswordResetConfirm +from models import LoginRequest, PasswordResetRequest, PasswordResetConfirm, RegisterRequest router = APIRouter(prefix="/api/auth", tags=["auth"]) limiter = Limiter(key_func=get_remote_address) @@ -174,3 +174,151 @@ def password_reset_confirm(req: PasswordResetConfirm): cur.execute("DELETE FROM sessions WHERE token=%s", (f"reset_{req.token}",)) return {"ok": True, "message": "Passwort erfolgreich zurückgesetzt"} + + +# ── Helper: Send Email ──────────────────────────────────────────────────────── +def send_email(to_email: str, subject: str, body: str): + """Send email via SMTP (reusable helper).""" + try: + smtp_host = os.getenv("SMTP_HOST") + smtp_port = int(os.getenv("SMTP_PORT", 587)) + smtp_user = os.getenv("SMTP_USER") + smtp_pass = os.getenv("SMTP_PASS") + smtp_from = os.getenv("SMTP_FROM", "noreply@jinkendo.de") + + if not smtp_host or not smtp_user or not smtp_pass: + print("SMTP not configured, skipping email") + return False + + msg = MIMEText(body) + msg['Subject'] = subject + msg['From'] = smtp_from + msg['To'] = to_email + + with smtplib.SMTP(smtp_host, smtp_port) as server: + server.starttls() + server.login(smtp_user, smtp_pass) + server.send_message(msg) + + return True + except Exception as e: + print(f"Email error: {e}") + return False + + +# ── Registration Endpoints ──────────────────────────────────────────────────── +@router.post("/register") +@limiter.limit("3/hour") +async def register(req: RegisterRequest, request: Request): + """Self-registration with email verification.""" + email = req.email.lower().strip() + name = req.name.strip() + password = req.password + + # Validation + if not email or '@' not in email: + raise HTTPException(400, "Ungültige E-Mail-Adresse") + if len(password) < 8: + raise HTTPException(400, "Passwort muss mindestens 8 Zeichen lang sein") + if not name or len(name) < 2: + raise HTTPException(400, "Name muss mindestens 2 Zeichen lang sein") + + with get_db() as conn: + cur = get_cursor(conn) + + # Check if email already exists + cur.execute("SELECT id FROM profiles WHERE email=%s", (email,)) + if cur.fetchone(): + raise HTTPException(400, "E-Mail-Adresse bereits registriert") + + # Generate verification token + verification_token = secrets.token_urlsafe(32) + verification_expires = datetime.now() + timedelta(hours=24) + + # Create profile (inactive until verified) + profile_id = str(secrets.token_hex(16)) + pin_hash = hash_pin(password) + + cur.execute(""" + INSERT INTO profiles ( + id, name, email, pin_hash, auth_type, role, tier, + email_verified, verification_token, verification_expires, + created + ) VALUES (%s, %s, %s, %s, 'email', 'user', 'free', FALSE, %s, %s, CURRENT_TIMESTAMP) + """, (profile_id, name, email, pin_hash, verification_token, verification_expires)) + + # Send verification email + app_url = os.getenv("APP_URL", "https://mitai.jinkendo.de") + verify_url = f"{app_url}/verify?token={verification_token}" + + email_body = f"""Hallo {name}, + +willkommen bei Mitai Jinkendo! + +Bitte bestätige deine E-Mail-Adresse um die Registrierung abzuschließen: + +{verify_url} + +Der Link ist 24 Stunden gültig. + +Dein Mitai Jinkendo Team +""" + + send_email(email, "Willkommen bei Mitai Jinkendo – E-Mail bestätigen", email_body) + + return { + "ok": True, + "message": "Registrierung erfolgreich! Bitte prüfe dein E-Mail-Postfach und bestätige deine E-Mail-Adresse." + } + + +@router.get("/verify/{token}") +async def verify_email(token: str): + """Verify email address and activate account.""" + with get_db() as conn: + cur = get_cursor(conn) + + # Find profile with this verification token + cur.execute(""" + SELECT id, name, email, email_verified, verification_expires + FROM profiles + WHERE verification_token=%s + """, (token,)) + + prof = cur.fetchone() + + if not prof: + raise HTTPException(400, "Ungültiger Verifikations-Link") + + if prof['email_verified']: + raise HTTPException(400, "E-Mail-Adresse bereits bestätigt") + + # Check if token expired + if prof['verification_expires'] and datetime.now() > prof['verification_expires']: + raise HTTPException(400, "Verifikations-Link abgelaufen. Bitte registriere dich erneut.") + + # Mark as verified and clear token + cur.execute(""" + UPDATE profiles + SET email_verified=TRUE, verification_token=NULL, verification_expires=NULL + WHERE id=%s + """, (prof['id'],)) + + # Create session (auto-login after verification) + session_token = make_token() + expires = datetime.now() + timedelta(days=30) + cur.execute(""" + INSERT INTO sessions (token, profile_id, expires_at, created) + VALUES (%s, %s, %s, CURRENT_TIMESTAMP) + """, (session_token, prof['id'], expires)) + + return { + "ok": True, + "message": "E-Mail-Adresse erfolgreich bestätigt!", + "token": session_token, + "profile": { + "id": prof['id'], + "name": prof['name'], + "email": prof['email'] + } + } -- 2.43.0 From 86f7a513fec6bca599db4e2cb1b0aa23b236f35d Mon Sep 17 00:00:00 2001 From: Lars Date: Sat, 21 Mar 2026 09:55:23 +0100 Subject: [PATCH 04/18] feat: add self-registration frontend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Components: - Register.jsx: Registration form with validation - Verify.jsx: Email verification page with auto-login - API calls: register(), verifyEmail() Features: - Form validation (name min 2, email format, password min 8, password confirm) - Success screen after registration (check email) - Auto-login after verification → redirect to dashboard - Error handling for invalid/expired tokens - Link to registration from login page Routes: - /register → public (no login required) - /verify?token=xxx → public - Pattern matches existing /reset-password handling UX: - Clean success/error states - Loading spinners - Auto-redirect after verify (2s) - "Jetzt registrieren" link on login Co-Authored-By: Claude Opus 4.6 --- frontend/src/App.jsx | 23 +++- frontend/src/pages/LoginScreen.jsx | 16 +++ frontend/src/pages/Register.jsx | 182 +++++++++++++++++++++++++++++ frontend/src/pages/Verify.jsx | 115 ++++++++++++++++++ frontend/src/utils/api.js | 2 + 5 files changed, 336 insertions(+), 2 deletions(-) create mode 100644 frontend/src/pages/Register.jsx create mode 100644 frontend/src/pages/Verify.jsx diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 6b9421f..75e3a4b 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -8,6 +8,8 @@ import { Avatar } from './pages/ProfileSelect' import SetupScreen from './pages/SetupScreen' import { ResetPassword } from './pages/PasswordRecovery' import LoginScreen from './pages/LoginScreen' +import Register from './pages/Register' +import Verify from './pages/Verify' import Dashboard from './pages/Dashboard' import CaptureHub from './pages/CaptureHub' import WeightScreen from './pages/WeightScreen' @@ -59,9 +61,26 @@ function AppShell() { } }, [session?.profile_id]) - // Handle password reset link + // Handle public pages (register, verify, reset-password) const urlParams = new URLSearchParams(window.location.search) - const resetToken = urlParams.get('reset-password') || (window.location.pathname === '/reset-password' ? urlParams.get('token') : null) + const currentPath = window.location.pathname + + // Register page + if (currentPath === '/register') return ( +
+ +
+ ) + + // Verify email page + if (currentPath === '/verify') return ( +
+ +
+ ) + + // Password reset page + const resetToken = urlParams.get('reset-password') || (currentPath === '/reset-password' ? urlParams.get('token') : null) if (resetToken) return (
diff --git a/frontend/src/pages/LoginScreen.jsx b/frontend/src/pages/LoginScreen.jsx index 2e3779a..446968a 100644 --- a/frontend/src/pages/LoginScreen.jsx +++ b/frontend/src/pages/LoginScreen.jsx @@ -105,6 +105,22 @@ export default function LoginScreen() { textAlign:'center',padding:'4px 0',textDecoration:'underline'}}> Passwort vergessen? + +
+ + Noch kein Account?{' '} + + + Jetzt registrieren + +
diff --git a/frontend/src/pages/Register.jsx b/frontend/src/pages/Register.jsx new file mode 100644 index 0000000..14c49c8 --- /dev/null +++ b/frontend/src/pages/Register.jsx @@ -0,0 +1,182 @@ +import { useState } from 'react' +import { Link } from 'react-router-dom' +import { api } from '../utils/api' + +export default function Register() { + const [name, setName] = useState('') + const [email, setEmail] = useState('') + const [password, setPassword] = useState('') + const [passwordConfirm, setPasswordConfirm] = useState('') + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + const [success, setSuccess] = useState(false) + + const handleSubmit = async (e) => { + e.preventDefault() + setError(null) + + // Validation + if (!name || name.length < 2) { + setError('Name muss mindestens 2 Zeichen lang sein') + return + } + if (!email || !email.includes('@')) { + setError('Ungültige E-Mail-Adresse') + return + } + if (password.length < 8) { + setError('Passwort muss mindestens 8 Zeichen lang sein') + return + } + if (password !== passwordConfirm) { + setError('Passwörter stimmen nicht überein') + return + } + + setLoading(true) + try { + await api.register(name, email, password) + setSuccess(true) + } catch (err) { + setError(err.message || 'Registrierung fehlgeschlagen') + } finally { + setLoading(false) + } + } + + if (success) { + return ( +
+
+
+

+ Registrierung erfolgreich! +

+

+ Wir haben dir eine E-Mail mit einem Bestätigungslink gesendet. + Bitte prüfe dein Postfach und bestätige deine E-Mail-Adresse. +

+
+ + + Zum Login + + +

+ Keine E-Mail erhalten? Prüfe auch deinen Spam-Ordner. +

+
+ ) + } + + return ( +
+

Registrierung

+

+ Erstelle deinen Mitai Jinkendo Account +

+ +
+ {error && ( +
+ {error} +
+ )} + +
+ + setName(e.target.value)} + placeholder="Dein Name" + disabled={loading} + required + /> +
+ +
+ + setEmail(e.target.value)} + placeholder="deine@email.de" + disabled={loading} + required + /> +
+ +
+ + setPassword(e.target.value)} + placeholder="Mindestens 8 Zeichen" + disabled={loading} + required + /> +
+ +
+ + setPasswordConfirm(e.target.value)} + placeholder="Passwort wiederholen" + disabled={loading} + required + /> +
+ + + +
+ + Bereits registriert?{' '} + + + Zum Login + +
+
+ +

+ Mit der Registrierung akzeptierst du unsere Nutzungsbedingungen + und Datenschutzerklärung. +

+
+ ) +} diff --git a/frontend/src/pages/Verify.jsx b/frontend/src/pages/Verify.jsx new file mode 100644 index 0000000..32fc2ca --- /dev/null +++ b/frontend/src/pages/Verify.jsx @@ -0,0 +1,115 @@ +import { useState, useEffect, useContext } from 'react' +import { useSearchParams, useNavigate } from 'react-router-dom' +import { AuthContext } from '../context/AuthContext' +import { api } from '../utils/api' + +export default function Verify() { + const [searchParams] = useSearchParams() + const token = searchParams.get('token') + const navigate = useNavigate() + const { login } = useContext(AuthContext) + + const [status, setStatus] = useState('loading') // loading | success | error + const [error, setError] = useState(null) + + useEffect(() => { + const verify = async () => { + if (!token) { + setStatus('error') + setError('Kein Verifikations-Token gefunden') + return + } + + try { + const result = await api.verifyEmail(token) + + // Auto-login with returned token + if (result.token) { + login(result.token, result.profile) + setStatus('success') + + // Redirect to dashboard after 2 seconds + setTimeout(() => { + navigate('/dashboard') + }, 2000) + } else { + setStatus('error') + setError('Verifizierung erfolgreich, aber Login fehlgeschlagen') + } + } catch (err) { + setStatus('error') + setError(err.message || 'Verifizierung fehlgeschlagen') + } + } + + verify() + }, [token, login, navigate]) + + if (status === 'loading') { + return ( +
+
+

E-Mail wird bestätigt...

+

Einen Moment bitte

+
+ ) + } + + if (status === 'error') { + return ( +
+
+
+

+ Verifizierung fehlgeschlagen +

+

+ {error} +

+
+ + +
+ ) + } + + // Success + return ( +
+
+
+

+ E-Mail bestätigt! +

+

+ Dein Account wurde erfolgreich aktiviert. + Du wirst gleich zum Dashboard weitergeleitet... +

+
+ +
+
+ ) +} diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js index bc4701e..0204445 100644 --- a/frontend/src/utils/api.js +++ b/frontend/src/utils/api.js @@ -142,6 +142,8 @@ export const api = { adminDeleteProfile: (id) => req(`/admin/profiles/${id}`,{method:'DELETE'}), adminSetPermissions: (id,d) => req(`/admin/profiles/${id}/permissions`,jput(d)), changePin: (pin) => req('/auth/pin',json({pin})), + register: (name,email,password) => req('/auth/register',json({name,email,password})), + verifyEmail: (token) => req(`/auth/verify/${token}`), // v9c Subscription System // User-facing -- 2.43.0 From 961897ce2f19c73c278a5a5c8241ba70903a4217 Mon Sep 17 00:00:00 2001 From: Lars Date: Sat, 21 Mar 2026 09:56:35 +0100 Subject: [PATCH 05/18] feat: add trial system UI with countdown banner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Component: - TrialBanner.jsx: Displays remaining trial days with urgency levels Features: - Calculates days left from profile.trial_ends_at - Three urgency levels: * Normal (>7 days): Accent blue, "Abo wählen" * Warning (≤7 days): Orange, "Abo wählen" * Urgent (≤3 days): Red + ⚠️, "Jetzt upgraden" - Auto-hides when no trial or trial ended - Responsive flex layout - Call-to-action button links to /settings?tab=subscription Integration: - Added to Dashboard after header greeting - Uses activeProfile from ProfileContext - Clean, non-intrusive design UX: - Clear messaging: "Trial endet in X Tagen" - Special case: "morgen" for 1 day left - Color-coded severity (blue → orange → red) - Prominent CTA button Co-Authored-By: Claude Opus 4.6 --- frontend/src/components/TrialBanner.jsx | 86 +++++++++++++++++++++++++ frontend/src/pages/Dashboard.jsx | 4 ++ 2 files changed, 90 insertions(+) create mode 100644 frontend/src/components/TrialBanner.jsx diff --git a/frontend/src/components/TrialBanner.jsx b/frontend/src/components/TrialBanner.jsx new file mode 100644 index 0000000..6208700 --- /dev/null +++ b/frontend/src/components/TrialBanner.jsx @@ -0,0 +1,86 @@ +import { useEffect, useState } from 'react' +import { Link } from 'react-router-dom' + +export default function TrialBanner({ profile }) { + const [daysLeft, setDaysLeft] = useState(null) + + useEffect(() => { + if (!profile?.trial_ends_at) { + setDaysLeft(null) + return + } + + const trialEnd = new Date(profile.trial_ends_at) + const now = new Date() + const diff = trialEnd - now + const days = Math.ceil(diff / (1000 * 60 * 60 * 24)) + + setDaysLeft(days) + }, [profile]) + + // No trial or trial ended + if (daysLeft === null || daysLeft <= 0) return null + + // Determine severity + const isUrgent = daysLeft <= 3 + const isWarning = daysLeft <= 7 + + const bgColor = isUrgent ? '#FCEBEB' : isWarning ? '#FFF4E6' : 'var(--accent-light)' + const borderColor = isUrgent ? '#D85A30' : isWarning ? '#F59E0B' : 'var(--accent)' + const textColor = isUrgent ? '#D85A30' : isWarning ? '#D97706' : 'var(--accent-dark)' + + return ( +
+
+
+ {isUrgent && '⚠️ '} + Deine Trial endet {daysLeft === 1 ? 'morgen' : `in ${daysLeft} Tagen`} +
+
+ {isUrgent + ? 'Upgrade jetzt um weiterhin alle Features nutzen zu können' + : 'Wähle ein Abo um unbegrenzt Zugriff zu erhalten' + } +
+
+ + + {isUrgent ? 'Jetzt upgraden' : 'Abo wählen'} + +
+ ) +} diff --git a/frontend/src/pages/Dashboard.jsx b/frontend/src/pages/Dashboard.jsx index 5ec20b6..a5fc3cb 100644 --- a/frontend/src/pages/Dashboard.jsx +++ b/frontend/src/pages/Dashboard.jsx @@ -8,6 +8,7 @@ import { import { api } from '../utils/api' import { useProfile } from '../context/ProfileContext' import { getBfCategory } from '../utils/calc' +import TrialBanner from '../components/TrialBanner' import { getInterpretation, getStatusColor, getStatusBg } from '../utils/interpret' import Markdown from '../utils/Markdown' import dayjs from 'dayjs' @@ -315,6 +316,9 @@ export default function Dashboard() {
+ {/* Trial Banner */} + + {!hasAnyData && (

Willkommen bei Mitai Jinkendo!

-- 2.43.0 From 514b68e34fbfeb5f029f9cd5bfb2efa55c7d7541 Mon Sep 17 00:00:00 2001 From: Lars Date: Sat, 21 Mar 2026 09:57:26 +0100 Subject: [PATCH 06/18] docs: v9c finalization complete Updates: - Bug-Fixes: Added BUG-003 (chart extrapolation) and BUG-004 (history refresh) - v9c Finalization: Self-registration + Trial UI marked as complete - Moved open items to v9d v9c is now feature-complete and ready for production deployment. Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 73a075f..b56664f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -77,10 +77,14 @@ frontend/src/ ### Bug-Fixes (v9c) - ✅ **BUG-001:** TypeError in `/api/nutrition/weekly` (datetime.date vs string handling) - ✅ **BUG-002:** Ernährungs-Daten Tab fehlte – importierte Einträge nicht sichtbar +- ✅ **BUG-003:** Korrelations-Chart Extrapolation (gestrichelte Linien für fehlende Werte) +- ✅ **BUG-004:** Import-Historie Refresh (Force remount via key prop) + +### v9c Finalisierung ✅ +- ✅ **Selbst-Registrierung:** POST /api/auth/register, E-Mail-Verifizierung, Auto-Login +- ✅ **Trial-System UI:** Countdown-Banner im Dashboard (3 Urgency-Level) ### Offen v9d 🔲 -- Selbst-Registrierung + E-Mail-Verifizierung -- Trial-System UI - Schlaf-Modul - Trainingstypen + Herzfrequenz -- 2.43.0 From 9fa60434c1c3b05f001edc194603c638bd3d221f Mon Sep 17 00:00:00 2001 From: Lars Date: Sat, 21 Mar 2026 09:59:59 +0100 Subject: [PATCH 07/18] fix: correct AuthContext import in Verify.jsx Fixed build error where AuthContext was imported directly instead of using the useAuth hook. Changed from import { AuthContext } + useContext(AuthContext) to import { useAuth } + useAuth(). This was blocking the Docker build and production deployment of v9c. Co-Authored-By: Claude Opus 4.6 --- frontend/src/pages/Verify.jsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/src/pages/Verify.jsx b/frontend/src/pages/Verify.jsx index 32fc2ca..9be07b3 100644 --- a/frontend/src/pages/Verify.jsx +++ b/frontend/src/pages/Verify.jsx @@ -1,13 +1,13 @@ -import { useState, useEffect, useContext } from 'react' +import { useState, useEffect } from 'react' import { useSearchParams, useNavigate } from 'react-router-dom' -import { AuthContext } from '../context/AuthContext' +import { useAuth } from '../context/AuthContext' import { api } from '../utils/api' export default function Verify() { const [searchParams] = useSearchParams() const token = searchParams.get('token') const navigate = useNavigate() - const { login } = useContext(AuthContext) + const { login } = useAuth() const [status, setStatus] = useState('loading') // loading | success | error const [error, setError] = useState(null) -- 2.43.0 From 22651647cbf241bed4e3d7c731458d933de05ed0 Mon Sep 17 00:00:00 2001 From: Lars Date: Sat, 21 Mar 2026 10:07:37 +0100 Subject: [PATCH 08/18] fix: add automatic migration system to db_init.py Added migration tracking and execution to db_init.py: - Created schema_migrations table to track applied migrations - Added run_migrations() to automatically apply pending SQL files - Migrations from backend/migrations/*.sql are now applied on startup This fixes the missing email verification columns (migration 003). Co-Authored-By: Claude Opus 4.6 --- backend/db_init.py | 107 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 107 insertions(+) diff --git a/backend/db_init.py b/backend/db_init.py index 8d7a663..07a16cd 100644 --- a/backend/db_init.py +++ b/backend/db_init.py @@ -91,6 +91,107 @@ def get_profile_count(): print(f"Error getting profile count: {e}") return -1 +def ensure_migration_table(): + """Create migration tracking table if it doesn't exist.""" + try: + conn = get_connection() + cur = conn.cursor() + cur.execute(""" + CREATE TABLE IF NOT EXISTS schema_migrations ( + id SERIAL PRIMARY KEY, + filename VARCHAR(255) UNIQUE NOT NULL, + applied_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP + ) + """) + conn.commit() + cur.close() + conn.close() + return True + except Exception as e: + print(f"Error creating migration table: {e}") + return False + +def get_applied_migrations(): + """Get list of already applied migrations.""" + try: + conn = get_connection() + cur = conn.cursor() + cur.execute("SELECT filename FROM schema_migrations ORDER BY filename") + migrations = [row[0] for row in cur.fetchall()] + cur.close() + conn.close() + return migrations + except Exception as e: + print(f"Error getting applied migrations: {e}") + return [] + +def apply_migration(filepath, filename): + """Apply a single migration file.""" + try: + with open(filepath, 'r') as f: + migration_sql = f.read() + + conn = get_connection() + cur = conn.cursor() + + # Execute migration + cur.execute(migration_sql) + + # Record migration + cur.execute( + "INSERT INTO schema_migrations (filename) VALUES (%s)", + (filename,) + ) + + conn.commit() + cur.close() + conn.close() + print(f" ✓ Applied: {filename}") + return True + except Exception as e: + print(f" ✗ Failed to apply {filename}: {e}") + return False + +def run_migrations(migrations_dir="/app/migrations"): + """Run all pending migrations.""" + import glob + + if not os.path.exists(migrations_dir): + print("✓ No migrations directory found") + return True + + # Ensure migration tracking table exists + if not ensure_migration_table(): + return False + + # Get already applied migrations + applied = get_applied_migrations() + + # Get all migration files + migration_files = sorted(glob.glob(os.path.join(migrations_dir, "*.sql"))) + + if not migration_files: + print("✓ No migration files found") + return True + + # Apply pending migrations + pending = [] + for filepath in migration_files: + filename = os.path.basename(filepath) + if filename not in applied: + pending.append((filepath, filename)) + + if not pending: + print(f"✓ All {len(applied)} migrations already applied") + return True + + print(f" Found {len(pending)} pending migration(s)...") + for filepath, filename in pending: + if not apply_migration(filepath, filename): + return False + + return True + if __name__ == "__main__": print("═══════════════════════════════════════════════════════════") print("MITAI JINKENDO - Database Initialization (v9c)") @@ -109,6 +210,12 @@ if __name__ == "__main__": else: print("✓ Schema already exists") + # Run migrations + print("\nRunning database migrations...") + if not run_migrations(): + print("✗ Migration failed") + sys.exit(1) + # Check for migration print("\nChecking for SQLite data migration...") sqlite_db = "/app/data/bodytrack.db" -- 2.43.0 From 913b48550099a6f101e5cea6734c595f5991cae5 Mon Sep 17 00:00:00 2001 From: Lars Date: Sat, 21 Mar 2026 10:08:56 +0100 Subject: [PATCH 09/18] fix: only process numbered migrations (XXX_*.sql pattern) Modified run_migrations() to only process files matching pattern: \d{3}_*.sql This prevents utility scripts (check_features.sql) and manually applied migrations (v9c_*.sql) from being executed. Only properly numbered migrations like 003_add_email_verification.sql will be processed. Co-Authored-By: Claude Opus 4.6 --- backend/db_init.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/backend/db_init.py b/backend/db_init.py index 07a16cd..6714613 100644 --- a/backend/db_init.py +++ b/backend/db_init.py @@ -155,6 +155,7 @@ def apply_migration(filepath, filename): def run_migrations(migrations_dir="/app/migrations"): """Run all pending migrations.""" import glob + import re if not os.path.exists(migrations_dir): print("✓ No migrations directory found") @@ -167,8 +168,10 @@ def run_migrations(migrations_dir="/app/migrations"): # Get already applied migrations applied = get_applied_migrations() - # Get all migration files - migration_files = sorted(glob.glob(os.path.join(migrations_dir, "*.sql"))) + # Get all migration files (only numbered migrations like 001_*.sql) + all_files = sorted(glob.glob(os.path.join(migrations_dir, "*.sql"))) + migration_pattern = re.compile(r'^\d{3}_.*\.sql$') + migration_files = [f for f in all_files if migration_pattern.match(os.path.basename(f))] if not migration_files: print("✓ No migration files found") -- 2.43.0 From 49467ca6e9e2333f88f1b34b2ce7f601e1a3be01 Mon Sep 17 00:00:00 2001 From: Lars Date: Sat, 21 Mar 2026 10:12:28 +0100 Subject: [PATCH 10/18] docs: document automatic migrations system Updated CLAUDE.md to reflect new database migrations system: - Added backend/migrations/ to directory structure - Added schema_migrations table to database schema - Updated deployment section with migration workflow - Added reference to .claude/docs/technical/MIGRATIONS.md The migrations system automatically applies SQL files (XXX_*.sql pattern) on container startup, with tracking in schema_migrations table. Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index b56664f..300a352 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -21,9 +21,11 @@ Teil der **Jinkendo**-App-Familie (人拳道). Domains: jinkendo.de / .com / .li backend/ ├── main.py # App-Setup + Router-Registration (~75 Zeilen) ├── db.py # PostgreSQL Connection Pool +├── db_init.py # DB-Init + Migrations-System (automatisch beim Start) ├── auth.py # Hash, Verify, Sessions, Feature-Access-Control ├── models.py # Pydantic Models ├── feature_logger.py # Strukturiertes JSON-Logging (Phase 2) +├── migrations/ # SQL-Migrationen (XXX_*.sql Pattern) └── routers/ # 14 Router-Module auth · profiles · weight · circumference · caliper activity · nutrition · photos · insights · prompts @@ -83,6 +85,7 @@ frontend/src/ ### v9c Finalisierung ✅ - ✅ **Selbst-Registrierung:** POST /api/auth/register, E-Mail-Verifizierung, Auto-Login - ✅ **Trial-System UI:** Countdown-Banner im Dashboard (3 Urgency-Level) +- ✅ **Migrations-System:** Automatische Schema-Migrationen beim Start (db_init.py) ### Offen v9d 🔲 - Schlaf-Modul @@ -121,6 +124,12 @@ Runner: Raspberry Pi (/home/lars/gitea-runner/) Manuell: cd /home/lars/docker/bodytrack[-dev] docker compose -f docker-compose[.dev-env].yml build --no-cache && up -d + +Migrations: + Werden automatisch beim Container-Start ausgeführt (db_init.py) + Nur nummerierte Dateien: backend/migrations/XXX_*.sql + Tracking in schema_migrations Tabelle + 📚 Details: .claude/docs/technical/MIGRATIONS.md ``` ## Datenbank-Schema (PostgreSQL 16) @@ -142,10 +151,14 @@ subscriptions · coupons · coupon_redemptions · features tier_limits · user_feature_restrictions · user_feature_usage access_grants · user_activity_log +Infrastruktur: +schema_migrations – Tracking für automatische DB-Migrationen + Feature-Logging (Phase 2): /app/logs/feature-usage.log # JSON-Format, alle Feature-Zugriffe Schema-Datei: backend/schema.sql +Migrationen: backend/migrations/*.sql (automatisch beim Start) ``` ## API & Auth @@ -233,6 +246,7 @@ Bottom-Padding Mobile: 80px (Navigation) | Backend-Architektur, Router, DB-Zugriff | `.claude/docs/architecture/BACKEND.md` | | Frontend-Architektur, api.js, Komponenten | `.claude/docs/architecture/FRONTEND.md` | | **Feature-Enforcement (neue Features hinzufügen)** | `.claude/docs/architecture/FEATURE_ENFORCEMENT.md` | +| **Database Migrations (Schema-Änderungen)** | `.claude/docs/technical/MIGRATIONS.md` | | Coding Rules (Pflichtregeln) | `.claude/docs/rules/CODING_RULES.md` | | Lessons Learned (Fehler vermeiden) | `.claude/docs/rules/LESSONS_LEARNED.md` | | Feature Backlog (Übersicht) | `.claude/docs/BACKLOG.md` | -- 2.43.0 From 9fb6e27256c8a3cb8fe44748b929ab8422de65f6 Mon Sep 17 00:00:00 2001 From: Lars Date: Sat, 21 Mar 2026 10:20:06 +0100 Subject: [PATCH 11/18] fix: email verification flow and trial system Backend fixes: - Fixed timezone-aware datetime comparison in verify_email endpoint - Added trial_ends_at (14 days) for new registrations - All datetime.now() calls now use timezone.utc Frontend additions: - Added EmailVerificationBanner component for unverified users - Banner shows warning before trial banner in Dashboard - Clear messaging about verification requirement This fixes the 500 error on email verification and ensures new users see both verification and trial status correctly. Co-Authored-By: Claude Opus 4.6 --- backend/routers/auth.py | 15 +++---- .../components/EmailVerificationBanner.jsx | 42 +++++++++++++++++++ frontend/src/pages/Dashboard.jsx | 4 ++ 3 files changed, 54 insertions(+), 7 deletions(-) create mode 100644 frontend/src/components/EmailVerificationBanner.jsx diff --git a/backend/routers/auth.py b/backend/routers/auth.py index f8ccce4..ec67cd6 100644 --- a/backend/routers/auth.py +++ b/backend/routers/auth.py @@ -7,7 +7,7 @@ import os import secrets import smtplib from typing import Optional -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone from email.mime.text import MIMEText from fastapi import APIRouter, HTTPException, Header, Depends @@ -233,19 +233,20 @@ async def register(req: RegisterRequest, request: Request): # Generate verification token verification_token = secrets.token_urlsafe(32) - verification_expires = datetime.now() + timedelta(hours=24) + verification_expires = datetime.now(timezone.utc) + timedelta(hours=24) # Create profile (inactive until verified) profile_id = str(secrets.token_hex(16)) pin_hash = hash_pin(password) + trial_ends = datetime.now(timezone.utc) + timedelta(days=14) # 14-day trial cur.execute(""" INSERT INTO profiles ( id, name, email, pin_hash, auth_type, role, tier, email_verified, verification_token, verification_expires, - created - ) VALUES (%s, %s, %s, %s, 'email', 'user', 'free', FALSE, %s, %s, CURRENT_TIMESTAMP) - """, (profile_id, name, email, pin_hash, verification_token, verification_expires)) + trial_ends_at, created + ) VALUES (%s, %s, %s, %s, 'email', 'user', 'free', FALSE, %s, %s, %s, CURRENT_TIMESTAMP) + """, (profile_id, name, email, pin_hash, verification_token, verification_expires, trial_ends)) # Send verification email app_url = os.getenv("APP_URL", "https://mitai.jinkendo.de") @@ -294,7 +295,7 @@ async def verify_email(token: str): raise HTTPException(400, "E-Mail-Adresse bereits bestätigt") # Check if token expired - if prof['verification_expires'] and datetime.now() > prof['verification_expires']: + if prof['verification_expires'] and datetime.now(timezone.utc) > prof['verification_expires']: raise HTTPException(400, "Verifikations-Link abgelaufen. Bitte registriere dich erneut.") # Mark as verified and clear token @@ -306,7 +307,7 @@ async def verify_email(token: str): # Create session (auto-login after verification) session_token = make_token() - expires = datetime.now() + timedelta(days=30) + expires = datetime.now(timezone.utc) + timedelta(days=30) cur.execute(""" INSERT INTO sessions (token, profile_id, expires_at, created) VALUES (%s, %s, %s, CURRENT_TIMESTAMP) diff --git a/frontend/src/components/EmailVerificationBanner.jsx b/frontend/src/components/EmailVerificationBanner.jsx new file mode 100644 index 0000000..302ffec --- /dev/null +++ b/frontend/src/components/EmailVerificationBanner.jsx @@ -0,0 +1,42 @@ +export default function EmailVerificationBanner({ profile }) { + // Only show if email is not verified + if (!profile || profile.email_verified !== false) return null + + return ( +
+
+ 📧 +
+
+
+ E-Mail-Adresse noch nicht bestätigt +
+
+ Bitte prüfe dein Postfach und klicke auf den Bestätigungslink. + Ohne Bestätigung ist dein Account eingeschränkt. +
+
+
+ ) +} diff --git a/frontend/src/pages/Dashboard.jsx b/frontend/src/pages/Dashboard.jsx index a5fc3cb..536367d 100644 --- a/frontend/src/pages/Dashboard.jsx +++ b/frontend/src/pages/Dashboard.jsx @@ -9,6 +9,7 @@ import { api } from '../utils/api' import { useProfile } from '../context/ProfileContext' import { getBfCategory } from '../utils/calc' import TrialBanner from '../components/TrialBanner' +import EmailVerificationBanner from '../components/EmailVerificationBanner' import { getInterpretation, getStatusColor, getStatusBg } from '../utils/interpret' import Markdown from '../utils/Markdown' import dayjs from 'dayjs' @@ -316,6 +317,9 @@ export default function Dashboard() {
+ {/* Email Verification Banner */} + + {/* Trial Banner */} -- 2.43.0 From f843d71d6ba2523ed51cb957fe71ae5cc901aa57 Mon Sep 17 00:00:00 2001 From: Lars Date: Sat, 21 Mar 2026 10:23:38 +0100 Subject: [PATCH 12/18] feat: resend verification email functionality MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend: - Added POST /api/auth/resend-verification endpoint - Rate limited to 3/hour to prevent abuse - Generates new verification token (24h validity) - Sends new verification email Frontend: - Verify.jsx: Added "expired" status with resend flow - Email input + "Neue Bestätigungs-E-Mail senden" button - EmailVerificationBanner: Added "Neue E-Mail senden" button - Shows success/error feedback inline - api.js: Added resendVerification() helper User flows: 1. Expired token → Verify page shows resend form 2. Email lost → Dashboard banner has resend button 3. Both flows use same backend endpoint Co-Authored-By: Claude Opus 4.6 --- backend/routers/auth.py | 71 +++++++++ .../components/EmailVerificationBanner.jsx | 60 +++++++- frontend/src/pages/Verify.jsx | 137 +++++++++++++++++- frontend/src/utils/api.js | 1 + 4 files changed, 263 insertions(+), 6 deletions(-) diff --git a/backend/routers/auth.py b/backend/routers/auth.py index ec67cd6..6f83e92 100644 --- a/backend/routers/auth.py +++ b/backend/routers/auth.py @@ -323,3 +323,74 @@ async def verify_email(token: str): "email": prof['email'] } } + + +@router.post("/resend-verification") +@limiter.limit("3/hour") +async def resend_verification(req: dict, request: Request): + """Resend verification email for unverified account.""" + email = req.get('email', '').strip().lower() + + if not email: + raise HTTPException(400, "E-Mail-Adresse erforderlich") + + with get_db() as conn: + cur = get_cursor(conn) + + # Find profile by email + cur.execute(""" + SELECT id, name, email, email_verified, verification_token, verification_expires + FROM profiles + WHERE email=%s + """, (email,)) + + prof = cur.fetchone() + + if not prof: + # Don't leak info about existing emails + return {"ok": True, "message": "Falls ein Account mit dieser E-Mail existiert, wurde eine Bestätigungs-E-Mail versendet."} + + if prof['email_verified']: + raise HTTPException(400, "E-Mail-Adresse bereits bestätigt") + + # Generate new verification token + verification_token = secrets.token_urlsafe(32) + verification_expires = datetime.now(timezone.utc) + timedelta(hours=24) + + cur.execute(""" + UPDATE profiles + SET verification_token=%s, verification_expires=%s + WHERE id=%s + """, (verification_token, verification_expires, prof['id'])) + + # Send verification email + app_url = os.getenv("APP_URL", "https://mitai.jinkendo.de") + verify_url = f"{app_url}/verify?token={verification_token}" + + email_body = f"""Hallo {prof['name']}, + +du hast eine neue Bestätigungs-E-Mail angefordert. + +Bitte bestätige deine E-Mail-Adresse, indem du auf folgenden Link klickst: + +{verify_url} + +Dieser Link ist 24 Stunden gültig. + +Falls du diese E-Mail nicht angefordert hast, kannst du sie einfach ignorieren. + +Viele Grüße +Dein Mitai Jinkendo Team +""" + + try: + send_email( + to=email, + subject="Neue Bestätigungs-E-Mail - Mitai Jinkendo", + body=email_body + ) + except Exception as e: + print(f"Failed to send verification email: {e}") + raise HTTPException(500, "E-Mail konnte nicht versendet werden") + + return {"ok": True, "message": "Bestätigungs-E-Mail wurde erneut versendet."} diff --git a/frontend/src/components/EmailVerificationBanner.jsx b/frontend/src/components/EmailVerificationBanner.jsx index 302ffec..cf65c98 100644 --- a/frontend/src/components/EmailVerificationBanner.jsx +++ b/frontend/src/components/EmailVerificationBanner.jsx @@ -1,7 +1,33 @@ +import { useState } from 'react' +import { api } from '../utils/api' + export default function EmailVerificationBanner({ profile }) { + const [resending, setResending] = useState(false) + const [success, setSuccess] = useState(false) + const [error, setError] = useState(null) + // Only show if email is not verified if (!profile || profile.email_verified !== false) return null + const handleResend = async () => { + if (!profile.email) return + + setResending(true) + setError(null) + setSuccess(false) + + try { + await api.resendVerification(profile.email) + setSuccess(true) + setTimeout(() => setSuccess(false), 5000) + } catch (err) { + setError(err.message || 'Fehler beim Versenden') + setTimeout(() => setError(null), 5000) + } finally { + setResending(false) + } + } + return (
📧
-
+
Bitte prüfe dein Postfach und klicke auf den Bestätigungslink. - Ohne Bestätigung ist dein Account eingeschränkt. + {success && ( + + ✓ Neue E-Mail versendet! + + )} + {error && ( + + ✗ {error} + + )}
+
) } diff --git a/frontend/src/pages/Verify.jsx b/frontend/src/pages/Verify.jsx index 9be07b3..81a13e7 100644 --- a/frontend/src/pages/Verify.jsx +++ b/frontend/src/pages/Verify.jsx @@ -9,8 +9,11 @@ export default function Verify() { const navigate = useNavigate() const { login } = useAuth() - const [status, setStatus] = useState('loading') // loading | success | error + const [status, setStatus] = useState('loading') // loading | success | error | expired const [error, setError] = useState(null) + const [email, setEmail] = useState('') + const [resending, setResending] = useState(false) + const [resendSuccess, setResendSuccess] = useState(false) useEffect(() => { const verify = async () => { @@ -37,14 +40,41 @@ export default function Verify() { setError('Verifizierung erfolgreich, aber Login fehlgeschlagen') } } catch (err) { - setStatus('error') - setError(err.message || 'Verifizierung fehlgeschlagen') + const errorMsg = err.message || 'Verifizierung fehlgeschlagen' + + // Check if token expired + if (errorMsg.includes('abgelaufen') || errorMsg.includes('expired')) { + setStatus('expired') + setError(errorMsg) + } else { + setStatus('error') + setError(errorMsg) + } } } verify() }, [token, login, navigate]) + const handleResend = async () => { + if (!email.trim()) { + setError('Bitte E-Mail-Adresse eingeben') + return + } + + setResending(true) + setError(null) + + try { + await api.resendVerification(email.trim().toLowerCase()) + setResendSuccess(true) + } catch (err) { + setError(err.message || 'E-Mail konnte nicht versendet werden') + } finally { + setResending(false) + } + } + if (status === 'loading') { return (
+
+
+

+ Verifikations-Link abgelaufen +

+

+ Dieser Link ist leider nicht mehr gültig. Bitte fordere eine neue Bestätigungs-E-Mail an. +

+ + {resendSuccess ? ( +
+
+
+ E-Mail versendet! +
+

+ Bitte prüfe dein Postfach. +

+
+ ) : ( + <> + setEmail(e.target.value)} + onKeyDown={e => e.key === 'Enter' && handleResend()} + style={{width:'100%', boxSizing:'border-box', marginBottom:12}} + autoFocus + /> + + {error && ( +
+ {error} +
+ )} + + + + )} +
+ + +
+ ) + } + if (status === 'error') { return (
navigate('/register')} className="btn btn-primary btn-full" + style={{marginBottom:12}} > Zur Registrierung + +
) } diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js index 0204445..5cdba29 100644 --- a/frontend/src/utils/api.js +++ b/frontend/src/utils/api.js @@ -144,6 +144,7 @@ export const api = { changePin: (pin) => req('/auth/pin',json({pin})), register: (name,email,password) => req('/auth/register',json({name,email,password})), verifyEmail: (token) => req(`/auth/verify/${token}`), + resendVerification: (email) => req('/auth/resend-verification',json({email})), // v9c Subscription System // User-facing -- 2.43.0 From ca9112ebc0f8c02c87d583d592185957520b7d4c Mon Sep 17 00:00:00 2001 From: Lars Date: Sat, 21 Mar 2026 10:32:24 +0100 Subject: [PATCH 13/18] fix: email verification auto-login and user experience MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AuthContext: - Added setAuthFromToken() for direct token/profile set - Used for email verification auto-login (no /login request) - Properly initializes session with token and profile Verify.jsx: - Fixed auto-login: now uses setAuthFromToken() instead of login() - Added "already_verified" status for better UX - Auto-redirect to /login after 3s if already verified - Shows friendly message instead of error This fixes: - 422 Unprocessable Entity error during auto-login - Empty dashboard page after verification (now redirects correctly) - "Ungültiger Link" error on second click (now shows "bereits bestätigt") Co-Authored-By: Claude Opus 4.6 --- frontend/src/context/AuthContext.jsx | 14 +++++++- frontend/src/pages/Verify.jsx | 52 ++++++++++++++++++++++++---- 2 files changed, 59 insertions(+), 7 deletions(-) diff --git a/frontend/src/context/AuthContext.jsx b/frontend/src/context/AuthContext.jsx index ece4633..1bcd76a 100644 --- a/frontend/src/context/AuthContext.jsx +++ b/frontend/src/context/AuthContext.jsx @@ -86,6 +86,18 @@ export function AuthProvider({ children }) { return data } + const setAuthFromToken = (token, profile) => { + // Direct token/profile set (for email verification auto-login) + localStorage.setItem(TOKEN_KEY, token) + localStorage.setItem(PROFILE_KEY, profile.id) + setSession({ + token, + profile_id: profile.id, + role: profile.role || 'user', + profile + }) + } + const logout = async () => { const token = localStorage.getItem(TOKEN_KEY) if (token) { @@ -102,7 +114,7 @@ export function AuthProvider({ children }) { return ( navigate('/login'), 3000) + } // Check if token expired - if (errorMsg.includes('abgelaufen') || errorMsg.includes('expired')) { + else if (errorMsg.includes('abgelaufen') || errorMsg.includes('expired')) { setStatus('expired') setError(errorMsg) } else { @@ -54,7 +60,7 @@ export default function Verify() { } verify() - }, [token, login, navigate]) + }, [token, setAuthFromToken, navigate]) const handleResend = async () => { if (!email.trim()) { @@ -177,6 +183,40 @@ export default function Verify() { ) } + if (status === 'already_verified') { + return ( +
+
+
+

+ E-Mail bereits bestätigt +

+

+ Deine E-Mail-Adresse wurde bereits verifiziert. + Du kannst dich jetzt anmelden. +

+

+ Du wirst gleich zum Login weitergeleitet... +

+
+ + +
+ ) + } + if (status === 'error') { return (
Date: Sat, 21 Mar 2026 11:38:03 +0100 Subject: [PATCH 14/18] fix: prevent React StrictMode double execution in Verify Added hasVerified flag to prevent useEffect from running twice in React 18 StrictMode (development mode). This was causing: 1. First call: 200 OK - verification successful 2. Second call: 400 Bad Request - already verified 3. Error shown to user despite successful verification The fix ensures verify() only runs once per component mount. Co-Authored-By: Claude Opus 4.6 --- frontend/src/pages/Verify.jsx | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/frontend/src/pages/Verify.jsx b/frontend/src/pages/Verify.jsx index df05c7f..5ceef6d 100644 --- a/frontend/src/pages/Verify.jsx +++ b/frontend/src/pages/Verify.jsx @@ -14,15 +14,17 @@ export default function Verify() { const [email, setEmail] = useState('') const [resending, setResending] = useState(false) const [resendSuccess, setResendSuccess] = useState(false) + const [hasVerified, setHasVerified] = useState(false) useEffect(() => { + if (hasVerified) return // Prevent React StrictMode double execution + if (!token) { + setStatus('error') + setError('Kein Verifikations-Token gefunden') + return + } + setHasVerified(true) const verify = async () => { - if (!token) { - setStatus('error') - setError('Kein Verifikations-Token gefunden') - return - } - try { const result = await api.verifyEmail(token) -- 2.43.0 From 2e68b29d9cf810bfa248880e50213955e1712424 Mon Sep 17 00:00:00 2001 From: Lars Date: Sat, 21 Mar 2026 11:56:09 +0100 Subject: [PATCH 15/18] fix: improve Dashboard error handling and add debug logging - Add .catch() handler to load() Promise to prevent infinite loading state - Add console.log statements for component lifecycle debugging - Make EmailVerificationBanner/TrialBanner conditional on activeProfile - Ensure greeting header always renders with fallback This should fix the empty dashboard issue for new users. Co-Authored-By: Claude Opus 4.6 --- frontend/src/pages/Dashboard.jsx | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/frontend/src/pages/Dashboard.jsx b/frontend/src/pages/Dashboard.jsx index 536367d..586805c 100644 --- a/frontend/src/pages/Dashboard.jsx +++ b/frontend/src/pages/Dashboard.jsx @@ -247,6 +247,12 @@ export default function Dashboard() { setNutrition(n); setActivities(a) setInsights(Array.isArray(ins)?ins:[]) setLoading(false) + }).catch(err => { + console.error('Dashboard load failed:', err) + // Set empty data on error so UI can still render + setStats(null); setWeights([]); setCalipers([]); setCircs([]) + setNutrition([]); setActivities([]); setInsights([]) + setLoading(false) }) const runPipeline = async () => { @@ -260,7 +266,12 @@ export default function Dashboard() { } finally { setPipelineLoading(false) } } - useEffect(()=>{ load() },[]) + useEffect(()=>{ + console.log('[Dashboard] Component mounted, loading data...') + load() + },[]) + + console.log('[Dashboard] Rendering, loading=', loading, 'activeProfile=', activeProfile?.name) if (loading) return
@@ -304,6 +315,8 @@ export default function Dashboard() { const hasAnyData = latestW||latestCal||nutrition.length>0 + console.log('[Dashboard] hasAnyData=', hasAnyData, 'latestW=', !!latestW, 'latestCal=', !!latestCal, 'nutrition.length=', nutrition.length) + return (
{/* Header greeting */} @@ -318,10 +331,10 @@ export default function Dashboard() {
{/* Email Verification Banner */} - + {activeProfile && } {/* Trial Banner */} - + {activeProfile && } {!hasAnyData && (
-- 2.43.0 From 1521c2f221b49d78dabf8489bd693415e7caefc9 Mon Sep 17 00:00:00 2001 From: Lars Date: Sat, 21 Mar 2026 12:09:37 +0100 Subject: [PATCH 16/18] fix: redirect to dashboard after successful login LoginScreen was not navigating after login, leaving users on empty page. Now explicitly redirects to '/' (dashboard) after successful login. This fixes the "empty page after first login" issue. Co-Authored-By: Claude Opus 4.6 --- frontend/src/pages/LoginScreen.jsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/frontend/src/pages/LoginScreen.jsx b/frontend/src/pages/LoginScreen.jsx index 446968a..b05553f 100644 --- a/frontend/src/pages/LoginScreen.jsx +++ b/frontend/src/pages/LoginScreen.jsx @@ -33,6 +33,8 @@ export default function LoginScreen() { setLoading(true); setError(null) try { await login({ email: email.trim().toLowerCase(), password: password }) + // Redirect to dashboard after successful login + window.location.href = '/' } catch(e) { setError(e.message || 'Ungültige E-Mail oder Passwort') } finally { setLoading(false) } -- 2.43.0 From 1cd93d521e7c69719c84fdd70af2218292a3b860 Mon Sep 17 00:00:00 2001 From: Lars Date: Sat, 21 Mar 2026 12:28:51 +0100 Subject: [PATCH 17/18] fix: email verification redirect and already-used token message 1. Use window.location.href instead of navigate() for reliable redirect 2. Improve backend error message for already-used verification tokens 3. Show user-friendly message when token was already verified 4. Reduce redirect delay from 2s to 1.5s for better UX Fixes: - Empty page after email verification - Generic error when clicking verification link twice Co-Authored-By: Claude Opus 4.6 --- backend/routers/auth.py | 4 +++- frontend/src/pages/Verify.jsx | 32 ++++++++++++++++++++------------ 2 files changed, 23 insertions(+), 13 deletions(-) diff --git a/backend/routers/auth.py b/backend/routers/auth.py index 6f83e92..494c1ae 100644 --- a/backend/routers/auth.py +++ b/backend/routers/auth.py @@ -289,7 +289,9 @@ async def verify_email(token: str): prof = cur.fetchone() if not prof: - raise HTTPException(400, "Ungültiger Verifikations-Link") + # Token not found - might be already used/verified + # Check if there's a verified profile (token was deleted after verification) + raise HTTPException(400, "Verifikations-Link ungültig oder bereits verwendet. Falls du bereits verifiziert bist, melde dich einfach an.") if prof['email_verified']: raise HTTPException(400, "E-Mail-Adresse bereits bestätigt") diff --git a/frontend/src/pages/Verify.jsx b/frontend/src/pages/Verify.jsx index 5ceef6d..111a3c6 100644 --- a/frontend/src/pages/Verify.jsx +++ b/frontend/src/pages/Verify.jsx @@ -33,10 +33,10 @@ export default function Verify() { setAuthFromToken(result.token, result.profile) setStatus('success') - // Redirect to dashboard after 2 seconds + // Redirect to dashboard after 1.5 seconds setTimeout(() => { - navigate('/dashboard') - }, 2000) + window.location.href = '/' + }, 1500) } else { setStatus('error') setError('Verifizierung erfolgreich, aber Login fehlgeschlagen') @@ -44,11 +44,13 @@ export default function Verify() { } catch (err) { const errorMsg = err.message || 'Verifizierung fehlgeschlagen' - // Check if already verified - if (errorMsg.includes('bereits bestätigt') || errorMsg.includes('already verified')) { + // Check if already verified or already used + if (errorMsg.includes('bereits bestätigt') || errorMsg.includes('already verified') || + errorMsg.includes('bereits verwendet') || errorMsg.includes('already used')) { setStatus('already_verified') + setError(errorMsg) // Show the actual message // Auto-redirect to login after 3 seconds - setTimeout(() => navigate('/login'), 3000) + setTimeout(() => { window.location.href = '/login' }, 3000) } // Check if token expired else if (errorMsg.includes('abgelaufen') || errorMsg.includes('expired')) { @@ -200,13 +202,19 @@ export default function Verify() {

E-Mail bereits bestätigt

-

- Deine E-Mail-Adresse wurde bereits verifiziert. - Du kannst dich jetzt anmelden. -

-

- Du wirst gleich zum Login weitergeleitet... +

+ {error || 'Deine E-Mail-Adresse wurde bereits verifiziert. Du kannst dich jetzt anmelden.'}

+ {!error && ( +

+ Du wirst gleich zum Login weitergeleitet... +

+ )} + {error && ( +

+ Du wirst gleich zum Login weitergeleitet... +

+ )}
) -- 2.43.0