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"
|
||||
}
|
||||
|
||||
# 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__":
|
||||
|
|
|
|||
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 { 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() {
|
||||
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 (
|
||||
<AuthProvider>
|
||||
<Router>
|
||||
<div className="app">
|
||||
<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>
|
||||
<AppRoutes />
|
||||
</Router>
|
||||
</AuthProvider>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<AuthContext.Provider value={{
|
||||
session, loading, needsSetup,
|
||||
login, setup, logout, setAuthFromToken,
|
||||
isAdmin, canUseAI, canExport,
|
||||
token: session?.token,
|
||||
profileId: session?.profile_id,
|
||||
}}>
|
||||
<AuthContext.Provider value={value}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
|
|
|
|||
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