258 lines
7.6 KiB
TypeScript
258 lines
7.6 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useTransition } from 'react';
|
|
|
|
import { zodResolver } from '@hookform/resolvers/zod';
|
|
import { ExclamationTriangleIcon } from '@radix-ui/react-icons';
|
|
import { useForm } from 'react-hook-form';
|
|
import { z } from 'zod';
|
|
|
|
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
|
|
import { Button } from '@kit/ui/button';
|
|
import {
|
|
Form,
|
|
FormControl,
|
|
FormDescription,
|
|
FormField,
|
|
FormItem,
|
|
FormMessage,
|
|
} from '@kit/ui/form';
|
|
import { If } from '@kit/ui/if';
|
|
import {
|
|
InputOTP,
|
|
InputOTPGroup,
|
|
InputOTPSeparator,
|
|
InputOTPSlot,
|
|
} from '@kit/ui/input-otp';
|
|
import { Spinner } from '@kit/ui/spinner';
|
|
import { Trans } from '@kit/ui/trans';
|
|
|
|
import { sendOtpEmailAction } from '../server/server-actions';
|
|
|
|
// Email form schema
|
|
const SendOtpSchema = z.object({
|
|
email: z.string().email({ message: 'Please enter a valid email address' }),
|
|
});
|
|
|
|
// OTP verification schema
|
|
const VerifyOtpSchema = z.object({
|
|
otp: z.string().min(6, { message: 'Please enter a valid OTP code' }).max(6),
|
|
});
|
|
|
|
type VerifyOtpFormProps = {
|
|
// Purpose of the OTP (e.g., 'email-verification', 'password-reset')
|
|
purpose: string;
|
|
// Callback when OTP is successfully verified
|
|
onSuccess: (otp: string) => void;
|
|
// Email address to send the OTP to
|
|
email: string;
|
|
// Customize form appearance
|
|
className?: string;
|
|
// Optional cancel button
|
|
CancelButton?: React.ReactNode;
|
|
};
|
|
|
|
export function VerifyOtpForm({
|
|
purpose,
|
|
email,
|
|
className,
|
|
CancelButton,
|
|
onSuccess,
|
|
}: VerifyOtpFormProps) {
|
|
// Track the current step (email entry or OTP verification)
|
|
const [step, setStep] = useState<'email' | 'otp'>('email');
|
|
const [isPending, startTransition] = useTransition();
|
|
|
|
// Track errors
|
|
const [error, setError] = useState<string | null>(null);
|
|
// Track verification success
|
|
const [, setVerificationSuccess] = useState(false);
|
|
|
|
// Email form
|
|
const emailForm = useForm<z.infer<typeof SendOtpSchema>>({
|
|
resolver: zodResolver(SendOtpSchema),
|
|
defaultValues: {
|
|
email,
|
|
},
|
|
});
|
|
|
|
// OTP verification form
|
|
const otpForm = useForm<z.infer<typeof VerifyOtpSchema>>({
|
|
resolver: zodResolver(VerifyOtpSchema),
|
|
defaultValues: {
|
|
otp: '',
|
|
},
|
|
});
|
|
|
|
// Handle sending OTP email
|
|
const handleSendOtp = () => {
|
|
setError(null);
|
|
|
|
startTransition(async () => {
|
|
try {
|
|
const result = await sendOtpEmailAction({
|
|
purpose,
|
|
email,
|
|
});
|
|
|
|
if (result.success) {
|
|
setStep('otp');
|
|
} else {
|
|
setError(result.error || 'Failed to send OTP. Please try again.');
|
|
}
|
|
} catch (err) {
|
|
setError('An unexpected error occurred. Please try again.');
|
|
console.error('Error sending OTP:', err);
|
|
}
|
|
});
|
|
};
|
|
|
|
// Handle OTP verification
|
|
const handleVerifyOtp = (data: z.infer<typeof VerifyOtpSchema>) => {
|
|
setVerificationSuccess(true);
|
|
onSuccess(data.otp);
|
|
};
|
|
|
|
return (
|
|
<div className={className}>
|
|
{step === 'email' ? (
|
|
<Form {...emailForm}>
|
|
<form
|
|
className="flex flex-col gap-y-8"
|
|
onSubmit={emailForm.handleSubmit(handleSendOtp)}
|
|
>
|
|
<div className="flex flex-col gap-y-2">
|
|
<p className="text-muted-foreground text-sm">
|
|
<Trans
|
|
i18nKey="common:otp.requestVerificationCodeDescription"
|
|
values={{ email }}
|
|
/>
|
|
</p>
|
|
</div>
|
|
|
|
<If condition={Boolean(error)}>
|
|
<Alert variant="destructive">
|
|
<ExclamationTriangleIcon className="h-4 w-4" />
|
|
|
|
<AlertTitle>
|
|
<Trans i18nKey="common:otp.errorSendingCode" />
|
|
</AlertTitle>
|
|
|
|
<AlertDescription>{error}</AlertDescription>
|
|
</Alert>
|
|
</If>
|
|
|
|
<div className="flex w-full justify-end gap-2">
|
|
{CancelButton}
|
|
|
|
<Button
|
|
type="submit"
|
|
disabled={isPending}
|
|
data-test="otp-send-verification-button"
|
|
>
|
|
{isPending ? (
|
|
<>
|
|
<Spinner className="mr-2 h-4 w-4" />
|
|
<Trans i18nKey="common:otp.sendingCode" />
|
|
</>
|
|
) : (
|
|
<Trans i18nKey="common:otp.sendVerificationCode" />
|
|
)}
|
|
</Button>
|
|
</div>
|
|
</form>
|
|
</Form>
|
|
) : (
|
|
<Form {...otpForm}>
|
|
<div className="flex w-full flex-col items-center gap-y-8">
|
|
<div className="text-muted-foreground text-sm">
|
|
<Trans i18nKey="common:otp.codeSentToEmail" values={{ email }} />
|
|
</div>
|
|
|
|
<form
|
|
className="flex w-full flex-col items-center space-y-8"
|
|
onSubmit={otpForm.handleSubmit(handleVerifyOtp)}
|
|
>
|
|
<If condition={Boolean(error)}>
|
|
<Alert variant="destructive">
|
|
<ExclamationTriangleIcon className="h-4 w-4" />
|
|
|
|
<AlertTitle>
|
|
<Trans i18nKey="common:error" />
|
|
</AlertTitle>
|
|
|
|
<AlertDescription>{error}</AlertDescription>
|
|
</Alert>
|
|
</If>
|
|
|
|
<FormField
|
|
name="otp"
|
|
control={otpForm.control}
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormControl>
|
|
<InputOTP
|
|
maxLength={6}
|
|
{...field}
|
|
disabled={isPending}
|
|
data-test="otp-input"
|
|
>
|
|
<InputOTPGroup>
|
|
<InputOTPSlot index={0} data-slot="0" />
|
|
<InputOTPSlot index={1} data-slot="1" />
|
|
<InputOTPSlot index={2} data-slot="2" />
|
|
</InputOTPGroup>
|
|
<InputOTPSeparator />
|
|
<InputOTPGroup>
|
|
<InputOTPSlot index={3} data-slot="3" />
|
|
<InputOTPSlot index={4} data-slot="4" />
|
|
<InputOTPSlot index={5} data-slot="5" />
|
|
</InputOTPGroup>
|
|
</InputOTP>
|
|
</FormControl>
|
|
|
|
<FormDescription>
|
|
<Trans i18nKey="common:otp.enterCodeFromEmail" />
|
|
</FormDescription>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
|
|
<div className="flex w-full justify-between gap-2">
|
|
{CancelButton}
|
|
|
|
<div className="flex justify-end gap-2">
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
disabled={isPending}
|
|
onClick={() => setStep('email')}
|
|
>
|
|
<Trans i18nKey="common:otp.requestNewCode" />
|
|
</Button>
|
|
|
|
<Button
|
|
type="submit"
|
|
disabled={isPending}
|
|
data-test="otp-verify-button"
|
|
>
|
|
{isPending ? (
|
|
<>
|
|
<Spinner className="mr-2 h-4 w-4" />
|
|
<Trans i18nKey="common:otp.verifying" />
|
|
</>
|
|
) : (
|
|
<Trans i18nKey="common:otp.verifyCode" />
|
|
)}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</Form>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|