feat: resend verification email functionality
All checks were successful
Deploy Development / deploy (push) Successful in 36s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 13s

Backend:
- Added POST /api/auth/resend-verification endpoint
- Rate limited to 3/hour to prevent abuse
- Generates new verification token (24h validity)
- Sends new verification email

Frontend:
- Verify.jsx: Added "expired" status with resend flow
- Email input + "Neue Bestätigungs-E-Mail senden" button
- EmailVerificationBanner: Added "Neue E-Mail senden" button
- Shows success/error feedback inline
- api.js: Added resendVerification() helper

User flows:
1. Expired token → Verify page shows resend form
2. Email lost → Dashboard banner has resend button
3. Both flows use same backend endpoint

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Lars 2026-03-21 10:23:38 +01:00
parent 9fb6e27256
commit f843d71d6b
4 changed files with 263 additions and 6 deletions

View File

@ -323,3 +323,74 @@ async def verify_email(token: str):
"email": prof['email']
}
}
@router.post("/resend-verification")
@limiter.limit("3/hour")
async def resend_verification(req: dict, request: Request):
"""Resend verification email for unverified account."""
email = req.get('email', '').strip().lower()
if not email:
raise HTTPException(400, "E-Mail-Adresse erforderlich")
with get_db() as conn:
cur = get_cursor(conn)
# Find profile by email
cur.execute("""
SELECT id, name, email, email_verified, verification_token, verification_expires
FROM profiles
WHERE email=%s
""", (email,))
prof = cur.fetchone()
if not prof:
# Don't leak info about existing emails
return {"ok": True, "message": "Falls ein Account mit dieser E-Mail existiert, wurde eine Bestätigungs-E-Mail versendet."}
if prof['email_verified']:
raise HTTPException(400, "E-Mail-Adresse bereits bestätigt")
# Generate new verification token
verification_token = secrets.token_urlsafe(32)
verification_expires = datetime.now(timezone.utc) + timedelta(hours=24)
cur.execute("""
UPDATE profiles
SET verification_token=%s, verification_expires=%s
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}"
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:
{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
"""
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")
return {"ok": True, "message": "Bestätigungs-E-Mail wurde erneut versendet."}

View File

@ -1,7 +1,33 @@
import { useState } from 'react'
import { api } from '../utils/api'
export default function EmailVerificationBanner({ profile }) {
const [resending, setResending] = useState(false)
const [success, setSuccess] = useState(false)
const [error, setError] = useState(null)
// Only show if email is not verified
if (!profile || profile.email_verified !== false) return null
const handleResend = async () => {
if (!profile.email) return
setResending(true)
setError(null)
setSuccess(false)
try {
await api.resendVerification(profile.email)
setSuccess(true)
setTimeout(() => setSuccess(false), 5000)
} catch (err) {
setError(err.message || 'Fehler beim Versenden')
setTimeout(() => setError(null), 5000)
} finally {
setResending(false)
}
}
return (
<div style={{
background: '#FFF4E6',
@ -11,7 +37,8 @@ export default function EmailVerificationBanner({ profile }) {
marginBottom: 20,
display: 'flex',
alignItems: 'center',
gap: 16
gap: 16,
flexWrap: 'wrap'
}}>
<div style={{
fontSize: 32,
@ -19,7 +46,7 @@ export default function EmailVerificationBanner({ profile }) {
}}>
📧
</div>
<div style={{flex: 1}}>
<div style={{flex: 1, minWidth: 200}}>
<div style={{
fontWeight: 700,
fontSize: 15,
@ -34,9 +61,36 @@ export default function EmailVerificationBanner({ profile }) {
lineHeight: 1.4
}}>
Bitte prüfe dein Postfach und klicke auf den Bestätigungslink.
Ohne Bestätigung ist dein Account eingeschränkt.
{success && (
<span style={{color:'var(--accent)', fontWeight:600, marginLeft:4}}>
Neue E-Mail versendet!
</span>
)}
{error && (
<span style={{color:'var(--danger)', fontWeight:600, marginLeft:4}}>
{error}
</span>
)}
</div>
</div>
<button
onClick={handleResend}
disabled={resending || success}
style={{
padding: '8px 16px',
borderRadius: 8,
background: success ? 'var(--accent)' : '#F59E0B',
color: 'white',
fontWeight: 600,
fontSize: 13,
border: 'none',
cursor: resending || success ? 'not-allowed' : 'pointer',
whiteSpace: 'nowrap',
opacity: resending || success ? 0.6 : 1
}}
>
{resending ? 'Sende...' : success ? '✓ Versendet' : 'Neue E-Mail senden'}
</button>
</div>
)
}

View File

@ -9,8 +9,11 @@ export default function Verify() {
const navigate = useNavigate()
const { login } = useAuth()
const [status, setStatus] = useState('loading') // loading | success | error
const [status, setStatus] = useState('loading') // loading | success | error | expired
const [error, setError] = useState(null)
const [email, setEmail] = useState('')
const [resending, setResending] = useState(false)
const [resendSuccess, setResendSuccess] = useState(false)
useEffect(() => {
const verify = async () => {
@ -37,14 +40,41 @@ export default function Verify() {
setError('Verifizierung erfolgreich, aber Login fehlgeschlagen')
}
} catch (err) {
setStatus('error')
setError(err.message || 'Verifizierung fehlgeschlagen')
const errorMsg = err.message || 'Verifizierung fehlgeschlagen'
// Check if token expired
if (errorMsg.includes('abgelaufen') || errorMsg.includes('expired')) {
setStatus('expired')
setError(errorMsg)
} else {
setStatus('error')
setError(errorMsg)
}
}
}
verify()
}, [token, login, 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 || 'E-Mail konnte nicht versendet werden')
} finally {
setResending(false)
}
}
if (status === 'loading') {
return (
<div style={{
@ -58,6 +88,95 @@ export default function Verify() {
)
}
if (status === 'expired') {
return (
<div style={{
maxWidth:420, margin:'0 auto', padding:'60px 20px',
textAlign:'center'
}}>
<div style={{
background:'#FFF4E6',
border:'2px solid #F59E0B',
borderRadius:16, padding:'40px 24px', marginBottom:24
}}>
<div style={{fontSize:48, marginBottom:16}}></div>
<h2 style={{marginBottom:12, color:'#D97706'}}>
Verifikations-Link abgelaufen
</h2>
<p style={{color:'var(--text2)', lineHeight:1.6, marginBottom:24}}>
Dieser Link ist leider nicht mehr gültig. Bitte fordere eine neue Bestätigungs-E-Mail an.
</p>
{resendSuccess ? (
<div style={{
background:'var(--accent-light)',
border:'1px solid var(--accent)',
borderRadius:8, padding:'16px', marginBottom:16
}}>
<div style={{fontSize:32, marginBottom:8}}></div>
<div style={{fontWeight:600, color:'var(--accent-dark)', marginBottom:4}}>
E-Mail versendet!
</div>
<p style={{fontSize:13, color:'var(--text2)', margin:0}}>
Bitte prüfe dein Postfach.
</p>
</div>
) : (
<>
<input
type="email"
className="form-input"
placeholder="deine@email.de"
value={email}
onChange={e => setEmail(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleResend()}
style={{width:'100%', boxSizing:'border-box', marginBottom:12}}
autoFocus
/>
{error && (
<div style={{
background:'rgba(216,90,48,0.1)',
color:'#D85A30',
fontSize:13,
padding:'10px 14px',
borderRadius:8,
marginBottom:12,
border:'1px solid rgba(216,90,48,0.2)'
}}>
{error}
</div>
)}
<button
onClick={handleResend}
disabled={resending || !email.trim()}
className="btn btn-primary btn-full"
style={{marginBottom:12}}
>
{resending ? (
<><div className="spinner" style={{width:14,height:14}}/> Sende...</>
) : (
'Neue Bestätigungs-E-Mail senden'
)}
</button>
</>
)}
</div>
<button
onClick={() => navigate('/login')}
style={{
background:'none', border:'none', cursor:'pointer',
fontSize:13, color:'var(--text3)', textDecoration:'underline'
}}
>
Zurück zum Login
</button>
</div>
)
}
if (status === 'error') {
return (
<div style={{
@ -81,9 +200,21 @@ export default function Verify() {
<button
onClick={() => navigate('/register')}
className="btn btn-primary btn-full"
style={{marginBottom:12}}
>
Zur Registrierung
</button>
<button
onClick={() => navigate('/login')}
style={{
background:'none', border:'none', cursor:'pointer',
fontSize:13, color:'var(--text3)', textDecoration:'underline',
width:'100%'
}}
>
Zum Login
</button>
</div>
)
}

View File

@ -144,6 +144,7 @@ export const api = {
changePin: (pin) => req('/auth/pin',json({pin})),
register: (name,email,password) => req('/auth/register',json({name,email,password})),
verifyEmail: (token) => req(`/auth/verify/${token}`),
resendVerification: (email) => req('/auth/resend-verification',json({email})),
// v9c Subscription System
// User-facing