This commit is contained in:
2026-02-25 02:54:32 +00:00
parent 625905fc29
commit 7078b68146
10 changed files with 175 additions and 67 deletions
@@ -1,94 +1,202 @@
import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router';
import { toast } from 'sonner';
import { DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Button } from '@/components/ui/button';
import { Card } from '@/components/Card';
import { cn } from '@/lib/utils';
import { useMounted } from 'hooks/useMounted';
import { useForm } from 'hooks/useForm';
import { useAuth } from 'hooks/useAuth';
import { useClient } from 'hooks/useClient';
export const Verify = () => {
const isMounted = useMounted();
const navigate = useNavigate();
const client = useClient('/api/auth');
const { verify } = useAuth();
const { state, formRef, update, isValid } = useForm<VerifyFormState>({}, validateForm);
const { state, formRef, update } = useForm<VerifyFormState>({ email: '' });
const [verificationCode] = useState(() => new URL(window.location.href).searchParams.get('verificationCode') ?? '');
const [tokenStatus, setTokenStatus] = useState<TokenStatus>('loading');
const [flow, setFlow] = useState<'bootstrap' | 'invite' | 'verify'>('verify');
const [isSubmitting, setIsSubmitting] = useState(false);
const [verified, setVerified] = useState(false);
const [resending, setResending] = useState(false);
const [resent, setResent] = useState(false);
const handleResend = async () => {
if (!email || resending) return;
setResending(true);
const verifyToken = async () => {
if (!verificationCode) return;
try {
await client.post('/resend-verification', { email });
setResent(true);
} catch (ex) {
const error = ex as { message?: string };
toast.error(error.message || 'Failed to resend. Please try again.');
} finally {
setResending(false);
const data = await client.post<{ ok: boolean; email: string; flow: 'bootstrap' | 'invite' | 'verify' }>('/verify-token', { verificationCode });
if (data.ok) {
setTokenStatus('valid');
setFlow(data.flow);
requestAnimationFrame(() => update({ email: data.email }));
} else {
setTokenStatus('invalid');
}
} catch {
setTokenStatus('invalid');
}
};
useEffect(() => {
// if (!open || !verificationCode) {
// if (!verificationCode) setTokenStatus('invalid');
// return;
// }
//
// window.history.replaceState(null, '', '/auth/verify');
//
// client
// .post<{ email: string }>('/verify-token', { verificationCode })
// .then((data) => {
// setEmail(data.email);
// setTokenStatus('valid');
// })
// .catch(() => {
// // Try to decode email from expired token for resend
// try {
// const payload = JSON.parse(atob(verificationCode.split('.')[1]!));
// if (payload.email) setEmail(payload.email);
// } catch {
// // ignore
// }
// setTokenStatus('invalid');
// });
}, [open]);
// Redirect after successful verification
useEffect(() => {
// if (!verified) return;
// const timer = setTimeout(() => navigate('/'), 3000);
// return () => clearTimeout(timer);
}, [verified]);
if (!isMounted) return;
if (!verificationCode) {
setTokenStatus('invalid');
return;
}
verifyToken();
}, [isMounted]);
const isValid = validateForm(state);
const handleSubmit = async (ev: React.FormEvent) => {
ev.preventDefault();
if (!isValid || isSubmitting) return;
setIsSubmitting(true);
try {
if (flow === 'bootstrap') {
await client.post('/bootstrap', {
token: verificationCode,
name: state.name,
username: state.username,
password: state.password,
confirmPassword: state.confirmPassword,
});
} else {
await client.post('/verify', {
verificationCode,
name: state.name,
username: state.username,
password: state.password,
confirmPassword: state.confirmPassword,
});
}
toast.success('Account created. Please sign in.');
navigate('/');
} catch (ex) {
const error = ex as { message?: string };
toast.error(error.message || 'Failed to create account. Please try again.');
} finally {
setIsSubmitting(false);
}
};
const loading = tokenStatus === 'loading';
const invalid = tokenStatus === 'invalid';
const hideForm = loading || invalid;
return (
<div className="flex flex-col items-center justify-center h-dvh">
<div className="text-center">
<div className="text-duck-dark text-2xl font-bold">Verify your account</div>
<div className="text-duck-dark/60">Enter your verification code</div>
</div>
</div>
<>
{loading && (
<Card className="py-12 px-24 flex flex-col gap-6">
<div className="text-center text-duck-dark/60">Verifying...</div>
</Card>
)}
{invalid && (
<Card className="py-12 px-24 flex flex-col gap-6">
<div className="text-center">
<div className="text-duck-dark text-2xl font-bold">Invalid Link</div>
<div className="text-duck-dark/60">This verification link is invalid or has expired.</div>
</div>
</Card>
)}
<Card className={cn('flex flex-col gap-6', hideForm && 'hidden')}>
<div className="text-center">
<div className="text-duck-dark text-2xl font-bold">
{flow === 'bootstrap' ? 'Create Your Account' : 'Accept Invitation'}
</div>
<div className="text-duck-dark/60">
{flow === 'bootstrap' ? 'Set up your administrator profile' : 'Set up your profile to get started'}
</div>
</div>
<form ref={formRef} onSubmit={handleSubmit} className="grid gap-4">
<Label className="grid gap-2">
<span className="text-duck-dark/70">Email</span>
<Input
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
type="email"
name="email"
disabled
/>
</Label>
<Label className="grid gap-2">
<span className="text-duck-dark/70">Name</span>
<Input
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
type="text"
name="name"
placeholder="Your name"
autoComplete="name"
/>
</Label>
<Label className="grid gap-2">
<span className="text-duck-dark/70">Username</span>
<Input
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
type="text"
name="username"
placeholder="your-username"
autoComplete="username"
/>
</Label>
<div className="grid md:flex gap-4">
<Label className="grid gap-2 flex-1">
<span className="text-duck-dark/70">Password</span>
<Input
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
type="password"
name="password"
placeholder="Min 12 characters"
autoComplete="new-password"
/>
</Label>
<Label className="grid gap-2 flex-1">
<span className="text-duck-dark/70">Confirm Password</span>
<Input
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
type="password"
name="confirmPassword"
placeholder="Confirm your password"
autoComplete="new-password"
/>
</Label>
</div>
<div className="pt-2">
<Button
type="submit"
disabled={!isValid || isSubmitting}
className="w-full h-11 bg-duck-yellow hover:bg-duck-yellow/90 text-duck-teal font-bold text-lg transition-all duration-200 hover:scale-105 cursor-pointer disabled:opacity-50 disabled:hover:scale-100"
>
{isSubmitting ? 'Creating account...' : 'Create Account'}
</Button>
</div>
</form>
</Card>
</>
);
};
const validateForm = (state: Partial<VerifyFormState>) => {
const { name, password, confirmPassword } = state;
if (!name || !password || !confirmPassword) return false;
const { name, username, password, confirmPassword } = state;
if (!name || !username || !password || !confirmPassword) return false;
if (password !== confirmPassword) return false;
return true;
};
type VerifyFormState = {
email?: string;
name?: string;
username?: string;
password?: string;
confirmPassword?: string;
};
type TokenStatus = 'loading' | 'valid' | 'invalid';