diff --git a/CLAUDE.md b/CLAUDE.md index 73a075f..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 @@ -77,10 +79,15 @@ 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) +- ✅ **Migrations-System:** Automatische Schema-Migrationen beim Start (db_init.py) ### Offen v9d 🔲 -- Selbst-Registrierung + E-Mail-Verifizierung -- Trial-System UI - Schlaf-Modul - Trainingstypen + Herzfrequenz @@ -117,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) @@ -138,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 @@ -229,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` | diff --git a/backend/db_init.py b/backend/db_init.py index 8d7a663..6714613 100644 --- a/backend/db_init.py +++ b/backend/db_init.py @@ -91,6 +91,110 @@ 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 + import re + + 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 (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") + 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 +213,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" 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..494c1ae 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 @@ -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,225 @@ 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(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, + 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") + 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: + # 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") + + # Check if token expired + 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 + 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(timezone.utc) + 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'] + } + } + + +@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/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/components/EmailVerificationBanner.jsx b/frontend/src/components/EmailVerificationBanner.jsx new file mode 100644 index 0000000..cf65c98 --- /dev/null +++ b/frontend/src/components/EmailVerificationBanner.jsx @@ -0,0 +1,96 @@ +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 ( +
+
+ 📧 +
+
+
+ E-Mail-Adresse noch nicht bestätigt +
+
+ Bitte prüfe dein Postfach und klicke auf den Bestätigungslink. + {success && ( + + ✓ Neue E-Mail versendet! + + )} + {error && ( + + ✗ {error} + + )} +
+
+ +
+ ) +} 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/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 ( { + 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 () => { @@ -258,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
@@ -302,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 */} @@ -315,6 +330,12 @@ export default function Dashboard() {
+ {/* Email Verification Banner */} + {activeProfile && } + + {/* Trial Banner */} + {activeProfile && } + {!hasAnyData && (

Willkommen bei Mitai Jinkendo!

diff --git a/frontend/src/pages/LoginScreen.jsx b/frontend/src/pages/LoginScreen.jsx index 2e3779a..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) } @@ -105,6 +107,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/NutritionPage.jsx b/frontend/src/pages/NutritionPage.jsx index ba48643..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 */} + ) @@ -743,6 +779,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 +821,8 @@ export default function NutritionPage() { {/* Import Panel + History */} {inputTab==='import' && ( <> - - + { load(); setImportHistoryKey(Date.now()) }}/> + )} 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..29275b9 --- /dev/null +++ b/frontend/src/pages/Verify.jsx @@ -0,0 +1,304 @@ +import { useState, useEffect } from 'react' +import { useSearchParams, useNavigate } from 'react-router-dom' +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 { setAuthFromToken } = useAuth() + + const [status, setStatus] = useState('loading') // loading | success | error | expired | already_verified + const [error, setError] = useState(null) + 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 () => { + try { + const result = await api.verifyEmail(token) + + // Auto-login with returned token + if (result.token && result.profile) { + setAuthFromToken(result.token, result.profile) + setStatus('success') + + // Redirect to dashboard after 1.5 seconds + setTimeout(() => { + window.location.href = '/' + }, 1500) + } else { + setStatus('error') + setError('Verifizierung erfolgreich, aber Login fehlgeschlagen') + } + } catch (err) { + let errorMsg = err.message || 'Verifizierung fehlgeschlagen' + + // Try to parse JSON error response + try { + const parsed = JSON.parse(errorMsg) + if (parsed.detail) errorMsg = parsed.detail + } catch (e) { + // Not JSON, use as-is + } + + // 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 dashboard after 3 seconds (let App.jsx decide login vs dashboard) + setTimeout(() => { window.location.href = '/' }, 3000) + } + // Check if token expired + else if (errorMsg.includes('abgelaufen') || errorMsg.includes('expired')) { + setStatus('expired') + setError(errorMsg) + } else { + setStatus('error') + setError(errorMsg) + } + } + } + + verify() + }, [token, setAuthFromToken, 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 ( +
+
+

E-Mail wird bestätigt...

+

Einen Moment bitte

+
+ ) + } + + if (status === 'expired') { + 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 === 'already_verified') { + return ( +
+
+
+

+ E-Mail bereits bestätigt +

+

+ {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... +

+ )} +
+ + +
+ ) + } + + 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..5cdba29 100644 --- a/frontend/src/utils/api.js +++ b/frontend/src/utils/api.js @@ -142,6 +142,9 @@ 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}`), + resendVerification: (email) => req('/auth/resend-verification',json({email})), // v9c Subscription System // User-facing