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
|
||||
Reference in New Issue
Block a user