Merge pull request 'Final Feature 9c' (#10) from develop into main
Reviewed-on: #10
This commit is contained in:
commit
51aa57f304
22
CLAUDE.md
22
CLAUDE.md
|
|
@ -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` |
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
25
backend/migrations/003_add_email_verification.sql
Normal file
25
backend/migrations/003_add_email_verification.sql
Normal 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)';
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
|
|
@ -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."}
|
||||||
|
|
|
||||||
|
|
@ -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}}>
|
||||||
|
|
|
||||||
96
frontend/src/components/EmailVerificationBanner.jsx
Normal file
96
frontend/src/components/EmailVerificationBanner.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
86
frontend/src/components/TrialBanner.jsx
Normal file
86
frontend/src/components/TrialBanner.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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)'}}>
|
||||||
|
|
|
||||||
|
|
@ -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}/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
182
frontend/src/pages/Register.jsx
Normal file
182
frontend/src/pages/Register.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
304
frontend/src/pages/Verify.jsx
Normal file
304
frontend/src/pages/Verify.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user