diff --git a/.claude/prompt/sms-login.md b/.claude/prompt/sms-login.md new file mode 100644 index 00000000..97241b89 --- /dev/null +++ b/.claude/prompt/sms-login.md @@ -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('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(1); +const [smsMobile, setSmsMobile] = useState(''); +const [smsCode, setSmsCode] = useState(''); +const [smsUuid, setSmsUuid] = useState(''); +const [smsLoading, setSmsLoading] = useState(false); + +// forgot mode +const [forgotStep, setForgotStep] = useState(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 | 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 diff --git a/assets/admin/pages/LoginPage.tsx b/assets/admin/pages/LoginPage.tsx index 06867c4f..190cfb7f 100644 --- a/assets/admin/pages/LoginPage.tsx +++ b/assets/admin/pages/LoginPage.tsx @@ -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; +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({ - resolver: zodResolver(schema), - }); + const [mode, setMode] = useState('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(1); + const [smsMobile, setSmsMobile] = useState(''); + const [smsCode, setSmsCode] = useState(''); + const [smsUuid, setSmsUuid] = useState(''); + const [smsLoading, setSmsLoading] = useState(false); + + const [forgotStep, setForgotStep] = useState(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 | 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 + ? ارسال مجدد تا {cooldown} ثانیه دیگر + : ; + return ( -
+
- {/* Logo */} +
-
- ♥ -
-

ورود به پنل ادمین

-

اطلاعات حساب مدیریتی خود را وارد کنید

+
+

ورود به پنل

+

+ {mode === 'forgot' ? 'بازیابی رمز عبور' : 'اطلاعات حساب خود را وارد کنید'} +

-
- {/* Mobile */} -
- - - {errors.mobile &&
{errors.mobile.message}
} + {mode !== 'forgot' && ( +
+ {(['password', 'sms'] as const).map((m) => ( + + ))}
+ )} - {/* Password */} -
- -
- - +
+
+ +
+
- {errors.password &&
{errors.password.message}
} + + )} + + {mode === 'sms' && ( +
+ {smsStep === 1 && ( + <> +
+ + setSmsMobile(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && handleSmsSend()} /> +
+ + + )} + {smsStep === 2 && ( + <> +

+ کد پیامک‌شده به {smsMobile} را وارد کنید +

+
+ + setSmsCode(e.target.value.replace(/\D/g, ''))} + onKeyDown={(e) => e.key === 'Enter' && handleSmsVerify()} autoFocus /> +
+ +
+ + )}
+ )} - - + {mode === 'forgot' && ( +
+
+ {([1, 2, 3] as const).map((s) => ( +
= s ? 'var(--primary)' : 'var(--border)', + }} /> + ))} +
-

+ {forgotStep === 1 && ( + <> +

+ + setForgotMobile(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && handleForgotSend()} /> +
+ + + )} + + {forgotStep === 2 && ( + <> +

+ کد پیامک‌شده به {forgotMobile} را وارد کنید +

+
+ + setForgotCode(e.target.value.replace(/\D/g, ''))} + onKeyDown={(e) => e.key === 'Enter' && handleForgotVerify()} autoFocus /> +
+ +
+ + )} + + {forgotStep === 3 && ( + <> +

رمز عبور جدید خود را وارد کنید

+
+ +
+ setForgotPass(e.target.value)} autoFocus /> + +
+
+
+ + setForgotPass2(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && handleForgotReset()} /> + {forgotPass2 && forgotPass !== forgotPass2 && ( +
رمزهای عبور یکسان نیستند
+ )} +
+ + + )} + +
+ +
+
+ )} + +

ClinicPro — نسخه ۱.۰.۰

diff --git a/config/packages/security.yaml b/config/packages/security.yaml index 8e520193..88087495 100644 --- a/config/packages/security.yaml +++ b/config/packages/security.yaml @@ -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 } diff --git a/docs/api/auth.md b/docs/api/auth.md index 81ee7430..052f5808 100644 --- a/docs/api/auth.md +++ b/docs/api/auth.md @@ -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 | کاربری با این شماره یافت نشد | diff --git a/src/Auth/Controller/AuthController.php b/src/Auth/Controller/AuthController.php index 0742e765..4e41fb60 100644 --- a/src/Auth/Controller/AuthController.php +++ b/src/Auth/Controller/AuthController.php @@ -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',