feat: implement email verification flow and enhance user experience
Some checks failed
Deploy Development / deploy (push) Failing after 12s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Failing after 1s
Test Suite / playwright-tests (push) Has been skipped

- 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.
This commit is contained in:
Lars 2026-04-29 11:37:54 +02:00
parent c6569abe1a
commit 2646bc776a
10 changed files with 390 additions and 17 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

View File

@ -7,6 +7,7 @@ import os
import secrets import secrets
import smtplib import smtplib
import ssl 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
@ -168,8 +169,8 @@ def _public_app_base() -> str:
def verification_link(token: str) -> str: def verification_link(token: str) -> str:
"""Öffentlicher Link zur Bestätigung (FastAPI unter /api/auth).""" """Link zur Web-App (`/verify?token=`); die SPA ruft wie bei Mitai die API auf."""
return f"{_public_app_base()}/api/auth/verify/{token}" return f"{_public_app_base()}/verify?token={quote(token, safe='')}"
def registration_role(cur, email_lower: str) -> str: 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. 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 Dein Shinkan Jinkendo Team
""" """

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,76 @@
import { useState } from 'react'
import api from '../utils/api'
/**
* 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 || profile.email_verified !== false) 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('')
@ -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() e.preventDefault()
if (newPw1.length < 4) { if (newPw1.length < 4) {
showErr('Neues Passwort: mindestens 4 Zeichen.') showErr('Neues Passwort: mindestens 4 Zeichen.')
@ -146,6 +160,18 @@ function AccountSettingsPage() {
noch nicht bestätigt noch nicht bestätigt
</span> </span>
)} )}
{!verified && 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,