feat: implement OTP login and password reset functionality with corresponding API endpoints and frontend updates
This commit is contained in:
@@ -0,0 +1,279 @@
|
||||
# پرامپت: ورود با پیامک و فراموشی رمز عبور
|
||||
|
||||
## هدف
|
||||
|
||||
در صفحه `/admin/login` دو قابلیت جدید اضافه شود:
|
||||
|
||||
1. **ورود با پیامک** — کاربر میتواند به جای رمز عبور، با دریافت کد OTP وارد شود
|
||||
2. **فراموشی رمز عبور** — کاربر با پیامک هویتش تأیید میشود و رمز عبور جدید تعریف میکند
|
||||
|
||||
---
|
||||
|
||||
## زمینه فنی موجود — مهم، قبل از هر چیز بخوان
|
||||
|
||||
### API های موجود (قابل استفاده مستقیم)
|
||||
|
||||
#### `POST /api/v1/user/send-code` — PUBLIC
|
||||
```json
|
||||
// request
|
||||
{ "mobile": "09123456789" }
|
||||
|
||||
// response (flat, بدون success wrapper)
|
||||
{ "uuid": "550e8400-...", "message": "کد تایید با موفقیت ارسال شد." }
|
||||
```
|
||||
> ⚠️ در dev کد همیشه `12345` است — پیامکی ارسال نمیشود
|
||||
|
||||
#### `POST /api/v1/user/verify-code` — PUBLIC
|
||||
```json
|
||||
// request
|
||||
{ "uuid": "550e8400-...", "code": "12345" }
|
||||
|
||||
// response
|
||||
{ "success": true, "data": { "message": "...", "is_new_user": false } }
|
||||
```
|
||||
|
||||
#### `POST /api/v1/user/login` — PUBLIC (PasswordAuthenticator)
|
||||
```json
|
||||
// request
|
||||
{ "mobile_number": "09123456789", "password": "..." }
|
||||
|
||||
// response (flat)
|
||||
{ "access_token": "eyJ...", "refresh_token": "...", "token_type": "Bearer", "expires_in": 3600, "refresh_token_expires_in": ... }
|
||||
```
|
||||
> ⚠️ این endpoint توسط `PasswordAuthenticator` intercept میشود — controller body اجرا نمیشود
|
||||
|
||||
#### `POST /oauth/token` — PUBLIC
|
||||
```json
|
||||
// request
|
||||
{ "grant_type": "mobile", "uuid": "550e8400-..." }
|
||||
|
||||
// response (از tokenService.issueTokens)
|
||||
{ "access_token": "eyJ...", "refresh_token": "...", "token_type": "Bearer", "expires_in": 3600 }
|
||||
```
|
||||
> ⚠️ **مشکل**: اگر کاربر وجود نداشته باشد کاربر جدید میسازد — برای admin login مناسب نیست
|
||||
|
||||
### authStore
|
||||
```ts
|
||||
// stores/authStore.ts
|
||||
login(token: string, refreshToken: string) // token = access_token
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## API های جدید که باید ساخته شوند
|
||||
|
||||
### ۱. `POST /api/v1/user/otp-login` — PUBLIC
|
||||
|
||||
مثل `oauth/token` ولی فقط کاربر موجود را لاگین میکند.
|
||||
|
||||
**فایل:** `src/Auth/Controller/AuthController.php`
|
||||
|
||||
**منطق:**
|
||||
1. `uuid` را از body بگیر — اگر خالی بود → `ERR_VALIDATION_002` / 422
|
||||
2. `$this->otpService->getVerifiedOtpData($uuid)` → mobile (اگر throw کرد → exception handler کار میکند)
|
||||
3. `$this->userRepo->findByMobile($mobile)` → اگر null بود → `ERR_AUTH_005` / 401
|
||||
4. `$this->otpService->deleteOtp($uuid)`
|
||||
5. `return new JsonResponse($this->tokenService->issueTokens($user))`
|
||||
|
||||
**Response `200`:**
|
||||
```json
|
||||
{ "access_token": "eyJ...", "refresh_token": "...", "token_type": "Bearer", "expires_in": 3600 }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### ۲. `POST /api/v1/user/reset-password` — PUBLIC
|
||||
|
||||
**فایل:** `src/Auth/Controller/AuthController.php`
|
||||
|
||||
**منطق:**
|
||||
1. `uuid` و `new_password` را validate کن (password ≥ 6 کاراکتر)
|
||||
2. `getVerifiedOtpData($uuid)` → mobile
|
||||
3. `findByMobile($mobile)` → اگر null → `ERR_NOT_FOUND_001` / 404
|
||||
4. `$user->setPasswordHash($this->hasher->hashPassword($user, $newPassword))`
|
||||
5. `$this->em->flush()`
|
||||
6. `$this->otpService->deleteOtp($uuid)`
|
||||
7. `return $this->success(['message' => 'رمز عبور با موفقیت تغییر یافت'])`
|
||||
|
||||
**Dependencies که باید به constructor اضافه شوند:**
|
||||
```php
|
||||
private readonly UserPasswordHasherInterface $hasher,
|
||||
private readonly EntityManagerInterface $em,
|
||||
```
|
||||
**Imports:**
|
||||
```php
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### ۳. security.yaml — public کردن endpoint های جدید
|
||||
|
||||
**فایل:** `config/packages/security.yaml`
|
||||
|
||||
**تغییر ۱ — pattern firewall:**
|
||||
```yaml
|
||||
# قبل
|
||||
pattern: ^/(api/v1/user/(send-code|verify-code|register)|...)
|
||||
# بعد
|
||||
pattern: ^/(api/v1/user/(send-code|verify-code|register|otp-login|reset-password)|...)
|
||||
```
|
||||
|
||||
**تغییر ۲ — access_control (بعد از خط `user/login`):**
|
||||
```yaml
|
||||
- { path: ^/api/v1/user/otp-login, roles: PUBLIC_ACCESS }
|
||||
- { path: ^/api/v1/user/reset-password, roles: PUBLIC_ACCESS }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## قابلیت Frontend — بازطراحی `assets/admin/pages/LoginPage.tsx`
|
||||
|
||||
### State Management (فقط useState — نه TanStack Query)
|
||||
|
||||
```ts
|
||||
type Mode = 'password' | 'sms' | 'forgot';
|
||||
type SmsStep = 1 | 2;
|
||||
type ForgotStep = 1 | 2 | 3;
|
||||
|
||||
// mode
|
||||
const [mode, setMode] = useState<Mode>('password');
|
||||
|
||||
// password mode
|
||||
const [pwMobile, setPwMobile] = useState('');
|
||||
const [pwPass, setPwPass] = useState('');
|
||||
const [showPass, setShowPass] = useState(false);
|
||||
const [pwLoading, setPwLoading] = useState(false);
|
||||
|
||||
// sms mode
|
||||
const [smsStep, setSmsStep] = useState<SmsStep>(1);
|
||||
const [smsMobile, setSmsMobile] = useState('');
|
||||
const [smsCode, setSmsCode] = useState('');
|
||||
const [smsUuid, setSmsUuid] = useState('');
|
||||
const [smsLoading, setSmsLoading] = useState(false);
|
||||
|
||||
// forgot mode
|
||||
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);
|
||||
|
||||
// cooldown مشترک
|
||||
const [cooldown, setCooldown] = useState(0);
|
||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
```
|
||||
|
||||
### جریانهای API
|
||||
|
||||
#### حالت password:
|
||||
```
|
||||
POST /api/v1/user/login → { access_token, refresh_token }
|
||||
authStore.login(json.access_token, json.refresh_token ?? '')
|
||||
```
|
||||
|
||||
#### حالت sms:
|
||||
```
|
||||
مرحله ۱: POST send-code → { uuid } → setSmsUuid + setSmsStep(2)
|
||||
مرحله ۲: POST verify-code → { success, data } → اگر ok:
|
||||
POST otp-login → { access_token, ... } → authStore.login(json.access_token, json.refresh_token)
|
||||
```
|
||||
|
||||
#### حالت forgot:
|
||||
```
|
||||
مرحله ۱: POST send-code → { uuid } → setForgotUuid + setForgotStep(2)
|
||||
مرحله ۲: POST verify-code → { success, data } → اگر ok: setForgotStep(3)
|
||||
مرحله ۳: POST reset-password → { success, data.message } → toast + switchMode('password')
|
||||
```
|
||||
|
||||
### UI
|
||||
|
||||
**Tab switcher** (فقط در حالت `password` و `sms`):
|
||||
```
|
||||
[ رمز عبور ] [ ورود با پیامک ]
|
||||
tab فعال: background: var(--primary), color: #fff
|
||||
tab غیرفعال: background: transparent, color: var(--text-2)
|
||||
container: background: var(--bg-2), borderRadius: 10, padding: 4
|
||||
```
|
||||
|
||||
**Step indicator** (فقط در حالت `forgot`):
|
||||
```
|
||||
سه نوار کوچک کنار هم: width:28, height:4, borderRadius:2
|
||||
فعال: background: var(--primary)
|
||||
غیرفعال: background: var(--border)
|
||||
```
|
||||
|
||||
**لینک «فراموشی رمز»** (در حالت `password`، زیر فرم):
|
||||
```
|
||||
→ switchMode('forgot')
|
||||
```
|
||||
|
||||
**Cooldown ارسال مجدد** (در مرحله ۲ هر دو حالت sms و forgot):
|
||||
```ts
|
||||
const startCooldown = () => {
|
||||
setCooldown(60);
|
||||
timerRef.current = setInterval(() => {
|
||||
setCooldown(v => { if (v <= 1) { clearInterval(timerRef.current!); return 0; } return v - 1; });
|
||||
}, 1000);
|
||||
};
|
||||
```
|
||||
|
||||
**بازگشت به حالت password** (در حالت `forgot`، زیر فرم):
|
||||
```
|
||||
لینک «بازگشت به ورود» → switchMode('password')
|
||||
```
|
||||
|
||||
**switchMode** — هنگام تغییر حالت همه state های مرتبط reset شوند و cooldown قطع شود.
|
||||
|
||||
### اصول UI:
|
||||
- از `className="input"` برای همه inputها استفاده کن
|
||||
- از `className="btn primary block"` برای دکمههای اصلی
|
||||
- از `className="form-row"` برای wrapper هر فیلد
|
||||
- از `className="muted"` برای متنهای راهنما
|
||||
- از `className="err-text"` برای خطاهای inline
|
||||
- import: فقط `EyeIcon`, `EyeSlashIcon` از heroicons — هیچ import دیگری از heroicons نیاز نیست
|
||||
- هیچ `useRef` جز برای timer نباید وجود داشته باشد
|
||||
|
||||
---
|
||||
|
||||
## ترتیب اجرا
|
||||
|
||||
1. Backend: endpoint `otp-login`
|
||||
2. Backend: endpoint `reset-password`
|
||||
3. Backend: `security.yaml` — public کردن هر دو
|
||||
4. تست backend (syntax + cache + routes)
|
||||
5. Frontend: بازطراحی `LoginPage.tsx`
|
||||
6. تست frontend (yarn dev + tsc)
|
||||
7. مستندسازی `docs/api/auth.md`
|
||||
|
||||
---
|
||||
|
||||
## تست هر مرحله
|
||||
|
||||
```bash
|
||||
# syntax PHP
|
||||
ddev exec php -l src/Auth/Controller/AuthController.php
|
||||
|
||||
# cache + routes
|
||||
ddev exec php bin/console cache:clear
|
||||
ddev exec php bin/console debug:router | grep -E "otp-login|reset-password"
|
||||
|
||||
# frontend
|
||||
ddev exec yarn dev
|
||||
ddev exec npx tsc --noEmit --project tsconfig.json 2>&1 | head -20
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## مستندسازی
|
||||
|
||||
بعد از تکمیل، دو endpoint جدید را به `docs/api/auth.md` اضافه کن با فرمت:
|
||||
```
|
||||
## POST /api/v1/user/otp-login
|
||||
## POST /api/v1/user/reset-password
|
||||
```
|
||||
شامل: request body، response format، error codes
|
||||
@@ -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>
|
||||
|
||||
@@ -33,7 +33,7 @@ security:
|
||||
provider: api_doc_provider
|
||||
|
||||
public_endpoints:
|
||||
pattern: ^/(api/v1/user/(send-code|verify-code|register)|oauth/token$|session/token|api/v1/categorys/|api/v1/doctors$|api/v1/clinics$|api/v1/clinic/doctor-list/|api/v1/clinic-pro/doctor-addresses/|api/v1/appointment-slots|api/v1/comments/|api/v1/rate/|api/v1/blogs$|api/v1/clinic-invitation/|api/v1/pre-registration$)
|
||||
pattern: ^/(api/v1/user/(send-code|verify-code|register|otp-login|reset-password)|oauth/token$|session/token|api/v1/categorys/|api/v1/doctors$|api/v1/clinics$|api/v1/clinic/doctor-list/|api/v1/clinic-pro/doctor-addresses/|api/v1/appointment-slots|api/v1/comments/|api/v1/rate/|api/v1/blogs$|api/v1/clinic-invitation/|api/v1/pre-registration$)
|
||||
stateless: true
|
||||
security: false
|
||||
|
||||
@@ -57,6 +57,8 @@ security:
|
||||
- { path: ^/api/v1/user/verify-code, roles: PUBLIC_ACCESS }
|
||||
- { path: ^/api/v1/user/register, roles: PUBLIC_ACCESS }
|
||||
- { path: ^/api/v1/user/login, roles: PUBLIC_ACCESS }
|
||||
- { path: ^/api/v1/user/otp-login, roles: PUBLIC_ACCESS }
|
||||
- { path: ^/api/v1/user/reset-password, roles: PUBLIC_ACCESS }
|
||||
- { path: ^/api/v1/appointment-slots, roles: PUBLIC_ACCESS }
|
||||
- { path: ^/api/v1/comments/, roles: PUBLIC_ACCESS }
|
||||
- { path: ^/api/v1/rate/, roles: PUBLIC_ACCESS }
|
||||
|
||||
@@ -554,3 +554,75 @@ Submit a pre-registration request (doctor or clinic). Public endpoint — no aut
|
||||
|------|------|---------|
|
||||
| `VALIDATION_ERROR` | 422 | Invalid type / mobile / name |
|
||||
| `DUPLICATE_REQUEST` | 409 | Pending request already exists for this mobile |
|
||||
|
||||
---
|
||||
|
||||
## POST `/api/v1/user/otp-login`
|
||||
|
||||
ورود با کد OTP تأییدشده — **فقط کاربران موجود**، کاربر جدید ایجاد نمیشود.
|
||||
|
||||
**Permission:** `PUBLIC`
|
||||
|
||||
### Request Body
|
||||
```json
|
||||
{ "uuid": "550e8400-e29b-41d4-a716-446655440000" }
|
||||
```
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `uuid` | string | ✅ | UUID از `verify-code` (باید قبلاً verify شده باشد) |
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"access_token": "eyJ...",
|
||||
"refresh_token": "...",
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 3600,
|
||||
"refresh_token_expires_in": 2592000
|
||||
}
|
||||
```
|
||||
|
||||
### Error Codes
|
||||
| Code | HTTP | Description |
|
||||
|------|------|-------------|
|
||||
| `ERR_VALIDATION_002` | 422 | uuid ارسال نشده |
|
||||
| `ERR_AUTH_002` | 400 | uuid نامعتبر یا تأیید نشده |
|
||||
| `ERR_AUTH_003` | 400 | OTP منقضی شده |
|
||||
| `ERR_AUTH_005` | 401 | کاربری با این شماره یافت نشد |
|
||||
|
||||
---
|
||||
|
||||
## POST `/api/v1/user/reset-password`
|
||||
|
||||
تغییر رمز عبور با تأیید هویت از طریق OTP.
|
||||
|
||||
**Permission:** `PUBLIC`
|
||||
|
||||
### Request Body
|
||||
```json
|
||||
{
|
||||
"uuid": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"new_password": "newpass123"
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Required | Validation |
|
||||
|-------|------|----------|------------|
|
||||
| `uuid` | string | ✅ | UUID از `verify-code` (باید قبلاً verify شده باشد) |
|
||||
| `new_password` | string | ✅ | حداقل ۶ کاراکتر |
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": { "message": "رمز عبور با موفقیت تغییر یافت" }
|
||||
}
|
||||
```
|
||||
|
||||
### Error Codes
|
||||
| Code | HTTP | Description |
|
||||
|------|------|-------------|
|
||||
| `ERR_VALIDATION_001` | 422 | uuid یا new_password نادرست/ناقص |
|
||||
| `ERR_AUTH_002` | 400 | uuid نامعتبر یا تأیید نشده |
|
||||
| `ERR_NOT_FOUND_001` | 404 | کاربری با این شماره یافت نشد |
|
||||
|
||||
@@ -13,9 +13,11 @@ use App\Doctor\Repository\DoctorRepository;
|
||||
use App\Secretary\Repository\DoctorSecretaryRepository;
|
||||
use App\Shared\Constant\ErrorCodes;
|
||||
use App\Shared\Controller\BaseController;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use OpenApi\Attributes as OA;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
||||
use Symfony\Component\RateLimiter\RateLimiterFactory;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\CurrentUser;
|
||||
@@ -25,14 +27,16 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
class AuthController extends BaseController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly UserRepository $userRepo,
|
||||
private readonly OtpService $otpService,
|
||||
private readonly TokenService $tokenService,
|
||||
private readonly RateLimiterFactory $sendCodeLimiter,
|
||||
private readonly DoctorRepository $doctorRepo,
|
||||
private readonly ClinicRepository $clinicRepo,
|
||||
private readonly DoctorSecretaryRepository $secretaryRepo,
|
||||
private readonly UserRepository $userRepo,
|
||||
private readonly OtpService $otpService,
|
||||
private readonly TokenService $tokenService,
|
||||
private readonly RateLimiterFactory $sendCodeLimiter,
|
||||
private readonly DoctorRepository $doctorRepo,
|
||||
private readonly ClinicRepository $clinicRepo,
|
||||
private readonly DoctorSecretaryRepository $secretaryRepo,
|
||||
private readonly UserActiveContextRepository $contextRepo,
|
||||
private readonly UserPasswordHasherInterface $hasher,
|
||||
private readonly EntityManagerInterface $em,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -345,6 +349,53 @@ class AuthController extends BaseController
|
||||
return new JsonResponse($this->tokenService->issueTokens($user));
|
||||
}
|
||||
|
||||
#[Route('/api/v1/user/otp-login', methods: ['POST'])]
|
||||
public function otpLogin(Request $request): JsonResponse
|
||||
{
|
||||
$data = json_decode($request->getContent(), true) ?? [];
|
||||
$uuid = trim($data['uuid'] ?? '');
|
||||
|
||||
if ($uuid === '') {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'uuid الزامی است', 422);
|
||||
}
|
||||
|
||||
$otpData = $this->otpService->getVerifiedOtpData($uuid);
|
||||
$user = $this->userRepo->findByMobile($otpData['mobile']);
|
||||
|
||||
if (!$user) {
|
||||
return $this->error(ErrorCodes::ERR_AUTH_005, 'کاربری با این شماره یافت نشد', 401);
|
||||
}
|
||||
|
||||
$this->otpService->deleteOtp($uuid);
|
||||
|
||||
return new JsonResponse($this->tokenService->issueTokens($user));
|
||||
}
|
||||
|
||||
#[Route('/api/v1/user/reset-password', methods: ['POST'])]
|
||||
public function resetPassword(Request $request): JsonResponse
|
||||
{
|
||||
$data = json_decode($request->getContent(), true) ?? [];
|
||||
$uuid = trim($data['uuid'] ?? '');
|
||||
$newPassword = trim($data['new_password'] ?? '');
|
||||
|
||||
if ($uuid === '' || mb_strlen($newPassword) < 6) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'uuid و رمز عبور (حداقل ۶ کاراکتر) الزامی است', 422);
|
||||
}
|
||||
|
||||
$otpData = $this->otpService->getVerifiedOtpData($uuid);
|
||||
$user = $this->userRepo->findByMobile($otpData['mobile']);
|
||||
|
||||
if (!$user) {
|
||||
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'کاربری با این شماره یافت نشد', 404);
|
||||
}
|
||||
|
||||
$user->setPasswordHash($this->hasher->hashPassword($user, $newPassword));
|
||||
$this->em->flush();
|
||||
$this->otpService->deleteOtp($uuid);
|
||||
|
||||
return $this->success(['message' => 'رمز عبور با موفقیت تغییر یافت']);
|
||||
}
|
||||
|
||||
#[OA\Post(
|
||||
path: '/oauth/token/refresh',
|
||||
summary: 'Refresh access token using a refresh token',
|
||||
|
||||
Reference in New Issue
Block a user