314 lines
14 KiB
TypeScript
314 lines
14 KiB
TypeScript
'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>
|
|
)
|
|
} |