334 lines
13 KiB
TypeScript
334 lines
13 KiB
TypeScript
'use client';
|
|
|
|
import { FormEvent, useState } from 'react';
|
|
import Link from 'next/link';
|
|
import { SegmentedOtpInput } from './segmented-otp-input';
|
|
|
|
export function RecoveryForm({ initialToken = '' }: { initialToken?: string }) {
|
|
const [step, setStep] = useState<'request' | 'reset'>(initialToken ? 'reset' : 'request');
|
|
const [email, setEmail] = useState('');
|
|
const [token, setToken] = useState(initialToken);
|
|
const [password, setPassword] = useState('');
|
|
const [confirmPassword, setConfirmPassword] = useState('');
|
|
const [message, setMessage] = useState('');
|
|
const [isSuccess, setIsSuccess] = useState(false);
|
|
const [busy, setBusy] = useState(false);
|
|
const [isLongToken, setIsLongToken] = useState(initialToken.length > 6);
|
|
const [showPassword, setShowPassword] = useState(false);
|
|
|
|
async function submitRequest(event: FormEvent) {
|
|
event.preventDefault();
|
|
if (!email.trim()) return;
|
|
|
|
setBusy(true);
|
|
setMessage('');
|
|
setIsSuccess(false);
|
|
|
|
try {
|
|
const response = await fetch('/api/auth/password-reset-request', {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify({ email: email.trim() }),
|
|
});
|
|
const body = await response.json().catch(() => ({}));
|
|
if (!response.ok) {
|
|
throw new Error(body.detail ?? 'Unable to request password recovery.');
|
|
}
|
|
setIsSuccess(true);
|
|
setMessage('Encrypted recovery code dispatched to your registered email address.');
|
|
setStep('reset');
|
|
} catch (error) {
|
|
setIsSuccess(false);
|
|
setMessage(error instanceof Error ? error.message : 'Unable to request reset.');
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}
|
|
|
|
async function submitReset(event: FormEvent) {
|
|
event.preventDefault();
|
|
if (!token.trim()) {
|
|
setMessage('Please enter your 6-digit verification code or token.');
|
|
return;
|
|
}
|
|
if (password.length < 12) {
|
|
setMessage('Password must be at least 12 characters long.');
|
|
return;
|
|
}
|
|
if (password !== confirmPassword) {
|
|
setMessage('Passwords do not match.');
|
|
return;
|
|
}
|
|
|
|
setBusy(true);
|
|
setMessage('');
|
|
setIsSuccess(false);
|
|
|
|
try {
|
|
const response = await fetch('/api/auth/password-reset', {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify({ token: token.trim(), password }),
|
|
});
|
|
const body = await response.json().catch(() => ({}));
|
|
if (!response.ok) {
|
|
throw new Error(body.detail ?? 'Unable to reset password.');
|
|
}
|
|
setIsSuccess(true);
|
|
setMessage('Password reset complete. Your credentials have been re-encrypted. You can now sign in.');
|
|
setToken('');
|
|
setPassword('');
|
|
setConfirmPassword('');
|
|
} catch (error) {
|
|
setIsSuccess(false);
|
|
setMessage(error instanceof Error ? error.message : 'Unable to reset password.');
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* Header section matching Stitch specification */}
|
|
<div className="flex flex-col items-center text-center space-y-2">
|
|
<div className="w-14 h-14 rounded-2xl bg-[#00f2fe]/10 border border-[#00f2fe]/30 flex items-center justify-center text-[#00f2fe] shadow-[0_0_20px_rgba(0,242,254,0.25)]">
|
|
<svg
|
|
className="w-7 h-7"
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
|
|
/>
|
|
</svg>
|
|
</div>
|
|
<h2 className="text-xl sm:text-2xl font-black tracking-wide text-white font-mono uppercase">
|
|
Recover Account Access
|
|
</h2>
|
|
<p className="text-xs sm:text-sm text-[#b9cacb] max-w-sm">
|
|
{step === 'request'
|
|
? 'Enter your registered email address to receive an encrypted 6-digit cryptographic verification code.'
|
|
: 'Enter your 6-digit verification code and specify a new secure master password.'}
|
|
</p>
|
|
</div>
|
|
|
|
{step === 'request' ? (
|
|
/* STEP 1: Enter Email */
|
|
<form onSubmit={submitRequest} className="space-y-5">
|
|
<div className="space-y-1.5 text-left">
|
|
<label className="block text-xs font-mono text-[#b9cacb] uppercase tracking-wider">
|
|
Email Address
|
|
</label>
|
|
<div className="relative">
|
|
<div className="absolute inset-y-0 left-0 pl-3.5 flex items-center pointer-events-none text-[#849495]">
|
|
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth="1.75"
|
|
d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
|
|
/>
|
|
</svg>
|
|
</div>
|
|
<input
|
|
required
|
|
type="email"
|
|
autoComplete="email"
|
|
placeholder="rider@example.com"
|
|
value={email}
|
|
onChange={(e) => setEmail(e.target.value)}
|
|
className="w-full pl-11 pr-4 py-3 bg-[#070a0f] border border-[#262a33] rounded-xl text-white font-mono text-sm focus:border-[#00f2fe] focus:ring-1 focus:ring-[#00f2fe] outline-none transition-all"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<button
|
|
type="submit"
|
|
disabled={busy || !email.trim()}
|
|
className="w-full py-3.5 px-6 rounded-xl font-mono text-sm font-bold tracking-wider uppercase transition-all duration-200
|
|
bg-[#00f2fe] text-[#002022] hover:bg-[#6ff6ff] hover:shadow-[0_0_24px_rgba(0,242,254,0.4)]
|
|
disabled:opacity-40 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
|
>
|
|
{busy ? (
|
|
<>
|
|
<span className="w-4 h-4 border-2 border-[#002022] border-t-transparent rounded-full animate-spin"></span>
|
|
<span>Generating Cryptographic Code...</span>
|
|
</>
|
|
) : (
|
|
'Send Recovery Code'
|
|
)}
|
|
</button>
|
|
|
|
<div className="pt-2 text-center">
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setStep('reset');
|
|
setMessage('');
|
|
}}
|
|
className="text-xs font-mono text-[#00dce6] hover:text-[#6ff6ff] underline underline-offset-4"
|
|
>
|
|
Already have a 6-digit code or reset link?
|
|
</button>
|
|
</div>
|
|
|
|
{message && (
|
|
<div
|
|
role="status"
|
|
className={`p-3.5 rounded-xl text-xs sm:text-sm font-mono border ${
|
|
isSuccess
|
|
? 'bg-[#60ff98]/10 border-[#60ff98]/40 text-[#60ff98]'
|
|
: 'bg-[#ffb4ab]/10 border-[#ffb4ab]/40 text-[#ffb4ab]'
|
|
}`}
|
|
>
|
|
{message}
|
|
</div>
|
|
)}
|
|
</form>
|
|
) : (
|
|
/* STEP 2: Enter Code & New Password */
|
|
<form onSubmit={submitReset} className="space-y-5">
|
|
{isLongToken ? (
|
|
<div className="space-y-1.5 text-left">
|
|
<label className="block text-xs font-mono text-[#b9cacb] uppercase tracking-wider">
|
|
Reset Token
|
|
</label>
|
|
<input
|
|
required
|
|
type="text"
|
|
value={token}
|
|
onChange={(e) => setToken(e.target.value)}
|
|
className="w-full px-4 py-3 bg-[#070a0f] border border-[#262a33] rounded-xl text-white font-mono text-sm focus:border-[#00f2fe] outline-none"
|
|
/>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-2 text-center">
|
|
<label className="block text-xs font-mono text-[#b9cacb] uppercase tracking-wider text-left">
|
|
6-Digit Recovery Code
|
|
</label>
|
|
<SegmentedOtpInput
|
|
value={token}
|
|
onChange={(val) => {
|
|
setToken(val);
|
|
setMessage('');
|
|
}}
|
|
disabled={busy || isSuccess}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
<div className="space-y-1.5 text-left">
|
|
<div className="flex items-center justify-between">
|
|
<label className="block text-xs font-mono text-[#b9cacb] uppercase tracking-wider">
|
|
New Password
|
|
</label>
|
|
<span className="text-[10px] font-mono text-[#849495]">Min. 12 characters</span>
|
|
</div>
|
|
<div className="relative">
|
|
<input
|
|
required
|
|
minLength={12}
|
|
type={showPassword ? 'text' : 'password'}
|
|
autoComplete="new-password"
|
|
placeholder="••••••••••••"
|
|
value={password}
|
|
onChange={(e) => setPassword(e.target.value)}
|
|
className="w-full px-4 py-3 bg-[#070a0f] border border-[#262a33] rounded-xl text-white font-mono text-sm focus:border-[#00f2fe] focus:ring-1 focus:ring-[#00f2fe] outline-none transition-all pr-12"
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowPassword(!showPassword)}
|
|
className="absolute inset-y-0 right-0 pr-3.5 flex items-center text-xs font-mono text-[#849495] hover:text-white"
|
|
>
|
|
{showPassword ? 'HIDE' : 'SHOW'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-1.5 text-left">
|
|
<label className="block text-xs font-mono text-[#b9cacb] uppercase tracking-wider">
|
|
Confirm New Password
|
|
</label>
|
|
<input
|
|
required
|
|
minLength={12}
|
|
type={showPassword ? 'text' : 'password'}
|
|
autoComplete="new-password"
|
|
placeholder="••••••••••••"
|
|
value={confirmPassword}
|
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
|
className="w-full px-4 py-3 bg-[#070a0f] border border-[#262a33] rounded-xl text-white font-mono text-sm focus:border-[#00f2fe] focus:ring-1 focus:ring-[#00f2fe] outline-none transition-all"
|
|
/>
|
|
</div>
|
|
|
|
<button
|
|
type="submit"
|
|
disabled={busy || isSuccess || (!isLongToken && token.length < 6) || !password}
|
|
className="w-full py-3.5 px-6 rounded-xl font-mono text-sm font-bold tracking-wider uppercase transition-all duration-200
|
|
bg-[#00f2fe] text-[#002022] hover:bg-[#6ff6ff] hover:shadow-[0_0_24px_rgba(0,242,254,0.4)]
|
|
disabled:opacity-40 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
|
>
|
|
{busy ? (
|
|
<>
|
|
<span className="w-4 h-4 border-2 border-[#002022] border-t-transparent rounded-full animate-spin"></span>
|
|
<span>Encrypting New Password...</span>
|
|
</>
|
|
) : isSuccess ? (
|
|
'✓ Credentials Updated'
|
|
) : (
|
|
'Set New Password'
|
|
)}
|
|
</button>
|
|
|
|
<div className="flex items-center justify-between text-xs font-mono text-[#849495] pt-1">
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setStep('request');
|
|
setMessage('');
|
|
}}
|
|
className="hover:text-white transition-colors"
|
|
>
|
|
← Change email address
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setIsLongToken(!isLongToken)}
|
|
className="hover:text-[#00dce6] transition-colors"
|
|
>
|
|
{isLongToken ? 'Switch to 6-digit OTP' : 'Switch to raw token link'}
|
|
</button>
|
|
</div>
|
|
|
|
{message && (
|
|
<div
|
|
role="status"
|
|
className={`p-3.5 rounded-xl text-xs sm:text-sm font-mono border ${
|
|
isSuccess
|
|
? 'bg-[#60ff98]/10 border-[#60ff98]/40 text-[#60ff98]'
|
|
: 'bg-[#ffb4ab]/10 border-[#ffb4ab]/40 text-[#ffb4ab]'
|
|
}`}
|
|
>
|
|
{message}
|
|
</div>
|
|
)}
|
|
</form>
|
|
)}
|
|
|
|
{/* Bottom Back Link */}
|
|
<div className="pt-4 border-t border-[#262a33] text-center">
|
|
<Link
|
|
href="/login"
|
|
className="inline-block text-xs font-mono text-[#849495] hover:text-white transition-colors"
|
|
>
|
|
← Return to Sign In
|
|
</Link>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|