- Implemented a helper function `displayDoctorName` to prepend "دکتر" to doctor names for consistent display across the application. - Updated various components (InviteDoctorModal, DashboardPage, DoctorDetailPage, DoctorsPage, etc.) to utilize the new helper for rendering doctor names. - Modified the DoctorFormPage to automatically add the "دکتر" title in the UI without requiring user input. - Fixed the EditSpecialtyPicker component to allow multiple specialty selections, resolving a UI bug where only one specialty could be selected at a time. - Ensured that the backend strips the "دکتر" title from the name during pre-registration and doctor creation processes. - Added tests for the new functionality, including checks for title handling and specialty selection logic. - Updated API documentation to reflect changes in name handling and display logic.
22 KiB
Authentication API
Prefix:
/api/v1/userand/oauth
Permission: All endpoints in this module are PUBLIC (no JWT required) exceptuserinfoandlogout
🛡️ ALTCHA captcha: وقتی
ALTCHA_ENABLED=trueاست (در prod)، endpointهایsend-code،register،otp-loginوreset-passwordعلاوه بر بدنهی خود، فیلدaltcha(payload حلشدهی widget) را الزامی میکنند؛ در غیر این صورت422با کدERR_CAPTCHA_001برمیگردد. جزئیات و مسیر challenge در captcha.md.
POST /api/v1/user/send-code
Send OTP code to mobile number.
Permission: PUBLIC
Request Body
{
"mobile": "09123456789",
"domain": "yasuj-nobat.ir"
}
| Field | Type | Required | Validation |
|---|---|---|---|
mobile |
string | ✅ | Format: 09XXXXXXXXX (11 digits) |
domain |
string | ⬜ | دامنهی شهرِ درخواستکننده (multi-domain). اگر ارسال شود، site_name شهرِ متناظر در جدول cities پیدا شده و بهعنوان متغیر {site} در متن پیامک OTP قابل استفاده است (شخصیسازی متن). اگر نیامد یا شهر پیدا نشد → مقدار پیشفرض «کلینیک پرو». حداکثر ۲۵۳ کاراکتر. |
Response 200
{
"success": true,
"data": {
"uuid": "550e8400-e29b-41d4-a716-446655440000",
"message": "کد تأیید ارسال شد"
}
}
Errors
| Code | HTTP | Description |
|---|---|---|
ERR_VALIDATION_001 |
422 | Invalid mobile format |
ERR_AUTH_004 |
429 | OTP rate limit exceeded |
ERR_RATE_LIMIT_001 |
429 | بیش از حد مجاز — هم per-IP (۵ در ۶۰ دقیقه) و هم per-mobile (۵ در ۶۰ دقیقه؛ ضد flood از IPهای چرخشی) |
POST /api/v1/user/verify-code
Verify OTP code. Returns whether this is a new or existing user.
Permission: PUBLIC
Request Body
{
"uuid": "550e8400-e29b-41d4-a716-446655440000",
"code": "123456"
}
| Field | Type | Required | Description |
|---|---|---|---|
uuid |
string | ✅ | UUID returned from send-code |
code |
string | ✅ | 6-digit OTP |
Response 200
{
"success": true,
"data": {
"message": "کد با موفقیت تایید شد.",
"grant": "ddf8a5994d6a2768203f15606621e3fa0e968438765e22061721a03d336d4039",
"is_new_user": false
}
}
grantیک توکنِ یکبارمصرف و کوتاهعمر (۱۲۰ ثانیه) است که در مرحلهی بعد (register/oauth/token/otp-login/reset-password) مصرف میشود. خودِ OTP (uuid) پس از تأیید موفق حذف میشود. هر grant فقط یکبار قابلاستفاده است؛ هر action که grant میخواهد یک grant جداگانه لازم دارد (یعنی برای new-user که همregisterو هم login لازم است، باید دوبار verify انجام شود یا مستقیماًoauth/tokenصدا زده شود که خودش کاربر را میسازد).
Errors
| Code | HTTP | Description |
|---|---|---|
ERR_AUTH_002 |
400 | Invalid OTP code |
ERR_AUTH_003 |
400 | OTP expired |
ERR_VALIDATION_002 |
422 | Missing required field |
ERR_RATE_LIMIT_001 |
429 | بیش از ۱۰ تلاش در ۱۵ دقیقه (per-IP) |
POST /api/v1/user/register
Complete registration for new users (called only when is_new_user: true).
Permission: PUBLIC
Request Body
{
"grant": "ddf8a5994d6a2768203f15606621e3fa0e968438765e22061721a03d336d4039",
"real_name": "علی احمدی"
}
| Field | Type | Required | Description |
|---|---|---|---|
grant |
string | ✅ | grant یکبارمصرف از verify-code |
real_name |
string | ❌ | User's full name |
Response 201
{
"success": true,
"data": {
"message": "ثبتنام با موفقیت انجام شد",
"uuid": "550e8400-e29b-41d4-a716-446655440000"
}
}
Errors
| Code | HTTP | Description |
|---|---|---|
ERR_VALIDATION_002 |
422 | Missing grant |
ERR_AUTH_002 |
400 | grant نامعتبر یا منقضی/مصرفشده |
POST /api/v1/user/login
Login with mobile number and password (for users who set a password).
Permission: PUBLIC
Request Body
{
"mobile_number": "09123456789",
"password": "mypassword"
}
| Field | Type | Required |
|---|---|---|
mobile_number |
string | ✅ |
password |
string | ✅ |
Response 200
{
"access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9...",
"refresh_token": "def50200...",
"token_type": "Bearer",
"expires_in": 900,
"refresh_token_expires_in": 2592000
}
فقط برای کاربران staff (
ROLE_ADMIN/DOCTOR/CLINIC/SECRETARY). Access token TTL: ۹۰۰ ثانیه (۱۵ دقیقه).
Errors
| Code | HTTP | Description |
|---|---|---|
ERR_AUTH_005 |
401 | Wrong credentials |
ERR_AUTH_006 |
403 | کاربر staff نیست |
429 |
429 | Too many attempts (۱۰ در دقیقه per-IP) |
POST /oauth/token
Exchange a one-time grant (from verify-code) for a JWT access token. اگر کاربری با آن موبایل وجود نداشته باشد، ساخته میشود.
Permission: PUBLIC
Request Body
{
"grant_type": "mobile",
"grant": "ddf8a5994d6a2768203f15606621e3fa0e968438765e22061721a03d336d4039"
}
| Field | Type | Required | Description |
|---|---|---|---|
grant_type |
string | ✅ | Must be "mobile" |
grant |
string | ✅ | grant یکبارمصرف از verify-code (عمر ۱۲۰ ثانیه) |
Response 200
{
"access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9...",
"refresh_token": "def50200...",
"token_type": "Bearer",
"expires_in": 900,
"refresh_token_expires_in": 2592000
}
JWT payload:
{ username: mobile_number, roles: [...], iat, exp }
Access token TTL: ۹۰۰ ثانیه (۱۵ دقیقه) | Refresh token TTL: ۳۰ روز (در cache، هششده و rotate-on-use)
Errors
| Code | HTTP | Description |
|---|---|---|
ERR_VALIDATION_001 |
400 | grant_type نامعتبر |
ERR_VALIDATION_002 |
422 | grant ارسال نشده |
ERR_AUTH_002 |
400 | grant نامعتبر، منقضی یا قبلاً مصرفشده |
ERR_RATE_LIMIT_001 |
429 | بیش از ۱۰ درخواست در ۵ دقیقه (per-IP) |
POST /oauth/token/refresh
Refresh expired JWT using refresh token.
Permission: PUBLIC
Request Body
{
"refresh_token": "def50200..."
}
قابل استفادهی مجدد: refresh token تا انقضای TTL خود معتبر است و در پاسخ بدون تغییر برگردانده میشود (یکبارمصرف/چرخشی نیست — سایت عمومی در هر render سمتسرور refresh میزند و نمیتواند توکن چرخشیافته را ذخیره کند). کاربر معلق (
status != 1) نمیتواند refresh کند (401).
Response 200
{
"access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9...",
"refresh_token": "<همان توکن ارسالی — بدون تغییر>",
"token_type": "Bearer",
"expires_in": 900,
"refresh_token_expires_in": 2592000
}
Errors
| Code | HTTP | Description |
|---|---|---|
ERR_AUTH_001 |
401 | Invalid/expired refresh token, or suspended user (status != 1) |
GET /oauth/userinfo
Get authenticated user info — extended with multi-context support.
Permission: AUTH — requires valid JWT
Headers
Authorization: Bearer <token>
Response 200
{
"success": true,
"data": {
"id": 4766,
"uuid": "550e8400-e29b-41d4-a716-446655440000",
"mobile_number": "09123456789",
"realName": "دکتر وحید درویشی",
"status": 1,
"roles": ["ROLE_USER", "ROLE_DOCTOR"],
"primary_role": "doctor",
"db_uuid": "a6ef5d29-38b8-4e69-b1ef-27a304696966",
"db_key": "hmac-sha256-hash...",
"doctor_uuid": "a6ef5d29-38b8-4e69-b1ef-27a304696966",
"context": {
"type": "doctor",
"db_uuid": "a6ef5d29-38b8-4e69-b1ef-27a304696966",
"name": "مطب شخصی دکتر وحید درویشی",
"role": "doctor"
},
"available_contexts": [
{
"type": "doctor",
"db_uuid": "a6ef5d29-38b8-4e69-b1ef-27a304696966",
"name": "مطب شخصی دکتر وحید درویشی",
"role": "doctor"
},
{
"type": "clinic",
"db_uuid": "clinic-uuid-...",
"name": "کلینیک سلامت",
"role": "doctor",
"scope": "clinic",
"doctor_uuid": "a6ef5d29-38b8-4e69-b1ef-27a304696966",
"permissions": {
"version": 1,
"resources": {
"appointments": { "view": true, "create": true, "cancel": true, "update_status": true },
"appointment_settings": { "view": true, "update": true },
"patients": { "view": true, "create": true, "update": true, "delete": false },
"payments": { "view": true, "create": false, "update": false, "delete": false },
"services": { "view": true, "update": false },
"clinic_info": { "view": true, "update": false }
}
}
}
]
}
}
| فیلد | نوع | توضیح |
|---|---|---|
primary_role |
string | نقش اصلی: admin | clinic | doctor | secretary | representation | user |
db_uuid |
string|null | UUID موجودیت فعال (null = هنوز context انتخاب نشده) |
db_key |
string|null | HMAC-SHA256(db_uuid, APP_SECRET) برای اعتبارسنجی |
doctor_uuid |
string|null | UUID دکتر — ثابت است حتی در context کلینیک که db_uuid برابر UUID کلینیک است. برای کاربران غیر دکتر: null |
context |
object|null | context فعال انتخابشده |
available_contexts |
array | همه محیطهای کاری قابل انتخاب |
فیلد permissions در هر context:
| حالت context | مقدار permissions |
|---|---|
مطب شخصی پزشک (type: doctor، role: doctor) |
null — محیط خودش، محدودیتی ندارد |
مالک کلینیک (role: clinic) |
null — مالک هرگز محدود نمیشود |
پزشکِ عضو کلینیک (role: doctor، scope: clinic) |
envelope کامل {version, resources} از clinic_doctor_permissions |
| پزشکِ عضوی که دسترسیاش غیرفعال شده | {version: 1, resources: {}} — یعنی هیچ دسترسی |
منشی (role: secretary) |
envelope کامل از doctor_secretaries |
نکتهٔ مهم برای کلاینت: نبودِ permissions (یا null) یعنی «بدون محدودیت»، نه «بدون دسترسی». ساختار و کلیدهای مجوز پزشکِ عضو کلینیک در docs/api/clinic.md → بخش Clinic Doctor Permissions آمده است.
قانون primary_role (اولویتبندی):
ROLE_ADMIN→"admin"ROLE_CLINIC→"clinic"ROLE_DOCTOR→"doctor"ROLE_SECRETARY→"secretary"ROLE_REPRESENTATION→"representation"(نماینده؛ دسترسی محدود به پنل ادمین: افزودن پزشک/کلینیک، نوبتهای پزشکانِ زیرمجموعه، داشبورد نماینده)- بقیه →
"user"
قانون context.role — نقشی که در آن محیط کاری فعال است:
- context مطب شخصی دکتر:
"doctor" - context کلینیک که دکتر عضو آن است (مالک نیست):
"doctor"+"scope": "clinic"— پزشک میماند و فقط نوبتهای خودش در آن کلینیک را میبیند؛ دسترسی مدیریتی پنل کلینیک ندارد - context کلینیک که دکتر صاحب آن است:
"clinic"(دسترسی کامل مالک) - context منشی:
"secretary"
نکته frontend: پس از
switchContext،primaryRoleدر store ازcontext.roleوscopeازcontext.scopeآپدیت میشود. وقتیrole:"doctor"وscope:"clinic"است (پزشکِ مهمان)، Sidebar فقط «داشبورد» و «نوبتها» را نشان میدهد و مسیرهای مدیریتی (staff,clinic-services,subscription,my-secretaries,my-patients,profile) به داشبورد ریدایرکت میشوند. در سمت backend هم endpointهای مدیریتی برای پزشک فقط scope شخصیِ خودش را برمیگردانند (نه کلینیک) و endpointهای ویرایش کلینیک مالکیت را چک میکنند (۴۰۳).
قانون db_uuid:
- اگر یک context وجود دارد: خودکار فعال میشود
- اگر چند context وجود دارد و کاربر هنوز انتخاب نکرده:
null— frontend باید صفحه انتخاب نشان دهد - پس از
POST /api/v1/auth/switch-context: برابر context انتخابشده
Errors
| Code | HTTP | Description |
|---|---|---|
ERR_AUTH_001 |
401 | Invalid or missing token |
POST /api/v1/auth/switch-context
تغییر محیط کاری فعال — باید از لیست available_contexts انتخاب شود.
Permission: AUTH
Request Body
{
"db_uuid": "clinic-uuid-..."
}
| فیلد | نوع | Required | توضیح |
|---|---|---|---|
db_uuid |
string (UUID) | ✅ | UUID محیط کاری از لیست available_contexts |
Response 200
{
"success": true,
"data": {
"db_uuid": "clinic-uuid-...",
"db_key": "new-hmac-hash...",
"context": {
"type": "clinic",
"db_uuid": "clinic-uuid-...",
"name": "کلینیک سلامت",
"role": "clinic",
"doctor_uuid": "a6ef5d29-38b8-4e69-b1ef-27a304696966"
}
}
}
Errors
| Code | HTTP | Description |
|---|---|---|
ERR_AUTH_001 |
401 | Missing or invalid token |
ERR_AUTH_006 |
403 | db_uuid در لیست context های این کاربر نیست |
ERR_VALIDATION_001 |
422 | db_uuid ارسال نشده |
Notification Mobile (OTP)
Endpoints for setting/verifying a separate SMS notification number for doctors and clinics. This number receives appointment SMS notifications instead of the account login mobile.
Permission: All 4 endpoints require
IS_AUTHENTICATED_FULLY(JWT).
POST /api/v1/notification-mobile/request-otp
Request an OTP code to verify a new notification mobile number.
Permission: AUTH
Request Body
{
"target": "doctor",
"new_mobile": "09123456789"
}
| Field | Type | Required | Validation |
|---|---|---|---|
target |
string | ✅ | doctor or clinic |
new_mobile |
string | ✅ | Format: 09XXXXXXXXX |
Response 200
{
"success": true,
"data": {
"message": "کد تأیید ارسال شد",
"expires_in": 300
}
}
Errors
| Code | HTTP | Description |
|---|---|---|
ERR_VALIDATION_001 |
422 | Invalid target or mobile format |
ERR_NOT_FOUND_001 |
404 | No doctor/clinic profile found for this user |
Notes
- Previous unused OTPs for the entity are deleted before creating a new one
- OTP is 6-digit, expires in 5 minutes
- Sends via SMS asynchronously
POST /api/v1/notification-mobile/verify
Verify the OTP and save the notification mobile number.
Permission: AUTH
Request Body
{
"target": "doctor",
"otp_code": "123456"
}
| Field | Type | Required |
|---|---|---|
target |
string | ✅ doctor or clinic |
otp_code |
string | ✅ 6-digit code |
Response 200
{
"success": true,
"data": {
"notification_mobile": "09123456789",
"message": "شماره اعلان با موفقیت ذخیره شد"
}
}
Errors
| Code | HTTP | Description |
|---|---|---|
ERR_VALIDATION_001 |
422 | Invalid target / OTP expired / wrong code |
ERR_NOT_FOUND_001 |
404 | No OTP request found or profile not found |
GET /api/v1/notification-mobile/{target}
Get the current notification mobile for the authenticated user.
Permission: AUTH
Path param: target — doctor or clinic
Response 200
{
"success": true,
"data": {
"notification_mobile": "09123456789"
}
}
- Returns
nullfornotification_mobileif not set
Errors
| Code | HTTP | Description |
|---|---|---|
ERR_NOT_FOUND_001 |
404 | No profile found for this user |
DELETE /api/v1/notification-mobile/{target}
Remove the notification mobile number.
Permission: AUTH
Path param: target — doctor or clinic
Response 200
{
"success": true,
"data": {
"message": "شماره اعلان حذف شد"
}
}
Errors
| Code | HTTP | Description |
|---|---|---|
ERR_NOT_FOUND_001 |
404 | No profile found for this user |
POST /oauth/logout
Invalidate the refresh token (stored in Redis).
Permission: AUTH
Request Body
{
"refresh_token": "def50200..."
}
| Field | Type | Required |
|---|---|---|
refresh_token |
string | ❌ |
Response 200
{
"success": true,
"data": {
"message": "با موفقیت خارج شدید"
}
}
POST /api/v1/pre-registration
Submit a pre-registration request (doctor or clinic). Public endpoint — no auth required.
Permission: Public
🛡️ ALTCHA: وقتی
ALTCHA_ENABLED=trueاست، فیلدaltcha(payload حلشدهی widget) الزامی است؛ در غیر این صورت422باERR_CAPTCHA_001. رجوع به captcha.md.
Request Body
{
"type": "independent_doctor",
"name": "علی احمدی",
"mobile": "09121234567",
"info": "متخصص داخلی، ۱۰ سال سابقه"
}
| Field | Type | Required | Notes |
|---|---|---|---|
type |
string | ✅ | independent_doctor | doctor_with_clinic | clinic_manager |
name |
string | ✅ | min 2 chars. عنوان «دکتر» ابتدای نام (و پیشوندهای تکراری «دکتر دکتر …») هنگام ثبت حذف میشود؛ نام تمیز ذخیره و در نمایش دوباره «دکتر» افزوده میشود. |
mobile |
string | ✅ | 10–15 chars |
info |
string | ❌ | specialty, address, etc. |
Type meanings:
| Value | Roles granted on approval | Entities created |
|---|---|---|
independent_doctor |
ROLE_DOCTOR |
Doctor |
doctor_with_clinic |
ROLE_DOCTOR + ROLE_CLINIC |
Doctor + Clinic |
clinic_manager |
ROLE_CLINIC |
Clinic |
Response 200
{
"success": true,
"data": { "uuid": "...", "status": "pending" }
}
Error Codes
| Code | HTTP | Meaning |
|---|---|---|
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
{ "grant": "ddf8a5994d6a2768203f15606621e3fa0e968438765e22061721a03d336d4039" }
| Field | Type | Required | Description |
|---|---|---|---|
grant |
string | ✅ | grant یکبارمصرف از verify-code |
Response 200
{
"access_token": "eyJ...",
"refresh_token": "...",
"token_type": "Bearer",
"expires_in": 900,
"refresh_token_expires_in": 2592000
}
Error Codes
| Code | HTTP | Description |
|---|---|---|
ERR_VALIDATION_002 |
422 | grant ارسال نشده |
ERR_AUTH_002 |
400 | grant نامعتبر، منقضی یا مصرفشده |
ERR_AUTH_005 |
401 | کاربری با این شماره یافت نشد |
ERR_RATE_LIMIT_001 |
429 | بیش از ۱۰ درخواست در ۵ دقیقه (per-IP) |
POST /api/v1/user/change-password
تغییر رمز عبور توسط کاربرِ احرازشده (بدون OTP). رمز فعلی راستیآزمایی میشود.
Permission: IS_AUTHENTICATED_FULLY
Request Body
{
"current_password": "oldpass1234",
"new_password": "newpass1234"
}
| Field | Type | Required | Validation |
|---|---|---|---|
current_password |
string | ✅ | باید با رمز فعلی مطابقت کند |
new_password |
string | ✅ | حداقل ۸ کاراکتر و متفاوت با رمز فعلی |
Response 200
{ "success": true, "data": { "message": "رمز عبور با موفقیت تغییر یافت" } }
Errors
| HTTP | Code | field | Description |
|---|---|---|---|
| 422 | ERR_VALIDATION_001 |
new_password |
رمز جدید کوتاه یا برابر رمز فعلی |
| 422 | ERR_VALIDATION_001 |
current_password |
رمز فعلی نادرست |
POST /api/v1/user/reset-password
تغییر رمز عبور با تأیید هویت از طریق OTP.
Permission: PUBLIC
Request Body
{
"grant": "ddf8a5994d6a2768203f15606621e3fa0e968438765e22061721a03d336d4039",
"new_password": "newpass1234"
}
| Field | Type | Required | Validation |
|---|---|---|---|
grant |
string | ✅ | grant یکبارمصرف از verify-code |
new_password |
string | ✅ | حداقل ۸ کاراکتر |
Response 200
{
"success": true,
"data": { "message": "رمز عبور با موفقیت تغییر یافت" }
}
Error Codes
| Code | HTTP | Description |
|---|---|---|
ERR_VALIDATION_001 |
422 | grant ارسال نشده یا رمز کمتر از ۸ کاراکتر |
ERR_AUTH_002 |
400 | grant نامعتبر، منقضی یا مصرفشده |
ERR_NOT_FOUND_001 |
404 | کاربری با این شماره یافت نشد |
ERR_RATE_LIMIT_001 |
429 | بیش از ۵ درخواست در ۶۰ دقیقه (per-IP) |