feat: resend verification email functionality
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:
parent
9fb6e27256
commit
f843d71d6b
|
|
@ -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."}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
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(err.message || 'Verifizierung fehlgeschlagen')
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user