bug fix #3

Merged
Lars merged 4 commits from develop into main 2026-04-29 11:54:18 +02:00
12 changed files with 521 additions and 80 deletions

View File

@ -1,3 +1,8 @@
# === Deploy (.env auf dem Host) ============================================
# Kopiere diese Datei nach `.env` im SELBEN Verzeichnis wie `docker-compose.yml` (Pi: ~/docker/shinkan).
# Docker Compose liest `.env` beim Start — dadurch wird z. B. SMTP_HOST=${SMTP_HOST} gefüllt.
# Ist die Datei weg/leer oder steht SMTP_PASS nicht darin → im Container keine SMTP-Daten ([SMTP] nicht konfiguriert).
# Database
DB_HOST=postgres
DB_PORT=5432
@ -15,6 +20,13 @@ SMTP_PORT=587
SMTP_USER=noreply@jinkendo.de
SMTP_PASS=your_smtp_password
SMTP_FROM=noreply@jinkendo.de
# Port 465 oft SSL; dann z. B. SMTP_SSL=true und SMTP_STARTTLS=false
SMTP_SSL=
SMTP_STARTTLS=
# Bootstrap: erste Registrierung (leere Nutzerliste) erhält Rolle admin; oder feste Admin-Mails:
AUTO_ADMIN_FIRST_USER=true
ADMIN_BOOTSTRAP_EMAILS=
# App
APP_URL=https://shinkan.jinkendo.de

View File

@ -117,7 +117,7 @@ def require_admin(x_auth_token: Optional[str] = Header(default=None)):
session = get_session(x_auth_token)
if not session:
raise HTTPException(401, "Nicht eingeloggt")
if session['role'] != 'admin':
if session.get("role") not in ("admin", "superadmin"):
raise HTTPException(403, "Nur für Admins")
return session

View File

