feat: Add Auth system with Login UI
All checks were successful
Deploy Development / deploy (push) Successful in 39s

Backend:
- Auth router (login, register, logout)
- Profiles router (get current profile)
- Registered in main.py

Frontend:
- LoginPage with login/register tabs
- Dashboard with welcome screen
- Simplified AuthContext for Shinkan
- Protected routes in App.jsx
- Public routes redirect when logged in

Ready for testing!

version: 0.1.0
This commit is contained in:
Lars 2026-04-21 14:56:16 +02:00
parent fd5efa8662
commit efc2a11a76
8 changed files with 1045 additions and 148 deletions

View File

@ -69,10 +69,15 @@ def read_root():
"health": "/health" "health": "/health"
} }
# TODO: Register routers here as they are created # Register routers
# from routers import auth, profiles, clubs, groups, skills, methods, exercises from routers import auth, profiles
# app.include_router(auth.router, prefix="/api")
# app.include_router(profiles.router, prefix="/api") 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 # ... etc
if __name__ == "__main__": if __name__ == "__main__":

398
backend/routers/auth.py Normal file
View File

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

156
backend/routers/profiles.py Normal file
View File

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

View File

@ -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 { 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 (
<div style={{
minHeight: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'var(--bg)'
}}>
<div className="spinner"></div>
</div>
)
}
return isAuthenticated ? children : <Navigate to="/login" replace />
}
// Public Route Component (redirect to dashboard if already logged in)
function PublicRoute({ children }) {
const { isAuthenticated, loading } = useAuth()
if (loading) {
return (
<div style={{
minHeight: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'var(--bg)'
}}>
<div className="spinner"></div>
</div>
)
}
return !isAuthenticated ? children : <Navigate to="/" replace />
}
function AppRoutes() {
return (
<Routes>
{/* Public Routes */}
<Route
path="/login"
element={
<PublicRoute>
<LoginPage />
</PublicRoute>
}
/>
{/* Protected Routes */}
<Route
path="/"
element={
<ProtectedRoute>
<Dashboard />
</ProtectedRoute>
}
/>
{/* Catch all - redirect to dashboard or login */}
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
)
}
function App() { 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 ( return (
<AuthProvider> <AuthProvider>
<Router> <Router>
<div className="app"> <AppRoutes />
<div className="container">
<h1>🥋 Shinkan Jinkendo</h1>
<p>Trainer- und Vereinsplattform für Kampfsport-Trainingsplanung</p>
{version && (
<div className="card" style={{ marginTop: '2rem' }}>
<h3>System Status</h3>
<p><strong>Version:</strong> {version.app_version}</p>
<p><strong>Build:</strong> {version.build_date}</p>
<p><strong>Environment:</strong> {version.environment}</p>
<p><strong>DB Schema:</strong> {version.db_schema_version}</p>
</div>
)}
<div className="card" style={{ marginTop: '2rem' }}>
<h3>🚧 In Entwicklung</h3>
<p>Die App wird gerade aufgebaut.</p>
<ul style={{ textAlign: 'left', marginTop: '1rem' }}>
<li> Backend-Basis</li>
<li> Docker-Setup</li>
<li> Datenbank-Schema</li>
<li>🔲 Auth-System</li>
<li>🔲 Übungsverwaltung</li>
<li>🔲 Trainingsplanung</li>
</ul>
</div>
</div>
</div>
</Router> </Router>
</AuthProvider> </AuthProvider>
) )

View File

@ -2,132 +2,74 @@ import { createContext, useContext, useState, useEffect } from 'react'
const AuthContext = createContext(null) const AuthContext = createContext(null)
const TOKEN_KEY = 'bodytrack_token'
const PROFILE_KEY = 'bodytrack_active_profile'
export function AuthProvider({ children }) { export function AuthProvider({ children }) {
const [session, setSession] = useState(null) // {token, profile_id, role} const [user, setUser] = useState(null)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [needsSetup, setNeedsSetup] = useState(false)
useEffect(() => { useEffect(() => {
checkStatus() checkAuth()
}, []) }, [])
const checkStatus = async () => { const checkAuth = async () => {
const token = localStorage.getItem('authToken')
if (!token) {
setLoading(false)
return
}
try { try {
const r = await fetch('/api/auth/status') const response = await fetch('/api/profiles/me', {
const status = await r.json() headers: {
'X-Auth-Token': token
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
} }
// Token expired })
localStorage.removeItem(TOKEN_KEY)
if (response.ok) {
const profile = await response.json()
setUser(profile)
} else {
// Token invalid
localStorage.removeItem('authToken')
} }
} catch(e) { } catch (err) {
console.error('Auth check failed', e) console.error('Auth check failed:', err)
localStorage.removeItem('authToken')
} finally {
setLoading(false)
} }
setLoading(false)
} }
const login = async (credentials) => { const login = (data) => {
// Support both new {email, pin} and legacy {profile_id, pin} setUser(data.profile || data)
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 setup = async (formData) => { const logout = () => {
const r = await fetch('/api/auth/setup', { setUser(null)
method: 'POST', localStorage.removeItem('authToken')
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 setAuthFromToken = (token, profile) => { const value = {
// Direct token/profile set (for email verification auto-login) user,
localStorage.setItem(TOKEN_KEY, token) isAuthenticated: !!user,
localStorage.setItem(PROFILE_KEY, profile.id) loading,
setSession({ login,
token, logout,
profile_id: profile.id, checkAuth
role: profile.role || 'user',
profile
})
} }
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 ( return (
<AuthContext.Provider value={{ <AuthContext.Provider value={value}>
session, loading, needsSetup,
login, setup, logout, setAuthFromToken,
isAdmin, canUseAI, canExport,
token: session?.token,
profileId: session?.profile_id,
}}>
{children} {children}
</AuthContext.Provider> </AuthContext.Provider>
) )
} }
export function useAuth() { 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() { export default AuthContext
return localStorage.getItem(TOKEN_KEY)
}

View File

@ -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 (
<div style={{ padding: '2rem', textAlign: 'center' }}>
<div className="spinner"></div>
<p>Laden...</p>
</div>
)
}
return (
<div style={{ minHeight: '100vh', background: 'var(--bg)', padding: '1rem' }}>
{/* Header */}
<div style={{
maxWidth: '1200px',
margin: '0 auto',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '2rem'
}}>
<div>
<h1 style={{ margin: 0 }}>🥋 Shinkan Jinkendo</h1>
<p style={{ color: 'var(--text2)', margin: '0.25rem 0 0 0' }}>
Willkommen, {profile?.name || profile?.email}
</p>
</div>
<button className="btn btn-secondary" onClick={handleLogout}>
Abmelden
</button>
</div>
{/* Main Content */}
<div style={{ maxWidth: '1200px', margin: '0 auto' }}>
{/* Welcome Card */}
<div className="card" style={{ marginBottom: '1.5rem' }}>
<h2>Dashboard</h2>
<p style={{ color: 'var(--text2)' }}>
Trainer- und Vereinsplattform für Kampfsport-Trainingsplanung
</p>
</div>
{/* Status Grid */}
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(300px, 1fr))',
gap: '1rem',
marginBottom: '1.5rem'
}}>
<div className="card">
<h3> Fertig</h3>
<ul style={{ marginTop: '1rem', paddingLeft: '1.5rem' }}>
<li>Backend-Basis</li>
<li>Datenbank-Schema</li>
<li>Auth-System</li>
<li>Login & Registrierung</li>
</ul>
</div>
<div className="card">
<h3>🚧 In Arbeit</h3>
<ul style={{ marginTop: '1rem', paddingLeft: '1.5rem' }}>
<li>Übungsverwaltung</li>
<li>Trainingsplanung</li>
<li>Kataloge (Skills, Methods)</li>
</ul>
</div>
<div className="card">
<h3>📋 Geplant</h3>
<ul style={{ marginTop: '1rem', paddingLeft: '1.5rem' }}>
<li>MediaWiki-Import</li>
<li>Trainingsprogramme</li>
<li>Admin-Panel</li>
</ul>
</div>
</div>
{/* System Info */}
{version && (
<div className="card">
<h3>System-Information</h3>
<div style={{ display: 'grid', gridTemplateColumns: '150px 1fr', gap: '0.5rem', marginTop: '1rem' }}>
<strong>Version:</strong>
<span>{version.app_version}</span>
<strong>Build:</strong>
<span>{version.build_date}</span>
<strong>Umgebung:</strong>
<span>{version.environment}</span>
<strong>DB Schema:</strong>
<span>{version.db_schema_version}</span>
<strong>Dein Tier:</strong>
<span style={{
padding: '0.25rem 0.5rem',
background: profile?.tier === 'premium' ? 'var(--accent)' : 'var(--surface2)',
color: profile?.tier === 'premium' ? 'white' : 'var(--text1)',
borderRadius: '4px',
display: 'inline-block'
}}>
{profile?.tier || 'free'}
</span>
<strong>Rolle:</strong>
<span>{profile?.role || 'user'}</span>
</div>
</div>
)}
</div>
</div>
)
}
export default Dashboard

View File

@ -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 (
<div className="login-container" style={{
minHeight: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'var(--bg)',
padding: '1rem'
}}>
<div className="card" style={{ maxWidth: '400px', width: '100%' }}>
<h1 style={{ textAlign: 'center', marginBottom: '0.5rem' }}>
🥋 Shinkan Jinkendo
</h1>
<p style={{ textAlign: 'center', color: 'var(--text2)', marginBottom: '2rem' }}>
Trainer- und Vereinsplattform
</p>
<div style={{ display: 'flex', gap: '0.5rem', marginBottom: '1.5rem' }}>
<button
className={mode === 'login' ? 'btn btn-primary' : 'btn btn-secondary'}
onClick={() => setMode('login')}
style={{ flex: 1 }}
>
Login
</button>
<button
className={mode === 'register' ? 'btn btn-primary' : 'btn btn-secondary'}
onClick={() => setMode('register')}
style={{ flex: 1 }}
>
Registrieren
</button>
</div>
<form onSubmit={handleSubmit}>
{mode === 'register' && (
<div className="form-row">
<label className="form-label">Name</label>
<input
type="text"
className="form-input"
value={name}
onChange={(e) => setName(e.target.value)}
required={mode === 'register'}
placeholder="Dein Name"
/>
</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)}
required
placeholder="name@beispiel.de"
/>
</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)}
required
placeholder="••••••••"
minLength="6"
/>
</div>
{error && (
<div style={{
padding: '0.75rem',
background: 'var(--danger)',
color: 'white',
borderRadius: '8px',
marginBottom: '1rem'
}}>
{error}
</div>
)}
{success && (
<div style={{
padding: '0.75rem',
background: 'var(--accent)',
color: 'white',
borderRadius: '8px',
marginBottom: '1rem'
}}>
{success}
</div>
)}
<button
type="submit"
className="btn btn-primary btn-full"
disabled={loading}
>
{loading ? 'Laden...' : mode === 'login' ? 'Anmelden' : 'Registrieren'}
</button>
</form>
<p style={{ textAlign: 'center', color: 'var(--text3)', marginTop: '1.5rem', fontSize: '0.875rem' }}>
v0.1.0 Development
</p>
</div>
</div>
)
}
export default LoginPage

44
test-frontend.js Normal file
View File

@ -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();
})();