Compare commits

..

19 Commits

Author SHA1 Message Date
51aa57f304 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
2026-03-21 12:41:41 +01:00
3dc3774d76 fix: parse JSON error messages and redirect to dashboard
All checks were successful
Deploy Development / deploy (push) Successful in 53s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 13s
1. Parse JSON error responses to extract 'detail' field
   Fixes: {"detail":"..."} shown as raw JSON instead of clean text
2. Redirect 'already_verified' to '/' instead of '/login'
   Fixes: Users land on empty page when already logged in
3. Change button text: "Jetzt anmelden" → "Weiter zum Dashboard"

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 12:35:04 +01:00
1cd93d521e fix: email verification redirect and already-used token message
All checks were successful
Deploy Development / deploy (push) Successful in 50s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 14s
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 <noreply@anthropic.com>
2026-03-21 12:28:51 +01:00
1521c2f221 fix: redirect to dashboard after successful login
All checks were successful
Deploy Development / deploy (push) Successful in 47s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 14s
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 <noreply@anthropic.com>
2026-03-21 12:09:37 +01:00
2e68b29d9c fix: improve Dashboard error handling and add debug logging
All checks were successful
Deploy Development / deploy (push) Successful in 43s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 13s
- 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 <noreply@anthropic.com>
2026-03-21 11:56:09 +01:00
e62b05c224 fix: prevent React StrictMode double execution in Verify
All checks were successful
Deploy Development / deploy (push) Successful in 56s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 13s
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 <noreply@anthropic.com>
2026-03-21 11:38:03 +01:00
ca9112ebc0 fix: email verification auto-login and user experience
All checks were successful
Deploy Development / deploy (push) Successful in 37s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 13s
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 <noreply@anthropic.com>
2026-03-21 10:32:24 +01:00
f843d71d6b feat: resend verification email functionality
All checks were successful
Deploy Development / deploy (push) Successful in 36s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 13s
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 <noreply@anthropic.com>
2026-03-21 10:23:38 +01:00
9fb6e27256 fix: email verification flow and trial system
All checks were successful
Deploy Development / deploy (push) Successful in 35s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 14s
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 <noreply@anthropic.com>
2026-03-21 10:20:06 +01:00
49467ca6e9 docs: document automatic migrations system
All checks were successful
Deploy Development / deploy (push) Successful in 36s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 13s
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 <noreply@anthropic.com>
2026-03-21 10:12:28 +01:00
913b485500 fix: only process numbered migrations (XXX_*.sql pattern)
All checks were successful
Deploy Development / deploy (push) Successful in 34s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 13s
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 <noreply@anthropic.com>
2026-03-21 10:08:56 +01:00
22651647cb fix: add automatic migration system to db_init.py
All checks were successful
Deploy Development / deploy (push) Successful in 35s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 13s
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 <noreply@anthropic.com>
2026-03-21 10:07:37 +01:00
9fa60434c1 fix: correct AuthContext import in Verify.jsx
All checks were successful
Deploy Development / deploy (push) Successful in 34s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 12s
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 <noreply@anthropic.com>
2026-03-21 09:59:59 +01:00
514b68e34f docs: v9c finalization complete
Some checks failed
Deploy Development / deploy (push) Failing after 24s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 12s
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 <noreply@anthropic.com>
2026-03-21 09:57:26 +01:00
961897ce2f feat: add trial system UI with countdown banner
Some checks failed
Deploy Development / deploy (push) Failing after 24s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 13s
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 <noreply@anthropic.com>
2026-03-21 09:56:35 +01:00
86f7a513fe feat: add self-registration frontend
Some checks failed
Deploy Development / deploy (push) Failing after 25s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 13s
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 <noreply@anthropic.com>
2026-03-21 09:55:23 +01:00
c1562a27f4 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 <noreply@anthropic.com>
2026-03-21 09:53:11 +01:00
888b5c3e40 fix: [BUG-003] correlations chart shows all weight data with extrapolation
All checks were successful
Deploy Development / deploy (push) Successful in 35s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 13s
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 <noreply@anthropic.com>
2026-03-21 09:51:20 +01:00
d1675dcc80 fix: [BUG-004] import history refreshes after CSV import
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 <noreply@anthropic.com>
2026-03-21 09:50:35 +01:00
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/ backend/
├── main.py # App-Setup + Router-Registration (~75 Zeilen) ├── main.py # App-Setup + Router-Registration (~75 Zeilen)
├── db.py # PostgreSQL Connection Pool ├── db.py # PostgreSQL Connection Pool
├── db_init.py # DB-Init + Migrations-System (automatisch beim Start)
├── auth.py # Hash, Verify, Sessions, Feature-Access-Control ├── auth.py # Hash, Verify, Sessions, Feature-Access-Control
├── models.py # Pydantic Models ├── models.py # Pydantic Models
├── feature_logger.py # Strukturiertes JSON-Logging (Phase 2) ├── feature_logger.py # Strukturiertes JSON-Logging (Phase 2)
├── migrations/ # SQL-Migrationen (XXX_*.sql Pattern)
└── routers/ # 14 Router-Module └── routers/ # 14 Router-Module
auth · profiles · weight · circumference · caliper auth · profiles · weight · circumference · caliper
activity · nutrition · photos · insights · prompts activity · nutrition · photos · insights · prompts
@ -77,10 +79,15 @@ frontend/src/
### Bug-Fixes (v9c) ### Bug-Fixes (v9c)
- ✅ **BUG-001:** TypeError in `/api/nutrition/weekly` (datetime.date vs string handling) - ✅ **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-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 🔲 ### Offen v9d 🔲
- Selbst-Registrierung + E-Mail-Verifizierung
- Trial-System UI
- Schlaf-Modul - Schlaf-Modul
- Trainingstypen + Herzfrequenz - Trainingstypen + Herzfrequenz
@ -117,6 +124,12 @@ Runner: Raspberry Pi (/home/lars/gitea-runner/)
Manuell: Manuell:
cd /home/lars/docker/bodytrack[-dev] cd /home/lars/docker/bodytrack[-dev]
docker compose -f docker-compose[.dev-env].yml build --no-cache && up -d 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) ## Datenbank-Schema (PostgreSQL 16)
@ -138,10 +151,14 @@ subscriptions · coupons · coupon_redemptions · features
tier_limits · user_feature_restrictions · user_feature_usage tier_limits · user_feature_restrictions · user_feature_usage
access_grants · user_activity_log access_grants · user_activity_log
Infrastruktur:
schema_migrations Tracking für automatische DB-Migrationen
Feature-Logging (Phase 2): Feature-Logging (Phase 2):
/app/logs/feature-usage.log # JSON-Format, alle Feature-Zugriffe /app/logs/feature-usage.log # JSON-Format, alle Feature-Zugriffe
Schema-Datei: backend/schema.sql Schema-Datei: backend/schema.sql
Migrationen: backend/migrations/*.sql (automatisch beim Start)
``` ```
## API & Auth ## API & Auth
@ -229,6 +246,7 @@ Bottom-Padding Mobile: 80px (Navigation)
| Backend-Architektur, Router, DB-Zugriff | `.claude/docs/architecture/BACKEND.md` | | Backend-Architektur, Router, DB-Zugriff | `.claude/docs/architecture/BACKEND.md` |
| Frontend-Architektur, api.js, Komponenten | `.claude/docs/architecture/FRONTEND.md` | | Frontend-Architektur, api.js, Komponenten | `.claude/docs/architecture/FRONTEND.md` |
| **Feature-Enforcement (neue Features hinzufügen)** | `.claude/docs/architecture/FEATURE_ENFORCEMENT.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` | | Coding Rules (Pflichtregeln) | `.claude/docs/rules/CODING_RULES.md` |
| Lessons Learned (Fehler vermeiden) | `.claude/docs/rules/LESSONS_LEARNED.md` | | Lessons Learned (Fehler vermeiden) | `.claude/docs/rules/LESSONS_LEARNED.md` |
| Feature Backlog (Übersicht) | `.claude/docs/BACKLOG.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}") print(f"Error getting profile count: {e}")
return -1 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__": if __name__ == "__main__":
print("═══════════════════════════════════════════════════════════") print("═══════════════════════════════════════════════════════════")
print("MITAI JINKENDO - Database Initialization (v9c)") print("MITAI JINKENDO - Database Initialization (v9c)")
@ -109,6 +213,12 @@ if __name__ == "__main__":
else: else:
print("✓ Schema already exists") print("✓ Schema already exists")
# Run migrations
print("\nRunning database migrations...")
if not run_migrations():
print("✗ Migration failed")
sys.exit(1)
# Check for migration # Check for migration
print("\nChecking for SQLite data migration...") print("\nChecking for SQLite data migration...")
sqlite_db = "/app/data/bodytrack.db" 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 new_password: str
class RegisterRequest(BaseModel):
name: str
email: str
password: str
# ── Admin Models ────────────────────────────────────────────────────────────── # ── Admin Models ──────────────────────────────────────────────────────────────
class AdminProfileUpdate(BaseModel): class AdminProfileUpdate(BaseModel):

View File

@ -7,7 +7,7 @@ import os
import secrets import secrets
import smtplib import smtplib
from typing import Optional from typing import Optional
from datetime import datetime, timedelta from datetime import datetime, timedelta, timezone
from email.mime.text import MIMEText from email.mime.text import MIMEText
from fastapi import APIRouter, HTTPException, Header, Depends 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 db import get_db, get_cursor
from auth import hash_pin, verify_pin, make_token, require_auth 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"]) router = APIRouter(prefix="/api/auth", tags=["auth"])
limiter = Limiter(key_func=get_remote_address) 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}",)) cur.execute("DELETE FROM sessions WHERE token=%s", (f"reset_{req.token}",))
return {"ok": True, "message": "Passwort erfolgreich zurückgesetzt"} 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 SetupScreen from './pages/SetupScreen'
import { ResetPassword } from './pages/PasswordRecovery' import { ResetPassword } from './pages/PasswordRecovery'
import LoginScreen from './pages/LoginScreen' import LoginScreen from './pages/LoginScreen'
import Register from './pages/Register'
import Verify from './pages/Verify'
import Dashboard from './pages/Dashboard' import Dashboard from './pages/Dashboard'
import CaptureHub from './pages/CaptureHub' import CaptureHub from './pages/CaptureHub'
import WeightScreen from './pages/WeightScreen' import WeightScreen from './pages/WeightScreen'
@ -59,9 +61,26 @@ function AppShell() {
} }
}, [session?.profile_id]) }, [session?.profile_id])
// Handle password reset link // Handle public pages (register, verify, reset-password)
const urlParams = new URLSearchParams(window.location.search) 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 ( if (resetToken) return (
<div style={{minHeight:'100vh',display:'flex',alignItems:'center',justifyContent:'center', <div style={{minHeight:'100vh',display:'flex',alignItems:'center',justifyContent:'center',
background:'var(--bg)',padding:24}}> 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 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 logout = async () => {
const token = localStorage.getItem(TOKEN_KEY) const token = localStorage.getItem(TOKEN_KEY)
if (token) { if (token) {
@ -102,7 +114,7 @@ export function AuthProvider({ children }) {
return ( return (
<AuthContext.Provider value={{ <AuthContext.Provider value={{
session, loading, needsSetup, session, loading, needsSetup,
login, setup, logout, login, setup, logout, setAuthFromToken,
isAdmin, canUseAI, canExport, isAdmin, canUseAI, canExport,
token: session?.token, token: session?.token,
profileId: session?.profile_id, profileId: session?.profile_id,

View File

@ -8,6 +8,8 @@ import {
import { api } from '../utils/api' import { api } from '../utils/api'
import { useProfile } from '../context/ProfileContext' import { useProfile } from '../context/ProfileContext'
import { getBfCategory } from '../utils/calc' import { getBfCategory } from '../utils/calc'
import TrialBanner from '../components/TrialBanner'
import EmailVerificationBanner from '../components/EmailVerificationBanner'
import { getInterpretation, getStatusColor, getStatusBg } from '../utils/interpret' import { getInterpretation, getStatusColor, getStatusBg } from '../utils/interpret'
import Markdown from '../utils/Markdown' import Markdown from '../utils/Markdown'
import dayjs from 'dayjs' import dayjs from 'dayjs'
@ -245,6 +247,12 @@ export default function Dashboard() {
setNutrition(n); setActivities(a) setNutrition(n); setActivities(a)
setInsights(Array.isArray(ins)?ins:[]) setInsights(Array.isArray(ins)?ins:[])
setLoading(false) 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 () => { const runPipeline = async () => {
@ -258,7 +266,12 @@ export default function Dashboard() {
} finally { setPipelineLoading(false) } } 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> 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 const hasAnyData = latestW||latestCal||nutrition.length>0
console.log('[Dashboard] hasAnyData=', hasAnyData, 'latestW=', !!latestW, 'latestCal=', !!latestCal, 'nutrition.length=', nutrition.length)
return ( return (
<div> <div>
{/* Header greeting */} {/* Header greeting */}
@ -315,6 +330,12 @@ export default function Dashboard() {
</div> </div>
</div> </div>
{/* Email Verification Banner */}
{activeProfile && <EmailVerificationBanner profile={activeProfile}/>}
{/* Trial Banner */}
{activeProfile && <TrialBanner profile={activeProfile}/>}
{!hasAnyData && ( {!hasAnyData && (
<div className="empty-state"> <div className="empty-state">
<h3>Willkommen bei Mitai Jinkendo!</h3> <h3>Willkommen bei Mitai Jinkendo!</h3>

View File

@ -33,6 +33,8 @@ export default function LoginScreen() {
setLoading(true); setError(null) setLoading(true); setError(null)
try { try {
await login({ email: email.trim().toLowerCase(), password: password }) await login({ email: email.trim().toLowerCase(), password: password })
// Redirect to dashboard after successful login
window.location.href = '/'
} catch(e) { } catch(e) {
setError(e.message || 'Ungültige E-Mail oder Passwort') setError(e.message || 'Ungültige E-Mail oder Passwort')
} finally { setLoading(false) } } finally { setLoading(false) }
@ -105,6 +107,22 @@ export default function LoginScreen() {
textAlign:'center',padding:'4px 0',textDecoration:'underline'}}> textAlign:'center',padding:'4px 0',textDecoration:'underline'}}>
Passwort vergessen? Passwort vergessen?
</button> </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>
<div style={{textAlign:'center',marginTop:20,fontSize:11,color:'var(--text3)'}}> <div style={{textAlign:'center',marginTop:20,fontSize:11,color:'var(--text3)'}}>

View File

@ -598,26 +598,62 @@ function OverviewCards({ data }) {
// Chart: Kalorien vs Gewicht // Chart: Kalorien vs Gewicht
function CaloriesVsWeight({ data }) { function CaloriesVsWeight({ data }) {
const filtered = data.filter(d => d.kcal && d.weight) // BUG-003 fix: Show all weight data, extrapolate kcal if missing
const withAvg = rollingAvg(filtered.map(d=>({...d,date:dayjs(d.date).format('DD.MM')})), 'kcal') const filtered = data.filter(d => d.kcal || d.weight)
if (filtered.length < 3) return ( if (filtered.length < 3) return (
<div style={{padding:20,textAlign:'center',color:'var(--text3)',fontSize:13}}> <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> </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 ( return (
<ResponsiveContainer width="100%" height={220}> <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"/> <CartesianGrid stroke="var(--border)" strokeDasharray="3 3"/>
<XAxis dataKey="date" tick={{fontSize:9,fill:'var(--text3)'}} tickLine={false} <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="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']}/> <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}} <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']}/> formatter={(v,n)=>[`${Math.round(v)} ${n==='weight'?'kg':'kcal'}`, n.includes('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"/> {/* Real kcal values - solid lines */}
<Line yAxisId="weight" type="monotone" dataKey="weight" stroke="#378ADD" strokeWidth={2} dot={{r:3,fill:'#378ADD'}} name="weight"/> <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> </LineChart>
</ResponsiveContainer> </ResponsiveContainer>
) )
@ -743,6 +779,7 @@ export default function NutritionPage() {
const [profile, setProf] = useState(null) const [profile, setProf] = useState(null)
const [loading, setLoad] = useState(true) const [loading, setLoad] = useState(true)
const [hasData, setHasData]= useState(false) const [hasData, setHasData]= useState(false)
const [importHistoryKey, setImportHistoryKey] = useState(Date.now()) // BUG-004 fix
const load = async () => { const load = async () => {
setLoad(true) setLoad(true)
@ -784,8 +821,8 @@ export default function NutritionPage() {
{/* Import Panel + History */} {/* Import Panel + History */}
{inputTab==='import' && ( {inputTab==='import' && (
<> <>
<ImportPanel onImported={load}/> <ImportPanel onImported={() => { load(); setImportHistoryKey(Date.now()) }}/>
<ImportHistory/> <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'}), adminDeleteProfile: (id) => req(`/admin/profiles/${id}`,{method:'DELETE'}),
adminSetPermissions: (id,d) => req(`/admin/profiles/${id}/permissions`,jput(d)), adminSetPermissions: (id,d) => req(`/admin/profiles/${id}/permissions`,jput(d)),
changePin: (pin) => req('/auth/pin',json({pin})), 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 // v9c Subscription System
// User-facing // User-facing