feat: implement OTP login and password reset functionality with corresponding API endpoints and frontend updates

This commit is contained in:
hamed
2026-06-12 12:54:29 +03:30
parent 8ad983310c
commit 63073c6a42
5 changed files with 753 additions and 102 deletions
+341 -94
View File
@@ -1,122 +1,369 @@
import React, { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import React, { useEffect, useRef, useState } from 'react';
import { toast } from 'sonner';
import { EyeIcon, EyeSlashIcon } from '@heroicons/react/24/outline';
import { useAuthStore } from '../stores/authStore';
const schema = z.object({
mobile: z.string().min(10, 'شماره موبایل معتبر نیست'),
password: z.string().min(6, 'رمز عبور باید حداقل ۶ کاراکتر باشد'),
});
type FormData = z.infer<typeof schema>;
type Mode = 'password' | 'sms' | 'forgot';
type SmsStep = 1 | 2;
type ForgotStep = 1 | 2 | 3;
export default function LoginPage() {
const login = useAuthStore((s) => s.login);
const [showPass, setShowPass] = useState(false);
const { register, handleSubmit, formState: { errors, isSubmitting } } = useForm<FormData>({
resolver: zodResolver(schema),
});
const [mode, setMode] = useState<Mode>('password');
const [pwMobile, setPwMobile] = useState('');
const [pwPass, setPwPass] = useState('');
const [showPass, setShowPass] = useState(false);
const [pwLoading, setPwLoading] = useState(false);
const onSubmit = async (data: FormData) => {
try {
const res = await fetch('/api/v1/user/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mobile_number: data.mobile, password: data.password }),
const [smsStep, setSmsStep] = useState<SmsStep>(1);
const [smsMobile, setSmsMobile] = useState('');
const [smsCode, setSmsCode] = useState('');
const [smsUuid, setSmsUuid] = useState('');
const [smsLoading, setSmsLoading] = useState(false);
const [forgotStep, setForgotStep] = useState<ForgotStep>(1);
const [forgotMobile, setForgotMobile] = useState('');
const [forgotCode, setForgotCode] = useState('');
const [forgotUuid, setForgotUuid] = useState('');
const [forgotPass, setForgotPass] = useState('');
const [forgotPass2, setForgotPass2] = useState('');
const [showNewPass, setShowNewPass] = useState(false);
const [forgotLoading, setForgotLoading] = useState(false);
const [cooldown, setCooldown] = useState(0);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
useEffect(() => () => { if (timerRef.current) clearInterval(timerRef.current); }, []);
const startCooldown = () => {
setCooldown(60);
timerRef.current = setInterval(() => {
setCooldown((v) => {
if (v <= 1) { clearInterval(timerRef.current!); return 0; }
return v - 1;
});
if (!res.ok) {
const err = await res.json();
toast.error(err?.errors?.[0]?.message ?? 'خطا در ورود');
return;
}
const json = await res.json();
login(json.access_token, json.refresh_token ?? '');
toast.success('خوش آمدید');
} catch {
toast.error('خطا در اتصال به سرور');
}
}, 1000);
};
const switchMode = (m: Mode) => {
setMode(m);
setSmsStep(1); setSmsMobile(''); setSmsCode(''); setSmsUuid('');
setForgotStep(1); setForgotMobile(''); setForgotCode(''); setForgotUuid('');
setForgotPass(''); setForgotPass2('');
setCooldown(0);
if (timerRef.current) { clearInterval(timerRef.current); }
};
const handlePasswordLogin = async (e: React.FormEvent) => {
e.preventDefault();
if (!pwMobile || !pwPass) { toast.error('شماره موبایل و رمز عبور الزامی است'); return; }
setPwLoading(true);
try {
const res = await fetch('/api/v1/user/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mobile_number: pwMobile, password: pwPass }),
});
const json = await res.json();
if (!res.ok) { toast.error(json?.errors?.[0]?.message ?? 'خطا در ورود'); return; }
login(json.access_token, json.refresh_token ?? '');
toast.success('خوش آمدید');
} catch { toast.error('خطا در اتصال به سرور'); }
finally { setPwLoading(false); }
};
const sendCode = async (mobile: string, onSuccess: (uuid: string) => void, setLoading: (v: boolean) => void) => {
setLoading(true);
try {
const res = await fetch('/api/v1/user/send-code', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mobile }),
});
const json = await res.json();
if (!res.ok) { toast.error(json?.errors?.[0]?.message ?? 'خطا در ارسال کد'); return; }
onSuccess(json.uuid);
startCooldown();
toast.success('کد تأیید ارسال شد');
} catch { toast.error('خطا در اتصال به سرور'); }
finally { setLoading(false); }
};
const handleSmsSend = () => {
if (!/^09[0-9]{9}$/.test(smsMobile)) { toast.error('شماره موبایل معتبر نیست'); return; }
sendCode(smsMobile, (uuid) => { setSmsUuid(uuid); setSmsStep(2); }, setSmsLoading);
};
const handleSmsVerify = async () => {
if (!smsCode) { toast.error('کد تأیید را وارد کنید'); return; }
setSmsLoading(true);
try {
const vRes = await fetch('/api/v1/user/verify-code', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ uuid: smsUuid, code: smsCode }),
});
const vJson = await vRes.json();
if (!vRes.ok) { toast.error(vJson?.errors?.[0]?.message ?? 'کد نادرست است'); return; }
const lRes = await fetch('/api/v1/user/otp-login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ uuid: smsUuid }),
});
const lJson = await lRes.json();
if (!lRes.ok) { toast.error(lJson?.errors?.[0]?.message ?? 'کاربری با این شماره یافت نشد'); return; }
login(lJson.access_token, lJson.refresh_token ?? '');
toast.success('خوش آمدید');
} catch { toast.error('خطا در اتصال به سرور'); }
finally { setSmsLoading(false); }
};
const handleForgotSend = () => {
if (!/^09[0-9]{9}$/.test(forgotMobile)) { toast.error('شماره موبایل معتبر نیست'); return; }
sendCode(forgotMobile, (uuid) => { setForgotUuid(uuid); setForgotStep(2); }, setForgotLoading);
};
const handleForgotVerify = async () => {
if (!forgotCode) { toast.error('کد تأیید را وارد کنید'); return; }
setForgotLoading(true);
try {
const res = await fetch('/api/v1/user/verify-code', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ uuid: forgotUuid, code: forgotCode }),
});
const json = await res.json();
if (!res.ok) { toast.error(json?.errors?.[0]?.message ?? 'کد نادرست است'); return; }
setForgotStep(3);
} catch { toast.error('خطا در اتصال به سرور'); }
finally { setForgotLoading(false); }
};
const handleForgotReset = async () => {
if (forgotPass.length < 6) { toast.error('رمز عبور باید حداقل ۶ کاراکتر باشد'); return; }
if (forgotPass !== forgotPass2) { toast.error('رمزهای عبور یکسان نیستند'); return; }
setForgotLoading(true);
try {
const res = await fetch('/api/v1/user/reset-password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ uuid: forgotUuid, new_password: forgotPass }),
});
const json = await res.json();
if (!res.ok) { toast.error(json?.errors?.[0]?.message ?? 'خطا در تغییر رمز'); return; }
toast.success('رمز عبور با موفقیت تغییر یافت');
switchMode('password');
} catch { toast.error('خطا در اتصال به سرور'); }
finally { setForgotLoading(false); }
};
const handleResend = () => {
setCooldown(0);
if (timerRef.current) clearInterval(timerRef.current);
if (mode === 'sms') { setSmsStep(1); setSmsCode(''); }
else { setForgotStep(1); setForgotCode(''); }
};
const ResendControl = () => cooldown > 0
? <span className="muted" style={{ fontSize: 13 }}>ارسال مجدد تا {cooldown} ثانیه دیگر</span>
: <button type="button" onClick={handleResend}
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--primary)', fontSize: 13 }}>
ارسال مجدد کد
</button>;
return (
<div style={{
minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center',
background: 'var(--bg)', padding: 20,
}}>
<div style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'var(--bg)', padding: 20 }}>
<div className="card card-pad" style={{ width: '100%', maxWidth: 420 }}>
{/* Logo */}
<div style={{ textAlign: 'center', marginBottom: 28 }}>
<div className="brand-logo" style={{
margin: '0 auto 16px', width: 54, height: 54, borderRadius: 16,
fontSize: 26,
}}>
</div>
<h1 style={{ fontSize: 22, fontWeight: 800, marginBottom: 6 }}>ورود به پنل ادمین</h1>
<p className="muted" style={{ fontSize: 13 }}>اطلاعات حساب مدیریتی خود را وارد کنید</p>
<div className="brand-logo" style={{ margin: '0 auto 16px', width: 54, height: 54, borderRadius: 16, fontSize: 26 }}></div>
<h1 style={{ fontSize: 22, fontWeight: 800, marginBottom: 6 }}>ورود به پنل</h1>
<p className="muted" style={{ fontSize: 13 }}>
{mode === 'forgot' ? 'بازیابی رمز عبور' : 'اطلاعات حساب خود را وارد کنید'}
</p>
</div>
<form onSubmit={handleSubmit(onSubmit)} noValidate>
{/* Mobile */}
<div className="form-row">
<label>شماره موبایل</label>
<input
{...register('mobile')}
className={`input${errors.mobile ? ' err' : ''}`}
type="tel"
dir="ltr"
placeholder="09xxxxxxxxx"
autoComplete="username"
style={{ textAlign: 'right' }}
/>
{errors.mobile && <div className="err-text">{errors.mobile.message}</div>}
{mode !== 'forgot' && (
<div style={{ display: 'flex', gap: 4, marginBottom: 24, background: 'var(--bg-2)', borderRadius: 10, padding: 4 }}>
{(['password', 'sms'] as const).map((m) => (
<button key={m} type="button" onClick={() => switchMode(m)} style={{
flex: 1, padding: '8px 0', borderRadius: 8, border: 'none', cursor: 'pointer',
fontFamily: 'inherit', fontSize: 13, fontWeight: 600, transition: 'all 0.15s',
background: mode === m ? 'var(--primary)' : 'transparent',
color: mode === m ? '#fff' : 'var(--text-2)',
}}>
{m === 'password' ? 'رمز عبور' : 'ورود با پیامک'}
</button>
))}
</div>
)}
{/* Password */}
<div className="form-row">
<label>رمز عبور</label>
<div style={{ position: 'relative' }}>
<input
{...register('password')}
className={`input${errors.password ? ' err' : ''}`}
type={showPass ? 'text' : 'password'}
placeholder="••••••••"
autoComplete="current-password"
style={{ paddingLeft: 44 }}
/>
<button
type="button"
onClick={() => setShowPass((v) => !v)}
style={{
{mode === 'password' && (
<form onSubmit={handlePasswordLogin} noValidate>
<div className="form-row">
<label>شماره موبایل</label>
<input className="input" type="tel" dir="ltr" placeholder="09xxxxxxxxx"
autoComplete="username" style={{ textAlign: 'right' }}
value={pwMobile} onChange={(e) => setPwMobile(e.target.value)} />
</div>
<div className="form-row">
<label>رمز عبور</label>
<div style={{ position: 'relative' }}>
<input className="input" type={showPass ? 'text' : 'password'}
placeholder="••••••••" autoComplete="current-password" style={{ paddingLeft: 44 }}
value={pwPass} onChange={(e) => setPwPass(e.target.value)} />
<button type="button" onClick={() => setShowPass((v) => !v)} style={{
position: 'absolute', left: 12, top: '50%', transform: 'translateY(-50%)',
color: 'var(--text-3)', background: 'none', border: 'none', cursor: 'pointer',
display: 'flex', alignItems: 'center',
}}
>
{showPass
? <EyeSlashIcon style={{ width: 18, height: 18 }} />
: <EyeIcon style={{ width: 18, height: 18 }} />}
background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-3)', display: 'flex',
}}>
{showPass ? <EyeSlashIcon style={{ width: 18, height: 18 }} /> : <EyeIcon style={{ width: 18, height: 18 }} />}
</button>
</div>
</div>
<button type="submit" className="btn primary block" disabled={pwLoading}
style={{ marginTop: 8, height: 46, fontSize: 15 }}>
{pwLoading ? 'در حال ورود...' : 'ورود به سیستم'}
</button>
<div style={{ textAlign: 'center', marginTop: 16 }}>
<button type="button" onClick={() => switchMode('forgot')}
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--primary)', fontSize: 13 }}>
رمز عبور را فراموش کردم
</button>
</div>
{errors.password && <div className="err-text">{errors.password.message}</div>}
</form>
)}
{mode === 'sms' && (
<div>
{smsStep === 1 && (
<>
<div className="form-row">
<label>شماره موبایل</label>
<input className="input" type="tel" dir="ltr" placeholder="09xxxxxxxxx"
style={{ textAlign: 'right' }}
value={smsMobile} onChange={(e) => setSmsMobile(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSmsSend()} />
</div>
<button className="btn primary block" disabled={smsLoading} onClick={handleSmsSend}
style={{ height: 46, fontSize: 15 }}>
{smsLoading ? 'در حال ارسال...' : 'ارسال کد تأیید'}
</button>
</>
)}
{smsStep === 2 && (
<>
<p className="muted" style={{ fontSize: 13, marginBottom: 16 }}>
کد پیامکشده به <strong dir="ltr">{smsMobile}</strong> را وارد کنید
</p>
<div className="form-row">
<label>کد تأیید</label>
<input className="input" type="text" dir="ltr" placeholder="12345"
maxLength={6} style={{ textAlign: 'center', letterSpacing: 6, fontSize: 20 }}
value={smsCode} onChange={(e) => setSmsCode(e.target.value.replace(/\D/g, ''))}
onKeyDown={(e) => e.key === 'Enter' && handleSmsVerify()} autoFocus />
</div>
<button className="btn primary block" disabled={smsLoading} onClick={handleSmsVerify}
style={{ height: 46, fontSize: 15 }}>
{smsLoading ? 'در حال تأیید...' : 'تأیید و ورود'}
</button>
<div style={{ textAlign: 'center', marginTop: 14 }}><ResendControl /></div>
</>
)}
</div>
)}
<button
type="submit"
className="btn primary block"
disabled={isSubmitting}
style={{ marginTop: 8, height: 46, fontSize: 15 }}
>
{isSubmitting ? 'در حال ورود...' : 'ورود به سیستم'}
</button>
</form>
{mode === 'forgot' && (
<div>
<div style={{ display: 'flex', gap: 6, marginBottom: 24, justifyContent: 'center' }}>
{([1, 2, 3] as const).map((s) => (
<div key={s} style={{
width: 28, height: 4, borderRadius: 2, transition: 'background 0.2s',
background: forgotStep >= s ? 'var(--primary)' : 'var(--border)',
}} />
))}
</div>
<p className="muted" style={{ textAlign: 'center', fontSize: 12, marginTop: 20 }}>
{forgotStep === 1 && (
<>
<div className="form-row">
<label>شماره موبایل</label>
<input className="input" type="tel" dir="ltr" placeholder="09xxxxxxxxx"
style={{ textAlign: 'right' }}
value={forgotMobile} onChange={(e) => setForgotMobile(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleForgotSend()} />
</div>
<button className="btn primary block" disabled={forgotLoading} onClick={handleForgotSend}
style={{ height: 46, fontSize: 15 }}>
{forgotLoading ? 'در حال ارسال...' : 'ارسال کد تأیید'}
</button>
</>
)}
{forgotStep === 2 && (
<>
<p className="muted" style={{ fontSize: 13, marginBottom: 16 }}>
کد پیامکشده به <strong dir="ltr">{forgotMobile}</strong> را وارد کنید
</p>
<div className="form-row">
<label>کد تأیید</label>
<input className="input" type="text" dir="ltr" placeholder="12345"
maxLength={6} style={{ textAlign: 'center', letterSpacing: 6, fontSize: 20 }}
value={forgotCode} onChange={(e) => setForgotCode(e.target.value.replace(/\D/g, ''))}
onKeyDown={(e) => e.key === 'Enter' && handleForgotVerify()} autoFocus />
</div>
<button className="btn primary block" disabled={forgotLoading} onClick={handleForgotVerify}
style={{ height: 46, fontSize: 15 }}>
{forgotLoading ? 'در حال تأیید...' : 'تأیید کد'}
</button>
<div style={{ textAlign: 'center', marginTop: 14 }}><ResendControl /></div>
</>
)}
{forgotStep === 3 && (
<>
<p className="muted" style={{ fontSize: 13, marginBottom: 16 }}>رمز عبور جدید خود را وارد کنید</p>
<div className="form-row">
<label>رمز عبور جدید</label>
<div style={{ position: 'relative' }}>
<input className="input" type={showNewPass ? 'text' : 'password'}
placeholder="حداقل ۶ کاراکتر" style={{ paddingLeft: 44 }}
value={forgotPass} onChange={(e) => setForgotPass(e.target.value)} autoFocus />
<button type="button" onClick={() => setShowNewPass((v) => !v)} style={{
position: 'absolute', left: 12, top: '50%', transform: 'translateY(-50%)',
background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-3)', display: 'flex',
}}>
{showNewPass ? <EyeSlashIcon style={{ width: 18, height: 18 }} /> : <EyeIcon style={{ width: 18, height: 18 }} />}
</button>
</div>
</div>
<div className="form-row">
<label>تکرار رمز عبور</label>
<input className="input" type="password" placeholder="تکرار رمز عبور جدید"
value={forgotPass2} onChange={(e) => setForgotPass2(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleForgotReset()} />
{forgotPass2 && forgotPass !== forgotPass2 && (
<div className="err-text">رمزهای عبور یکسان نیستند</div>
)}
</div>
<button className="btn primary block" disabled={forgotLoading} onClick={handleForgotReset}
style={{ height: 46, fontSize: 15 }}>
{forgotLoading ? 'در حال ذخیره...' : 'تغییر رمز عبور'}
</button>
</>
)}
<div style={{ textAlign: 'center', marginTop: 16 }}>
<button type="button" onClick={() => switchMode('password')}
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-3)', fontSize: 13 }}>
بازگشت به ورود
</button>
</div>
</div>
)}
<p className="muted" style={{ textAlign: 'center', fontSize: 12, marginTop: 24 }}>
ClinicPro نسخه ۱.۰.۰
</p>
</div>