feat: cant spam email verify mail

This commit is contained in:
DeathKaioken 2025-10-16 09:28:17 +02:00
parent 604556ca06
commit fb82536a09
2 changed files with 209 additions and 61 deletions

View File

@ -96,6 +96,32 @@ export default function QuickActionDashboardPage() {
const canCompleteInfo = emailVerified && idUploaded const canCompleteInfo = emailVerified && idUploaded
const canSignContract = emailVerified && idUploaded && additionalInfo const canSignContract = emailVerified && idUploaded && additionalInfo
// NEW: resend cooldown tracking (10 minutes like verify page)
const RESEND_INTERVAL_MS = 10 * 60 * 1000
const getStorageKey = (email?: string | null) => `emailVerify:lastSent:${email || 'anon'}`
const getLastSentAt = (email?: string | null) => {
if (typeof window === 'undefined') return 0
try { return parseInt(localStorage.getItem(getStorageKey(email)) || '0', 10) || 0 } catch { return 0 }
}
const [resendRemainingSec, setResendRemainingSec] = useState(0)
const formatMmSs = (total: number) => {
const m = Math.floor(total / 60)
const s = total % 60
return `${m}:${String(s).padStart(2, '0')}`
}
useEffect(() => {
if (!isClient || emailVerified) return
const last = getLastSentAt(user?.email)
const remainingMs = Math.max(0, RESEND_INTERVAL_MS - (Date.now() - last))
setResendRemainingSec(Math.ceil(remainingMs / 1000))
if (remainingMs <= 0) return
const id = setInterval(() => {
setResendRemainingSec(s => (s > 0 ? s - 1 : 0))
}, 1000)
return () => clearInterval(id)
}, [isClient, emailVerified, user?.email])
return ( return (
<PageLayout> <PageLayout>
<div className="relative min-h-screen w-full px-3 sm:px-4 py-10"> <div className="relative min-h-screen w-full px-3 sm:px-4 py-10">
@ -231,6 +257,7 @@ export default function QuickActionDashboardPage() {
</div> </div>
<div className="grid gap-4 sm:gap-6 grid-cols-1 md:grid-cols-2 xl:grid-cols-4"> <div className="grid gap-4 sm:gap-6 grid-cols-1 md:grid-cols-2 xl:grid-cols-4">
{/* Email Verification */} {/* Email Verification */}
<div className="flex flex-col">
<button <button
onClick={handleVerifyEmail} onClick={handleVerifyEmail}
disabled={emailVerified} disabled={emailVerified}
@ -243,6 +270,15 @@ export default function QuickActionDashboardPage() {
<EnvelopeOpenIcon className="h-6 w-6 mb-2" /> <EnvelopeOpenIcon className="h-6 w-6 mb-2" />
{emailVerified ? 'Email Verified' : 'Verify Email'} {emailVerified ? 'Email Verified' : 'Verify Email'}
</button> </button>
{/* NEW: resend feedback (only when not verified) */}
{!emailVerified && (
<p className="mt-2 text-[11px] text-[#112c55] text-center">
{resendRemainingSec > 0
? `Resend available in ${formatMmSs(resendRemainingSec)}`
: 'You can request a new code now'}
</p>
)}
</div>
{/* ID Upload */} {/* ID Upload */}
<button <button

View File

@ -4,6 +4,7 @@ import { useState, useEffect, useCallback, useRef } from 'react'
import PageLayout from '../../components/PageLayout' import PageLayout from '../../components/PageLayout'
import useAuthStore from '../../store/authStore' import useAuthStore from '../../store/authStore'
import { useUserStatus } from '../../hooks/useUserStatus' import { useUserStatus } from '../../hooks/useUserStatus'
import { useRouter } from 'next/navigation' // NEW
export default function EmailVerifyPage() { export default function EmailVerifyPage() {
const user = useAuthStore(s => s.user) const user = useAuthStore(s => s.user)
@ -16,16 +17,39 @@ export default function EmailVerifyPage() {
const [resendCooldown, setResendCooldown] = useState(0) const [resendCooldown, setResendCooldown] = useState(0)
const [initialEmailSent, setInitialEmailSent] = useState(false) const [initialEmailSent, setInitialEmailSent] = useState(false)
const inputsRef = useRef<Array<HTMLInputElement | null>>([]) const inputsRef = useRef<Array<HTMLInputElement | null>>([])
const emailSentRef = useRef(false) // Prevent double email sending const emailSentRef = useRef(false)
const router = useRouter() // NEW
// Send verification email automatically on page load // NEW: resend and validity windows
const RESEND_INTERVAL_MS = 10 * 60 * 1000 // 10 minutes
const CODE_VALIDITY_MS = 15 * 60 * 1000 // 15 minutes (informational)
// NEW: helpers to persist per-user last sent timestamp
const getStorageKey = (email?: string | null) => `emailVerify:lastSent:${email || 'anon'}`
const getLastSentAt = (email?: string | null) => {
try { return parseInt(localStorage.getItem(getStorageKey(email)) || '0', 10) || 0 } catch { return 0 }
}
const setLastSentAt = (ts: number, email?: string | null) => {
try { localStorage.setItem(getStorageKey(email), String(ts)) } catch {}
}
// Send verification email automatically on page load (respect cooldown)
useEffect(() => { useEffect(() => {
if (!token || initialEmailSent || emailSentRef.current) return if (!token || emailSentRef.current) return
const now = Date.now()
const last = getLastSentAt(user?.email)
const remaining = RESEND_INTERVAL_MS - (now - last)
if (last && remaining > 0) {
// Already sent recently: honor cooldown, don't resend
setInitialEmailSent(true)
setResendCooldown(Math.ceil(remaining / 1000))
return
}
const sendInitialEmail = async () => { const sendInitialEmail = async () => {
// Set the ref immediately to prevent race conditions
emailSentRef.current = true emailSentRef.current = true
try { try {
const response = await fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL}/api/send-verification-email`, { const response = await fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL}/api/send-verification-email`, {
method: 'POST', method: 'POST',
@ -34,26 +58,23 @@ export default function EmailVerifyPage() {
'Content-Type': 'application/json' 'Content-Type': 'application/json'
} }
}) })
const data = await response.json() const data = await response.json()
if (response.ok && data.success) { if (response.ok && data.success) {
setInitialEmailSent(true) setInitialEmailSent(true)
setResendCooldown(30) // Start cooldown after initial send setLastSentAt(Date.now(), user?.email)
setResendCooldown(Math.ceil(RESEND_INTERVAL_MS / 1000))
} else { } else {
console.error('Failed to send initial verification email:', data.message) console.error('Failed to send initial verification email:', data?.message)
// Reset ref on failure so it can be retried
emailSentRef.current = false emailSentRef.current = false
} }
} catch (error) { } catch (error) {
console.error('Error sending initial verification email:', error) console.error('Error sending initial verification email:', error)
// Reset ref on failure so it can be retried
emailSentRef.current = false emailSentRef.current = false
} }
} }
sendInitialEmail() sendInitialEmail()
}, [token, initialEmailSent]) }, [token, user])
// Cooldown timer // Cooldown timer
useEffect(() => { useEffect(() => {
@ -64,27 +85,85 @@ export default function EmailVerifyPage() {
return () => clearInterval(t) return () => clearInterval(t)
}, [resendCooldown]) }, [resendCooldown])
// Replace handleChange to support multi-digit input distribution
const handleChange = (idx: number, val: string) => { const handleChange = (idx: number, val: string) => {
if (!/^\d?$/.test(val)) return const digits = (val || '').replace(/\D/g, '')
if (digits.length === 0) {
const next = [...code] const next = [...code]
next[idx] = val next[idx] = ''
setCode(next) setCode(next)
setError('') setError('')
if (val && idx < 5) { return
inputsRef.current[idx + 1]?.focus()
} }
// Distribute digits across fields starting from idx
const next = [...code]
let i = idx
for (const ch of digits) {
if (i > 5) break
next[i] = ch
i++
}
setCode(next)
setError('')
// Focus next empty or last filled
const focusTo = Math.min(i, 5)
inputsRef.current[focusTo]?.focus()
}
// New: paste handler to fill all fields
const handlePaste = (idx: number, e: React.ClipboardEvent<HTMLInputElement>) => {
e.preventDefault()
const pasted = e.clipboardData.getData('text') || ''
const digits = pasted.replace(/\D/g, '').slice(0, 6)
if (!digits) return
const next = [...code]
let i = idx
for (const ch of digits) {
if (i > 5) break
next[i] = ch
i++
}
setCode(next)
// Move focus to last populated field
const focusTo = Math.min(i - 1, 5)
if (focusTo >= 0) inputsRef.current[focusTo]?.focus()
} }
const handleKeyDown = (idx: number, e: React.KeyboardEvent<HTMLInputElement>) => { const handleKeyDown = (idx: number, e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Backspace' && !code[idx] && idx > 0) { if (e.key === 'Backspace') {
e.preventDefault()
const next = [...code]
if (next[idx]) {
// If current has a value, clear it and stay
next[idx] = ''
setCode(next)
setError('')
inputsRef.current[idx]?.focus()
} else if (idx > 0) {
// If empty, move to previous and clear it
const prev = idx - 1 const prev = idx - 1
next[prev] = ''
setCode(next)
setError('')
inputsRef.current[prev]?.focus() inputsRef.current[prev]?.focus()
} }
if (e.key === 'ArrowLeft' && idx > 0) { return
inputsRef.current[idx - 1]?.focus()
} }
if (e.key === 'ArrowLeft' && idx > 0) {
e.preventDefault()
inputsRef.current[idx - 1]?.focus()
return
}
if (e.key === 'ArrowRight' && idx < 5) { if (e.key === 'ArrowRight' && idx < 5) {
e.preventDefault()
inputsRef.current[idx + 1]?.focus() inputsRef.current[idx + 1]?.focus()
return
} }
} }
@ -93,11 +172,11 @@ export default function EmailVerifyPage() {
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault() e.preventDefault()
if (fullCode.length !== 6) { if (fullCode.length !== 6) {
setError('Bitte 6-stelligen Code eingeben.') setError('Please enter the 6-digit code.')
return return
} }
if (!token) { if (!token) {
setError('Nicht authentifiziert. Bitte erneut einloggen.') setError('Not authenticated. Please log in again.')
return return
} }
@ -123,18 +202,29 @@ export default function EmailVerifyPage() {
window.location.href = '/quickaction-dashboard' window.location.href = '/quickaction-dashboard'
}, 2000) }, 2000)
} else { } else {
setError(data.error || 'Verifizierung fehlgeschlagen. Bitte erneut versuchen.') setError(data.error || 'Verification failed. Please try again.')
} }
} catch (error) { } catch (error) {
console.error('Email verification error:', error) console.error('Email verification error:', error)
setError('Netzwerkfehler. Bitte erneut versuchen.') setError('Network error. Please try again.')
} finally { } finally {
setSubmitting(false) setSubmitting(false)
} }
} }
// Resend (respect 10min interval even across navigation)
const handleResend = useCallback(async () => { const handleResend = useCallback(async () => {
if (resendCooldown || !token) return if (submitting || success) return
const now = Date.now()
const last = getLastSentAt(user?.email)
const remaining = RESEND_INTERVAL_MS - (now - last)
if (remaining > 0) {
setResendCooldown(Math.ceil(remaining / 1000))
return
}
if (!token) return
setError('') setError('')
try { try {
@ -149,15 +239,24 @@ export default function EmailVerifyPage() {
const data = await response.json() const data = await response.json()
if (response.ok && data.success) { if (response.ok && data.success) {
setResendCooldown(30) setLastSentAt(Date.now(), user?.email)
setResendCooldown(Math.ceil(RESEND_INTERVAL_MS / 1000))
if (!initialEmailSent) setInitialEmailSent(true)
} else { } else {
setError(data.message || 'Fehler beim Senden der E-Mail.') setError(data?.message || 'Error sending the email.')
} }
} catch (error) { } catch (error) {
console.error('Resend email error:', error) console.error('Resend email error:', error)
setError('Netzwerkfehler beim Senden der E-Mail.') setError('Network error while sending the email.')
}
}, [token, submitting, success, user, initialEmailSent])
// NEW: format seconds to m:ss
const formatMmSs = (total: number) => {
const m = Math.floor(total / 60)
const s = total % 60
return `${m}:${String(s).padStart(2, '0')}`
} }
}, [resendCooldown, token])
return ( return (
<PageLayout> <PageLayout>
@ -202,22 +301,22 @@ export default function EmailVerifyPage() {
<div className="max-w-xl mx-auto"> <div className="max-w-xl mx-auto">
<div className="text-center mb-10"> <div className="text-center mb-10">
<h1 className="text-3xl sm:text-4xl font-semibold tracking-tight text-white"> <h1 className="text-3xl sm:text-4xl font-semibold tracking-tight text-white">
E-Mail verifizieren Verify your email
</h1> </h1>
<p className="mt-3 text-gray-300 text-sm sm:text-base"> <p className="mt-3 text-gray-300 text-sm sm:text-base">
{initialEmailSent ? ( {initialEmailSent ? (
<> <>
Wir haben einen 6-stelligen Code an{' '} We sent a 6-digit code to{' '}
<span className="text-indigo-300 font-medium"> <span className="text-indigo-300 font-medium">
{user?.email || 'deine E-Mail'} {user?.email || 'your email'}
</span>{' '} </span>
gesendet. Gib ihn unten ein. . Enter it below.
</> </>
) : ( ) : (
<> <>
E-Mail wird gesendet an{' '} Sending verification email to{' '}
<span className="text-indigo-300 font-medium"> <span className="text-indigo-300 font-medium">
{user?.email || 'deine E-Mail'} {user?.email || 'your email'}
</span> </span>
... ...
</> </>
@ -237,12 +336,13 @@ export default function EmailVerifyPage() {
key={i} key={i}
ref={el => { inputsRef.current[i] = el }} ref={el => { inputsRef.current[i] = el }}
inputMode="numeric" inputMode="numeric"
aria-label={`Code Ziffer ${i + 1}`} aria-label={`Code digit ${i + 1}`}
autoComplete="one-time-code" autoComplete="one-time-code"
maxLength={1} maxLength={1}
value={v} value={v}
onChange={e => handleChange(i, e.target.value)} onChange={e => handleChange(i, e.target.value)}
onKeyDown={e => handleKeyDown(i, e)} onKeyDown={e => handleKeyDown(i, e)}
onPaste={e => handlePaste(i, e)}
className={`w-12 h-14 sm:w-14 sm:h-16 text-center text-2xl font-semibold rounded-lg border transition-colors outline-none className={`w-12 h-14 sm:w-14 sm:h-16 text-center text-2xl font-semibold rounded-lg border transition-colors outline-none
${v ${v
? 'border-indigo-500 ring-2 ring-indigo-400/40 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100' ? 'border-indigo-500 ring-2 ring-indigo-400/40 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100'
@ -260,7 +360,7 @@ export default function EmailVerifyPage() {
{success && ( {success && (
<div className="rounded-lg border border-green-300 bg-green-50 px-4 py-3 text-sm text-green-700"> <div className="rounded-lg border border-green-300 bg-green-50 px-4 py-3 text-sm text-green-700">
Verifiziert! Weiterleitung in Kürze... Verified! Redirecting shortly...
</div> </div>
)} )}
@ -272,9 +372,9 @@ export default function EmailVerifyPage() {
{submitting ? ( {submitting ? (
<> <>
<span className="size-4 mr-2 rounded-full border-2 border-white border-b-transparent animate-spin" /> <span className="size-4 mr-2 rounded-full border-2 border-white border-b-transparent animate-spin" />
Prüfe... Verifying...
</> </>
) : success ? 'Verifiziert' : 'Code bestätigen'} ) : success ? 'Verified' : 'Confirm code'}
</button> </button>
<button <button
@ -284,17 +384,29 @@ export default function EmailVerifyPage() {
className="text-sm font-medium text-indigo-600 dark:text-indigo-400 hover:underline disabled:text-gray-400 disabled:cursor-not-allowed" className="text-sm font-medium text-indigo-600 dark:text-indigo-400 hover:underline disabled:text-gray-400 disabled:cursor-not-allowed"
> >
{resendCooldown {resendCooldown
? `Erneut senden in ${resendCooldown}s` ? `Resend in ${formatMmSs(resendCooldown)}`
: 'Code erneut senden'} : 'Resend code'}
</button>
</div>
{/* NEW: Back to dashboard button */}
<div className="mt-1 text-center">
<button
type="button"
onClick={() => router.push('/quickaction-dashboard')}
className="text-sm font-medium text-gray-700 dark:text-gray-300 hover:underline"
>
Back to dashboard
</button> </button>
</div> </div>
</fieldset> </fieldset>
{/* Helper text with validity + spam/junk reminder + support */}
<div className="mt-8 text-center text-xs text-gray-500 dark:text-gray-400"> <div className="mt-8 text-center text-xs text-gray-500 dark:text-gray-400">
Probleme? Prüfe deinen Spam-Ordner oder{' '} Didnt receive the email? Please check your junk/spam folder. Still having issues?{' '}
<span className="text-indigo-600 dark:text-indigo-400 font-medium"> <a href="mailto:test@test.com" className="text-indigo-600 dark:text-indigo-400 hover:underline">
kontaktiere den Support Contact support
</span> </a>
. .
</div> </div>
</form> </form>