Merge pull request 'Final Feature 9c' (#10) from develop into main
All checks were successful
Deploy Production / deploy (push) Successful in 49s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 14s

Reviewed-on: #10
This commit is contained in:
Lars 2026-03-21 12:41:41 +01:00
commit 51aa57f304
15 changed files with 1178 additions and 19 deletions

View File

@ -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` |

View File

@ -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"

View File

@ -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)';

View File

@ -110,6 +110,12 @@ class PasswordResetConfirm(BaseModel):
new_password: str
class RegisterRequest(BaseModel):
name: str
email: str
password: str
# ── Admin Models ──────────────────────────────────────────────────────────────
class AdminProfileUpdate(BaseModel):

View File

@ -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."}

View File

@ -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 (
<div style={{minHeight:'100vh',background:'var(--bg)',padding:24}}>
<Register/>
</div>
)
// Verify email page
if (currentPath === '/verify') return (
<div style={{minHeight:'100vh',background:'var(--bg)',padding:24}}>
<Verify/>
</div>
)
// Password reset page
const resetToken = urlParams.get('reset-password') || (currentPath === '/reset-password' ? urlParams.get('token') : null)
if (resetToken) return (
<div style={{minHeight:'100vh',display:'flex',alignItems:'center',justifyContent:'center',
background:'var(--bg)',padding:24}}>

View File

@ -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 (
<div style={{
background: '#FFF4E6',
border: '2px solid #F59E0B',
borderRadius: 12,
padding: '16px 20px',
marginBottom: 20,
display: 'flex',
alignItems: 'center',
gap: 16,
flexWrap: 'wrap'
}}>
<div style={{
fontSize: 32,
lineHeight: 1
}}>
📧
</div>
<div style={{flex: 1, minWidth: 200}}>
<div style={{
fontWeight: 700,
fontSize: 15,
color: '#D97706',
marginBottom: 4
}}>
E-Mail-Adresse noch nicht bestätigt
</div>
<div style={{
fontSize: 13,
color: 'var(--text2)',
lineHeight: 1.4
}}>
Bitte prüfe dein Postfach und klicke auf den Bestätigungslink.
{success && (
<span style={{color:'var(--accent)', fontWeight:600, marginLeft:4}}>
Neue E-Mail versendet!
</span>
)}
{error && (
<span style={{color:'var(--danger)', fontWeight:600, marginLeft:4}}>
{error}
</span>
)}
</div>
</div>
<button
onClick={handleResend}
disabled={resending || success}
style={{
padding: '8px 16px',
borderRadius: 8,
background: success ? 'var(--accent)' : '#F59E0B',
color: 'white',
fontWeight: 600,
fontSize: 13,
border: 'none',
cursor: resending || success ? 'not-allowed' : 'pointer',
whiteSpace: 'nowrap',
opacity: resending || success ? 0.6 : 1
}}
>
{resending ? 'Sende...' : success ? '✓ Versendet' : 'Neue E-Mail senden'}
</button>
</div>
)
}

View File

@ -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 (
<div style={{
background: bgColor,
border: `2px solid ${borderColor}`,
borderRadius: 12,
padding: '16px 20px',
marginBottom: 20,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
flexWrap: 'wrap',
gap: 12
}}>
<div style={{flex: 1, minWidth: 200}}>
<div style={{
fontWeight: 700,
fontSize: 15,
color: textColor,
marginBottom: 4
}}>
{isUrgent && '⚠️ '}
Deine Trial endet {daysLeft === 1 ? 'morgen' : `in ${daysLeft} Tagen`}
</div>
<div style={{
fontSize: 13,
color: 'var(--text2)',
lineHeight: 1.4
}}>
{isUrgent
? 'Upgrade jetzt um weiterhin alle Features nutzen zu können'
: 'Wähle ein Abo um unbegrenzt Zugriff zu erhalten'
}
</div>
</div>
<Link
to="/settings?tab=subscription"
style={{
padding: '10px 20px',
borderRadius: 8,
background: isUrgent ? '#D85A30' : 'var(--accent)',
color: 'white',
fontWeight: 600,
fontSize: 14,
textDecoration: 'none',
whiteSpace: 'nowrap',
border: 'none',
cursor: 'pointer'
}}
>
{isUrgent ? 'Jetzt upgraden' : 'Abo wählen'}
</Link>
</div>
)
}

View File

@ -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 (
<AuthContext.Provider value={{
session, loading, needsSetup,
login, setup, logout,
login, setup, logout, setAuthFromToken,
isAdmin, canUseAI, canExport,
token: session?.token,
profileId: session?.profile_id,

View File

@ -8,6 +8,8 @@ import {
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'
@ -245,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 () => {
@ -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 <div className="empty-state"><div className="spinner"/></div>
@ -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 (
<div>
{/* Header greeting */}
@ -315,6 +330,12 @@ export default function Dashboard() {
</div>
</div>
{/* Email Verification Banner */}
{activeProfile && <EmailVerificationBanner profile={activeProfile}/>}
{/* Trial Banner */}
{activeProfile && <TrialBanner profile={activeProfile}/>}
{!hasAnyData && (
<div className="empty-state">
<h3>Willkommen bei Mitai Jinkendo!</h3>

View File

@ -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?
</button>
<div style={{
marginTop:24, paddingTop:20,
borderTop:'1px solid var(--border)',
textAlign:'center'
}}>
<span style={{color:'var(--text2)',fontSize:14}}>
Noch kein Account?{' '}
</span>
<a href="/register" style={{
color:'var(--accent)',fontWeight:600,fontSize:14,
textDecoration:'none'
}}>
Jetzt registrieren
</a>
</div>
</div>
<div style={{textAlign:'center',marginTop:20,fontSize:11,color:'var(--text3)'}}>

View File

@ -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 (
<div style={{padding:20,textAlign:'center',color:'var(--text3)',fontSize:13}}>
Zu wenig gemeinsame Daten (Gewicht + Kalorien am selben Tag nötig)
Zu wenig Daten für diese Auswertung
</div>
)
// 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 (
<ResponsiveContainer width="100%" height={220}>
<LineChart data={withAvg} margin={{top:4,right:8,bottom:0,left:-16}}>
<LineChart data={realData} margin={{top:4,right:8,bottom:0,left:-16}}>
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3"/>
<XAxis dataKey="date" tick={{fontSize:9,fill:'var(--text3)'}} tickLine={false}
interval={Math.max(0,Math.floor(withAvg.length/6)-1)}/>
interval={Math.max(0,Math.floor(realData.length/6)-1)}/>
<YAxis yAxisId="kcal" tick={{fontSize:9,fill:'var(--text3)'}} tickLine={false} domain={['auto','auto']}/>
<YAxis yAxisId="weight" orientation="right" tick={{fontSize:9,fill:'var(--text3)'}} tickLine={false} domain={['auto','auto']}/>
<Tooltip contentStyle={{background:'var(--surface)',border:'1px solid var(--border)',borderRadius:8,fontSize:11}}
formatter={(v,n)=>[`${Math.round(v)} ${n==='weight'?'kg':'kcal'}`, n==='kcal_avg'?'Ø 7T Kalorien':n==='weight'?'Gewicht':'Kalorien']}/>
<Line yAxisId="kcal" type="monotone" dataKey="kcal" stroke="#EF9F2744" strokeWidth={1} dot={false}/>
<Line yAxisId="kcal" type="monotone" dataKey="kcal_avg" stroke="#EF9F27" strokeWidth={2} dot={false} name="kcal_avg"/>
<Line yAxisId="weight" type="monotone" dataKey="weight" stroke="#378ADD" strokeWidth={2} dot={{r:3,fill:'#378ADD'}} name="weight"/>
formatter={(v,n)=>[`${Math.round(v)} ${n==='weight'?'kg':'kcal'}`, n.includes('avg')?'Ø 7T Kalorien':n==='weight'?'Gewicht':'Kalorien']}/>
{/* Real kcal values - solid lines */}
<Line yAxisId="kcal" type="monotone" dataKey="kcal" stroke="#EF9F2744" strokeWidth={1} dot={false} connectNulls={false}/>
<Line yAxisId="kcal" type="monotone" dataKey="kcal_avg" stroke="#EF9F27" strokeWidth={2} dot={false} name="kcal_avg" connectNulls={false}/>
{/* Extrapolated kcal values - dashed lines */}
<Line yAxisId="kcal" type="monotone" dataKey="kcal_extrap" stroke="#EF9F2744" strokeWidth={1} strokeDasharray="3 3" dot={false} connectNulls={false}/>
<Line yAxisId="kcal" type="monotone" dataKey="kcal_avg_extrap" stroke="#EF9F27" strokeWidth={2} strokeDasharray="3 3" dot={false} connectNulls={false}/>
{/* Weight - always solid */}
<Line yAxisId="weight" type="monotone" dataKey="weight" stroke="#378ADD" strokeWidth={2} dot={{r:3,fill:'#378ADD'}} name="weight" connectNulls={true}/>
</LineChart>
</ResponsiveContainer>
)
@ -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' && (
<>
<ImportPanel onImported={load}/>
<ImportHistory/>
<ImportPanel onImported={() => { load(); setImportHistoryKey(Date.now()) }}/>
<ImportHistory key={importHistoryKey}/>
</>
)}

View File

@ -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 (
<div style={{
maxWidth:420, margin:'0 auto', padding:'60px 20px',
textAlign:'center'
}}>
<div style={{
background:'var(--accent-light)',
border:'2px solid var(--accent)',
borderRadius:16, padding:'40px 24px', marginBottom:24
}}>
<div style={{fontSize:48, marginBottom:16}}></div>
<h2 style={{marginBottom:12, color:'var(--accent-dark)'}}>
Registrierung erfolgreich!
</h2>
<p style={{color:'var(--text2)', lineHeight:1.6}}>
Wir haben dir eine E-Mail mit einem Bestätigungslink gesendet.
Bitte prüfe dein Postfach und bestätige deine E-Mail-Adresse.
</p>
</div>
<Link to="/login" className="btn btn-primary btn-full">
Zum Login
</Link>
<p style={{fontSize:12, color:'var(--text3)', marginTop:20}}>
Keine E-Mail erhalten? Prüfe auch deinen Spam-Ordner.
</p>
</div>
)
}
return (
<div style={{maxWidth:420, margin:'0 auto', padding:'40px 20px'}}>
<h1 style={{textAlign:'center', marginBottom:8}}>Registrierung</h1>
<p style={{textAlign:'center', color:'var(--text2)', marginBottom:32}}>
Erstelle deinen Mitai Jinkendo Account
</p>
<form onSubmit={handleSubmit} className="card" style={{padding:24}}>
{error && (
<div style={{
padding:'12px 16px', background:'#FCEBEB',
border:'1px solid #D85A30', borderRadius:8,
color:'#D85A30', fontSize:14, marginBottom:20
}}>
{error}
</div>
)}
<div className="form-row">
<label className="form-label">Name *</label>
<input
type="text"
className="form-input"
value={name}
onChange={e => setName(e.target.value)}
placeholder="Dein Name"
disabled={loading}
required
/>
</div>
<div className="form-row">
<label className="form-label">E-Mail *</label>
<input
type="email"
className="form-input"
value={email}
onChange={e => setEmail(e.target.value)}
placeholder="deine@email.de"
disabled={loading}
required
/>
</div>
<div className="form-row">
<label className="form-label">Passwort *</label>
<input
type="password"
className="form-input"
value={password}
onChange={e => setPassword(e.target.value)}
placeholder="Mindestens 8 Zeichen"
disabled={loading}
required
/>
</div>
<div className="form-row">
<label className="form-label">Passwort bestätigen *</label>
<input
type="password"
className="form-input"
value={passwordConfirm}
onChange={e => setPasswordConfirm(e.target.value)}
placeholder="Passwort wiederholen"
disabled={loading}
required
/>
</div>
<button
type="submit"
className="btn btn-primary btn-full"
disabled={loading}
style={{marginTop:8}}
>
{loading ? 'Registriere...' : 'Registrieren'}
</button>
<div style={{
textAlign:'center', marginTop:24,
paddingTop:20, borderTop:'1px solid var(--border)'
}}>
<span style={{color:'var(--text2)', fontSize:14}}>
Bereits registriert?{' '}
</span>
<Link to="/login" style={{
color:'var(--accent)', fontWeight:600, fontSize:14
}}>
Zum Login
</Link>
</div>
</form>
<p style={{
fontSize:11, color:'var(--text3)', textAlign:'center',
marginTop:20, lineHeight:1.6
}}>
Mit der Registrierung akzeptierst du unsere Nutzungsbedingungen
und Datenschutzerklärung.
</p>
</div>
)
}

View File

@ -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 (
<div style={{
maxWidth:420, margin:'0 auto', padding:'60px 20px',
textAlign:'center'
}}>
<div className="spinner" style={{width:48, height:48, margin:'0 auto 24px'}}/>
<h2>E-Mail wird bestätigt...</h2>
<p style={{color:'var(--text2)'}}>Einen Moment bitte</p>
</div>
)
}
if (status === 'expired') {
return (
<div style={{
maxWidth:420, margin:'0 auto', padding:'60px 20px',
textAlign:'center'
}}>
<div style={{
background:'#FFF4E6',
border:'2px solid #F59E0B',
borderRadius:16, padding:'40px 24px', marginBottom:24
}}>
<div style={{fontSize:48, marginBottom:16}}></div>
<h2 style={{marginBottom:12, color:'#D97706'}}>
Verifikations-Link abgelaufen
</h2>
<p style={{color:'var(--text2)', lineHeight:1.6, marginBottom:24}}>
Dieser Link ist leider nicht mehr gültig. Bitte fordere eine neue Bestätigungs-E-Mail an.
</p>
{resendSuccess ? (
<div style={{
background:'var(--accent-light)',
border:'1px solid var(--accent)',
borderRadius:8, padding:'16px', marginBottom:16
}}>
<div style={{fontSize:32, marginBottom:8}}></div>
<div style={{fontWeight:600, color:'var(--accent-dark)', marginBottom:4}}>
E-Mail versendet!
</div>
<p style={{fontSize:13, color:'var(--text2)', margin:0}}>
Bitte prüfe dein Postfach.
</p>
</div>
) : (
<>
<input
type="email"
className="form-input"
placeholder="deine@email.de"
value={email}
onChange={e => setEmail(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleResend()}
style={{width:'100%', boxSizing:'border-box', marginBottom:12}}
autoFocus
/>
{error && (
<div style={{
background:'rgba(216,90,48,0.1)',
color:'#D85A30',
fontSize:13,
padding:'10px 14px',
borderRadius:8,
marginBottom:12,
border:'1px solid rgba(216,90,48,0.2)'
}}>
{error}
</div>
)}
<button
onClick={handleResend}
disabled={resending || !email.trim()}
className="btn btn-primary btn-full"
style={{marginBottom:12}}
>
{resending ? (
<><div className="spinner" style={{width:14,height:14}}/> Sende...</>
) : (
'Neue Bestätigungs-E-Mail senden'
)}
</button>
</>
)}
</div>
<button
onClick={() => navigate('/login')}
style={{
background:'none', border:'none', cursor:'pointer',
fontSize:13, color:'var(--text3)', textDecoration:'underline'
}}
>
Zurück zum Login
</button>
</div>
)
}
if (status === 'already_verified') {
return (
<div style={{
maxWidth:420, margin:'0 auto', padding:'60px 20px',
textAlign:'center'
}}>
<div style={{
background:'var(--accent-light)',
border:'2px solid var(--accent)',
borderRadius:16, padding:'40px 24px', marginBottom:24
}}>
<div style={{fontSize:48, marginBottom:16}}></div>
<h2 style={{marginBottom:12, color:'var(--accent-dark)'}}>
E-Mail bereits bestätigt
</h2>
<p style={{color:'var(--text2)', lineHeight:1.6, marginBottom:error?12:24}}>
{error || 'Deine E-Mail-Adresse wurde bereits verifiziert. Du kannst dich jetzt anmelden.'}
</p>
{!error && (
<p style={{fontSize:13, color:'var(--text3)'}}>
Du wirst gleich zum Login weitergeleitet...
</p>
)}
{error && (
<p style={{fontSize:13, color:'var(--text3)', marginTop:16}}>
Du wirst gleich zum Login weitergeleitet...
</p>
)}
</div>
<button
onClick={() => { window.location.href = '/' }}
className="btn btn-primary btn-full"
>
Weiter zum Dashboard
</button>
</div>
)
}
if (status === 'error') {
return (
<div style={{
maxWidth:420, margin:'0 auto', padding:'60px 20px',
textAlign:'center'
}}>
<div style={{
background:'#FCEBEB',
border:'2px solid #D85A30',
borderRadius:16, padding:'40px 24px', marginBottom:24
}}>
<div style={{fontSize:48, marginBottom:16}}></div>
<h2 style={{marginBottom:12, color:'#D85A30'}}>
Verifizierung fehlgeschlagen
</h2>
<p style={{color:'var(--text2)', lineHeight:1.6}}>
{error}
</p>
</div>
<button
onClick={() => navigate('/register')}
className="btn btn-primary btn-full"
style={{marginBottom:12}}
>
Zur Registrierung
</button>
<button
onClick={() => navigate('/login')}
style={{
background:'none', border:'none', cursor:'pointer',
fontSize:13, color:'var(--text3)', textDecoration:'underline',
width:'100%'
}}
>
Zum Login
</button>
</div>
)
}
// Success
return (
<div style={{
maxWidth:420, margin:'0 auto', padding:'60px 20px',
textAlign:'center'
}}>
<div style={{
background:'var(--accent-light)',
border:'2px solid var(--accent)',
borderRadius:16, padding:'40px 24px', marginBottom:24
}}>
<div style={{fontSize:48, marginBottom:16}}></div>
<h2 style={{marginBottom:12, color:'var(--accent-dark)'}}>
E-Mail bestätigt!
</h2>
<p style={{color:'var(--text2)', lineHeight:1.6}}>
Dein Account wurde erfolgreich aktiviert.
Du wirst gleich zum Dashboard weitergeleitet...
</p>
</div>
<div className="spinner" style={{width:32, height:32, margin:'0 auto'}}/>
</div>
)
}

View File

@ -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