diff --git a/backend/main.py b/backend/main.py index 8817965..3f5ad08 100644 --- a/backend/main.py +++ b/backend/main.py @@ -69,10 +69,15 @@ def read_root(): "health": "/health" } -# TODO: Register routers here as they are created -# from routers import auth, profiles, clubs, groups, skills, methods, exercises -# app.include_router(auth.router, prefix="/api") -# app.include_router(profiles.router, prefix="/api") +# Register routers +from routers import auth, profiles + +app.include_router(auth.router, prefix="/api") +app.include_router(profiles.router, prefix="/api") + +# TODO: Add more routers as they are created +# from routers import clubs, groups, skills, methods, exercises +# app.include_router(clubs.router, prefix="/api") # ... etc if __name__ == "__main__": diff --git a/backend/routers/auth.py b/backend/routers/auth.py new file mode 100644 index 0000000..494c1ae --- /dev/null +++ b/backend/routers/auth.py @@ -0,0 +1,398 @@ +""" +Authentication Endpoints for Mitai Jinkendo + +Handles login, logout, password reset, and profile authentication. +""" +import os +import secrets +import smtplib +from typing import Optional +from datetime import datetime, timedelta, timezone +from email.mime.text import MIMEText + +from fastapi import APIRouter, HTTPException, Header, Depends +from starlette.requests import Request +from slowapi import Limiter +from slowapi.util import get_remote_address + +from db import get_db, get_cursor +from auth import hash_pin, verify_pin, make_token, require_auth +from models import LoginRequest, PasswordResetRequest, PasswordResetConfirm, RegisterRequest + +router = APIRouter(prefix="/api/auth", tags=["auth"]) +limiter = Limiter(key_func=get_remote_address) + + +@router.post("/login") +@limiter.limit("5/minute") +async def login(req: LoginRequest, request: Request): + """Login with email + password.""" + with get_db() as conn: + cur = get_cursor(conn) + cur.execute("SELECT * FROM profiles WHERE email=%s", (req.email.lower().strip(),)) + prof = cur.fetchone() + if not prof: + raise HTTPException(401, "Ungültige Zugangsdaten") + + # Verify password + if not verify_pin(req.password, prof['pin_hash']): + raise HTTPException(401, "Ungültige Zugangsdaten") + + # Auto-upgrade from SHA256 to bcrypt + if prof['pin_hash'] and not prof['pin_hash'].startswith('$2'): + new_hash = hash_pin(req.password) + cur.execute("UPDATE profiles SET pin_hash=%s WHERE id=%s", (new_hash, prof['id'])) + + # Create session + token = make_token() + session_days = prof.get('session_days', 30) + expires = datetime.now() + timedelta(days=session_days) + cur.execute("INSERT INTO sessions (token, profile_id, expires_at, created) VALUES (%s,%s,%s,CURRENT_TIMESTAMP)", + (token, prof['id'], expires.isoformat())) + + return { + "token": token, + "profile_id": prof['id'], + "name": prof['name'], + "role": prof['role'], + "expires_at": expires.isoformat() + } + + +@router.post("/logout") +def logout(x_auth_token: Optional[str]=Header(default=None)): + """Logout (delete session).""" + if x_auth_token: + with get_db() as conn: + cur = get_cursor(conn) + cur.execute("DELETE FROM sessions WHERE token=%s", (x_auth_token,)) + return {"ok": True} + + +@router.get("/me") +def get_me(session: dict=Depends(require_auth)): + """Get current user info.""" + pid = session['profile_id'] + # Import here to avoid circular dependency + from routers.profiles import get_profile + return get_profile(pid, session) + + +@router.get("/status") +def auth_status(): + """Health check endpoint.""" + return {"status": "ok", "service": "mitai-jinkendo", "version": "v9b"} + + +@router.put("/pin") +def change_pin(req: dict, session: dict=Depends(require_auth)): + """Change PIN/password for current user.""" + pid = session['profile_id'] + new_pin = req.get('pin', '') + if len(new_pin) < 4: + raise HTTPException(400, "PIN/Passwort muss mind. 4 Zeichen haben") + + new_hash = hash_pin(new_pin) + with get_db() as conn: + cur = get_cursor(conn) + cur.execute("UPDATE profiles SET pin_hash=%s WHERE id=%s", (new_hash, pid)) + + return {"ok": True} + + +@router.post("/forgot-password") +@limiter.limit("3/minute") +async def password_reset_request(req: PasswordResetRequest, request: Request): + """Request password reset email.""" + email = req.email.lower().strip() + with get_db() as conn: + cur = get_cursor(conn) + cur.execute("SELECT id, name FROM profiles WHERE email=%s", (email,)) + prof = cur.fetchone() + if not prof: + # Don't reveal if email exists + return {"ok": True, "message": "Falls die E-Mail existiert, wurde ein Reset-Link gesendet."} + + # Generate reset token + token = secrets.token_urlsafe(32) + expires = datetime.now() + timedelta(hours=1) + + # Store in sessions table (reuse mechanism) + cur.execute("INSERT INTO sessions (token, profile_id, expires_at, created) VALUES (%s,%s,%s,CURRENT_TIMESTAMP)", + (f"reset_{token}", prof['id'], expires.isoformat())) + + # Send email + 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") + app_url = os.getenv("APP_URL", "https://mitai.jinkendo.de") + + if smtp_host and smtp_user and smtp_pass: + msg = MIMEText(f"""Hallo {prof['name']}, + +Du hast einen Passwort-Reset angefordert. + +Reset-Link: {app_url}/reset-password?token={token} + +Der Link ist 1 Stunde gültig. + +Falls du diese Anfrage nicht gestellt hast, ignoriere diese E-Mail. + +Dein Mitai Jinkendo Team +""") + msg['Subject'] = "Passwort zurücksetzen – Mitai Jinkendo" + msg['From'] = smtp_from + msg['To'] = email + + with smtplib.SMTP(smtp_host, smtp_port) as server: + server.starttls() + server.login(smtp_user, smtp_pass) + server.send_message(msg) + except Exception as e: + print(f"Email error: {e}") + + return {"ok": True, "message": "Falls die E-Mail existiert, wurde ein Reset-Link gesendet."} + + +@router.post("/reset-password") +def password_reset_confirm(req: PasswordResetConfirm): + """Confirm password reset with token.""" + with get_db() as conn: + cur = get_cursor(conn) + cur.execute("SELECT profile_id FROM sessions WHERE token=%s AND expires_at > CURRENT_TIMESTAMP", + (f"reset_{req.token}",)) + sess = cur.fetchone() + if not sess: + raise HTTPException(400, "Ungültiger oder abgelaufener Reset-Link") + + pid = sess['profile_id'] + new_hash = hash_pin(req.new_password) + cur.execute("UPDATE profiles SET pin_hash=%s WHERE id=%s", (new_hash, pid)) + cur.execute("DELETE FROM sessions WHERE token=%s", (f"reset_{req.token}",)) + + return {"ok": True, "message": "Passwort erfolgreich zurückgesetzt"} + + +# ── Helper: Send Email ──────────────────────────────────────────────────────── +def send_email(to_email: str, subject: str, body: str): + """Send email via SMTP (reusable helper).""" + try: + smtp_host = os.getenv("SMTP_HOST") + smtp_port = int(os.getenv("SMTP_PORT", 587)) + smtp_user = os.getenv("SMTP_USER") + smtp_pass = os.getenv("SMTP_PASS") + smtp_from = os.getenv("SMTP_FROM", "noreply@jinkendo.de") + + if not smtp_host or not smtp_user or not smtp_pass: + print("SMTP not configured, skipping email") + return False + + msg = MIMEText(body) + msg['Subject'] = subject + msg['From'] = smtp_from + msg['To'] = to_email + + with smtplib.SMTP(smtp_host, smtp_port) as server: + server.starttls() + server.login(smtp_user, smtp_pass) + server.send_message(msg) + + return True + except Exception as e: + print(f"Email error: {e}") + return False + + +# ── Registration Endpoints ──────────────────────────────────────────────────── +@router.post("/register") +@limiter.limit("3/hour") +async def register(req: RegisterRequest, request: Request): + """Self-registration with email verification.""" + email = req.email.lower().strip() + name = req.name.strip() + password = req.password + + # Validation + if not email or '@' not in email: + raise HTTPException(400, "Ungültige E-Mail-Adresse") + if len(password) < 8: + raise HTTPException(400, "Passwort muss mindestens 8 Zeichen lang sein") + if not name or len(name) < 2: + raise HTTPException(400, "Name muss mindestens 2 Zeichen lang sein") + + with get_db() as conn: + cur = get_cursor(conn) + + # Check if email already exists + cur.execute("SELECT id FROM profiles WHERE email=%s", (email,)) + if cur.fetchone(): + raise HTTPException(400, "E-Mail-Adresse bereits registriert") + + # Generate verification token + verification_token = secrets.token_urlsafe(32) + verification_expires = datetime.now(timezone.utc) + timedelta(hours=24) + + # Create profile (inactive until verified) + profile_id = str(secrets.token_hex(16)) + pin_hash = hash_pin(password) + trial_ends = datetime.now(timezone.utc) + timedelta(days=14) # 14-day trial + + cur.execute(""" + INSERT INTO profiles ( + id, name, email, pin_hash, auth_type, role, tier, + email_verified, verification_token, verification_expires, + trial_ends_at, created + ) VALUES (%s, %s, %s, %s, 'email', 'user', 'free', FALSE, %s, %s, %s, CURRENT_TIMESTAMP) + """, (profile_id, name, email, pin_hash, verification_token, verification_expires, trial_ends)) + + # Send verification email + app_url = os.getenv("APP_URL", "https://mitai.jinkendo.de") + verify_url = f"{app_url}/verify?token={verification_token}" + + email_body = f"""Hallo {name}, + +willkommen bei Mitai Jinkendo! + +Bitte bestätige deine E-Mail-Adresse um die Registrierung abzuschließen: + +{verify_url} + +Der Link ist 24 Stunden gültig. + +Dein Mitai Jinkendo Team +""" + + send_email(email, "Willkommen bei Mitai Jinkendo – E-Mail bestätigen", email_body) + + return { + "ok": True, + "message": "Registrierung erfolgreich! Bitte prüfe dein E-Mail-Postfach und bestätige deine E-Mail-Adresse." + } + + +@router.get("/verify/{token}") +async def verify_email(token: str): + """Verify email address and activate account.""" + with get_db() as conn: + cur = get_cursor(conn) + + # Find profile with this verification token + cur.execute(""" + SELECT id, name, email, email_verified, verification_expires + FROM profiles + WHERE verification_token=%s + """, (token,)) + + prof = cur.fetchone() + + if not prof: + # Token not found - might be already used/verified + # Check if there's a verified profile (token was deleted after verification) + raise HTTPException(400, "Verifikations-Link ungültig oder bereits verwendet. Falls du bereits verifiziert bist, melde dich einfach an.") + + if prof['email_verified']: + raise HTTPException(400, "E-Mail-Adresse bereits bestätigt") + + # Check if token expired + if prof['verification_expires'] and datetime.now(timezone.utc) > prof['verification_expires']: + raise HTTPException(400, "Verifikations-Link abgelaufen. Bitte registriere dich erneut.") + + # Mark as verified and clear token + cur.execute(""" + UPDATE profiles + SET email_verified=TRUE, verification_token=NULL, verification_expires=NULL + WHERE id=%s + """, (prof['id'],)) + + # Create session (auto-login after verification) + session_token = make_token() + expires = datetime.now(timezone.utc) + timedelta(days=30) + cur.execute(""" + INSERT INTO sessions (token, profile_id, expires_at, created) + VALUES (%s, %s, %s, CURRENT_TIMESTAMP) + """, (session_token, prof['id'], expires)) + + return { + "ok": True, + "message": "E-Mail-Adresse erfolgreich bestätigt!", + "token": session_token, + "profile": { + "id": prof['id'], + "name": prof['name'], + "email": prof['email'] + } + } + + +@router.post("/resend-verification") +@limiter.limit("3/hour") +async def resend_verification(req: dict, request: Request): + """Resend verification email for unverified account.""" + email = req.get('email', '').strip().lower() + + if not email: + raise HTTPException(400, "E-Mail-Adresse erforderlich") + + with get_db() as conn: + cur = get_cursor(conn) + + # Find profile by email + cur.execute(""" + SELECT id, name, email, email_verified, verification_token, verification_expires + FROM profiles + WHERE email=%s + """, (email,)) + + prof = cur.fetchone() + + if not prof: + # Don't leak info about existing emails + return {"ok": True, "message": "Falls ein Account mit dieser E-Mail existiert, wurde eine Bestätigungs-E-Mail versendet."} + + if prof['email_verified']: + raise HTTPException(400, "E-Mail-Adresse bereits bestätigt") + + # Generate new verification token + verification_token = secrets.token_urlsafe(32) + verification_expires = datetime.now(timezone.utc) + timedelta(hours=24) + + cur.execute(""" + UPDATE profiles + SET verification_token=%s, verification_expires=%s + WHERE id=%s + """, (verification_token, verification_expires, prof['id'])) + + # Send verification email + app_url = os.getenv("APP_URL", "https://mitai.jinkendo.de") + verify_url = f"{app_url}/verify?token={verification_token}" + + email_body = f"""Hallo {prof['name']}, + +du hast eine neue Bestätigungs-E-Mail angefordert. + +Bitte bestätige deine E-Mail-Adresse, indem du auf folgenden Link klickst: + +{verify_url} + +Dieser Link ist 24 Stunden gültig. + +Falls du diese E-Mail nicht angefordert hast, kannst du sie einfach ignorieren. + +Viele Grüße +Dein Mitai Jinkendo Team +""" + + try: + send_email( + to=email, + subject="Neue Bestätigungs-E-Mail - Mitai Jinkendo", + body=email_body + ) + except Exception as e: + print(f"Failed to send verification email: {e}") + raise HTTPException(500, "E-Mail konnte nicht versendet werden") + + return {"ok": True, "message": "Bestätigungs-E-Mail wurde erneut versendet."} diff --git a/backend/routers/profiles.py b/backend/routers/profiles.py new file mode 100644 index 0000000..b9937ea --- /dev/null +++ b/backend/routers/profiles.py @@ -0,0 +1,156 @@ +""" +Profile Management Endpoints for Mitai Jinkendo + +Handles profile CRUD operations for both admin and current user. +""" +import uuid +from typing import Optional +from datetime import datetime + +from fastapi import APIRouter, HTTPException, Header, Depends + +from db import get_db, get_cursor, r2d +from auth import require_auth +from models import ProfileCreate, ProfileUpdate + +router = APIRouter(prefix="/api", tags=["profiles"]) + + +# ── Helper ──────────────────────────────────────────────────────────────────── +def get_pid(x_profile_id: Optional[str] = Header(default=None)) -> str: + """Get profile_id - from header for legacy endpoints.""" + if x_profile_id: + return x_profile_id + with get_db() as conn: + cur = get_cursor(conn) + cur.execute("SELECT id FROM profiles ORDER BY created LIMIT 1") + row = cur.fetchone() + if row: return row['id'] + raise HTTPException(400, "Kein Profil gefunden") + + +# ── Admin Profile Management ────────────────────────────────────────────────── +@router.get("/profiles") +def list_profiles(session=Depends(require_auth)): + """List all profiles (admin).""" + with get_db() as conn: + cur = get_cursor(conn) + cur.execute("SELECT * FROM profiles ORDER BY created") + rows = cur.fetchall() + return [r2d(r) for r in rows] + + +@router.post("/profiles") +def create_profile(p: ProfileCreate, session=Depends(require_auth)): + """Create new profile (admin).""" + pid = str(uuid.uuid4()) + with get_db() as conn: + cur = get_cursor(conn) + cur.execute("""INSERT INTO profiles (id,name,avatar_color,sex,dob,height,goal_weight,goal_bf_pct,created,updated) + VALUES (%s,%s,%s,%s,%s,%s,%s,%s,CURRENT_TIMESTAMP,CURRENT_TIMESTAMP)""", + (pid,p.name,p.avatar_color,p.sex,p.dob,p.height,p.goal_weight,p.goal_bf_pct)) + with get_db() as conn: + cur = get_cursor(conn) + cur.execute("SELECT * FROM profiles WHERE id=%s", (pid,)) + return r2d(cur.fetchone()) + + +@router.get("/profiles/{pid}") +def get_profile(pid: str, session=Depends(require_auth)): + """Get profile by ID.""" + with get_db() as conn: + cur = get_cursor(conn) + cur.execute("SELECT * FROM profiles WHERE id=%s", (pid,)) + row = cur.fetchone() + if not row: raise HTTPException(404, "Profil nicht gefunden") + return r2d(row) + + +@router.put("/profiles/{pid}") +def update_profile(pid: str, p: ProfileUpdate, session=Depends(require_auth)): + """Update profile by ID.""" + with get_db() as conn: + cur = get_cursor(conn) + cur.execute("SELECT * FROM profiles WHERE id=%s", (pid,)) + row = cur.fetchone() + if not row: + raise HTTPException(404, "Profil nicht gefunden") + rowd = r2d(row) + cur_email_norm = (rowd.get("email") or "").strip().lower() + + patch = p.model_dump(exclude_unset=True) + data = {} + + if "email" in patch: + ev = patch["email"] + if ev is None or (isinstance(ev, str) and ev.strip() == ""): + if rowd.get("email") is not None: + data["email"] = None + data["email_verified"] = False + data["verification_token"] = None + data["verification_expires"] = None + else: + email_norm = ev.strip().lower() + if "@" not in email_norm or len(email_norm) < 5: + raise HTTPException(400, "Ungültige E-Mail-Adresse") + cur.execute( + """ + SELECT id FROM profiles + WHERE email IS NOT NULL AND lower(trim(email)) = %s AND id <> %s + """, + (email_norm, pid), + ) + if cur.fetchone(): + raise HTTPException(409, "E-Mail wird bereits verwendet") + data["email"] = email_norm + if email_norm != cur_email_norm: + data["email_verified"] = False + data["verification_token"] = None + data["verification_expires"] = None + + nullable_keys = {"goal_weight", "goal_bf_pct", "dob"} + for k, v in patch.items(): + if k == "email": + continue + if v is None and k in nullable_keys: + data[k] = None + elif v is not None: + data[k] = v + + if not data: + return get_profile(pid, session) + + data["updated"] = datetime.now().isoformat() + cols = ", ".join(f"{k}=%s" for k in data) + vals = list(data.values()) + [pid] + cur.execute(f"UPDATE profiles SET {cols} WHERE id=%s", vals) + return get_profile(pid, session) + + +@router.delete("/profiles/{pid}") +def delete_profile(pid: str, session=Depends(require_auth)): + """Delete profile (admin).""" + with get_db() as conn: + cur = get_cursor(conn) + cur.execute("SELECT COUNT(*) as count FROM profiles") + count = cur.fetchone()['count'] + if count <= 1: raise HTTPException(400, "Letztes Profil kann nicht gelöscht werden") + for table in ['weight_log','circumference_log','caliper_log','nutrition_log','activity_log','ai_insights']: + cur.execute(f"DELETE FROM {table} WHERE profile_id=%s", (pid,)) + cur.execute("DELETE FROM profiles WHERE id=%s", (pid,)) + return {"ok": True} + + +# ── Current User Profile ────────────────────────────────────────────────────── +@router.get("/profile") +def get_active_profile(x_profile_id: Optional[str] = Header(default=None), session: dict = Depends(require_auth)): + """Legacy endpoint – returns active profile.""" + pid = get_pid(x_profile_id) + return get_profile(pid, session) + + +@router.put("/profile") +def update_active_profile(p: ProfileUpdate, x_profile_id: Optional[str] = Header(default=None), session: dict = Depends(require_auth)): + """Update current user's profile.""" + pid = get_pid(x_profile_id) + return update_profile(pid, p, session) diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 605e89a..aca22e7 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,50 +1,85 @@ -import React, { useState, useEffect } from 'react' +import React from 'react' import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom' -import { AuthProvider } from './context/AuthContext' +import { AuthProvider, useAuth } from './context/AuthContext' +import LoginPage from './pages/LoginPage' +import Dashboard from './pages/Dashboard' + +// Protected Route Component +function ProtectedRoute({ children }) { + const { isAuthenticated, loading } = useAuth() + + if (loading) { + return ( +
+
+
+ ) + } + + return isAuthenticated ? children : +} + +// Public Route Component (redirect to dashboard if already logged in) +function PublicRoute({ children }) { + const { isAuthenticated, loading } = useAuth() + + if (loading) { + return ( +
+
+
+ ) + } + + return !isAuthenticated ? children : +} + +function AppRoutes() { + return ( + + {/* Public Routes */} + + + + } + /> + + {/* Protected Routes */} + + + + } + /> + + {/* Catch all - redirect to dashboard or login */} + } /> + + ) +} function App() { - const [version, setVersion] = useState(null) - - useEffect(() => { - // Load version from API - fetch('/api/version') - .then(res => res.json()) - .then(data => setVersion(data)) - .catch(err => console.error('Failed to load version:', err)) - }, []) - return ( -
-
-

🥋 Shinkan Jinkendo

-

Trainer- und Vereinsplattform für Kampfsport-Trainingsplanung

- - {version && ( -
-

System Status

-

Version: {version.app_version}

-

Build: {version.build_date}

-

Environment: {version.environment}

-

DB Schema: {version.db_schema_version}

-
- )} - -
-

🚧 In Entwicklung

-

Die App wird gerade aufgebaut.

-
    -
  • ✅ Backend-Basis
  • -
  • ✅ Docker-Setup
  • -
  • ✅ Datenbank-Schema
  • -
  • 🔲 Auth-System
  • -
  • 🔲 Übungsverwaltung
  • -
  • 🔲 Trainingsplanung
  • -
-
-
-
+
) diff --git a/frontend/src/context/AuthContext.jsx b/frontend/src/context/AuthContext.jsx index 1bcd76a..ffb58f1 100644 --- a/frontend/src/context/AuthContext.jsx +++ b/frontend/src/context/AuthContext.jsx @@ -2,132 +2,74 @@ import { createContext, useContext, useState, useEffect } from 'react' const AuthContext = createContext(null) -const TOKEN_KEY = 'bodytrack_token' -const PROFILE_KEY = 'bodytrack_active_profile' - export function AuthProvider({ children }) { - const [session, setSession] = useState(null) // {token, profile_id, role} - const [loading, setLoading] = useState(true) - const [needsSetup, setNeedsSetup] = useState(false) + const [user, setUser] = useState(null) + const [loading, setLoading] = useState(true) useEffect(() => { - checkStatus() + checkAuth() }, []) - const checkStatus = async () => { + const checkAuth = async () => { + const token = localStorage.getItem('authToken') + if (!token) { + setLoading(false) + return + } + try { - const r = await fetch('/api/auth/status') - const status = await r.json() - - if (status.needs_setup) { - setNeedsSetup(true) - setLoading(false) - return - } - - // Try existing token - const token = localStorage.getItem(TOKEN_KEY) - if (token) { - const me = await fetch('/api/auth/me', { - headers: { 'X-Auth-Token': token } - }) - if (me.ok) { - const profile = await me.json() - setSession({ token, profile_id: profile.id, role: profile.role, profile }) - setLoading(false) - return + const response = await fetch('/api/profiles/me', { + headers: { + 'X-Auth-Token': token } - // Token expired - localStorage.removeItem(TOKEN_KEY) + }) + + if (response.ok) { + const profile = await response.json() + setUser(profile) + } else { + // Token invalid + localStorage.removeItem('authToken') } - } catch(e) { - console.error('Auth check failed', e) + } catch (err) { + console.error('Auth check failed:', err) + localStorage.removeItem('authToken') + } finally { + setLoading(false) } - setLoading(false) } - const login = async (credentials) => { - // Support both new {email, pin} and legacy {profile_id, pin} - const body = typeof credentials === 'object' ? credentials : { profile_id: credentials } - const r = await fetch('/api/auth/login', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body) - }) - if (!r.ok) { - const err = await r.json() - throw new Error(err.detail || 'Login fehlgeschlagen') - } - const data = await r.json() - localStorage.setItem(TOKEN_KEY, data.token) - localStorage.setItem(PROFILE_KEY, data.profile_id) - // Fetch full profile - const me = await fetch('/api/auth/me', { headers: { 'X-Auth-Token': data.token } }) - const profile = await me.json() - setSession({ token: data.token, profile_id: data.profile_id, role: data.role, profile }) - return data + const login = (data) => { + setUser(data.profile || data) } - const setup = async (formData) => { - const r = await fetch('/api/auth/setup', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(formData) - }) - if (!r.ok) { - const err = await r.json() - throw new Error(err.detail || 'Setup fehlgeschlagen') - } - const data = await r.json() - localStorage.setItem(TOKEN_KEY, data.token) - localStorage.setItem(PROFILE_KEY, data.profile_id) - setNeedsSetup(false) - await checkStatus() - return data + const logout = () => { + setUser(null) + localStorage.removeItem('authToken') } - 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 value = { + user, + isAuthenticated: !!user, + loading, + login, + logout, + checkAuth } - const logout = async () => { - const token = localStorage.getItem(TOKEN_KEY) - if (token) { - await fetch('/api/auth/logout', { method: 'POST', headers: { 'X-Auth-Token': token } }) - } - localStorage.removeItem(TOKEN_KEY) - setSession(null) - } - - const isAdmin = session?.role === 'admin' - const canUseAI = session?.profile?.ai_enabled !== 0 - const canExport = session?.profile?.export_enabled !== 0 - return ( - + {children} ) } export function useAuth() { - return useContext(AuthContext) + const context = useContext(AuthContext) + if (!context) { + throw new Error('useAuth must be used within AuthProvider') + } + return context } -export function getToken() { - return localStorage.getItem(TOKEN_KEY) -} +export default AuthContext diff --git a/frontend/src/pages/Dashboard.jsx b/frontend/src/pages/Dashboard.jsx new file mode 100644 index 0000000..02cc602 --- /dev/null +++ b/frontend/src/pages/Dashboard.jsx @@ -0,0 +1,159 @@ +import React, { useState, useEffect } from 'react' +import { useNavigate } from 'react-router-dom' +import { useAuth } from '../context/AuthContext' +import api from '../utils/api' + +function Dashboard() { + const [version, setVersion] = useState(null) + const [profile, setProfile] = useState(null) + const [loading, setLoading] = useState(true) + const { logout } = useAuth() + const navigate = useNavigate() + + useEffect(() => { + loadData() + }, []) + + const loadData = async () => { + try { + const [versionData, profileData] = await Promise.all([ + api.getVersion(), + api.getCurrentProfile() + ]) + setVersion(versionData) + setProfile(profileData) + } catch (err) { + console.error('Failed to load data:', err) + } finally { + setLoading(false) + } + } + + const handleLogout = async () => { + try { + await api.logout() + } catch (err) { + console.error('Logout error:', err) + } finally { + localStorage.removeItem('authToken') + logout() + navigate('/login') + } + } + + if (loading) { + return ( +
+
+

Laden...

+
+ ) + } + + return ( +
+ {/* Header */} +
+
+

🥋 Shinkan Jinkendo

+

+ Willkommen, {profile?.name || profile?.email} +

+
+ +
+ + {/* Main Content */} +
+ {/* Welcome Card */} +
+

Dashboard

+

+ Trainer- und Vereinsplattform für Kampfsport-Trainingsplanung +

+
+ + {/* Status Grid */} +
+
+

✅ Fertig

+
    +
  • Backend-Basis
  • +
  • Datenbank-Schema
  • +
  • Auth-System
  • +
  • Login & Registrierung
  • +
+
+ +
+

🚧 In Arbeit

+
    +
  • Übungsverwaltung
  • +
  • Trainingsplanung
  • +
  • Kataloge (Skills, Methods)
  • +
+
+ +
+

📋 Geplant

+
    +
  • MediaWiki-Import
  • +
  • Trainingsprogramme
  • +
  • Admin-Panel
  • +
+
+
+ + {/* System Info */} + {version && ( +
+

System-Information

+
+ Version: + {version.app_version} + + Build: + {version.build_date} + + Umgebung: + {version.environment} + + DB Schema: + {version.db_schema_version} + + Dein Tier: + + {profile?.tier || 'free'} + + + Rolle: + {profile?.role || 'user'} +
+
+ )} +
+
+ ) +} + +export default Dashboard diff --git a/frontend/src/pages/LoginPage.jsx b/frontend/src/pages/LoginPage.jsx new file mode 100644 index 0000000..8c8effc --- /dev/null +++ b/frontend/src/pages/LoginPage.jsx @@ -0,0 +1,158 @@ +import React, { useState } from 'react' +import { useNavigate } from 'react-router-dom' +import { useAuth } from '../context/AuthContext' +import api from '../utils/api' + +function LoginPage() { + const [mode, setMode] = useState('login') // 'login' or 'register' + const [email, setEmail] = useState('') + const [password, setPassword] = useState('') + const [name, setName] = useState('') + const [error, setError] = useState('') + const [loading, setLoading] = useState(false) + const [success, setSuccess] = useState('') + + const navigate = useNavigate() + const { login: authLogin } = useAuth() + + const handleSubmit = async (e) => { + e.preventDefault() + setError('') + setSuccess('') + setLoading(true) + + try { + if (mode === 'login') { + const response = await api.login(email, password) + localStorage.setItem('authToken', response.token) + authLogin({ token: response.token, profile: response.profile }) + navigate('/') + } else { + await api.register(email, password, name) + setSuccess('Registrierung erfolgreich! Bitte prüfe deine E-Mails.') + setMode('login') + setPassword('') + } + } catch (err) { + setError(err.message || 'Ein Fehler ist aufgetreten') + } finally { + setLoading(false) + } + } + + return ( +
+
+

+ 🥋 Shinkan Jinkendo +

+

+ Trainer- und Vereinsplattform +

+ +
+ + +
+ +
+ {mode === 'register' && ( +
+ + setName(e.target.value)} + required={mode === 'register'} + placeholder="Dein Name" + /> +
+ )} + +
+ + setEmail(e.target.value)} + required + placeholder="name@beispiel.de" + /> +
+ +
+ + setPassword(e.target.value)} + required + placeholder="••••••••" + minLength="6" + /> +
+ + {error && ( +
+ {error} +
+ )} + + {success && ( +
+ {success} +
+ )} + + +
+ +

