Merge pull request 'bug fix' (#3) from develop into main
Reviewed-on: #3
This commit is contained in:
commit
38d78abc3b
12
.env.example
12
.env.example
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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."}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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={
|
||||||
|
|
|
||||||
82
frontend/src/components/EmailVerificationBanner.jsx
Normal file
82
frontend/src/components/EmailVerificationBanner.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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) => {
|
||||||
|
|
|
||||||
|
|
@ -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}>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 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>
|
||||||
|
|
|
||||||
182
frontend/src/pages/VerifyPage.jsx
Normal file
182
frontend/src/pages/VerifyPage.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user