412 lines
15 KiB
TypeScript
412 lines
15 KiB
TypeScript
import { Card, CardContent, CardHeader, CardFooter } from '@/components/ui/card';
|
|
import { ResetPasswordSchema } from '@/data/form';
|
|
import { Field, FieldError, FieldLabel } from '@/components/ui/field';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Input } from '@/components/ui/input';
|
|
import { zodResolver } from '@hookform/resolvers/zod';
|
|
import { useState, useCallback } from 'react';
|
|
import { Controller, useForm } from 'react-hook-form';
|
|
import { useNavigate } from 'react-router-dom';
|
|
import { PageRouting } from '@/const/PageRouting';
|
|
import { Stepper, StepperContent, StepperIndicator, StepperItem, StepperNav, StepperPanel, StepperSeparator, StepperTrigger } from '@/components/ui/stepper';
|
|
import * as z from 'zod';
|
|
import { InputOTP, InputOTPGroup, InputOTPSlot } from '@/components/ui/input-otp';
|
|
import { Validator } from '@/util/Validator';
|
|
import { Eye, EyeOff, LoaderCircleIcon, CircleCheckBigIcon } from 'lucide-react';
|
|
import { AccountNetwork } from '@/network/AccountNetwork';
|
|
import { Label } from '@/components/ui/label';
|
|
|
|
const steps = [1, 2, 3, 4];
|
|
|
|
export default function ResetPasswordPage() {
|
|
const [currentStep, setCurrentStep] = useState(1);
|
|
const [showPassword, setShowPassword] = useState(false);
|
|
const [showPasswordConfirm, setShowPasswordConfirm] = useState(false);
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
const navigate = useNavigate();
|
|
const accountNetwork = new AccountNetwork();
|
|
|
|
const resetPasswordForm = useForm<z.infer<typeof ResetPasswordSchema>>({
|
|
resolver: zodResolver(ResetPasswordSchema),
|
|
defaultValues: {
|
|
email: "",
|
|
code: "",
|
|
password: "",
|
|
passwordConfirm: ""
|
|
}
|
|
});
|
|
|
|
const { email, code, password, passwordConfirm } = resetPasswordForm.watch();
|
|
|
|
const moveToLoginPage = useCallback(() => {
|
|
navigate(PageRouting["LOGIN"].path);
|
|
}, []);
|
|
|
|
const handleClickFirstStepButton = async () => {
|
|
if (isLoading) return;
|
|
if (!email || email.trim().length === 0) {
|
|
resetPasswordForm.setError('email', {
|
|
type: 'manual',
|
|
message: '이메일을 입력해주십시오'
|
|
});
|
|
return;
|
|
}
|
|
const isEmailValid = await resetPasswordForm.trigger('email');
|
|
if (!isEmailValid) {
|
|
resetPasswordForm.setError('email', {
|
|
type: 'validate',
|
|
message: '이메일 형식이 올바르지 않습니다'
|
|
});
|
|
return;
|
|
}
|
|
|
|
setIsLoading(true);
|
|
|
|
try {
|
|
|
|
const response = await accountNetwork.sendResetPasswordCode({ email: email });
|
|
const resData = response.data;
|
|
|
|
if (!resData.success) {
|
|
resetPasswordForm.setError('email', {
|
|
message: '서버 오류로 코드 발송에 실패하였습니다. 잠시 후 다시 시도해주십시오.'
|
|
});
|
|
return;
|
|
}
|
|
|
|
setCurrentStep(current => current + 1);
|
|
|
|
} catch (err) {
|
|
resetPasswordForm.setError('email', {
|
|
message: '서버 오류로 코드 발송에 실패하였습니다. 잠시 후 다시 시도해주십시오.'
|
|
});
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
}
|
|
|
|
const handleSecondStepOTPCompleted = async () => {
|
|
if (isLoading) return;
|
|
const codeValid = await resetPasswordForm.trigger('code');
|
|
if (!codeValid) {
|
|
return;
|
|
}
|
|
|
|
const data = {
|
|
email: email,
|
|
code: code
|
|
}
|
|
|
|
setIsLoading(true);
|
|
|
|
try {
|
|
const response = await accountNetwork.verifyResetPasswordCode(data);
|
|
const resData = response.data;
|
|
console.log(resData);
|
|
if (!resData.success || !resData.verified) {
|
|
resetPasswordForm.setError('code', {
|
|
type: 'value',
|
|
message: resData.error
|
|
});
|
|
return;
|
|
}
|
|
|
|
setCurrentStep(current => current + 1);
|
|
} catch (err) {
|
|
resetPasswordForm.setError('code', {
|
|
type: 'value',
|
|
message: '서버 오류로 코드 인증에 실패하였습니다. 잠시 후 다시 시도해주십시오.'
|
|
});
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
}
|
|
|
|
const handleThirdStepButton = async () => {
|
|
if (isLoading) return;
|
|
const passwordValid = await resetPasswordForm.trigger('password');
|
|
if (!passwordValid) return;
|
|
|
|
const passwordConfirmValid = await resetPasswordForm.trigger('passwordConfirm');
|
|
if (!passwordConfirmValid) return;
|
|
|
|
const data = {
|
|
email: email,
|
|
password: password
|
|
}
|
|
|
|
setIsLoading(true);
|
|
|
|
try {
|
|
const response = await accountNetwork.resetPassword(data);
|
|
const resData = response.data;
|
|
|
|
if (!resData.success) {
|
|
resetPasswordForm.setError('password', {
|
|
message: '서버 오류로 비밀번호 변경에 실패하였습니다. 잠시 후 다시 시도해주십시오.'
|
|
});
|
|
return;
|
|
}
|
|
|
|
setCurrentStep(current => current + 1);
|
|
} catch (err) {
|
|
resetPasswordForm.setError('password', {
|
|
message: '서버 오류로 비밀번호 변경에 실패하였습니다. 잠시 후 다시 시도해주십시오.'
|
|
});
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
|
|
}
|
|
|
|
return (
|
|
<Stepper
|
|
value={currentStep}
|
|
onValueChange={setCurrentStep}
|
|
className="w-full h-full flex flex-col justify-center items-center"
|
|
indicators={{
|
|
loading: <LoaderCircleIcon className="size-3.5 animate-spin" />
|
|
}}
|
|
>
|
|
<Card className="w-md pl-2 pr-2">
|
|
<CardHeader>
|
|
비밀번호 초기화
|
|
</CardHeader>
|
|
<CardContent>
|
|
<StepperNav className="select-none">
|
|
{steps.map((step) => (
|
|
<StepperItem key={step} step={step} loading={isLoading}>
|
|
<StepperTrigger asChild>
|
|
<StepperIndicator
|
|
className={[
|
|
`transition-all duration-300`,
|
|
`bg-accent text-accent-foreground rounded-full text-xs data-[state=completed]:bg-indigo-500 data-[state=completed]:text-primary-foreground data-[state=active]:bg-indigo-300 data-[state=active]:text-primary-foreground`,
|
|
].join(' ')}
|
|
>
|
|
{step}
|
|
</StepperIndicator>
|
|
</StepperTrigger>
|
|
{
|
|
steps.length > step
|
|
&& <StepperSeparator
|
|
className="transition-all duration-300 group-data-[state=completed]/step:bg-indigo-500"
|
|
/>
|
|
}
|
|
</StepperItem>
|
|
))}
|
|
</StepperNav>
|
|
</CardContent>
|
|
<CardContent>
|
|
<StepperPanel>
|
|
<StepperContent value={1} key={1}>
|
|
<Field data-invalid={resetPasswordForm.formState.errors.email?.message ? true : false}>
|
|
<FieldLabel htmlFor="reseet-password-email">이메일</FieldLabel>
|
|
<Controller
|
|
name="email"
|
|
control={resetPasswordForm.control}
|
|
render={({ field, fieldState }) => (
|
|
<>
|
|
<Input
|
|
{...field}
|
|
type="email"
|
|
id="reset-password-email"
|
|
aria-invalid={fieldState.invalid}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter') {
|
|
e.preventDefault();
|
|
if (email.length === 0) return;
|
|
handleClickFirstStepButton();
|
|
}
|
|
}}
|
|
/>
|
|
<FieldError className="font-[12px]" errors={[fieldState.error]} />
|
|
</>
|
|
)}
|
|
/>
|
|
</Field>
|
|
</StepperContent>
|
|
<StepperContent value={2} key={2}>
|
|
<Controller
|
|
name="code"
|
|
control={resetPasswordForm.control}
|
|
render={
|
|
({ field, fieldState }) => (
|
|
<div className="w-full flex flex-col justify-center gap-5">
|
|
<FieldLabel htmlFor="reset-password-code">코드 입력</FieldLabel>
|
|
<div className="flex flex-row justify-center items-center">
|
|
<InputOTP
|
|
maxLength={8}
|
|
inputMode="text"
|
|
id="reset-password-code"
|
|
onComplete={handleSecondStepOTPCompleted}
|
|
value={field.value}
|
|
onChange={(value) => field.onChange(value)}
|
|
onBlur={field.onBlur}
|
|
required
|
|
>
|
|
<InputOTPGroup
|
|
className="gap-2.5 *:data-[slot=input-otp-slot]:rounded-md *:data-[slot=input-otp-slot]:border"
|
|
>
|
|
{
|
|
[0, 1, 2, 3, 4, 5, 6, 7].map((idx) => (
|
|
<InputOTPSlot index={idx} />
|
|
))
|
|
}
|
|
</InputOTPGroup>
|
|
</InputOTP>
|
|
</div>
|
|
<FieldError errors={[fieldState.error]} />
|
|
</div>
|
|
)
|
|
}
|
|
/>
|
|
</StepperContent>
|
|
<StepperContent value={3} key={3}>
|
|
<div className="w-full flex flex-col gap-5 items-start">
|
|
<FieldLabel htmlFor="reset-password-password">새 비밀번호</FieldLabel>
|
|
<Controller
|
|
name="password"
|
|
control={resetPasswordForm.control}
|
|
render={({ field, fieldState }) => (
|
|
<>
|
|
<div className="relative w-full">
|
|
<Input
|
|
{...field}
|
|
type={ showPassword ? "text" : "password" }
|
|
id="reset-password-password"
|
|
aria-invalid={fieldState.invalid}
|
|
className="pr-10"
|
|
/>
|
|
<button
|
|
type="button"
|
|
className="absolute top-1/2 right-2 -translate-y-1/2"
|
|
onClick={() => setShowPassword(prev => !prev)}
|
|
>
|
|
{
|
|
showPassword
|
|
? <EyeOff size={14} />
|
|
: <Eye size={14} />
|
|
}
|
|
</button>
|
|
</div>
|
|
<FieldError className="text-[12px]" errors={[fieldState.error]} />
|
|
</>
|
|
)}
|
|
/>
|
|
<FieldLabel htmlFor="reset-password-password-confirm">새 비밀번호 확인</FieldLabel>
|
|
<Controller
|
|
name="passwordConfirm"
|
|
control={resetPasswordForm.control}
|
|
render={({ field, fieldState}) => (
|
|
<>
|
|
<div className="w-full relative">
|
|
<Input
|
|
{...field}
|
|
type={ showPasswordConfirm ? "text" : "password" }
|
|
id="reset-password-password-confirm"
|
|
className="pr-10"
|
|
aria-invalid={fieldState.invalid}
|
|
/>
|
|
<button
|
|
type="button"
|
|
className="absolute top-1/2 right-2 -translate-y-1/2"
|
|
onClick={() => setShowPasswordConfirm(prev => !prev)}
|
|
>
|
|
{
|
|
showPasswordConfirm
|
|
? <EyeOff size={14} />
|
|
: <Eye size={14} />
|
|
}
|
|
</button>
|
|
</div>
|
|
<FieldError className="text-[12px]" errors={[fieldState.error]} />
|
|
</>
|
|
)}
|
|
/>
|
|
</div>
|
|
</StepperContent>
|
|
<StepperContent value={4} key={4}>
|
|
<div className="w-full flex flex-col justify-center items-center gap-5">
|
|
<CircleCheckBigIcon size={50} className="text-indigo-500" />
|
|
<Label className="font-extrabold text-xl">비밀번호 변경 완료</Label>
|
|
</div>
|
|
</StepperContent>
|
|
</StepperPanel>
|
|
</CardContent>
|
|
<CardFooter
|
|
className="w-full"
|
|
>
|
|
<StepperPanel className="w-full">
|
|
<StepperContent value={1}>
|
|
<div className="w-full flex flex-row items-center gap-5">
|
|
<Button
|
|
className="flex-8 bg-indigo-500 hover:bg-indigo-400"
|
|
type="button"
|
|
form="form-reset-password"
|
|
disabled={
|
|
(email.trim().length < 1)
|
|
}
|
|
onClick={handleClickFirstStepButton}
|
|
>
|
|
인증 번호 발송
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
className="flex-1 bg-stone-300 text-white hover:bg-stone-400"
|
|
onClick={moveToLoginPage}
|
|
>
|
|
취소
|
|
</Button>
|
|
</div>
|
|
</StepperContent>
|
|
<StepperContent value={2} key={2}>
|
|
<div className="w-full flex flex-row justify-end items-center gap-5">
|
|
<Button
|
|
type="button"
|
|
className="bg-stone-300 text-white hover:bg-stone-400"
|
|
onClick={moveToLoginPage}
|
|
>
|
|
취소
|
|
</Button>
|
|
</div>
|
|
</StepperContent>
|
|
<StepperContent value={3} key={3}>
|
|
<div className="w-full flex flex-row align-center gap-5">
|
|
<Button
|
|
className="flex-8 bg-indigo-500 hover:bg-indigo-400"
|
|
type="button"
|
|
form="form-reset-password"
|
|
disabled={
|
|
(password.trim().length < 1)
|
|
&& (passwordConfirm.trim().length < 1)
|
|
}
|
|
onClick={handleThirdStepButton}
|
|
>
|
|
비밀번호 변경
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
className="flex-1 bg-stone-300 text-white hover:bg-stone-400"
|
|
onClick={moveToLoginPage}
|
|
>
|
|
취소
|
|
</Button>
|
|
</div>
|
|
</StepperContent>
|
|
<StepperContent value={4} key={4}>
|
|
<div className="w-full flex flex-row justify-end items-center gap-5">
|
|
<Button
|
|
type="button"
|
|
className="bg-stone-500 text-white hover:bg-stone-400"
|
|
onClick={moveToLoginPage}
|
|
>
|
|
로그인 화면으로 이동
|
|
</Button>
|
|
</div>
|
|
</StepperContent>
|
|
</StepperPanel>
|
|
</CardFooter>
|
|
</Card>
|
|
</Stepper>
|
|
)
|
|
} |