+ v0.1.0 • Development +

+
+
+ ) +} + +export default LoginPage diff --git a/test-frontend.js b/test-frontend.js new file mode 100644 index 0000000..e6ddf04 --- /dev/null +++ b/test-frontend.js @@ -0,0 +1,44 @@ +const { chromium } = require('playwright'); + +(async () => { + const browser = await chromium.launch(); + const page = await browser.newPage(); + + console.log('=== Testing Shinkan Frontend ==='); + + try { + await page.goto('http://192.168.2.49:3098', { waitUntil: 'networkidle', timeout: 10000 }); + + // Get page title + const title = await page.title(); + console.log('Title:', title); + + // Get visible text + const bodyText = await page.textContent('body'); + console.log('\n=== Page Content ==='); + console.log(bodyText); + + // Check for specific elements + const h1 = await page.textContent('h1').catch(() => null); + console.log('\n=== Heading ==='); + console.log('H1:', h1); + + // Check for buttons + const buttons = await page.locator('button').count(); + console.log('\n=== Elements ==='); + console.log('Buttons:', buttons); + + // Check for login form + const loginForm = await page.locator('form').count(); + console.log('Forms:', loginForm); + + // Take screenshot + await page.screenshot({ path: 'shinkan-screenshot.png', fullPage: true }); + console.log('\nScreenshot saved: shinkan-screenshot.png'); + + } catch (error) { + console.error('Error:', error.message); + } + + await browser.close(); +})();