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,