@ -6,6 +6,8 @@ Handles login, logout, password reset, and profile authentication.
import os
import secrets
import smtplib
import ssl
from urllib.parse import quote
from typing import Optional
from datetime import datetime, timedelta, timezone
from email.mime.text import MIMEText
@ -123,38 +125,21 @@ async def password_reset_request(req: PasswordResetRequest, request: Request):
cur.execute("INSERT INTO sessions (token, profile_id, expires_at, created_at) 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']},
app_base = (os.getenv("APP_URL") or "https://shinkan.jinkendo.de").rstrip("/")
reset_body = f"""Hallo {prof['name']},
Du hast einen Passwort-Reset angefordert.
Reset-Link: {app_url}/reset-password?token={token}
Reset-Link: {app_base}/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}")
Dein Shinkan Jinkendo Team
"""
if not send_email(email, "Passwort zurücksetzen Shinkan Jinkendo", reset_body):
print("[SMTP] Passwort-Reset konnte nicht gesendet werden (SMTP prüfen).")
return {"ok": True, "message": "Falls die E-Mail existiert, wurde ein Reset-Link gesendet."}
@ -177,34 +162,101 @@ def password_reset_confirm(req: PasswordResetConfirm):
return {"ok": True, "message": "Passwort erfolgreich zurückgesetzt"}
# ── Helpers (Registrierung / E-Mail-Links) ─────────────────────────────────────
def _public_app_base() -> str:
return (os.getenv("APP_URL") or "https://shinkan.jinkendo.de").rstrip("/")
def _verification_link_expired(expires_at) -> bool:
"""
True, wenn Ablaufzeit in der Vergangenheit liegt.
PG liefert TIMESTAMP oft als naive datetime Vergleich mit timezone-aware UTC
würde sonst TypeError 500.
"""
if expires_at is None:
return False
deadline = expires_at.replace(tzinfo=timezone.utc) if expires_at.tzinfo is None else expires_at.astimezone(timezone.utc)
return datetime.now(timezone.utc) > deadline
def verification_link(token: str) -> str:
"""Link zur Web-App (`/verify?token=`); die SPA ruft wie bei Mitai die API auf."""
return f"{_public_app_base()}/verify?token={quote(token, safe='')}"
def registration_role(cur, email_lower: str) -> str:
"""
bootstrap: erste Registrierung in leerer DB admin,
oder E-Mail ADMIN_BOOTSTRAP_EMAILS (kommasepariert, Groß/Klein egal).
Um alle Self-Regs als Trainer zu haben: AUTO_ADMIN_FIRST_USER=false und keine ADMIN_BOOTSTRAP_EMAILS.
"""
bootstrap = {
x.strip().lower()
for x in os.getenv("ADMIN_BOOTSTRAP_EMAILS", "").split(",")
if x.strip()
}
if email_lower in bootstrap:
return "admin"
if os.getenv("AUTO_ADMIN_FIRST_USER", "true").strip().lower() not in ("1", "true", "yes"):
return "trainer"
cur.execute("SELECT COUNT(*) AS n FROM profiles")
row = cur.fetchone()
try:
n = int(row["n"]) if row is not None else 0
except (KeyError, TypeError, ValueError):
n = 0
return "admin" if n == 0 else "trainer"
# ── Helper: Send Email ────────────────────────────────────────────────────────
def send_email(to_email: str, subject: str, body: str):
"""Send email via SMTP (reusable helper)."""
"""Send mail via SMTP. Port 465 → SSL; sonst SMTP + optional STARTTLS (587)."""
try:
smtp_host = os.getenv("SMTP_HOST")
smtp_port = int(os.getenv("SMTP_PORT", 587))
smtp_user = os.getenv("SMTP_USER")
smtp_host = (os.getenv("SMTP_HOST") or "").strip()
smtp_port_raw = os.getenv("SMTP_PORT") or "587"
try:
smtp_port = int(str(smtp_port_raw).strip())
except ValueError:
smtp_port = 587
smtp_user = (os.getenv("SMTP_USER") or "").strip()
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")
if not smtp_host or not smtp_user or smtp_pass is None or smtp_pass == "":
print("[SMTP] Nicht konfiguriert — setze SMTP_HOST, SMTP_USER, SMTP_PASS (und ggf. SMTP_PORT)")
return False
msg = MIMEText(body)
msg['Subject'] = subject
msg['From'] = smtp_from
msg['To'] = to_email
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)
use_tls_env = (os.getenv("SMTP_STARTTLS", "true").strip().lower() not in ("0", "false", "no"))
force_ssl = (os.getenv("SMTP_SSL", "").strip().lower() in ("1", "true", "yes")) or smtp_port == 465
ctx = ssl.create_default_context()
if force_ssl:
print(f"[SMTP] Versand (SSL) an …@{to_email.split('@')[-1]} über {smtp_host}:{smtp_port}")
with smtplib.SMTP_SSL(smtp_host, smtp_port, context=ctx, timeout=30) as server:
server.login(smtp_user, smtp_pass)
server.send_message(msg)
else:
print(f"[SMTP] Versand (STARTTLS={use_tls_env}) an …@{to_email.split('@')[-1]} über {smtp_host}:{smtp_port}")
with smtplib.SMTP(smtp_host, smtp_port, timeout=30) as server:
server.ehlo()
if use_tls_env:
server.starttls(context=ctx)
server.ehlo()
server.login(smtp_user, smtp_pass)
server.send_message(msg)
print("[SMTP] Nachricht erfolgreich akzeptiert (Server-Queue)")
return True
except Exception as e:
print(f"Email error: {e}")
print(f"[SMTP] Fehler: {e}")
return False
@ -237,6 +289,9 @@ async def register(req: RegisterRequest, request: Request):
verification_token = secrets.token_urlsafe(32)
verification_expires = datetime.now(timezone.utc) + timedelta(hours=24)
# Rolle: erster Nutzer oder ADMIN_BOOTSTRAP_EMAILS → admin
role = registration_role(cur, email)
# Create profile (inactive until verified) — profiles.id ist SERIAL (INT), keine String-IDs einfügen.
pin_hash = hash_pin(password)
trial_ends = datetime.now(timezone.utc) + timedelta(days=14) # 14-day trial
@ -246,27 +301,26 @@ async def register(req: RegisterRequest, request: Request):
name, email, pin_hash, auth_type, role, tier,
email_verified, verification_token, verification_expires,
trial_ends_at, created_at
) VALUES (%s, %s, %s, 'email', 'trainer', 'free', FALSE, %s, %s, %s, CURRENT_TIMESTAMP)
""", (name, email, pin_hash, verification_token, verification_expires, trial_ends))
) VALUES (%s, %s, %s, 'email', %s, 'free', FALSE, %s, %s, %s, CURRENT_TIMESTAMP)
""", (name, email, pin_hash, role, 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}"
verify_url = verification_link(verification_token)
email_body = f"""Hallo {name},
willkommen bei Mitai Jinkendo!
willkommen bei Shinkan Jinkendo!
Bitte bestätige deine E-Mail-Adresse um die Registrierung abzuschließen:
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
Dein Shinkan Jinkendo Team
"""
send_email(email, "Willkommen bei Mitai Jinkendo E-Mail bestätigen", email_body)
if not send_email(email, "Shinkan Jinkendo E-Mail bestätigen", email_body):
print("[SMTP] Verifizierungs-Mail konnte nicht gesendet werden — Logs prüfen, SMTP_* in der Laufzeitumgebung.")
return {
"ok": True,
@ -298,7 +352,7 @@ async def verify_email(token: str):
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']:
if _verification_link_expired(prof["verification_expires"]):
raise HTTPException(400, "Verifikations-Link abgelaufen. Bitte registriere dich erneut.")
# Mark as verified and clear token
@ -366,34 +420,22 @@ async def resend_verification(req: dict, request: Request):
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}"
verify_url = verification_link(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:
Bitte bestätige deine E-Mail-Adresse mit diesem Link:
{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
Dein Shinkan 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")
if not send_email(email, "Shinkan Jinkendo E-Mail bestätigen", email_body):
raise HTTPException(502, "E-Mail konnte nicht versendet werden. SMTP-Einstellungen und Container-Logs prüfen.")
return {"ok": True, "message": "Bestätigungs-E-Mail wurde erneut versendet."}

View File

@ -34,6 +34,12 @@ services:
SMTP_USER: ${SMTP_USER}
SMTP_PASS: ${SMTP_PASS}
SMTP_FROM: ${SMTP_FROM}
# SMTP_STARTTLS=false | SMTP_SSL=true nach Anbieter (z. B. Port 465)
SMTP_SSL: ${SMTP_SSL:-}
SMTP_STARTTLS: ${SMTP_STARTTLS:-}
# Erste Self-Registration → Admin; oder ADMIN_BOOTSTRAP_EMAILS=mail@…,weitere@…
AUTO_ADMIN_FIRST_USER: "${AUTO_ADMIN_FIRST_USER:-true}"
ADMIN_BOOTSTRAP_EMAILS: "${ADMIN_BOOTSTRAP_EMAILS:-}"
APP_URL: https://shinkan.jinkendo.de
ALLOWED_ORIGINS: https://shinkan.jinkendo.de
ENVIRONMENT: production

View File

@ -12,6 +12,7 @@ import { AuthProvider, useAuth } from './context/AuthContext'
import DesktopSidebar from './components/DesktopSidebar'
import { getMainNavItems } from './config/appNav'
import LoginPage from './pages/LoginPage'
import VerifyPage from './pages/VerifyPage'
import Dashboard from './pages/Dashboard'
import AccountSettingsPage from './pages/AccountSettingsPage'
import ExercisesListPage from './pages/ExercisesListPage'
@ -133,6 +134,8 @@ function PublicRoute({ children }) {
function AppRoutes() {
return (
<Routes>
<Route path="/verify" element={<VerifyPage />} />
<Route
path="/login"
element={

View File

@ -0,0 +1,82 @@
import { useState } from 'react'
import api from '../utils/api'
function isEmailVerifiedRow(p) {
if (!p) return false
const v = p.email_verified
return v === true || v === 't' || v === 1 || v === 'true'
}
/**
* Hinweis + Erneut senden, wenn eingeloggt aber E-Mail noch nicht verifiziert (wie Mitai).
*/
export default function EmailVerificationBanner({ profile }) {
const [resending, setResending] = useState(false)
const [success, setSuccess] = useState(false)
const [error, setError] = useState('')
if (!profile?.email || isEmailVerifiedRow(profile)) return null
const handleResend = async () => {
if (!profile.email) return
setResending(true)
setError('')
setSuccess(false)
try {
await api.resendVerification(profile.email)
setSuccess(true)
setTimeout(() => setSuccess(false), 6000)
} catch (err) {
setError(err.message || 'Versand fehlgeschlagen')
setTimeout(() => setError(''), 7000)
} finally {
setResending(false)
}
}
return (
<div
className="card"
style={{
marginBottom: '1.25rem',
borderLeft: '4px solid #D97706',
background: 'var(--surface2)',
}}
role="status"
>
<div
style={{
display: 'flex',
flexWrap: 'wrap',
alignItems: 'center',
gap: '12px',
}}
>
<span style={{ fontSize: '1.5rem', lineHeight: 1 }} aria-hidden>
📧
</span>
<div style={{ flex: '1 1 200px' }}>
<div style={{ fontWeight: 700, fontSize: '0.95rem', color: '#D97706', marginBottom: 4 }}>
E-Mail noch nicht bestätigt
</div>
<p style={{ fontSize: '0.875rem', color: 'var(--text2)', lineHeight: 1.45, margin: 0 }}>
Bitte prüfe dein Postfach und öffne den Bestätigungslink (auch Spam-Ordner).
{success && (
<span style={{ color: 'var(--accent-dark)', fontWeight: 600, marginLeft: 8 }}>Neue Mail wurde angefordert.</span>
)}
{error && <span style={{ color: 'var(--danger)', fontWeight: 600, marginLeft: 8 }}>{error}</span>}
</p>
</div>
<button
type="button"
className="btn btn-secondary"
onClick={handleResend}
disabled={resending || success}
style={{ flexShrink: 0 }}
>
{resending ? 'Sende…' : success ? '✓ Unterwegs' : 'Erneut senden'}
</button>
</div>
</div>
)
}

View File

@ -1,4 +1,4 @@
import { createContext, useContext, useState, useEffect } from 'react'
import { createContext, useContext, useState, useEffect, useCallback } from 'react'
import api from '../utils/api'
const AuthContext = createContext(null)
@ -7,11 +7,7 @@ export function AuthProvider({ children }) {
const [user, setUser] = useState(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
checkAuth()
}, [])
const checkAuth = async () => {
const checkAuth = useCallback(async () => {
const token = localStorage.getItem('authToken')
if (!token) {
setLoading(false)
@ -27,7 +23,11 @@ export function AuthProvider({ children }) {
} finally {
setLoading(false)
}
}
}, [])
useEffect(() => {
checkAuth()
}, [checkAuth])
/** Fallback, falls ohne checkAuth gesetzt wird (Legacy / Token-Injektion) */
const login = (payload) => {

View File

@ -13,6 +13,7 @@ function AccountSettingsPage() {
const [newPw1, setNewPw1] = useState('')
const [newPw2, setNewPw2] = useState('')
const [savingPw, setSavingPw] = useState(false)
const [resendingVerify, setResendingVerify] = useState(false)
const [message, setMessage] = useState('')
const [error, setError] = useState('')
@ -21,7 +22,12 @@ function AccountSettingsPage() {
setName(typeof user?.name === 'string' ? user.name : '')
}, [user])
const verified = !!user?.email_verified
/** API: boolean true / Legacy: fehlt oder false → als „nicht verifiziert“ behandeln */
const emailExplicitlyVerified =
user?.email_verified === true ||
user?.email_verified === 't' ||
user?.email_verified === 1 ||
user?.email_verified === 'true'
const showOk = (text) => {
setMessage(text)
@ -54,6 +60,20 @@ function AccountSettingsPage() {
}
}
const handleResendVerification = async () => {
const em = user?.email
if (!em) return
setResendingVerify(true)
try {
await api.resendVerification(em)
showOk('Falls diese Adresse einen unbestätigten Account hat: E-Mail ist unterwegs — Postfach prüfen.')
} catch (err) {
showErr(err.message || 'Konnte keine E-Mail senden.')
} finally {
setResendingVerify(false)
}
}
const handleChangePassword = async (e) => {
e.preventDefault()
if (newPw1.length < 4) {
@ -119,7 +139,7 @@ function AccountSettingsPage() {
<strong style={{ color: 'var(--text1)' }}>E-Mail</strong>
<br />
{user?.email || '—'}{' '}
{verified ? (
{emailExplicitlyVerified ? (
<span
style={{
marginLeft: '0.5rem',
@ -146,6 +166,18 @@ function AccountSettingsPage() {
noch nicht bestätigt
</span>
)}
{!emailExplicitlyVerified && user?.email ? (
<div style={{ marginTop: '0.75rem' }}>
<button
type="button"
className="btn btn-secondary"
disabled={resendingVerify}
onClick={handleResendVerification}
>
{resendingVerify ? 'Sende…' : 'Bestätigung erneut senden'}
</button>
</div>
) : null}
</div>
<form onSubmit={handleSaveName}>

View File

@ -1,6 +1,7 @@
import React, { useState, useEffect } from 'react'
import { useAuth } from '../context/AuthContext'
import api from '../utils/api'
import EmailVerificationBanner from '../components/EmailVerificationBanner'
function Dashboard() {
const [version, setVersion] = useState(null)
@ -43,6 +44,7 @@ function Dashboard() {
<p style={{ color: 'var(--text2)', marginTop: '0.5rem' }}>
Willkommen, {user?.name || user?.email}!
</p>
{profile && <EmailVerificationBanner profile={profile} />}
{/* Welcome Card */}
<div className="card" style={{ marginTop: '1.5rem', marginBottom: '1.5rem' }}>
<h2>Willkommen bei Shinkan Jinkendo</h2>

View File

@ -11,6 +11,7 @@ function LoginPage() {
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const [success, setSuccess] = useState('')
const [resending, setResending] = useState(false)
const navigate = useNavigate()
const { checkAuth } = useAuth()
@ -29,7 +30,7 @@ function LoginPage() {
navigate('/')
} else {
await api.register(email, password, name)
setSuccess('Registrierung erfolgreich! Bitte prüfe deine E-Mails.')
setSuccess('Registrierung erfolgreich! Bitte prüfe deine E-Mails (auch Spam).')
setMode('login')
setPassword('')
}
@ -40,6 +41,25 @@ function LoginPage() {
}
}
const handleResendVerification = async () => {
if (!email.trim()) {
setError('Zuerst die E-Mail-Adresse eintragen.')
return
}
setError('')
setResending(true)
try {
await api.resendVerification(email.trim().toLowerCase())
setSuccess(
'Wenn diese Adresse für einen noch unbestätigten Account existiert, erhältst du gleich eine E-Mail.'
)
} catch (err) {
setError(err.message || 'Versand fehlgeschlagen')
} finally {
setResending(false)
}
}
return (
<div className="login-container" style={{
minHeight: '100vh',
@ -59,15 +79,23 @@ function LoginPage() {
<div style={{ display: 'flex', gap: '0.5rem', marginBottom: '1.5rem' }}>
<button
type="button"
className={mode === 'login' ? 'btn btn-primary' : 'btn btn-secondary'}
onClick={() => setMode('login')}
onClick={() => {
setMode('login')
setError('')
}}
style={{ flex: 1 }}
>
Login
</button>
<button
type="button"
className={mode === 'register' ? 'btn btn-primary' : 'btn btn-secondary'}
onClick={() => setMode('register')}
onClick={() => {
setMode('register')
setError('')
}}
style={{ flex: 1 }}
>
Registrieren
@ -147,6 +175,35 @@ function LoginPage() {
</button>
</form>
{mode === 'login' && (
<div
style={{
marginTop: '1.25rem',
paddingTop: '1rem',
borderTop: '1px solid var(--border)',
}}
>
<button
type="button"
className="btn btn-secondary btn-full"
disabled={resending || !email.trim()}
onClick={handleResendVerification}
>
{resending ? 'Sende…' : 'Bestätigungs-Link erneut senden'}
</button>
<p
style={{
marginTop: '0.45rem',
fontSize: '0.76rem',
color: 'var(--text3)',
lineHeight: 1.4,
textAlign: 'center',
}}
>
Nutzt die E-Mail-Adresse vom Formular für noch nicht verifizierte Konten (Rate Limit 3&nbsp;h).
</p>
</div>
)}
<p style={{ textAlign: 'center', color: 'var(--text3)', marginTop: '1.5rem', fontSize: '0.875rem' }}>
v0.1.0 Development
</p>

View File

@ -0,0 +1,182 @@
import { useState, useEffect } from 'react'
import { useSearchParams, useNavigate } from 'react-router-dom'
import { useAuth } from '../context/AuthContext'
import api from '../utils/api'
/**
* E-Mail-Bestätigung (?token=) gleiches Muster wie Mitai: SPA ruft /api/auth/verify/{token} auf.
*/
export default function VerifyPage() {
const [searchParams] = useSearchParams()
const token = searchParams.get('token')
const navigate = useNavigate()
const { checkAuth } = useAuth()
const [status, setStatus] = useState('loading')
const [error, setError] = useState(null)
const [email, setEmail] = useState('')
const [resending, setResending] = useState(false)
const [resendSuccess, setResendSuccess] = useState(false)
const [hasStarted, setHasStarted] = useState(false)
useEffect(() => {
if (hasStarted) return
if (!token?.trim()) {
setStatus('error')
setError('Kein Verifikations-Token in der URL')
return
}
setHasStarted(true)
const run = async () => {
try {
const result = await api.verifyEmail(token.trim())
if (result.token) {
localStorage.setItem('authToken', result.token)
await checkAuth()
}
setStatus('success')
setTimeout(() => navigate('/', { replace: true }), 1500)
} catch (err) {
const msg = err.message || 'Verifizierung fehlgeschlagen'
if (msg.includes('bereits bestätigt') || msg.includes('bereits verwendet')) {
setStatus('already_verified')
setError(msg)
setTimeout(() => navigate('/login', { replace: true }), 3500)
} else if (msg.includes('abgelaufen')) {
setStatus('expired')
setError(msg)
} else {
setStatus('error')
setError(msg)
}
}
}
run()
}, [token, hasStarted, checkAuth, navigate])
const handleResend = async () => {
if (!email.trim()) {
setError('Bitte E-Mail-Adresse eingeben')
return
}
setResending(true)
setError(null)
try {
await api.resendVerification(email.trim().toLowerCase())
setResendSuccess(true)
} catch (err) {
setError(err.message || 'Versand fehlgeschlagen')
} finally {
setResending(false)
}
}
if (status === 'loading') {
return (
<div style={{ maxWidth: 420, margin: '0 auto', padding: '3rem 1rem', textAlign: 'center' }}>
<div className="spinner" style={{ width: 48, height: 48, margin: '0 auto 1rem' }} />
<h2 style={{ fontSize: '1.25rem' }}>E-Mail wird bestätigt</h2>
<p style={{ color: 'var(--text2)' }}>Einen Moment bitte.</p>
</div>
)
}
if (status === 'expired') {
return (
<div style={{ maxWidth: 420, margin: '0 auto', padding: '2rem 1rem' }}>
<div className="card" style={{ marginBottom: '1rem' }}>
<h2 style={{ fontSize: '1.15rem', marginBottom: '0.5rem' }}>Link abgelaufen</h2>
<p style={{ color: 'var(--text2)', fontSize: '0.9rem', lineHeight: 1.5 }}>
{error || 'Bitte fordere eine neue Bestätigungs-E-Mail an.'}
</p>
{resendSuccess ? (
<p style={{ color: 'var(--accent)', fontWeight: 600, marginTop: '1rem' }}>E-Mail unterwegs Postfach prüfen.</p>
) : (
<>
<label className="form-label" style={{ marginTop: '1rem' }}>
Registrierte E-Mail
</label>
<input
type="email"
className="form-input"
placeholder="du@beispiel.de"
value={email}
onChange={(e) => setEmail(e.target.value)}
autoComplete="email"
/>
{error && !resendSuccess && (
<p style={{ color: 'var(--danger)', fontSize: '0.85rem', marginTop: '0.5rem' }}>{error}</p>
)}
<button
type="button"
className="btn btn-primary btn-full"
style={{ marginTop: '0.85rem' }}
disabled={resending || !email.trim()}
onClick={handleResend}
>
{resending ? 'Sende…' : 'Erneut senden'}
</button>
</>
)}
</div>
<button
type="button"
onClick={() => navigate('/login')}
style={{
background: 'none',
border: 'none',
color: 'var(--text3)',
textDecoration: 'underline',
cursor: 'pointer',
width: '100%',
}}
>
Zum Login
</button>
</div>
)
}
if (status === 'already_verified') {
return (
<div style={{ maxWidth: 420, margin: '0 auto', padding: '2rem 1rem', textAlign: 'center' }}>
<div className="card">
<h2 style={{ fontSize: '1.15rem' }}>Schon erledigt</h2>
<p style={{ color: 'var(--text2)', fontSize: '0.92rem', marginTop: '0.5rem' }}>
{error || 'E-Mail bereits bestätigt — du kannst dich anmelden.'}
</p>
<p style={{ fontSize: '0.82rem', color: 'var(--text3)', marginTop: '0.75rem' }}>Weiterleitung zum Login</p>
</div>
</div>
)
}
if (status === 'error') {
return (
<div style={{ maxWidth: 420, margin: '0 auto', padding: '2rem 1rem', textAlign: 'center' }}>
<div className="card" style={{ borderColor: 'var(--danger)' }}>
<h2 style={{ fontSize: '1.15rem', color: 'var(--danger)' }}>Konnte nicht bestätigen</h2>
<p style={{ color: 'var(--text2)', fontSize: '0.92rem', marginTop: '0.5rem' }}>{error}</p>
<button type="button" className="btn btn-primary btn-full" style={{ marginTop: '1rem' }} onClick={() => navigate('/login')}>
Zum Login
</button>
</div>
</div>
)
}
/* success */
return (
<div style={{ maxWidth: 420, margin: '0 auto', padding: '2rem 1rem', textAlign: 'center' }}>
<div className="card">
<h2 style={{ fontSize: '1.15rem', color: 'var(--accent-dark)', marginBottom: '0.5rem' }}>
Bestätigung erfolgreich
</h2>
<p style={{ color: 'var(--text2)', fontSize: '0.92rem' }}>Du wirst weitergeleitet</p>
<div className="spinner" style={{ width: 32, height: 32, margin: '1rem auto 0' }} />
</div>
</div>
)
}

View File

@ -13,10 +13,16 @@ const API_URL = import.meta.env.VITE_API_URL || ''
*/
async function request(endpoint, options = {}) {
const token = localStorage.getItem('authToken')
const method = (options.method || 'GET').toUpperCase()
const headers = {
'Content-Type': 'application/json',
...options.headers
...options.headers,
}
// GET ohne Body: kein Content-Type: application/json (manche Proxies/Headers stören sich)
if (method !== 'GET' && method !== 'HEAD') {
if (!headers['Content-Type'] && !headers['content-type']) {
headers['Content-Type'] = 'application/json'
}
}
if (token) {
@ -107,6 +113,21 @@ export async function changePassword(newPassword) {
})
}
/** GET /api/auth/verify/{token} — keine Auth nötig; Token gehört zur URL des Bestätigungslinks */
export async function verifyEmail(token) {
const t = encodeURIComponent(token)
return request(`/api/auth/verify/${t}`, {
method: 'GET',
})
}
export async function resendVerification(email) {
return request('/api/auth/resend-verification', {
method: 'POST',
body: JSON.stringify({ email }),
})
}
// ============================================================================
// Clubs & Groups
// ============================================================================
@ -881,6 +902,8 @@ export const api = {
getCurrentProfile,
updateProfile,
changePassword,
verifyEmail,
resendVerification,
// Clubs & Groups
listClubs,