369 lines
15 KiB
TypeScript
369 lines
15 KiB
TypeScript
'use client'
|
|
|
|
import { useState, useEffect, Suspense } from 'react' // CHANGED: add Suspense
|
|
import { useSearchParams, useRouter } from 'next/navigation'
|
|
import PageLayout from '../components/PageLayout'
|
|
import Waves from '../components/background/waves'
|
|
import { ToastProvider, useToast } from '../components/toast/toastComponent'
|
|
|
|
function PasswordResetPageInner() {
|
|
const searchParams = useSearchParams()
|
|
const router = useRouter()
|
|
const token = searchParams.get('token')
|
|
|
|
// Email request state
|
|
const [email, setEmail] = useState('')
|
|
const [requestLoading, setRequestLoading] = useState(false)
|
|
const [requestSuccess, setRequestSuccess] = useState(false)
|
|
const [requestError, setRequestError] = useState('')
|
|
|
|
// Reset-with-token state
|
|
const [password, setPassword] = useState('')
|
|
const [confirmPassword, setConfirmPassword] = useState('')
|
|
const [showPassword, setShowPassword] = useState(false)
|
|
const [resetLoading, setResetLoading] = useState(false)
|
|
const [resetSuccess, setResetSuccess] = useState(false)
|
|
const [resetError, setResetError] = useState('')
|
|
const { showToast } = useToast()
|
|
|
|
// Basic validators
|
|
const validEmail = (val: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(val)
|
|
const validPassword = (val: string) =>
|
|
/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[\W_]).{8,}$/.test(val)
|
|
|
|
// Auto-redirect after successful password reset
|
|
useEffect(() => {
|
|
if (resetSuccess) {
|
|
const t = setTimeout(() => router.push('/login'), 1800)
|
|
return () => clearTimeout(t)
|
|
}
|
|
}, [resetSuccess, router])
|
|
|
|
const handleRequestSubmit = async (e: React.FormEvent) => {
|
|
e.preventDefault()
|
|
if (requestLoading) return
|
|
if (!validEmail(email)) {
|
|
const msg = 'Please enter a valid email address.'
|
|
setRequestError(msg)
|
|
showToast({
|
|
variant: 'error',
|
|
title: 'Invalid email',
|
|
message: msg,
|
|
})
|
|
return
|
|
}
|
|
setRequestError('')
|
|
setRequestLoading(true)
|
|
try {
|
|
// TODO: call API endpoint: POST /auth/password-reset/request
|
|
await new Promise(r => setTimeout(r, 1100))
|
|
setRequestSuccess(true)
|
|
showToast({
|
|
variant: 'success',
|
|
title: 'Password reset email',
|
|
message: 'If this email exists, a reset link has been sent.',
|
|
})
|
|
} catch {
|
|
const msg = 'Request failed. Please try again.'
|
|
setRequestError(msg)
|
|
showToast({
|
|
variant: 'error',
|
|
title: 'Request failed',
|
|
message: msg,
|
|
})
|
|
} finally {
|
|
setRequestLoading(false)
|
|
}
|
|
}
|
|
|
|
const handleResetSubmit = async (e: React.FormEvent) => {
|
|
e.preventDefault()
|
|
if (resetLoading) return
|
|
if (!validPassword(password)) {
|
|
const msg = 'Password does not meet the requirements.'
|
|
setResetError(msg)
|
|
showToast({
|
|
variant: 'error',
|
|
title: 'Invalid password',
|
|
message: msg,
|
|
})
|
|
return
|
|
}
|
|
if (password !== confirmPassword) {
|
|
const msg = 'Passwords do not match.'
|
|
setResetError(msg)
|
|
showToast({
|
|
variant: 'error',
|
|
title: 'Passwords do not match',
|
|
message: msg,
|
|
})
|
|
return
|
|
}
|
|
setResetError('')
|
|
setResetLoading(true)
|
|
try {
|
|
// TODO: call API endpoint: POST /auth/password-reset/confirm { token, password }
|
|
await new Promise(r => setTimeout(r, 1200))
|
|
setResetSuccess(true)
|
|
showToast({
|
|
variant: 'success',
|
|
title: 'Password updated',
|
|
message: 'Your password has been changed. Redirecting to login...',
|
|
})
|
|
} catch {
|
|
const msg = 'Reset failed. Please try again.'
|
|
setResetError(msg)
|
|
showToast({
|
|
variant: 'error',
|
|
title: 'Reset failed',
|
|
message: msg,
|
|
})
|
|
} finally {
|
|
setResetLoading(false)
|
|
}
|
|
}
|
|
|
|
const passwordHints = [
|
|
{ label: 'At least 8 characters', pass: password.length >= 8 },
|
|
{ label: 'Uppercase letter (A-Z)', pass: /[A-Z]/.test(password) },
|
|
{ label: 'Lowercase letter (a-z)', pass: /[a-z]/.test(password) },
|
|
{ label: 'Number (0-9)', pass: /\d/.test(password) },
|
|
{ label: 'Special character (!@#$...)', pass: /[\W_]/.test(password) }
|
|
]
|
|
|
|
return (
|
|
<PageLayout>
|
|
<div
|
|
className="relative w-full flex flex-col min-h-screen overflow-hidden"
|
|
style={{ backgroundImage: 'none', background: 'none' }}
|
|
>
|
|
<Waves
|
|
className="pointer-events-none"
|
|
lineColor="#0f172a"
|
|
backgroundColor="rgba(245, 245, 240, 1)"
|
|
waveSpeedX={0.02}
|
|
waveSpeedY={0.01}
|
|
waveAmpX={40}
|
|
waveAmpY={20}
|
|
friction={0.9}
|
|
tension={0.01}
|
|
maxCursorMove={120}
|
|
xGap={12}
|
|
yGap={36}
|
|
/>
|
|
|
|
{/* push content a bit further down while still centering */}
|
|
<main className="relative z-10 flex flex-col flex-1 items-center justify-center pt-32 sm:pt-0 pb-8 sm:pb-10">
|
|
{/* Widened container to match header */}
|
|
<div className="mx-auto max-w-7xl px-6 lg:px-10 flex flex-col w-full">
|
|
{/* Translucent form card (matching login glass style) */}
|
|
<div
|
|
className="mx-auto w-full max-w-3xl rounded-3xl shadow-2xl relative border border-white/35 overflow-hidden p-6 sm:p-10 md:py-12 md:px-14"
|
|
style={{
|
|
backgroundColor: 'rgba(255,255,255,0.55)',
|
|
backdropFilter: 'blur(18px)',
|
|
WebkitBackdropFilter: 'blur(18px)',
|
|
boxShadow: '0 18px 45px rgba(15,23,42,0.45)',
|
|
}}
|
|
>
|
|
<div className="absolute -top-24 -right-24 size-56 rounded-full bg-gradient-to-br from-indigo-500/10 to-fuchsia-500/10 blur-3xl pointer-events-none" />
|
|
<div className="relative">
|
|
<div className="mx-auto max-w-2xl text-center mb-8">
|
|
<h1 className="text-3xl sm:text-4xl font-extrabold tracking-tight text-[#0F172A]">
|
|
Reset password
|
|
</h1>
|
|
<p className="mt-3 text-slate-700 text-base sm:text-lg">
|
|
{!token
|
|
? 'Request a link to reset your password.'
|
|
: 'Set a new secure password.'}
|
|
</p>
|
|
</div>
|
|
{!token && (
|
|
<form onSubmit={handleRequestSubmit} className="space-y-6">
|
|
<div>
|
|
<label className="block text-sm font-semibold text-[#0F172A] mb-2" htmlFor="email">
|
|
Email address
|
|
</label>
|
|
<input
|
|
id="email"
|
|
type="email"
|
|
value={email}
|
|
onChange={e => { setEmail(e.target.value); setRequestError(''); setRequestSuccess(false)}}
|
|
className="w-full rounded-xl border border-white/40 bg-white/60 px-4 py-3 text-[#0F172A] placeholder-slate-600/80 shadow-sm focus:outline-none focus:ring-2 focus:ring-[#8D6B1D]/80 focus:border-[#8D6B1D] transition"
|
|
placeholder="your.email@example.com"
|
|
required
|
|
/>
|
|
</div>
|
|
|
|
{requestError && (
|
|
<div className="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-600">
|
|
{requestError}
|
|
</div>
|
|
)}
|
|
|
|
{requestSuccess && (
|
|
<div className="rounded-lg border border-green-300 bg-green-50 px-4 py-3 text-sm text-green-700">
|
|
Email sent (if the address exists). Please check your inbox.
|
|
</div>
|
|
)}
|
|
|
|
<button
|
|
type="submit"
|
|
disabled={requestLoading}
|
|
className={`w-full flex items-center justify-center rounded-xl px-6 py-3 text-base font-semibold transition-all duration-200 transform hover:-translate-y-0.5 border ${
|
|
requestLoading
|
|
? 'border-white/30 bg-white/20 text-slate-300 cursor-wait'
|
|
: 'border-white/55 bg-white/30 text-[#0F172A] shadow-[0_10px_30px_rgba(15,23,42,0.45)] hover:bg-white/40 hover:shadow-[0_16px_40px_rgba(15,23,42,0.6)] focus:outline-none focus:ring-2 focus:ring-[#8D6B1D]/80'
|
|
}`}
|
|
>
|
|
{requestLoading ? (
|
|
<>
|
|
<span className="size-4 mr-2 rounded-full border-2 border-white border-b-transparent animate-spin" />
|
|
Senden...
|
|
</>
|
|
) : (
|
|
'Request reset link'
|
|
)}
|
|
</button>
|
|
|
|
<div className="text-center text-sm text-gray-700">
|
|
Remember it now?{' '}
|
|
<button
|
|
type="button"
|
|
onClick={() => router.push('/login')}
|
|
className="text-[#8D6B1D] hover:text-[#7A5E1A] hover:underline font-medium"
|
|
>
|
|
Back to login
|
|
</button>
|
|
</div>
|
|
</form>
|
|
)}
|
|
|
|
{token && (
|
|
<form onSubmit={handleResetSubmit} className="space-y-6">
|
|
<div className="grid gap-6 sm:grid-cols-2">
|
|
<div className="sm:col-span-2">
|
|
<label className="block text-sm font-medium text-gray-900 mb-2" htmlFor="password">
|
|
New password
|
|
</label>
|
|
<div className="relative">
|
|
<input
|
|
id="password"
|
|
type={showPassword ? 'text' : 'password'}
|
|
value={password}
|
|
onChange={e => { setPassword(e.target.value); setResetError(''); setResetSuccess(false)}}
|
|
className="w-full rounded-xl border border-white/40 bg-white/60 px-4 py-3 pr-12 text-[#0F172A] placeholder-slate-600/80 shadow-sm focus:outline-none focus:ring-2 focus:ring-[#8D6B1D]/80 focus:border-[#8D6B1D] transition"
|
|
placeholder="Your new password"
|
|
required
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowPassword(p => !p)}
|
|
className="absolute inset-y-0 right-0 px-3 text-xs font-medium text-indigo-700 hover:underline"
|
|
>
|
|
{showPassword ? 'Hide' : 'Show'}
|
|
</button>
|
|
</div>
|
|
<div className="mt-3 grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-2 text-xs">
|
|
{passwordHints.map(h => (
|
|
<div
|
|
key={h.label}
|
|
className={`flex items-center gap-2 ${
|
|
h.pass ? 'text-green-600' : 'text-gray-500'
|
|
}`}
|
|
>
|
|
<span className={`inline-block size-2 rounded-full ${h.pass ? 'bg-green-500' : 'bg-gray-400'}`} />
|
|
{h.label}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="sm:col-span-2">
|
|
<label className="block text-sm font-medium text-gray-900 mb-2" htmlFor="confirm">
|
|
Confirm password
|
|
</label>
|
|
<input
|
|
id="confirm"
|
|
type={showPassword ? 'text' : 'password'}
|
|
value={confirmPassword}
|
|
onChange={e => { setConfirmPassword(e.target.value); setResetError(''); setResetSuccess(false)}}
|
|
className="w-full rounded-xl border border-white/40 bg-white/60 px-4 py-3 text-[#0F172A] placeholder-slate-600/80 shadow-sm focus:outline-none focus:ring-2 focus:ring-[#8D6B1D]/80 focus:border-[#8D6B1D] transition"
|
|
placeholder="Confirm password"
|
|
required
|
|
/>
|
|
{confirmPassword && password !== confirmPassword && (
|
|
<p className="mt-2 text-xs text-red-500">Passwords do not match.</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{resetError && (
|
|
<div className="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-600">
|
|
{resetError}
|
|
</div>
|
|
)}
|
|
{resetSuccess && (
|
|
<div className="rounded-lg border border-green-300 bg-green-50 px-4 py-3 text-sm text-green-700">
|
|
Password saved. Redirecting to login...
|
|
</div>
|
|
)}
|
|
|
|
<button
|
|
type="submit"
|
|
disabled={resetLoading}
|
|
className={`w-full flex items-center justify-center rounded-xl px-6 py-3 text-base font-semibold transition-all duration-200 transform hover:-translate-y-0.5 border ${
|
|
resetLoading
|
|
? 'border-white/30 bg-white/20 text-slate-300 cursor-wait'
|
|
: 'border-white/55 bg-white/30 text-[#0F172A] shadow-[0_10px_30px_rgba(15,23,42,0.45)] hover:bg-white/40 hover:shadow-[0_16px_40px_rgba(15,23,42,0.6)] focus:outline-none focus:ring-2 focus:ring-[#8D6B1D]/80'
|
|
}`}
|
|
>
|
|
{resetLoading ? (
|
|
<>
|
|
<span className="size-4 mr-2 rounded-full border-2 border-white border-b-transparent animate-spin" />
|
|
Saving...
|
|
</>
|
|
) : (
|
|
'Set new password'
|
|
)}
|
|
</button>
|
|
|
|
<div className="text-center text-sm text-gray-600 dark:text-gray-400">
|
|
Link expired?{' '}
|
|
<button
|
|
type="button"
|
|
onClick={() => router.push('/password-reset')}
|
|
className="text-indigo-600 dark:text-indigo-400 hover:underline font-medium"
|
|
>
|
|
Request again
|
|
</button>
|
|
</div>
|
|
</form>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</main>
|
|
</div>
|
|
</PageLayout>
|
|
)
|
|
}
|
|
|
|
export default function PasswordResetPage() {
|
|
return (
|
|
<ToastProvider>
|
|
<Suspense
|
|
fallback={
|
|
<div className="min-h-screen flex items-center justify-center">
|
|
<div className="text-center">
|
|
<div className="animate-spin rounded-full h-10 w-10 border-b-2 border-[#8D6B1D] mx-auto mb-3" />
|
|
<p className="text-[#4A4A4A]">Loading...</p>
|
|
</div>
|
|
</div>
|
|
}
|
|
>
|
|
<PasswordResetPageInner />
|
|
</Suspense>
|
|
</ToastProvider>
|
|
)
|
|
} |