feat: Add Auth system with Login UI
All checks were successful
Deploy Development / deploy (push) Successful in 39s
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:
parent
fd5efa8662
commit
efc2a11a76
|
|
@ -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
398
backend/routers/auth.py
Normal 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
156
backend/routers/profiles.py
Normal 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)
|
||||||
|
|
@ -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>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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()
|
if (response.ok) {
|
||||||
setSession({ token, profile_id: profile.id, role: profile.role, profile })
|
const profile = await response.json()
|
||||||
setLoading(false)
|
setUser(profile)
|
||||||
return
|
} else {
|
||||||
}
|
// Token invalid
|
||||||
// Token expired
|
localStorage.removeItem('authToken')
|
||||||
localStorage.removeItem(TOKEN_KEY)
|
|
||||||
}
|
|
||||||
} 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 setup = async (formData) => {
|
const login = (data) => {
|
||||||
const r = await fetch('/api/auth/setup', {
|
setUser(data.profile || data)
|
||||||
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 setAuthFromToken = (token, profile) => {
|
const logout = () => {
|
||||||
// Direct token/profile set (for email verification auto-login)
|
setUser(null)
|
||||||
localStorage.setItem(TOKEN_KEY, token)
|
localStorage.removeItem('authToken')
|
||||||
localStorage.setItem(PROFILE_KEY, profile.id)
|
|
||||||
setSession({
|
|
||||||
token,
|
|
||||||
profile_id: profile.id,
|
|
||||||
role: profile.role || 'user',
|
|
||||||
profile
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const logout = async () => {
|
const value = {
|
||||||
const token = localStorage.getItem(TOKEN_KEY)
|
user,
|
||||||
if (token) {
|
isAuthenticated: !!user,
|
||||||
await fetch('/api/auth/logout', { method: 'POST', headers: { 'X-Auth-Token': token } })
|
loading,
|
||||||
|
login,
|
||||||
|
logout,
|
||||||
|
checkAuth
|
||||||
}
|
}
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
|
||||||
159
frontend/src/pages/Dashboard.jsx
Normal file
159
frontend/src/pages/Dashboard.jsx
Normal 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
|
||||||
158
frontend/src/pages/LoginPage.jsx
Normal file
158
frontend/src/pages/LoginPage.jsx
Normal 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
44
test-frontend.js
Normal 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();
|
||||||
|
})();
|
||||||
Loading…
Reference in New Issue
Block a user