Files
clinicpro/assets/admin/pages/LoginPage.tsx
T

389 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useEffect, useRef, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { toast } from 'sonner';
import { EyeIcon, EyeSlashIcon } from '@heroicons/react/24/outline';
import { useAuthStore } from '../stores/authStore';
import PwaLoginCard from '../components/ui/PwaLoginCard';
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 [searchParams] = useSearchParams();
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 [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;
});
}, 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 className="card card-pad" style={{ width: '100%', maxWidth: 420 }}>
<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 }}>
{mode === 'forgot' ? 'بازیابی رمز عبور' : 'اطلاعات حساب خود را وارد کنید'}
</p>
</div>
{searchParams.get('error') === 'access_denied' && (
<div style={{
background: '#fee2e2', border: '1px solid #fca5a5',
borderRadius: 8, padding: '10px 14px',
fontSize: 13, color: '#dc2626', marginBottom: 16,
textAlign: 'center',
}}>
حساب شما دسترسی به پنل مدیریت را ندارد
</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>
)}
{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%)',
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>
</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>
)}
{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>
{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>
<PwaLoginCard />
</div>
);
}