feature: add password reset page
This commit is contained in:
parent
4ec041a49f
commit
57e0f4ecac
314
src/app/password-reset/page.tsx
Normal file
314
src/app/password-reset/page.tsx
Normal file
@ -0,0 +1,314 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useSearchParams, useRouter } from 'next/navigation'
|
||||
import PageLayout from '../components/PageLayout'
|
||||
|
||||
export default function PasswordResetPage() {
|
||||
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('')
|
||||
|
||||
// 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)) {
|
||||
setRequestError('Bitte eine gültige E-Mail eingeben.')
|
||||
return
|
||||
}
|
||||
setRequestError('')
|
||||
setRequestLoading(true)
|
||||
try {
|
||||
// TODO: call API endpoint: POST /auth/password-reset/request
|
||||
await new Promise(r => setTimeout(r, 1100))
|
||||
setRequestSuccess(true)
|
||||
} catch {
|
||||
setRequestError('Anfrage fehlgeschlagen. Bitte erneut versuchen.')
|
||||
} finally {
|
||||
setRequestLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleResetSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (resetLoading) return
|
||||
if (!validPassword(password)) {
|
||||
setResetError('Passwort erfüllt nicht die Anforderungen.')
|
||||
return
|
||||
}
|
||||
if (password !== confirmPassword) {
|
||||
setResetError('Passwörter stimmen nicht überein.')
|
||||
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)
|
||||
} catch {
|
||||
setResetError('Zurücksetzen fehlgeschlagen. Bitte erneut versuchen.')
|
||||
} finally {
|
||||
setResetLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const passwordHints = [
|
||||
{ label: 'Mindestens 8 Zeichen', pass: password.length >= 8 },
|
||||
{ label: 'Großbuchstabe (A-Z)', pass: /[A-Z]/.test(password) },
|
||||
{ label: 'Kleinbuchstabe (a-z)', pass: /[a-z]/.test(password) },
|
||||
{ label: 'Ziffer (0-9)', pass: /\d/.test(password) },
|
||||
{ label: 'Sonderzeichen (!@#$...)', pass: /[\W_]/.test(password) }
|
||||
]
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
<main className="relative flex flex-col flex-1 pt-20 sm:pt-28 pb-12 sm:pb-16 overflow-hidden">
|
||||
{/* Background Pattern */}
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="absolute inset-0 -z-10 h-full w-full stroke-white/10"
|
||||
>
|
||||
<defs>
|
||||
<pattern
|
||||
x="50%"
|
||||
y={-1}
|
||||
id="affiliate-pattern"
|
||||
width={200}
|
||||
height={200}
|
||||
patternUnits="userSpaceOnUse"
|
||||
>
|
||||
<path d="M.5 200V.5H200" fill="none" stroke="rgba(255,255,255,0.05)" />
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect fill="url(#affiliate-pattern)" width="100%" height="100%" strokeWidth={0} />
|
||||
</svg>
|
||||
{/* Colored Blur Effect */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="absolute top-0 right-0 left-1/2 -z-10 -ml-24 transform-gpu overflow-hidden blur-3xl lg:ml-24 xl:ml-48"
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
clipPath:
|
||||
'polygon(63.1% 29.5%, 100% 17.1%, 76.6% 3%, 48.4% 0%, 44.6% 4.7%, 54.5% 25.3%, 59.8% 49%, 55.2% 57.8%, 44.4% 57.2%, 27.8% 47.9%, 35.1% 81.5%, 0% 97.7%, 39.2% 100%, 35.2% 81.4%, 97.2% 52.8%, 63.1% 29.5%)',
|
||||
}}
|
||||
className="aspect-[801/1036] w-[50.0625rem] bg-gradient-to-tr from-[#ff80b5] to-[#9089fc] opacity-50"
|
||||
/>
|
||||
</div>
|
||||
{/* Gradient base */}
|
||||
<div className="absolute inset-0 -z-20 bg-gradient-to-b from-gray-900/95 via-gray-900/80 to-gray-900" />
|
||||
|
||||
{/* Widened container to match header */}
|
||||
<div className="mx-auto max-w-7xl px-6 lg:px-10 flex-1 flex flex-col w-full">
|
||||
<div className="mx-auto max-w-2xl text-center mb-10">
|
||||
<h1 className="text-4xl font-semibold tracking-tight text-white sm:text-5xl">
|
||||
Passwort zurücksetzen
|
||||
</h1>
|
||||
<p className="mt-3 text-gray-300 text-lg/7">
|
||||
{!token
|
||||
? 'Fordere einen Link zum Zurücksetzen deines Passworts an.'
|
||||
: 'Lege ein neues sicheres Passwort fest.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Wider form card */}
|
||||
<div className="mx-auto w-full max-w-3xl bg-white dark:bg-gray-900 rounded-2xl shadow-2xl ring-1 ring-gray-200 dark:ring-white/10 p-6 sm:p-10 md:py-12 md:px-14 relative overflow-hidden">
|
||||
<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">
|
||||
{!token && (
|
||||
<form onSubmit={handleRequestSubmit} className="space-y-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-900 dark:text-gray-100 mb-2" htmlFor="email">
|
||||
E-Mail-Adresse
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={e => { setEmail(e.target.value); setRequestError(''); setRequestSuccess(false)}}
|
||||
className="w-full rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-4 py-3 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
|
||||
placeholder="dein.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">
|
||||
E-Mail gesendet (falls Adresse existiert). Prüfe dein Postfach.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={requestLoading}
|
||||
className={`w-full flex items-center justify-center rounded-lg px-5 py-3 font-semibold text-white transition-colors ${
|
||||
requestLoading
|
||||
? 'bg-gray-400 cursor-wait'
|
||||
: 'bg-indigo-600 hover:bg-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 focus:ring-offset-white dark:focus:ring-offset-gray-900'
|
||||
}`}
|
||||
>
|
||||
{requestLoading ? (
|
||||
<>
|
||||
<span className="size-4 mr-2 rounded-full border-2 border-white border-b-transparent animate-spin" />
|
||||
Senden...
|
||||
</>
|
||||
) : (
|
||||
'Zurücksetzlink anfordern'
|
||||
)}
|
||||
</button>
|
||||
|
||||
<div className="text-center text-sm text-gray-600 dark:text-gray-400">
|
||||
Erinnerst du dich?{' '}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push('/login')}
|
||||
className="text-indigo-600 dark:text-indigo-400 hover:underline font-medium"
|
||||
>
|
||||
Zum 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 dark:text-gray-100 mb-2" htmlFor="password">
|
||||
Neues Passwort
|
||||
</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-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-4 py-3 pr-12 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
|
||||
placeholder="••••••••"
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(p => !p)}
|
||||
className="absolute inset-y-0 right-0 px-3 text-xs font-medium text-indigo-600 dark:text-indigo-400 hover:underline"
|
||||
>
|
||||
{showPassword ? 'Verbergen' : 'Anzeigen'}
|
||||
</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 dark:text-gray-100 mb-2" htmlFor="confirm">
|
||||
Passwort bestätigen
|
||||
</label>
|
||||
<input
|
||||
id="confirm"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={confirmPassword}
|
||||
onChange={e => { setConfirmPassword(e.target.value); setResetError(''); setResetSuccess(false)}}
|
||||
className="w-full rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-4 py-3 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
|
||||
placeholder="Bestätigung"
|
||||
required
|
||||
/>
|
||||
{confirmPassword && password !== confirmPassword && (
|
||||
<p className="mt-2 text-xs text-red-500">Passwörter stimmen nicht überein.</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">
|
||||
Passwort gespeichert. Weiterleitung zum Login...
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={resetLoading}
|
||||
className={`w-full flex items-center justify-center rounded-lg px-5 py-3 font-semibold text-white transition-colors ${
|
||||
resetLoading
|
||||
? 'bg-gray-400 cursor-wait'
|
||||
: 'bg-indigo-600 hover:bg-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 focus:ring-offset-white dark:focus:ring-offset-gray-900'
|
||||
}`}
|
||||
>
|
||||
{resetLoading ? (
|
||||
<>
|
||||
<span className="size-4 mr-2 rounded-full border-2 border-white border-b-transparent animate-spin" />
|
||||
Speichern...
|
||||
</>
|
||||
) : (
|
||||
'Neues Passwort setzen'
|
||||
)}
|
||||
</button>
|
||||
|
||||
<div className="text-center text-sm text-gray-600 dark:text-gray-400">
|
||||
Link abgelaufen?{' '}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push('/password-reset')}
|
||||
className="text-indigo-600 dark:text-indigo-400 hover:underline font-medium"
|
||||
>
|
||||
Erneut anfordern
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</PageLayout>
|
||||
)
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user