MotorSignal-App/website/components/recovery-form.tsx

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