From c6569abe1af6dd549dc4f624b6ec1b162db54894 Mon Sep 17 00:00:00 2001 From: Lars Date: Wed, 29 Apr 2026 11:28:07 +0200 Subject: [PATCH 1/4] feat: enhance email configuration and registration logic - Added SMTP_SSL and SMTP_STARTTLS options to .env.example and docker-compose.yml for improved email sending configuration. - Updated password reset email logic to utilize a new send_email helper function with SSL and STARTTLS support. - Implemented role assignment for the first registered user or based on ADMIN_BOOTSTRAP_EMAILS during registration, enhancing user management capabilities. --- .env.example | 7 ++ backend/auth.py | 2 +- backend/routers/auth.py | 159 ++++++++++++++++++++++++---------------- docker-compose.yml | 6 ++ 4 files changed, 109 insertions(+), 65 deletions(-) diff --git a/.env.example b/.env.example index a5b4153..26b5726 100644 --- a/.env.example +++ b/.env.example @@ -15,6 +15,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 diff --git a/backend/auth.py b/backend/auth.py index e4acbea..a68cf09 100644 --- a/backend/auth.py +++ b/backend/auth.py @@ -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 diff --git a/backend/routers/auth.py b/backend/routers/auth.py index da49987..605e82a 100644 --- a/backend/routers/auth.py +++ b/backend/routers/auth.py @@ -6,6 +6,7 @@ Handles login, logout, password reset, and profile authentication. import os import secrets import smtplib +import ssl from typing import Optional from datetime import datetime, timedelta, timezone from email.mime.text import MIMEText @@ -123,38 +124,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 +161,89 @@ 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(token: str) -> str: + """Öffentlicher Link zur Bestätigung (FastAPI unter /api/auth).""" + return f"{_public_app_base()}/api/auth/verify/{token}" + + +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 +276,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 +288,28 @@ 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 +Nach dem Aufruf des Links bist du bestätigt (Browser zeigt eine kurze JSON-Antwort – das ist in Ordnung). + +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, @@ -366,34 +409,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."} diff --git a/docker-compose.yml b/docker-compose.yml index 859a61f..f20b602 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 From 2646bc776a65d1bd5180c58a279c3ab477882b2f Mon Sep 17 00:00:00 2001 From: Lars Date: Wed, 29 Apr 2026 11:37:54 +0200 Subject: [PATCH 2/4] feat: implement email verification flow and enhance user experience - Added EmailVerificationBanner component to notify users about unverified email status and provide a resend verification option. - Introduced VerifyPage for handling email verification via a token in the URL, including success and error handling. - Updated LoginPage and AccountSettingsPage to allow users to resend verification emails directly from these pages. - Enhanced API utility with new functions for verifying emails and resending verification requests. - Updated routing to include the new verification page and improved link structure for verification links. --- .env.example | 5 + backend/routers/auth.py | 7 +- frontend/src/App.jsx | 3 + .../components/EmailVerificationBanner.jsx | 76 ++++++++ frontend/src/context/AuthContext.jsx | 14 +- frontend/src/pages/AccountSettingsPage.jsx | 28 ++- frontend/src/pages/Dashboard.jsx | 2 + frontend/src/pages/LoginPage.jsx | 63 +++++- frontend/src/pages/VerifyPage.jsx | 182 ++++++++++++++++++ frontend/src/utils/api.js | 27 ++- 10 files changed, 390 insertions(+), 17 deletions(-) create mode 100644 frontend/src/components/EmailVerificationBanner.jsx create mode 100644 frontend/src/pages/VerifyPage.jsx diff --git a/.env.example b/.env.example index 26b5726..60dfe75 100644 --- a/.env.example +++ b/.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 DB_HOST=postgres DB_PORT=5432 diff --git a/backend/routers/auth.py b/backend/routers/auth.py index 605e82a..f9ee14e 100644 --- a/backend/routers/auth.py +++ b/backend/routers/auth.py @@ -7,6 +7,7 @@ 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 @@ -168,8 +169,8 @@ def _public_app_base() -> str: def verification_link(token: str) -> str: - """Öffentlicher Link zur Bestätigung (FastAPI unter /api/auth).""" - return f"{_public_app_base()}/api/auth/verify/{token}" + """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: @@ -303,8 +304,6 @@ Bitte bestätige deine E-Mail-Adresse, um die Registrierung abzuschließen: Der Link ist 24 Stunden gültig. -Nach dem Aufruf des Links bist du bestätigt (Browser zeigt eine kurze JSON-Antwort – das ist in Ordnung). - Dein Shinkan Jinkendo Team """ diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 1f42013..9675635 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -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 ( + } /> + { + 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 ( +
+
+ + 📧 + +
+
+ E-Mail noch nicht bestätigt +
+

+ Bitte prüfe dein Postfach und öffne den Bestätigungslink (auch Spam-Ordner). + {success && ( + Neue Mail wurde angefordert. + )} + {error && {error}} +

+
+ +
+
+ ) +} diff --git a/frontend/src/context/AuthContext.jsx b/frontend/src/context/AuthContext.jsx index 0015b28..db07aae 100644 --- a/frontend/src/context/AuthContext.jsx +++ b/frontend/src/context/AuthContext.jsx @@ -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) => { diff --git a/frontend/src/pages/AccountSettingsPage.jsx b/frontend/src/pages/AccountSettingsPage.jsx index b4044f6..0108f69 100644 --- a/frontend/src/pages/AccountSettingsPage.jsx +++ b/frontend/src/pages/AccountSettingsPage.jsx @@ -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('') @@ -54,7 +55,20 @@ function AccountSettingsPage() { } } - const handleChangePassword = async (e) => { + 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) + } + } + e.preventDefault() if (newPw1.length < 4) { showErr('Neues Passwort: mindestens 4 Zeichen.') @@ -146,6 +160,18 @@ function AccountSettingsPage() { noch nicht bestätigt )} + {!verified && user?.email ? ( +
+ +
+ ) : null}
diff --git a/frontend/src/pages/Dashboard.jsx b/frontend/src/pages/Dashboard.jsx index 7cfbff8..a0212eb 100644 --- a/frontend/src/pages/Dashboard.jsx +++ b/frontend/src/pages/Dashboard.jsx @@ -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() {

Willkommen, {user?.name || user?.email}!

+ {profile && } {/* Welcome Card */}

Willkommen bei Shinkan Jinkendo

diff --git a/frontend/src/pages/LoginPage.jsx b/frontend/src/pages/LoginPage.jsx index 5318609..c13dd81 100644 --- a/frontend/src/pages/LoginPage.jsx +++ b/frontend/src/pages/LoginPage.jsx @@ -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 (
+ {mode === 'login' && ( +
+ +

+ Nutzt die E-Mail-Adresse vom Formular — für noch nicht verifizierte Konten (Rate Limit 3 h). +

+
+ )}

v0.1.0 • Development

diff --git a/frontend/src/pages/VerifyPage.jsx b/frontend/src/pages/VerifyPage.jsx new file mode 100644 index 0000000..5bd53ed --- /dev/null +++ b/frontend/src/pages/VerifyPage.jsx @@ -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 ( +
+
+

E-Mail wird bestätigt…

+

Einen Moment bitte.

+
+ ) + } + + if (status === 'expired') { + return ( +
+
+

Link abgelaufen

+

+ {error || 'Bitte fordere eine neue Bestätigungs-E-Mail an.'} +

+ {resendSuccess ? ( +

E-Mail unterwegs — Postfach prüfen.

+ ) : ( + <> + + setEmail(e.target.value)} + autoComplete="email" + /> + {error && !resendSuccess && ( +

{error}

+ )} + + + )} +
+ +
+ ) + } + + if (status === 'already_verified') { + return ( +
+
+

Schon erledigt

+

+ {error || 'E-Mail bereits bestätigt — du kannst dich anmelden.'} +

+

Weiterleitung zum Login…

+
+
+ ) + } + + if (status === 'error') { + return ( +
+
+

Konnte nicht bestätigen

+

{error}

+ +
+
+ ) + } + + /* success */ + return ( +
+
+

+ Bestätigung erfolgreich +

+

Du wirst weitergeleitet…

+
+
+
+ ) +} diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js index 7e5281c..18912cb 100644 --- a/frontend/src/utils/api.js +++ b/frontend/src/utils/api.js @@ -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, From a748f4607d971442ac1a7cf6f13a92e095ee4dd5 Mon Sep 17 00:00:00 2001 From: Lars Date: Wed, 29 Apr 2026 11:48:04 +0200 Subject: [PATCH 3/4] refactor: improve email verification handling in components - Introduced a utility function to standardize email verification checks across EmailVerificationBanner and AccountSettingsPage. - Updated conditional logic to enhance clarity and maintainability regarding email verification status. - Ensured consistent treatment of various representations of email verification status in user profiles. --- frontend/src/components/EmailVerificationBanner.jsx | 8 +++++++- frontend/src/pages/AccountSettingsPage.jsx | 12 +++++++++--- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/frontend/src/components/EmailVerificationBanner.jsx b/frontend/src/components/EmailVerificationBanner.jsx index 7a67259..b1409ad 100644 --- a/frontend/src/components/EmailVerificationBanner.jsx +++ b/frontend/src/components/EmailVerificationBanner.jsx @@ -1,6 +1,12 @@ 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). */ @@ -9,7 +15,7 @@ export default function EmailVerificationBanner({ profile }) { const [success, setSuccess] = useState(false) const [error, setError] = useState('') - if (!profile?.email || profile.email_verified !== false) return null + if (!profile?.email || isEmailVerifiedRow(profile)) return null const handleResend = async () => { if (!profile.email) return diff --git a/frontend/src/pages/AccountSettingsPage.jsx b/frontend/src/pages/AccountSettingsPage.jsx index 0108f69..0edb2c0 100644 --- a/frontend/src/pages/AccountSettingsPage.jsx +++ b/frontend/src/pages/AccountSettingsPage.jsx @@ -22,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) @@ -69,6 +74,7 @@ function AccountSettingsPage() { } } + const handleChangePassword = async (e) => { e.preventDefault() if (newPw1.length < 4) { showErr('Neues Passwort: mindestens 4 Zeichen.') @@ -133,7 +139,7 @@ function AccountSettingsPage() { E-Mail
{user?.email || '—'}{' '} - {verified ? ( + {emailExplicitlyVerified ? ( )} - {!verified && user?.email ? ( + {!emailExplicitlyVerified && user?.email ? (