forgot password in login page and account info implemented, sms.ir service works fine with sandbox key.
This commit is contained in:
206
frontend/src/app/[locale]/(public)/forgot-password/page.tsx
Normal file
206
frontend/src/app/[locale]/(public)/forgot-password/page.tsx
Normal file
@@ -0,0 +1,206 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user