feat: cant spam email verify mail
This commit is contained in:
parent
604556ca06
commit
fb82536a09
@ -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
|
||||
|
||||
@ -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>
|
||||
Didn’t 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>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user