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 ( +
+ Wir haben dir eine E-Mail mit einem Bestätigungslink gesendet. + Bitte prüfe dein Postfach und bestätige deine E-Mail-Adresse. +
++ Keine E-Mail erhalten? Prüfe auch deinen Spam-Ordner. +
++ Erstelle deinen Mitai Jinkendo Account +
+ + + ++ Mit der Registrierung akzeptierst du unsere Nutzungsbedingungen + und Datenschutzerklärung. +
+Einen Moment bitte
++ Dieser Link ist leider nicht mehr gültig. Bitte fordere eine neue Bestätigungs-E-Mail an. +
+ + {resendSuccess ? ( ++ Bitte prüfe dein Postfach. +
++ {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... +
+ )} ++ {error} +
++ Dein Account wurde erfolgreich aktiviert. + Du wirst gleich zum Dashboard weitergeleitet... +
+