Files
clinicpro/assets/admin/pages/LoginPage.tsx
T
hamed 6876135a53 feat: add BlogBodySanitizer for HTML sanitization on article save
- Implemented BlogBodySanitizer to clean HTML content before saving articles, ensuring security against XSS attacks.
- Added tests for BlogBodySanitizer to verify that unsafe tags and attributes are stripped from the content.
- Introduced ApiLeastPrivilegeTest to ensure that unauthorized users cannot access sensitive API routes, maintaining strict access control.
2026-08-07 21:13:38 +03:30

415 lines
20 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';
import { toast } from 'sonner';
import { EyeIcon, EyeSlashIcon } from '@heroicons/react/24/outline';
import { useAuthStore } from '../stores/authStore';
import PwaLoginCard from '../components/ui/PwaLoginCard';
import Altcha from '../components/ui/Altcha';
import { sanitizeMobileInput } from '../lib/utils';
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 [forgotGrant, setForgotGrant] = useState('');
const [forgotPass, setForgotPass] = useState('');
const [forgotPass2, setForgotPass2] = useState('');
const [showNewPass, setShowNewPass] = useState(false);
const [forgotLoading, setForgotLoading] = useState(false);
const [cooldown, setCooldown] = useState(0);
// آخرین payload حل‌شده‌ی ALTCHA برای مرحله‌ی جاری (یک‌بارمصرف؛ بین مراحل ریست می‌شود).
const [altcha, setAltcha] = useState('');
// با تغییر key، widget رمزِ فرمِ ورود پس از هر تلاش ناموفق دوباره challenge تازه می‌گیرد.
const [pwCaptchaKey, setPwCaptchaKey] = 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);
setAltcha('');
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, altcha }),
});
const json = await res.json();
if (!res.ok) {
toast.error(json?.errors?.[0]?.message ?? 'خطا در ورود');
setAltcha(''); setPwCaptchaKey((k) => k + 1); // challenge یک‌بارمصرف؛ تازه بگیر
return;
}
login(json.access_token, json.refresh_token);
toast.success('خوش آمدید');
} catch {
toast.error('خطا در اتصال به سرور');
setAltcha(''); setPwCaptchaKey((k) => k + 1);
}
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, altcha }),
});
const json = await res.json();
if (!res.ok) { toast.error(json?.errors?.[0]?.message ?? 'خطا در ارسال کد'); return; }
setAltcha('');
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 grant = vJson?.data?.grant;
const lRes = await fetch('/api/v1/user/otp-login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ grant, altcha }),
});
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; }
setForgotGrant(json?.data?.grant ?? '');
setForgotStep(3);
} catch { toast.error('خطا در اتصال به سرور'); }
finally { setForgotLoading(false); }
};
const handleForgotReset = async () => {
if (forgotPass.length < 8) { 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({ grant: forgotGrant, new_password: forgotPass, altcha }),
});
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 }}>
<img src="/logo.svg" alt="Clinic Pro" className="brand-img" />
</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: 'var(--danger-bg)', border: '1px solid var(--danger)',
borderRadius: 8, padding: '10px 14px',
fontSize: 13, color: 'var(--danger)', 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 ? 'var(--on-primary)' : '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(sanitizeMobileInput(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>
<div style={{ marginTop: 12 }}><Altcha key={`pw-login-${pwCaptchaKey}`} onVerified={setAltcha} /></div>
<button type="submit" className="btn primary block" disabled={pwLoading}
style={{ marginTop: 12, 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(sanitizeMobileInput(e.target.value))}
onKeyDown={(e) => e.key === 'Enter' && handleSmsSend()} />
</div>
<div style={{ marginBottom: 12 }}><Altcha key="sms-send" onVerified={setAltcha} /></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>
<div style={{ marginBottom: 12 }}><Altcha key="sms-login" onVerified={setAltcha} /></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(sanitizeMobileInput(e.target.value))}
onKeyDown={(e) => e.key === 'Enter' && handleForgotSend()} />
</div>
<div style={{ marginBottom: 12 }}><Altcha key="forgot-send" onVerified={setAltcha} /></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>
<div style={{ marginBottom: 12 }}><Altcha key="forgot-reset" onVerified={setAltcha} /></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>
);
}