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 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 (
<PageLayout>
<div className="relative min-h-screen w-full px-3 sm:px-4 py-10">
@ -231,18 +257,28 @@ export default function QuickActionDashboardPage() {
</div>
<div className="grid gap-4 sm:gap-6 grid-cols-1 md:grid-cols-2 xl:grid-cols-4">
{/* Email Verification */}
<button
onClick={handleVerifyEmail}
disabled={emailVerified}
className={`relative flex flex-col items-center justify-center rounded-lg px-4 py-5 text-center border font-medium text-sm transition-all ${
emailVerified
? 'bg-emerald-50 border-emerald-100 text-emerald-600 cursor-default'
: 'bg-blue-600 hover:bg-blue-500 text-white border-blue-600 shadow'
}`}
>
<EnvelopeOpenIcon className="h-6 w-6 mb-2" />
{emailVerified ? 'Email Verified' : 'Verify Email'}
</button>
<div className="flex flex-col">
<button
onClick={handleVerifyEmail}
disabled={emailVerified}
className={`relative flex flex-col items-center justify-center rounded-lg px-4 py-5 text-center border font-medium text-sm transition-all ${
emailVerified
? 'bg-emerald-50 border-emerald-100 text-emerald-600 cursor-default'
: 'bg-blue-600 hover:bg-blue-500 text-white border-blue-600 shadow'
}`}
>
<EnvelopeOpenIcon className="h-6 w-6 mb-2" />
{emailVerified ? 'Email Verified' : 'Verify Email'}
</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 */}
<button

View File

@ -4,6 +4,7 @@ import { useState, useEffect, useCallback, useRef } from 'react'
import PageLayout from '../../components/PageLayout'
import useAuthStore from '../../store/authStore'
import { useUserStatus } from '../../hooks/useUserStatus'
import { useRouter } from 'next/navigation' // NEW
export default function EmailVerifyPage() {
const user = useAuthStore(s => s.user)
@ -16,16 +17,39 @@ export default function EmailVerifyPage() {
const [resendCooldown, setResendCooldown] = useState(0)
const [initialEmailSent, setInitialEmailSent] = useState(false)
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(() => {
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 () => {
// Set the ref immediately to prevent race conditions
emailSentRef.current = true
try {
const response = await fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL}/api/send-verification-email`, {
method: 'POST',
@ -34,26 +58,23 @@ export default function EmailVerifyPage() {
'Content-Type': 'application/json'
}
})
const data = await response.json()
if (response.ok && data.success) {
setInitialEmailSent(true)
setResendCooldown(30) // Start cooldown after initial send
setLastSentAt(Date.now(), user?.email)
setResendCooldown(Math.ceil(RESEND_INTERVAL_MS / 1000))
} else {
console.error('Failed to send initial verification email:', data.message)
// Reset ref on failure so it can be retried
console.error('Failed to send initial verification email:', data?.message)
emailSentRef.current = false
}
} catch (error) {
console.error('Error sending initial verification email:', error)
// Reset ref on failure so it can be retried
emailSentRef.current = false
}
}
sendInitialEmail()
}, [token, initialEmailSent])
}, [token, user])
// Cooldown timer
useEffect(() => {
@ -64,27 +85,85 @@ export default function EmailVerifyPage() {
return () => clearInterval(t)
}, [resendCooldown])
// Replace handleChange to support multi-digit input distribution
const handleChange = (idx: number, val: string) => {
if (!/^\d?$/.test(val)) return
const digits = (val || '').replace(/\D/g, '')
if (digits.length === 0) {
const next = [...code]
next[idx] = ''
setCode(next)
setError('')
return
}
// Distribute digits across fields starting from idx
const next = [...code]
next[idx] = val
let i = idx
for (const ch of digits) {
if (i > 5) break
next[i] = ch
i++
}
setCode(next)
setError('')
if (val && idx < 5) {
inputsRef.current[idx + 1]?.focus()
// 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>) => {
if (e.key === 'Backspace' && !code[idx] && idx > 0) {
const prev = idx - 1
inputsRef.current[prev]?.focus()
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
next[prev] = ''
setCode(next)
setError('')
inputsRef.current[prev]?.focus()
}
return
}
if (e.key === 'ArrowLeft' && idx > 0) {
e.preventDefault()
inputsRef.current[idx - 1]?.focus()
return
}
if (e.key === 'ArrowRight' && idx < 5) {
e.preventDefault()
inputsRef.current[idx + 1]?.focus()
return
}
}
@ -93,11 +172,11 @@ export default function EmailVerifyPage() {
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (fullCode.length !== 6) {
setError('Bitte 6-stelligen Code eingeben.')
setError('Please enter the 6-digit code.')
return
}
if (!token) {
setError('Nicht authentifiziert. Bitte erneut einloggen.')
setError('Not authenticated. Please log in again.')
return
}
@ -123,20 +202,31 @@ export default function EmailVerifyPage() {
window.location.href = '/quickaction-dashboard'
}, 2000)
} else {
setError(data.error || 'Verifizierung fehlgeschlagen. Bitte erneut versuchen.')
setError(data.error || 'Verification failed. Please try again.')
}
} catch (error) {
console.error('Email verification error:', error)
setError('Netzwerkfehler. Bitte erneut versuchen.')
setError('Network error. Please try again.')
} finally {
setSubmitting(false)
}
}
// Resend (respect 10min interval even across navigation)
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('')
try {
const response = await fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL}/api/send-verification-email`, {
method: 'POST',
@ -149,15 +239,24 @@ export default function EmailVerifyPage() {
const data = await response.json()
if (response.ok && data.success) {
setResendCooldown(30)
setLastSentAt(Date.now(), user?.email)
setResendCooldown(Math.ceil(RESEND_INTERVAL_MS / 1000))
if (!initialEmailSent) setInitialEmailSent(true)
} else {
setError(data.message || 'Fehler beim Senden der E-Mail.')
setError(data?.message || 'Error sending the email.')
}
} catch (error) {
console.error('Resend email error:', error)
setError('Netzwerkfehler beim Senden der E-Mail.')
setError('Network error while sending the email.')
}
}, [resendCooldown, token])
}, [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')}`
}
return (
<PageLayout>
@ -202,22 +301,22 @@ export default function EmailVerifyPage() {
<div className="max-w-xl mx-auto">
<div className="text-center mb-10">
<h1 className="text-3xl sm:text-4xl font-semibold tracking-tight text-white">
E-Mail verifizieren
Verify your email
</h1>
<p className="mt-3 text-gray-300 text-sm sm:text-base">
{initialEmailSent ? (
<>
Wir haben einen 6-stelligen Code an{' '}
We sent a 6-digit code to{' '}
<span className="text-indigo-300 font-medium">
{user?.email || 'deine E-Mail'}
</span>{' '}
gesendet. Gib ihn unten ein.
{user?.email || 'your email'}
</span>
. Enter it below.
</>
) : (
<>
E-Mail wird gesendet an{' '}
Sending verification email to{' '}
<span className="text-indigo-300 font-medium">
{user?.email || 'deine E-Mail'}
{user?.email || 'your email'}
</span>
...
</>
@ -225,7 +324,7 @@ export default function EmailVerifyPage() {
</p>
</div>
{/* Card */}
{/* Card */}
<form
onSubmit={handleSubmit}
className="bg-white/95 dark:bg-gray-900/95 backdrop-blur rounded-2xl shadow-2xl ring-1 ring-black/10 dark:ring-white/10 px-6 py-8 sm:px-10 sm:py-10"
@ -237,12 +336,13 @@ export default function EmailVerifyPage() {
key={i}
ref={el => { inputsRef.current[i] = el }}
inputMode="numeric"
aria-label={`Code Ziffer ${i + 1}`}
aria-label={`Code digit ${i + 1}`}
autoComplete="one-time-code"
maxLength={1}
value={v}
onChange={e => handleChange(i, e.target.value)}
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
${v
? '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 && (
<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>
)}
@ -272,9 +372,9 @@ export default function EmailVerifyPage() {
{submitting ? (
<>
<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
@ -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"
>
{resendCooldown
? `Erneut senden in ${resendCooldown}s`
: 'Code erneut senden'}
? `Resend in ${formatMmSs(resendCooldown)}`
: '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>
</div>
</fieldset>
{/* Helper text with validity + spam/junk reminder + support */}
<div className="mt-8 text-center text-xs text-gray-500 dark:text-gray-400">
Probleme? Prüfe deinen Spam-Ordner oder{' '}
<span className="text-indigo-600 dark:text-indigo-400 font-medium">
kontaktiere den Support
</span>
Didnt receive the email? Please check your junk/spam folder. Still having issues?{' '}
<a href="mailto:test@test.com" className="text-indigo-600 dark:text-indigo-400 hover:underline">
Contact support
</a>
.
</div>
</form>