Merge pull request 'bug fix' (#3) from develop into main
Some checks failed
Deploy Production / deploy (push) Successful in 38s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 6s
Test Suite / playwright-tests (push) Failing after 1m59s

Reviewed-on: #3
This commit is contained in:
Lars 2026-04-29 11:54:17 +02:00
commit 38d78abc3b
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 # Database
DB_HOST=postgres DB_HOST=postgres
DB_PORT=5432 DB_PORT=5432
@ -15,6 +20,13 @@ SMTP_PORT=587
SMTP_USER=noreply@jinkendo.de SMTP_USER=noreply@jinkendo.de
SMTP_PASS=your_smtp_password SMTP_PASS=your_smtp_password
SMTP_FROM=noreply@jinkendo.de 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
APP_URL=https://shinkan.jinkendo.de 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) session = get_session(x_auth_token)
if not session: if not session:
raise HTTPException(401, "Nicht eingeloggt") raise HTTPException(401, "Nicht eingeloggt")
if session['role'] != 'admin': if session.get("role") not in ("admin", "superadmin"):
raise HTTPException(403, "Nur für Admins") raise HTTPException(403, "Nur für Admins")
return session return session

View File

@ -6,6 +6,8 @@ Handles login, logout, password reset, and profile authentication.
import os import os
import secrets import secrets
import smtplib import smtplib
import ssl
from urllib.parse import quote
from typing import Optional from typing import Optional
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from email.mime.text import MIMEText 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)", 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())) (f"reset_{token}", prof['id'], expires.isoformat()))
# Send email app_base = (os.getenv("APP_URL") or "https://shinkan.jinkendo.de").rstrip("/")
try: reset_body = f"""Hallo {prof['name']},
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. 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. Der Link ist 1 Stunde gültig.
Falls du diese Anfrage nicht gestellt hast, ignoriere diese E-Mail. Falls du diese Anfrage nicht gestellt hast, ignoriere diese E-Mail.
Dein Mitai Jinkendo Team Dein Shinkan Jinkendo Team
""") """
msg['Subject'] = "Passwort zurücksetzen Mitai Jinkendo" if not send_email(email, "Passwort zurücksetzen Shinkan Jinkendo", reset_body):
msg['From'] = smtp_from print("[SMTP] Passwort-Reset konnte nicht gesendet werden (SMTP prüfen).")
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."} 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"} 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 ──────────────────────────────────────────────────────── # ── Helper: Send Email ────────────────────────────────────────────────────────
def send_email(to_email: str, subject: str, body: str): 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: try:
smtp_host = os.getenv("SMTP_HOST") smtp_host = (os.getenv("SMTP_HOST") or "").strip()
smtp_port = int(os.getenv("SMTP_PORT", 587)) smtp_port_raw = os.getenv("SMTP_PORT") or "587"
smtp_user = os.getenv("SMTP_USER") 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_pass = os.getenv("SMTP_PASS")
smtp_from = os.getenv("SMTP_FROM", "noreply@jinkendo.de") smtp_from = os.getenv("SMTP_FROM", "noreply@jinkendo.de")
if not smtp_host or not smtp_user or not smtp_pass: if not smtp_host or not smtp_user or smtp_pass is None or smtp_pass == "":
print("SMTP not configured, skipping email") print("[SMTP] Nicht konfiguriert — setze SMTP_HOST, SMTP_USER, SMTP_PASS (und ggf. SMTP_PORT)")
return False return False
msg = MIMEText(body) msg = MIMEText(body)
msg['Subject'] = subject msg["Subject"] = subject
msg['From'] = smtp_from msg["From"] = smtp_from
msg['To'] = to_email msg["To"] = to_email
with smtplib.SMTP(smtp_host, smtp_port) as server: use_tls_env = (os.getenv("SMTP_STARTTLS", "true").strip().lower() not in ("0", "false", "no"))
server.starttls() 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.login(smtp_user, smtp_pass)
server.send_message(msg) server.send_message(msg)
print("[SMTP] Nachricht erfolgreich akzeptiert (Server-Queue)")
return True return True
except Exception as e: except Exception as e:
print(f"Email error: {e}") print(f"[SMTP] Fehler: {e}")
return False return False
@ -237,6 +289,9 @@ async def register(req: RegisterRequest, request: Request):
verification_token = secrets.token_urlsafe(32) verification_token = secrets.token_urlsafe(32)
verification_expires = datetime.now(timezone.utc) + timedelta(hours=24) 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. # Create profile (inactive until verified) — profiles.id ist SERIAL (INT), keine String-IDs einfügen.
pin_hash = hash_pin(password) pin_hash = hash_pin(password)
trial_ends = datetime.now(timezone.utc) + timedelta(days=14) # 14-day trial 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, name, email, pin_hash, auth_type, role, tier,
email_verified, verification_token, verification_expires, email_verified, verification_token, verification_expires,
trial_ends_at, created_at trial_ends_at, created_at
) VALUES (%s, %s, %s, 'email', 'trainer', 'free', FALSE, %s, %s, %s, CURRENT_TIMESTAMP) ) VALUES (%s, %s, %s, 'email', %s, 'free', FALSE, %s, %s, %s, CURRENT_TIMESTAMP)
""", (name, email, pin_hash, verification_token, verification_expires, trial_ends)) """, (name, email, pin_hash, role, verification_token, verification_expires, trial_ends))
# Send verification email verify_url = verification_link(verification_token)
app_url = os.getenv("APP_URL", "https://mitai.jinkendo.de")
verify_url = f"{app_url}/verify?token={verification_token}"
email_body = f"""Hallo {name}, 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} {verify_url}
Der Link ist 24 Stunden gültig. 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 { return {
"ok": True, "ok": True,
@ -298,7 +352,7 @@ async def verify_email(token: str):
raise HTTPException(400, "E-Mail-Adresse bereits bestätigt") raise HTTPException(400, "E-Mail-Adresse bereits bestätigt")
# Check if token expired # 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.") raise HTTPException(400, "Verifikations-Link abgelaufen. Bitte registriere dich erneut.")
# Mark as verified and clear token # Mark as verified and clear token
@ -366,34 +420,22 @@ async def resend_verification(req: dict, request: Request):
WHERE id=%s WHERE id=%s
""", (verification_token, verification_expires, prof['id'])) """, (verification_token, verification_expires, prof['id']))
# Send verification email verify_url = verification_link(verification_token)
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']}, email_body = f"""Hallo {prof['name']},
du hast eine neue Bestätigungs-E-Mail angefordert. 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} {verify_url}
Dieser Link ist 24 Stunden gültig. Dieser Link ist 24 Stunden gültig.
Falls du diese E-Mail nicht angefordert hast, kannst du sie einfach ignorieren. Dein Shinkan Jinkendo Team
Viele Grüße
Dein Mitai Jinkendo Team
""" """
try: if not send_email(email, "Shinkan Jinkendo E-Mail bestätigen", email_body):
send_email( raise HTTPException(502, "E-Mail konnte nicht versendet werden. SMTP-Einstellungen und Container-Logs prüfen.")
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."} return {"ok": True, "message": "Bestätigungs-E-Mail wurde erneut versendet."}

View File

@ -34,6 +34,12 @@ services:
SMTP_USER: ${SMTP_USER} SMTP_USER: ${SMTP_USER}
SMTP_PASS: ${SMTP_PASS} SMTP_PASS: ${SMTP_PASS}
SMTP_FROM: ${SMTP_FROM} 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 APP_URL: https://shinkan.jinkendo.de
ALLOWED_ORIGINS: https://shinkan.jinkendo.de ALLOWED_ORIGINS: https://shinkan.jinkendo.de
ENVIRONMENT: production ENVIRONMENT: production

View File

@ -12,6 +12,7 @@ import { AuthProvider, useAuth } from './context/AuthContext'
import DesktopSidebar from './components/DesktopSidebar' import DesktopSidebar from './components/DesktopSidebar'
import { getMainNavItems } from './config/appNav' import { getMainNavItems } from './config/appNav'
import LoginPage from './pages/LoginPage' import LoginPage from './pages/LoginPage'
import VerifyPage from './pages/VerifyPage'
import Dashboard from './pages/Dashboard' import Dashboard from './pages/Dashboard'
import AccountSettingsPage from './pages/AccountSettingsPage' import AccountSettingsPage from './pages/AccountSettingsPage'
import ExercisesListPage from './pages/ExercisesListPage' import ExercisesListPage from './pages/ExercisesListPage'
@ -133,6 +134,8 @@ function PublicRoute({ children }) {
function AppRoutes() { function AppRoutes() {
return ( return (
<Routes> <Routes>
<Route path="/verify" element={<VerifyPage />} />
<Route <Route
path="/login" path="/login"
element={ 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' import api from '../utils/api'
const AuthContext = createContext(null) const AuthContext = createContext(null)
@ -7,11 +7,7 @@ export function AuthProvider({ children }) {
const [user, setUser] = useState(null) const [user, setUser] = useState(null)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
useEffect(() => { const checkAuth = useCallback(async () => {
checkAuth()
}, [])
const checkAuth = async () => {
const token = localStorage.getItem('authToken') const token = localStorage.getItem('authToken')
if (!token) { if (!token) {
setLoading(false) setLoading(false)
@ -27,7 +23,11 @@ export function AuthProvider({ children }) {
} finally { } finally {
setLoading(false) setLoading(false)
} }
} }, [])
useEffect(() => {
checkAuth()
}, [checkAuth])
/** Fallback, falls ohne checkAuth gesetzt wird (Legacy / Token-Injektion) */ /** Fallback, falls ohne checkAuth gesetzt wird (Legacy / Token-Injektion) */
const login = (payload) => { const login = (payload) => {

View File

@ -13,6 +13,7 @@ function AccountSettingsPage() {
const [newPw1, setNewPw1] = useState('') const [newPw1, setNewPw1] = useState('')
const [newPw2, setNewPw2] = useState('') const [newPw2, setNewPw2] = useState('')
const [savingPw, setSavingPw] = useState(false) const [savingPw, setSavingPw] = useState(false)
const [resendingVerify, setResendingVerify] = useState(false)
const [message, setMessage] = useState('') const [message, setMessage] = useState('')
const [error, setError] = useState('') const [error, setError] = useState('')
@ -21,7 +22,12 @@ function AccountSettingsPage() {
setName(typeof user?.name === 'string' ? user.name : '') setName(typeof user?.name === 'string' ? user.name : '')
}, [user]) }, [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) => { const showOk = (text) => {
setMessage(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) => { const handleChangePassword = async (e) => {
e.preventDefault() e.preventDefault()
if (newPw1.length < 4) { if (newPw1.length < 4) {
@ -119,7 +139,7 @@ function AccountSettingsPage() {
<strong style={{ color: 'var(--text1)' }}>E-Mail</strong> <strong style={{ color: 'var(--text1)' }}>E-Mail</strong>
<br /> <br />
{user?.email || '—'}{' '} {user?.email || '—'}{' '}
{verified ? ( {emailExplicitlyVerified ? (
<span <span
style={{ style={{
marginLeft: '0.5rem', marginLeft: '0.5rem',
@ -146,6 +166,18 @@ function AccountSettingsPage() {
noch nicht bestätigt noch nicht bestätigt
</span> </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> </div>
<form onSubmit={handleSaveName}> <form onSubmit={handleSaveName}>

View File

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

View File

@ -11,6 +11,7 @@ function LoginPage() {
const [error, setError] = useState('') const [error, setError] = useState('')
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [success, setSuccess] = useState('') const [success, setSuccess] = useState('')
const [resending, setResending] = useState(false)
const navigate = useNavigate() const navigate = useNavigate()
const { checkAuth } = useAuth() const { checkAuth } = useAuth()
@ -29,7 +30,7 @@ function LoginPage() {
navigate('/') navigate('/')
} else { } else {
await api.register(email, password, name) 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') setMode('login')
setPassword('') 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 ( return (
<div className="login-container" style={{ <div className="login-container" style={{
minHeight: '100vh', minHeight: '100vh',
@ -59,15 +79,23 @@ function LoginPage() {
<div style={{ display: 'flex', gap: '0.5rem', marginBottom: '1.5rem' }}> <div style={{ display: 'flex', gap: '0.5rem', marginBottom: '1.5rem' }}>
<button <button
type="button"
className={mode === 'login' ? 'btn btn-primary' : 'btn btn-secondary'} className={mode === 'login' ? 'btn btn-primary' : 'btn btn-secondary'}
onClick={() => setMode('login')} onClick={() => {
setMode('login')
setError('')
}}
style={{ flex: 1 }} style={{ flex: 1 }}
> >
Login Login
</button> </button>
<button <button
type="button"
className={mode === 'register' ? 'btn btn-primary' : 'btn btn-secondary'} className={mode === 'register' ? 'btn btn-primary' : 'btn btn-secondary'}
onClick={() => setMode('register')} onClick={() => {
setMode('register')
setError('')
}}
style={{ flex: 1 }} style={{ flex: 1 }}
> >
Registrieren Registrieren
@ -147,6 +175,35 @@ function LoginPage() {
</button> </button>
</form> </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' }}> <p style={{ textAlign: 'center', color: 'var(--text3)', marginTop: '1.5rem', fontSize: '0.875rem' }}>
v0.1.0 Development v0.1.0 Development
</p> </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 = {}) { async function request(endpoint, options = {}) {
const token = localStorage.getItem('authToken') const token = localStorage.getItem('authToken')
const method = (options.method || 'GET').toUpperCase()
const headers = { 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) { 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 // Clubs & Groups
// ============================================================================ // ============================================================================
@ -881,6 +902,8 @@ export const api = {
getCurrentProfile, getCurrentProfile,
updateProfile, updateProfile,
changePassword, changePassword,
verifyEmail,
resendVerification,
// Clubs & Groups // Clubs & Groups
listClubs, listClubs,