feature: add password reset page

This commit is contained in:
DeathKaioken 2025-10-04 00:05:33 +02:00
parent 4ec041a49f
commit 57e0f4ecac

View 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>
)
}