207 lines
6.5 KiB
TypeScript
207 lines
6.5 KiB
TypeScript
|
|
'use client';
|
||
|
|
|
||
|
|
import { useMemo, useState } from 'react';
|
||
|
|
import { useForm } from 'react-hook-form';
|
||
|
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||
|
|
import * as z from 'zod';
|
||
|
|
import { useTranslations } from 'next-intl';
|
||
|
|
import { Link, useRouter } from '@/i18n/navigation';
|
||
|
|
import { Phone, ShieldCheck } from 'lucide-react';
|
||
|
|
import { authApi } from '@/lib/api/auth';
|
||
|
|
import { useAuth } from '@/lib/hooks/useAuth';
|
||
|
|
import { Button } from '@/components/ui/shared/Button';
|
||
|
|
import { Input } from '@/components/ui/shared/Input';
|
||
|
|
import { TopBarControls } from '@/components/ui/shared/TopBarControls';
|
||
|
|
|
||
|
|
type ForgotPasswordForm = {
|
||
|
|
mobile: string;
|
||
|
|
code: string;
|
||
|
|
};
|
||
|
|
|
||
|
|
function normalizeIranMobile(input: string): string {
|
||
|
|
let digits = input.replace(/\D/g, '');
|
||
|
|
if (digits.startsWith('98') && digits.length === 12) digits = digits.slice(2);
|
||
|
|
if (digits.startsWith('0') && digits.length === 11) digits = digits.slice(1);
|
||
|
|
return digits;
|
||
|
|
}
|
||
|
|
|
||
|
|
export default function ForgotPasswordPage() {
|
||
|
|
const t = useTranslations('auth');
|
||
|
|
const tCommon = useTranslations('common');
|
||
|
|
const tValidation = useTranslations('validation');
|
||
|
|
const router = useRouter();
|
||
|
|
const { refreshSession } = useAuth();
|
||
|
|
const [step, setStep] = useState<'mobile' | 'code'>('mobile');
|
||
|
|
const [error, setError] = useState<string | null>(null);
|
||
|
|
const [isSending, setIsSending] = useState(false);
|
||
|
|
const [isVerifying, setIsVerifying] = useState(false);
|
||
|
|
const [sentMobile, setSentMobile] = useState('');
|
||
|
|
|
||
|
|
const schema = useMemo(
|
||
|
|
() =>
|
||
|
|
z.object({
|
||
|
|
mobile: z
|
||
|
|
.string()
|
||
|
|
.min(1, tValidation('mobileRequired'))
|
||
|
|
.refine((value) => /^9\d{9}$/.test(normalizeIranMobile(value)), {
|
||
|
|
message: tValidation('mobileInvalid'),
|
||
|
|
}),
|
||
|
|
code: z.string(),
|
||
|
|
}),
|
||
|
|
[tValidation],
|
||
|
|
);
|
||
|
|
|
||
|
|
const {
|
||
|
|
register,
|
||
|
|
handleSubmit,
|
||
|
|
getValues,
|
||
|
|
formState: { errors },
|
||
|
|
} = useForm<ForgotPasswordForm>({
|
||
|
|
resolver: zodResolver(schema),
|
||
|
|
defaultValues: { mobile: '', code: '' },
|
||
|
|
});
|
||
|
|
|
||
|
|
const onSendCode = async () => {
|
||
|
|
const mobile = getValues('mobile');
|
||
|
|
const parsed = schema.safeParse({ mobile, code: '' });
|
||
|
|
if (!parsed.success) {
|
||
|
|
setError(parsed.error.issues[0]?.message ?? tValidation('mobileInvalid'));
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
try {
|
||
|
|
setError(null);
|
||
|
|
setIsSending(true);
|
||
|
|
await authApi.sendForgotPasswordCode(mobile);
|
||
|
|
setSentMobile(mobile);
|
||
|
|
setStep('code');
|
||
|
|
} catch (err: unknown) {
|
||
|
|
const message = err instanceof Error ? err.message : t('codeSendFailed');
|
||
|
|
setError(message || t('codeSendFailed'));
|
||
|
|
} finally {
|
||
|
|
setIsSending(false);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
const onVerify = async (data: ForgotPasswordForm) => {
|
||
|
|
if (!data.code.trim()) {
|
||
|
|
setError(tValidation('codeRequired'));
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
try {
|
||
|
|
setError(null);
|
||
|
|
setIsVerifying(true);
|
||
|
|
const response = await authApi.verifyForgotPasswordCode(
|
||
|
|
sentMobile || data.mobile,
|
||
|
|
data.code.trim(),
|
||
|
|
);
|
||
|
|
|
||
|
|
const orgs = response.data.organizations;
|
||
|
|
if (orgs.length === 1) {
|
||
|
|
await authApi.selectOrganization(orgs[0].id);
|
||
|
|
localStorage.setItem('currentOrganizationId', orgs[0].id);
|
||
|
|
await refreshSession();
|
||
|
|
router.push('/settings/account?reset=1');
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (orgs.length > 1) {
|
||
|
|
sessionStorage.setItem('authRedirect', '/settings/account?reset=1');
|
||
|
|
await refreshSession();
|
||
|
|
router.push('/select-organization');
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
await refreshSession();
|
||
|
|
router.push('/settings/account?reset=1');
|
||
|
|
} catch (err: unknown) {
|
||
|
|
const message = err instanceof Error ? err.message : t('verifyFailed');
|
||
|
|
setError(message || t('verifyFailed'));
|
||
|
|
} finally {
|
||
|
|
setIsVerifying(false);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className="relative min-h-screen app-web-bg flex flex-col justify-center py-12 sm:px-6 lg:px-8">
|
||
|
|
<div className="absolute top-4 right-4">
|
||
|
|
<TopBarControls />
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="sm:mx-auto sm:w-full sm:max-w-md">
|
||
|
|
<Link href="/" className="flex justify-center">
|
||
|
|
<span className="text-3xl font-semibold text-text-primary">{tCommon('appName')}</span>
|
||
|
|
</Link>
|
||
|
|
<h2 className="mt-6 text-center text-3xl font-semibold text-text-primary">
|
||
|
|
{t('forgotPasswordTitle')}
|
||
|
|
</h2>
|
||
|
|
<p className="mt-2 text-center text-sm text-text-secondary">
|
||
|
|
{step === 'mobile' ? t('forgotPasswordSubtitle') : t('codeSentHint')}
|
||
|
|
</p>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
|
||
|
|
<div className="surface-card py-8 px-4 sm:px-10">
|
||
|
|
<form
|
||
|
|
className="space-y-6"
|
||
|
|
onSubmit={handleSubmit(step === 'code' ? onVerify : () => undefined)}
|
||
|
|
>
|
||
|
|
{step === 'mobile' ? (
|
||
|
|
<Input
|
||
|
|
label={t('mobile')}
|
||
|
|
{...register('mobile')}
|
||
|
|
type="tel"
|
||
|
|
inputMode="tel"
|
||
|
|
autoComplete="tel"
|
||
|
|
placeholder={t('mobilePlaceholder')}
|
||
|
|
error={errors.mobile?.message}
|
||
|
|
icon={<Phone className="h-5 w-5 icon-flat" />}
|
||
|
|
/>
|
||
|
|
) : (
|
||
|
|
<Input
|
||
|
|
label={t('verificationCode')}
|
||
|
|
{...register('code')}
|
||
|
|
type="text"
|
||
|
|
inputMode="numeric"
|
||
|
|
autoComplete="one-time-code"
|
||
|
|
placeholder={t('verificationCodePlaceholder')}
|
||
|
|
error={errors.code?.message}
|
||
|
|
icon={<ShieldCheck className="h-5 w-5 icon-flat" />}
|
||
|
|
/>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{error && (
|
||
|
|
<div className="p-3 bg-red-50 border border-red-200 rounded-lg">
|
||
|
|
<p className="text-sm text-red-600">{error}</p>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{step === 'mobile' ? (
|
||
|
|
<Button
|
||
|
|
type="button"
|
||
|
|
variant="primary"
|
||
|
|
isLoading={isSending}
|
||
|
|
fullWidth
|
||
|
|
onClick={() => void onSendCode()}
|
||
|
|
>
|
||
|
|
{t('sendCode')}
|
||
|
|
</Button>
|
||
|
|
) : (
|
||
|
|
<Button type="submit" variant="primary" isLoading={isVerifying} fullWidth>
|
||
|
|
{t('verifyAndContinue')}
|
||
|
|
</Button>
|
||
|
|
)}
|
||
|
|
|
||
|
|
<p className="text-center text-sm text-text-secondary">
|
||
|
|
<Link href="/login" className="font-medium text-primary hover:opacity-90">
|
||
|
|
{t('backToSignIn')}
|
||
|
|
</Link>
|
||
|
|
</p>
|
||
|
|
</form>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|