UPDATE AUTH
This commit is contained in:
+4
-6
@@ -1,12 +1,10 @@
|
|||||||
# Server Configuration
|
# Server Configuration
|
||||||
PORT=9031
|
PORT=3000
|
||||||
NODE_ENV=development
|
NODE_ENV=development
|
||||||
|
|
||||||
|
|
||||||
# Next.js Configuration
|
# Next.js Configuration
|
||||||
BASEPATH=
|
BASEPATH=
|
||||||
NEXT_PUBLIC_APP_URL=http://localhost:9031
|
NEXT_PUBLIC_APP_URL=http://node.crop-logic.ir
|
||||||
|
|
||||||
# API Configuration (Envoy Gateway)
|
NEXT_PUBLIC_API_URL=http://node.crop-logic.ir
|
||||||
NEXT_PUBLIC_API_URL=http://85.208.253.135:8000
|
|
||||||
|
|
||||||
# MAPBOX_ACCESS_TOKEN=your-mapbox-access-token
|
|
||||||
|
|||||||
+432
@@ -0,0 +1,432 @@
|
|||||||
|
# Authentication & Account API Documentation
|
||||||
|
|
||||||
|
## فهرست مطالب
|
||||||
|
|
||||||
|
- [مکانیزم احراز هویت](#مکانیزم-احراز-هویت)
|
||||||
|
- [فرمت کلی پاسخها](#فرمت-کلی-پاسخها)
|
||||||
|
- [مدل کاربر](#مدل-کاربر)
|
||||||
|
- [Auth Endpoints](#auth-endpoints)
|
||||||
|
- [ثبتنام](#1-ثبتنام)
|
||||||
|
- [ورود با رمز عبور](#2-ورود-با-رمز-عبور)
|
||||||
|
- [درخواست OTP](#3-درخواست-otp)
|
||||||
|
- [تأیید OTP](#4-تأیید-otp)
|
||||||
|
- [رفرش توکن](#5-رفرش-توکن)
|
||||||
|
- [Account Endpoints](#account-endpoints)
|
||||||
|
- [آپدیت پروفایل](#1-آپدیت-پروفایل)
|
||||||
|
- [احراز هویت در درخواستها](#احراز-هویت-در-درخواستها)
|
||||||
|
- [کدهای خطا](#کدهای-خطا)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## مکانیزم احراز هویت
|
||||||
|
|
||||||
|
پروژه از **JWT (JSON Web Token)** با کتابخانه `djangorestframework-simplejwt` استفاده میکند.
|
||||||
|
|
||||||
|
- هر بار که کاربر ثبتنام یا لاگین موفق انجام میدهد، دو توکن دریافت میکند:
|
||||||
|
- **`access`** — توکن کوتاهمدت برای احراز هویت درخواستها
|
||||||
|
- **`refresh`** — توکن بلندمدت برای گرفتن `access` جدید
|
||||||
|
- برای اندپوینتهایی که نیاز به احراز هویت دارند، باید توکن `access` را در هدر `Authorization` ارسال کنید:
|
||||||
|
|
||||||
|
```
|
||||||
|
Authorization: Bearer <access_token>
|
||||||
|
```
|
||||||
|
|
||||||
|
- Backend احراز هویت: `MultiFieldBackend` — کاربر میتواند با `username`، `email` یا `phone_number` لاگین کند.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## فرمت کلی پاسخها
|
||||||
|
|
||||||
|
همه پاسخها از یک ساختار یکسان پیروی میکنند:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 200,
|
||||||
|
"msg": "success",
|
||||||
|
"data": { ... }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| فیلد | نوع | توضیح |
|
||||||
|
|---------|---------|---------------------------------------------|
|
||||||
|
| `code` | integer | کد وضعیت (مشابه HTTP status) |
|
||||||
|
| `msg` | string | پیام وضعیت (`"success"` یا متن خطا) |
|
||||||
|
| `data` | object | دادههای برگشتی (در صورت وجود) |
|
||||||
|
| `token` | object | توکنهای JWT (فقط در پاسخهای auth) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## مدل کاربر
|
||||||
|
|
||||||
|
شیء `AuthUser` که در پاسخهای احراز هویت و پروفایل برگردانده میشود:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"username": "john_doe",
|
||||||
|
"email": "john@example.com",
|
||||||
|
"first_name": "John",
|
||||||
|
"last_name": "Doe",
|
||||||
|
"phone_number": "09121234567"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Auth Endpoints
|
||||||
|
|
||||||
|
Base URL: `/api/auth/`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 1. ثبتنام
|
||||||
|
|
||||||
|
**`POST /api/auth/register/`**
|
||||||
|
|
||||||
|
ساخت حساب کاربری جدید با نام کاربری، ایمیل، شماره موبایل و رمز عبور.
|
||||||
|
|
||||||
|
#### Request Body
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"username": "john_doe",
|
||||||
|
"email": "john@example.com",
|
||||||
|
"phone_number": "09121234567",
|
||||||
|
"password": "securepass123",
|
||||||
|
"first_name": "John",
|
||||||
|
"last_name": "Doe"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| فیلد | نوع | الزامی | توضیح |
|
||||||
|
|----------------|--------|--------|-------------------------------|
|
||||||
|
| `username` | string | ✅ | حداکثر ۱۵۰ کاراکتر، یکتا |
|
||||||
|
| `email` | string | ✅ | فرمت ایمیل، یکتا |
|
||||||
|
| `phone_number` | string | ✅ | حداکثر ۳۲ کاراکتر، یکتا |
|
||||||
|
| `password` | string | ✅ | حداقل ۸ کاراکتر |
|
||||||
|
| `first_name` | string | ❌ | حداکثر ۱۵۰ کاراکتر |
|
||||||
|
| `last_name` | string | ❌ | حداکثر ۱۵۰ کاراکتر |
|
||||||
|
|
||||||
|
#### Response موفق — `201 Created`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 201,
|
||||||
|
"msg": "success",
|
||||||
|
"data": {
|
||||||
|
"id": 1,
|
||||||
|
"username": "john_doe",
|
||||||
|
"email": "john@example.com",
|
||||||
|
"first_name": "John",
|
||||||
|
"last_name": "Doe",
|
||||||
|
"phone_number": "09121234567"
|
||||||
|
},
|
||||||
|
"token": {
|
||||||
|
"access": "<access_token>",
|
||||||
|
"refresh": "<refresh_token>"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Response خطا — `400 Bad Request`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 400,
|
||||||
|
"msg": "A user with this username already exists."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
پیامهای خطای احتمالی:
|
||||||
|
- `"A user with this username already exists."`
|
||||||
|
- `"A user with this email already exists."`
|
||||||
|
- `"A user with this phone number already exists."`
|
||||||
|
- `"A user with these credentials already exists."`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. ورود با رمز عبور
|
||||||
|
|
||||||
|
**`POST /api/auth/login/`**
|
||||||
|
|
||||||
|
ورود با استفاده از `username`، `email` یا `phone_number` به همراه رمز عبور.
|
||||||
|
|
||||||
|
#### Request Body
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"identifier": "john_doe",
|
||||||
|
"password": "securepass123"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| فیلد | نوع | الزامی | توضیح |
|
||||||
|
|--------------|--------|--------|-----------------------------------------------------|
|
||||||
|
| `identifier` | string | ✅ | میتواند `username`، `email` یا `phone_number` باشد |
|
||||||
|
| `password` | string | ✅ | رمز عبور کاربر |
|
||||||
|
|
||||||
|
#### Response موفق — `200 OK`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 200,
|
||||||
|
"msg": "success",
|
||||||
|
"data": {
|
||||||
|
"id": 1,
|
||||||
|
"username": "john_doe",
|
||||||
|
"email": "john@example.com",
|
||||||
|
"first_name": "John",
|
||||||
|
"last_name": "Doe",
|
||||||
|
"phone_number": "09121234567"
|
||||||
|
},
|
||||||
|
"token": {
|
||||||
|
"access": "<access_token>",
|
||||||
|
"refresh": "<refresh_token>"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Response خطا — `401 Unauthorized`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 401,
|
||||||
|
"msg": "Invalid credentials."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. درخواست OTP
|
||||||
|
|
||||||
|
**`POST /api/auth/request-otp/`**
|
||||||
|
|
||||||
|
> ⚠️ این endpoint در حال حاضر در `urls.py` کامنت شده است (غیرفعال).
|
||||||
|
|
||||||
|
ارسال کد یکبار مصرف (OTP) به شماره موبایل از طریق SMS.ir.
|
||||||
|
|
||||||
|
#### Request Body
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"phone_number": "09121234567"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| فیلد | نوع | الزامی | توضیح |
|
||||||
|
|----------------|--------|--------|---------------------|
|
||||||
|
| `phone_number` | string | ✅ | حداکثر ۳۲ کاراکتر |
|
||||||
|
|
||||||
|
#### Response موفق — `200 OK`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 200,
|
||||||
|
"msg": "success",
|
||||||
|
"token": "<otp_token>"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| فیلد | توضیح |
|
||||||
|
|---------------|--------------------------------------------------------------|
|
||||||
|
| `token` | توکن امضاشده که باید در مرحله verify-otp ارسال شود |
|
||||||
|
| `sms_warning` | (اختیاری) در صورت شکست ارسال پیامک ظاهر میشود |
|
||||||
|
| `debug_otp` | (اختیاری) فقط در حالت `DEBUG=True` — کد OTP در پاسخ میآید |
|
||||||
|
|
||||||
|
> کد OTP مدت اعتبار **۳۰۰ ثانیه (۵ دقیقه)** دارد.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. تأیید OTP
|
||||||
|
|
||||||
|
**`POST /api/auth/verify-otp/`**
|
||||||
|
|
||||||
|
> ⚠️ این endpoint در حال حاضر در `urls.py` کامنت شده است (غیرفعال).
|
||||||
|
|
||||||
|
تأیید کد OTP دریافتشده و ورود/ثبتنام خودکار کاربر.
|
||||||
|
|
||||||
|
#### Request Body
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"token": "<otp_token>",
|
||||||
|
"otp_code": "123456"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| فیلد | نوع | الزامی | توضیح |
|
||||||
|
|------------|--------|--------|-------------------------------------------|
|
||||||
|
| `token` | string | ✅ | توکن دریافتشده از مرحله request-otp |
|
||||||
|
| `otp_code` | string | ✅ | کد ۶ رقمی ارسالشده به موبایل |
|
||||||
|
|
||||||
|
#### Response موفق — `200 OK`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 200,
|
||||||
|
"msg": "success",
|
||||||
|
"data": {
|
||||||
|
"id": 1,
|
||||||
|
"username": "09121234567",
|
||||||
|
"email": "09121234567@otp.local",
|
||||||
|
"first_name": "",
|
||||||
|
"last_name": "",
|
||||||
|
"phone_number": "09121234567"
|
||||||
|
},
|
||||||
|
"token": {
|
||||||
|
"access": "<access_token>",
|
||||||
|
"refresh": "<refresh_token>"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> اگر کاربر با این شماره موبایل قبلاً ثبتنام نکرده باشد، حساب جدید بهصورت خودکار ساخته میشود.
|
||||||
|
|
||||||
|
#### Response خطا — `400 Bad Request`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 400,
|
||||||
|
"msg": "Token is invalid or expired."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
یا:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 400,
|
||||||
|
"msg": "OTP code is invalid or expired."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5. رفرش توکن
|
||||||
|
|
||||||
|
**`POST /api/auth/token/refresh/`**
|
||||||
|
|
||||||
|
دریافت `access` token جدید با استفاده از `refresh` token.
|
||||||
|
|
||||||
|
> این endpoint توسط `djangorestframework-simplejwt` بهصورت پیشفرض فراهم میشود.
|
||||||
|
|
||||||
|
#### Request Body
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"refresh": "<refresh_token>"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Response موفق — `200 OK`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"access": "<new_access_token>"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Account Endpoints
|
||||||
|
|
||||||
|
Base URL: `/api/account/`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 1. آپدیت پروفایل
|
||||||
|
|
||||||
|
**`PATCH /api/account/profile/`**
|
||||||
|
|
||||||
|
> 🔒 نیاز به احراز هویت دارد — هدر `Authorization: Bearer <access_token>` الزامی است.
|
||||||
|
|
||||||
|
ویرایش اطلاعات پروفایل کاربر لاگینشده.
|
||||||
|
|
||||||
|
#### Request Body
|
||||||
|
|
||||||
|
همه فیلدها اختیاری هستند (partial update):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"first_name": "علی",
|
||||||
|
"last_name": "احمدی",
|
||||||
|
"email": "new_email@example.com"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| فیلد | نوع | الزامی | توضیح |
|
||||||
|
|--------------|--------|--------|---------------------|
|
||||||
|
| `first_name` | string | ❌ | حداکثر ۱۵۰ کاراکتر |
|
||||||
|
| `last_name` | string | ❌ | حداکثر ۱۵۰ کاراکتر |
|
||||||
|
| `email` | string | ❌ | فرمت ایمیل معتبر |
|
||||||
|
|
||||||
|
#### Response موفق — `200 OK`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 200,
|
||||||
|
"msg": "success",
|
||||||
|
"data": {
|
||||||
|
"id": 1,
|
||||||
|
"username": "john_doe",
|
||||||
|
"email": "new_email@example.com",
|
||||||
|
"first_name": "علی",
|
||||||
|
"last_name": "احمدی",
|
||||||
|
"phone_number": "09121234567"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Response خطا — `401 Unauthorized`
|
||||||
|
|
||||||
|
در صورت نبود یا منقضی بودن توکن:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"detail": "Authentication credentials were not provided."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## احراز هویت در درخواستها
|
||||||
|
|
||||||
|
برای اندپوینتهایی که نیاز به احراز هویت دارند (مانند `PATCH /api/account/profile/`)، توکن `access` را در هدر HTTP ارسال کنید:
|
||||||
|
|
||||||
|
```
|
||||||
|
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
|
||||||
|
```
|
||||||
|
|
||||||
|
### چرخه عمر توکن
|
||||||
|
|
||||||
|
```
|
||||||
|
[ثبتنام / لاگین]
|
||||||
|
↓
|
||||||
|
دریافت access + refresh
|
||||||
|
↓
|
||||||
|
ارسال درخواستها با access
|
||||||
|
↓
|
||||||
|
منقضی شدن access (401)
|
||||||
|
↓
|
||||||
|
ارسال refresh به POST /api/auth/token/refresh/
|
||||||
|
↓
|
||||||
|
دریافت access جدید
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## کدهای خطا
|
||||||
|
|
||||||
|
| HTTP Status | code | معنا |
|
||||||
|
|-------------|------|-----------------------------------------------|
|
||||||
|
| 201 | 201 | ثبتنام موفق |
|
||||||
|
| 200 | 200 | عملیات موفق |
|
||||||
|
| 400 | 400 | دادههای نامعتبر یا OTP/توکن اشتباه |
|
||||||
|
| 401 | 401 | احراز هویت ناموفق (رمز اشتباه یا بدون توکن) |
|
||||||
|
|
||||||
|
> پاسخهای خطای اعتبارسنجی serializer (مانند فیلد اجباری) با فرمت استاندارد DRF برگردانده میشوند:
|
||||||
|
> ```json
|
||||||
|
> {
|
||||||
|
> "field_name": ["This field is required."]
|
||||||
|
> }
|
||||||
|
> ```
|
||||||
@@ -5,8 +5,6 @@ services:
|
|||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
container_name: croplogic-frontend
|
container_name: croplogic-frontend
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
|
||||||
- "80:9031"
|
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
environment:
|
environment:
|
||||||
|
|||||||
+11
-15
@@ -9,26 +9,22 @@
|
|||||||
"title": "ورود",
|
"title": "ورود",
|
||||||
"description": "ورود به حساب کاربری",
|
"description": "ورود به حساب کاربری",
|
||||||
"welcome": "خوش آمدید به {templateName}! 👋🏻",
|
"welcome": "خوش آمدید به {templateName}! 👋🏻",
|
||||||
"phoneStep": "شماره موبایل خود را برای دریافت کد OTP وارد کنید",
|
"subtitle": "برای ادامه وارد حساب کاربری خود شوید.",
|
||||||
"otpStep": "کد OTP ارسال شده به موبایل خود را وارد کنید",
|
"identifier": "نام کاربری، ایمیل یا موبایل",
|
||||||
"phoneNumber": "شماره موبایل",
|
"placeholderIdentifier": "نام کاربری، ایمیل یا شماره موبایل را وارد کنید",
|
||||||
"placeholderPhone": "شماره موبایل خود را وارد کنید",
|
"password": "رمز عبور",
|
||||||
"sendOtp": "ارسال OTP",
|
"submit": "ورود",
|
||||||
"verifyOtp": "تایید OTP",
|
"rememberMe": "مرا به خاطر بسپار",
|
||||||
"backToPhone": "بازگشت به شماره موبایل",
|
"forgotPassword": "رمز عبور را فراموش کردهاید؟",
|
||||||
"newUser": "جدید هستید؟",
|
"newUser": "جدید هستید؟",
|
||||||
"createAccount": "ثبت نام کنید",
|
"createAccount": "ثبت نام کنید",
|
||||||
"otpSent": "کد OTP به {phone} ارسال شد",
|
|
||||||
"validation": {
|
"validation": {
|
||||||
"phoneRequired": "شماره موبایل الزامی است",
|
"identifierRequired": "نام کاربری، ایمیل یا موبایل الزامی است",
|
||||||
"phoneMinLength": "شماره موبایل باید حداقل ۱۰ رقم باشد",
|
"passwordRequired": "رمز عبور الزامی است",
|
||||||
"phoneMaxLength": "شماره موبایل باید حداکثر ۱۵ رقم باشد",
|
"passwordMinLength": "رمز عبور باید حداقل ۸ کاراکتر باشد"
|
||||||
"phoneDigitsOnly": "شماره موبایل باید فقط عدد باشد"
|
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"sendOtpFailed": "ارسال OTP ناموفق بود",
|
"loginFailed": "ورود ناموفق بود"
|
||||||
"incompleteOtp": "لطفاً کد ۶ رقمی OTP را کامل وارد کنید",
|
|
||||||
"otpVerificationFailed": "تایید OTP ناموفق بود"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"navigation": {
|
"navigation": {
|
||||||
|
|||||||
@@ -1,27 +1,25 @@
|
|||||||
// Next Imports
|
// Next Imports
|
||||||
import type { Metadata } from 'next'
|
import type { Metadata } from "next";
|
||||||
import { getTranslations } from 'next-intl/server'
|
import { getTranslations } from "next-intl/server";
|
||||||
|
|
||||||
// Component Imports
|
// Component Imports
|
||||||
import Login from '@views/Login'
|
import Login from "@views/Login";
|
||||||
|
|
||||||
// Server Action Imports
|
|
||||||
import { getServerMode } from '@core/utils/serverHelpers'
|
|
||||||
|
|
||||||
export async function generateMetadata(): Promise<Metadata> {
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
const t = await getTranslations('login')
|
const t = await getTranslations("login");
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: t('title'),
|
title: t("title"),
|
||||||
description: t('description')
|
description: t("description"),
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const LoginPage = async () => {
|
const LoginPage = async () => {
|
||||||
// Vars
|
return (
|
||||||
const mode = await getServerMode()
|
<div className="flex flex-col justify-center items-center min-bs-[100dvh] p-6">
|
||||||
|
<Login />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return <Login mode={mode} />
|
export default LoginPage;
|
||||||
}
|
|
||||||
|
|
||||||
export default LoginPage
|
|
||||||
|
|||||||
@@ -1,22 +1,20 @@
|
|||||||
// Next Imports
|
// Next Imports
|
||||||
import type { Metadata } from 'next'
|
import type { Metadata } from "next";
|
||||||
|
|
||||||
// Component Imports
|
// Component Imports
|
||||||
import Register from '@views/Register'
|
import Register from "@views/Register";
|
||||||
|
|
||||||
// Server Action Imports
|
|
||||||
import { getServerMode } from '@core/utils/serverHelpers'
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: 'Register',
|
title: "Register",
|
||||||
description: 'Register to your account'
|
description: "Register to your account",
|
||||||
}
|
};
|
||||||
|
|
||||||
const RegisterPage = async () => {
|
const RegisterPage = async () => {
|
||||||
// Vars
|
return (
|
||||||
const mode = await getServerMode()
|
<div className="flex flex-col justify-center items-center min-bs-[100dvh] p-6">
|
||||||
|
<Register />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return <Register mode={mode} />
|
export default RegisterPage;
|
||||||
}
|
|
||||||
|
|
||||||
export default RegisterPage
|
|
||||||
|
|||||||
+112
-93
@@ -1,147 +1,166 @@
|
|||||||
'use client'
|
"use client";
|
||||||
|
|
||||||
// React Imports
|
// React Imports
|
||||||
import { createContext, useContext, useEffect, useState, ReactNode } from 'react'
|
import { createContext, useContext, useEffect, useState } from "react";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
// API Imports
|
// API Imports
|
||||||
import { authService, type AuthUser } from '@/libs/api/services/authService'
|
import {
|
||||||
|
authService,
|
||||||
|
type AuthResponse,
|
||||||
|
type AuthUser,
|
||||||
|
type RegisterRequest,
|
||||||
|
} from "@/libs/api/services/authService";
|
||||||
|
|
||||||
export type LoginResult = { user: AuthUser | null }
|
export type LoginResult = { user: AuthUser | null };
|
||||||
|
export type RegisterPayload = RegisterRequest;
|
||||||
|
|
||||||
interface AuthContextType {
|
interface AuthContextType {
|
||||||
user: AuthUser | null
|
user: AuthUser | null;
|
||||||
isLoading: boolean
|
isLoading: boolean;
|
||||||
isAuthenticated: boolean
|
isAuthenticated: boolean;
|
||||||
login: (phoneNumber: string, otpCode: string, tempToken: string) => Promise<LoginResult>
|
login: (identifier: string, password: string) => Promise<LoginResult>;
|
||||||
requestOTP: (phoneNumber: string) => Promise<string>
|
register: (payload: RegisterPayload) => Promise<LoginResult>;
|
||||||
logout: () => void
|
logout: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const AuthContext = createContext<AuthContextType | undefined>(undefined)
|
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||||
|
|
||||||
export const useAuth = () => {
|
export const useAuth = () => {
|
||||||
const context = useContext(AuthContext)
|
const context = useContext(AuthContext);
|
||||||
|
|
||||||
if (!context) {
|
if (!context) {
|
||||||
throw new Error('useAuth must be used within an AuthProvider')
|
throw new Error("useAuth must be used within an AuthProvider");
|
||||||
}
|
}
|
||||||
return context
|
|
||||||
}
|
return context;
|
||||||
|
};
|
||||||
|
|
||||||
interface AuthProviderProps {
|
interface AuthProviderProps {
|
||||||
children: ReactNode
|
children: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
const STORAGE_KEYS = {
|
const STORAGE_KEYS = {
|
||||||
authUser: 'auth_user',
|
authUser: "auth_user",
|
||||||
authToken: 'auth_token'
|
} as const;
|
||||||
} as const
|
|
||||||
|
|
||||||
export const AuthProvider = ({ children }: AuthProviderProps) => {
|
export const AuthProvider = ({ children }: AuthProviderProps) => {
|
||||||
const [user, setUser] = useState<AuthUser | null>(null)
|
const [user, setUser] = useState<AuthUser | null>(null);
|
||||||
const [isLoading, setIsLoading] = useState(true)
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
// Check if user is authenticated on mount
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Check for stored token and user data
|
|
||||||
const token =
|
const token =
|
||||||
typeof window !== 'undefined' ? localStorage.getItem(STORAGE_KEYS.authToken) : null
|
typeof window !== "undefined" ? localStorage.getItem("auth_token") : null;
|
||||||
const storedUser =
|
const storedUser =
|
||||||
typeof window !== 'undefined' ? localStorage.getItem(STORAGE_KEYS.authUser) : null
|
typeof window !== "undefined"
|
||||||
|
? localStorage.getItem(STORAGE_KEYS.authUser)
|
||||||
|
: null;
|
||||||
|
|
||||||
if (token && storedUser) {
|
if (token && storedUser) {
|
||||||
try {
|
try {
|
||||||
const userData = JSON.parse(storedUser)
|
const userData = JSON.parse(storedUser) as AuthUser;
|
||||||
setUser(userData)
|
|
||||||
|
setUser(userData);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error parsing stored user data:', error)
|
console.error("Error parsing stored user data:", error);
|
||||||
// Clear invalid data
|
authService.logout();
|
||||||
localStorage.removeItem(STORAGE_KEYS.authToken)
|
localStorage.removeItem(STORAGE_KEYS.authUser);
|
||||||
localStorage.removeItem(STORAGE_KEYS.authUser)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setIsLoading(false)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const requestOTP = async (phoneNumber: string): Promise<string> => {
|
setIsLoading(false);
|
||||||
const response = await authService.requestOTP(phoneNumber)
|
}, []);
|
||||||
if (response.code === 200) {
|
|
||||||
return response.token // Return temp token for OTP verification
|
|
||||||
}
|
|
||||||
throw new Error(response.msg || 'Failed to request OTP')
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
const normalizeUser = (
|
||||||
* Normalize API user object to AuthUser (handles data vs user key and snake_case vs camelCase).
|
raw: Record<string, unknown> | null | undefined,
|
||||||
*/
|
): AuthUser => {
|
||||||
const normalizeUser = (raw: Record<string, unknown> | null | undefined): AuthUser => {
|
if (!raw || typeof raw !== "object") {
|
||||||
if (!raw || typeof raw !== 'object') {
|
|
||||||
return {
|
return {
|
||||||
id: 0,
|
id: 0,
|
||||||
username: '',
|
username: "",
|
||||||
email: '',
|
email: "",
|
||||||
first_name: '',
|
first_name: "",
|
||||||
last_name: '',
|
last_name: "",
|
||||||
phone_number: ''
|
phone_number: "",
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
const first = (raw.first_name as string) ?? (raw.firstName as string) ?? ''
|
|
||||||
const last = (raw.last_name as string) ?? (raw.lastName as string) ?? ''
|
|
||||||
return {
|
return {
|
||||||
id: Number(raw.id) || 0,
|
id: Number(raw.id) || 0,
|
||||||
username: (raw.username as string) ?? '',
|
username: (raw.username as string) ?? "",
|
||||||
email: (raw.email as string) ?? '',
|
email: (raw.email as string) ?? "",
|
||||||
first_name: first,
|
first_name: (raw.first_name as string) ?? (raw.firstName as string) ?? "",
|
||||||
last_name: last,
|
last_name: (raw.last_name as string) ?? (raw.lastName as string) ?? "",
|
||||||
phone_number: (raw.phone_number as string) ?? (raw.phoneNumber as string) ?? ''
|
phone_number:
|
||||||
}
|
(raw.phone_number as string) ?? (raw.phoneNumber as string) ?? "",
|
||||||
}
|
};
|
||||||
|
};
|
||||||
|
|
||||||
const login = async (phoneNumber: string, otpCode: string, tempToken: string): Promise<LoginResult> => {
|
const persistUser = (userData: AuthUser) => {
|
||||||
const response = await authService.verifyOTP(tempToken, otpCode) as {
|
setUser(userData);
|
||||||
code: number
|
|
||||||
msg?: string
|
if (typeof window !== "undefined") {
|
||||||
data?: AuthUser | Record<string, unknown>
|
localStorage.setItem(STORAGE_KEYS.authUser, JSON.stringify(userData));
|
||||||
user?: AuthUser | Record<string, unknown>
|
}
|
||||||
token?: string
|
};
|
||||||
|
|
||||||
|
const authenticate = (
|
||||||
|
response: AuthResponse,
|
||||||
|
fallbackMessage: string,
|
||||||
|
): LoginResult => {
|
||||||
|
if (!response.data || !response.token?.access) {
|
||||||
|
throw new Error(response.msg || fallbackMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (response.code !== 200 || !response.token) {
|
const userData = normalizeUser(
|
||||||
throw new Error(response.msg || 'OTP verification failed')
|
response.data as unknown as Record<string, unknown>,
|
||||||
|
);
|
||||||
|
|
||||||
|
persistUser(userData);
|
||||||
|
|
||||||
|
return { user: userData };
|
||||||
|
};
|
||||||
|
|
||||||
|
const login = async (
|
||||||
|
identifier: string,
|
||||||
|
password: string,
|
||||||
|
): Promise<LoginResult> => {
|
||||||
|
const response = await authService.login({ identifier, password });
|
||||||
|
|
||||||
|
if (response.code !== 200) {
|
||||||
|
throw new Error(response.msg || "Login failed");
|
||||||
}
|
}
|
||||||
|
|
||||||
const rawUser = response.data ?? response.user
|
return authenticate(response, "Login failed");
|
||||||
const partialUserRaw =
|
};
|
||||||
rawUser && typeof rawUser === 'object' && rawUser !== null
|
|
||||||
? (rawUser as Record<string, unknown>)
|
|
||||||
: undefined
|
|
||||||
|
|
||||||
const userData: AuthUser = partialUserRaw
|
const register = async (payload: RegisterPayload): Promise<LoginResult> => {
|
||||||
? normalizeUser(partialUserRaw)
|
const response = await authService.register(payload);
|
||||||
: normalizeUser(undefined)
|
|
||||||
setUser(userData)
|
if (response.code !== 200 && response.code !== 201) {
|
||||||
if (typeof window !== 'undefined') {
|
throw new Error(response.msg || "Registration failed");
|
||||||
localStorage.setItem(STORAGE_KEYS.authUser, JSON.stringify(userData))
|
|
||||||
}
|
}
|
||||||
return { user: userData }
|
|
||||||
}
|
return authenticate(response, "Registration failed");
|
||||||
|
};
|
||||||
|
|
||||||
const logout = () => {
|
const logout = () => {
|
||||||
authService.logout()
|
authService.logout();
|
||||||
setUser(null)
|
setUser(null);
|
||||||
if (typeof window !== 'undefined') {
|
|
||||||
localStorage.removeItem(STORAGE_KEYS.authUser)
|
if (typeof window !== "undefined") {
|
||||||
|
localStorage.removeItem(STORAGE_KEYS.authUser);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
const value: AuthContextType = {
|
const value: AuthContextType = {
|
||||||
user,
|
user,
|
||||||
isLoading,
|
isLoading,
|
||||||
isAuthenticated: !!user,
|
isAuthenticated: !!user,
|
||||||
login,
|
login,
|
||||||
requestOTP,
|
register,
|
||||||
logout
|
logout,
|
||||||
}
|
};
|
||||||
|
|
||||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
|
|
||||||
}
|
|
||||||
|
|
||||||
|
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||||
|
};
|
||||||
|
|||||||
+119
-67
@@ -2,39 +2,67 @@
|
|||||||
* API Client for communicating with Backend via Envoy Gateway
|
* API Client for communicating with Backend via Envoy Gateway
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || process.env.ENVOY_GATEWAY_URL || 'http://85.208.253.135:8000'
|
const API_BASE_URL =
|
||||||
|
process.env.NEXT_PUBLIC_API_URL ||
|
||||||
|
process.env.ENVOY_GATEWAY_URL ||
|
||||||
|
"http://85.208.253.135:8000";
|
||||||
|
|
||||||
|
const AUTH_STORAGE_KEYS = {
|
||||||
|
accessToken: "auth_token",
|
||||||
|
refreshToken: "auth_refresh_token",
|
||||||
|
} as const;
|
||||||
|
|
||||||
export interface ApiError {
|
export interface ApiError {
|
||||||
message: string
|
message: string;
|
||||||
code?: number
|
code?: number;
|
||||||
details?: any
|
details?: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ApiClient {
|
export class ApiClient {
|
||||||
private baseURL: string
|
private baseURL: string;
|
||||||
private defaultHeaders: Record<string, string>
|
private defaultHeaders: Record<string, string>;
|
||||||
|
|
||||||
constructor(baseURL: string = API_BASE_URL) {
|
constructor(baseURL: string = API_BASE_URL) {
|
||||||
this.baseURL = baseURL.replace(/\/$/, '') // Remove trailing slash
|
this.baseURL = baseURL.replace(/\/$/, ""); // Remove trailing slash
|
||||||
this.defaultHeaders = {
|
this.defaultHeaders = {
|
||||||
'Content-Type': 'application/json',
|
"Content-Type": "application/json",
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get authorization token from localStorage
|
* Get authorization token from localStorage
|
||||||
*/
|
*/
|
||||||
private getAuthToken(): string | null {
|
private getAuthToken(): string | null {
|
||||||
if (typeof window === 'undefined') return null
|
if (typeof window === "undefined") return null;
|
||||||
return localStorage.getItem('auth_token')
|
return localStorage.getItem(AUTH_STORAGE_KEYS.accessToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set authorization token in localStorage
|
* Set authorization token in localStorage
|
||||||
*/
|
*/
|
||||||
setAuthToken(token: string): void {
|
setAuthToken(token: string): void {
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== "undefined") {
|
||||||
localStorage.setItem('auth_token', token)
|
localStorage.setItem(AUTH_STORAGE_KEYS.accessToken, token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set refresh token in localStorage
|
||||||
|
*/
|
||||||
|
setRefreshToken(token: string): void {
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
localStorage.setItem(AUTH_STORAGE_KEYS.refreshToken, token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set access and refresh tokens in localStorage
|
||||||
|
*/
|
||||||
|
setAuthTokens(tokens: { access: string; refresh?: string }): void {
|
||||||
|
this.setAuthToken(tokens.access);
|
||||||
|
|
||||||
|
if (tokens.refresh) {
|
||||||
|
this.setRefreshToken(tokens.refresh);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,23 +70,26 @@ export class ApiClient {
|
|||||||
* Clear authorization token
|
* Clear authorization token
|
||||||
*/
|
*/
|
||||||
clearAuthToken(): void {
|
clearAuthToken(): void {
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== "undefined") {
|
||||||
localStorage.removeItem('auth_token')
|
localStorage.removeItem(AUTH_STORAGE_KEYS.accessToken);
|
||||||
|
localStorage.removeItem(AUTH_STORAGE_KEYS.refreshToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get headers with authentication token
|
* Get headers with authentication token
|
||||||
*/
|
*/
|
||||||
private getHeaders(customHeaders?: Record<string, string>): Record<string, string> {
|
private getHeaders(
|
||||||
const headers = { ...this.defaultHeaders, ...customHeaders }
|
customHeaders?: Record<string, string>,
|
||||||
const token = this.getAuthToken()
|
): Record<string, string> {
|
||||||
|
const headers = { ...this.defaultHeaders, ...customHeaders };
|
||||||
|
const token = this.getAuthToken();
|
||||||
|
|
||||||
if (token) {
|
if (token) {
|
||||||
headers['Authorization'] = `Bearer ${token}`
|
headers["Authorization"] = `Bearer ${token}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return headers
|
return headers;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -66,121 +97,142 @@ export class ApiClient {
|
|||||||
*/
|
*/
|
||||||
private async handleResponse<T>(response: Response): Promise<T> {
|
private async handleResponse<T>(response: Response): Promise<T> {
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
let errorData: any
|
let errorData: any;
|
||||||
try {
|
try {
|
||||||
errorData = await response.json()
|
errorData = await response.json();
|
||||||
} catch {
|
} catch {
|
||||||
errorData = { message: response.statusText }
|
errorData = { message: response.statusText };
|
||||||
}
|
}
|
||||||
|
|
||||||
const error: ApiError = {
|
const error: ApiError = {
|
||||||
message: errorData.msg || errorData.message || 'An error occurred',
|
message: errorData.msg || errorData.message || "An error occurred",
|
||||||
code: errorData.code || response.status,
|
code: errorData.code || response.status,
|
||||||
details: errorData
|
details: errorData,
|
||||||
}
|
};
|
||||||
|
|
||||||
// If unauthorized, clear token
|
// If unauthorized, clear token
|
||||||
if (response.status === 401) {
|
if (response.status === 401) {
|
||||||
this.clearAuthToken()
|
this.clearAuthToken();
|
||||||
}
|
}
|
||||||
|
|
||||||
throw error
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle empty responses
|
// Handle empty responses
|
||||||
const contentType = response.headers.get('content-type')
|
const contentType = response.headers.get("content-type");
|
||||||
if (!contentType || !contentType.includes('application/json')) {
|
if (!contentType || !contentType.includes("application/json")) {
|
||||||
return {} as T
|
return {} as T;
|
||||||
}
|
}
|
||||||
|
|
||||||
return response.json()
|
return response.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET request
|
* GET request
|
||||||
*/
|
*/
|
||||||
async get<T>(endpoint: string, customHeaders?: Record<string, string>): Promise<T> {
|
async get<T>(
|
||||||
const url = `${this.baseURL}${endpoint}`
|
endpoint: string,
|
||||||
|
customHeaders?: Record<string, string>,
|
||||||
|
): Promise<T> {
|
||||||
|
const url = `${this.baseURL}${endpoint}`;
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
method: 'GET',
|
method: "GET",
|
||||||
headers: this.getHeaders(customHeaders),
|
headers: this.getHeaders(customHeaders),
|
||||||
})
|
});
|
||||||
|
|
||||||
return this.handleResponse<T>(response)
|
return this.handleResponse<T>(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST request
|
* POST request
|
||||||
*/
|
*/
|
||||||
async post<T>(endpoint: string, data?: any, customHeaders?: Record<string, string>): Promise<T> {
|
async post<T>(
|
||||||
const url = `${this.baseURL}${endpoint}`
|
endpoint: string,
|
||||||
|
data?: any,
|
||||||
|
customHeaders?: Record<string, string>,
|
||||||
|
): Promise<T> {
|
||||||
|
const url = `${this.baseURL}${endpoint}`;
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
method: 'POST',
|
method: "POST",
|
||||||
headers: this.getHeaders(customHeaders),
|
headers: this.getHeaders(customHeaders),
|
||||||
body: data ? JSON.stringify(data) : undefined,
|
body: data ? JSON.stringify(data) : undefined,
|
||||||
})
|
});
|
||||||
|
|
||||||
return this.handleResponse<T>(response)
|
return this.handleResponse<T>(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST request with FormData (e.g. file upload). Does not set Content-Type so browser sets multipart/form-data.
|
* POST request with FormData (e.g. file upload). Does not set Content-Type so browser sets multipart/form-data.
|
||||||
*/
|
*/
|
||||||
async postFormData<T>(endpoint: string, formData: FormData, customHeaders?: Record<string, string>): Promise<T> {
|
async postFormData<T>(
|
||||||
const url = `${this.baseURL}${endpoint}`
|
endpoint: string,
|
||||||
const headers = { ...this.getHeaders(customHeaders) }
|
formData: FormData,
|
||||||
delete headers['Content-Type']
|
customHeaders?: Record<string, string>,
|
||||||
|
): Promise<T> {
|
||||||
|
const url = `${this.baseURL}${endpoint}`;
|
||||||
|
const headers = { ...this.getHeaders(customHeaders) };
|
||||||
|
delete headers["Content-Type"];
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
method: 'POST',
|
method: "POST",
|
||||||
headers,
|
headers,
|
||||||
body: formData,
|
body: formData,
|
||||||
})
|
});
|
||||||
|
|
||||||
return this.handleResponse<T>(response)
|
return this.handleResponse<T>(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PUT request
|
* PUT request
|
||||||
*/
|
*/
|
||||||
async put<T>(endpoint: string, data?: any, customHeaders?: Record<string, string>): Promise<T> {
|
async put<T>(
|
||||||
const url = `${this.baseURL}${endpoint}`
|
endpoint: string,
|
||||||
|
data?: any,
|
||||||
|
customHeaders?: Record<string, string>,
|
||||||
|
): Promise<T> {
|
||||||
|
const url = `${this.baseURL}${endpoint}`;
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
method: 'PUT',
|
method: "PUT",
|
||||||
headers: this.getHeaders(customHeaders),
|
headers: this.getHeaders(customHeaders),
|
||||||
body: data ? JSON.stringify(data) : undefined,
|
body: data ? JSON.stringify(data) : undefined,
|
||||||
})
|
});
|
||||||
|
|
||||||
return this.handleResponse<T>(response)
|
return this.handleResponse<T>(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PATCH request
|
* PATCH request
|
||||||
*/
|
*/
|
||||||
async patch<T>(endpoint: string, data?: any, customHeaders?: Record<string, string>): Promise<T> {
|
async patch<T>(
|
||||||
const url = `${this.baseURL}${endpoint}`
|
endpoint: string,
|
||||||
|
data?: any,
|
||||||
|
customHeaders?: Record<string, string>,
|
||||||
|
): Promise<T> {
|
||||||
|
const url = `${this.baseURL}${endpoint}`;
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
method: 'PATCH',
|
method: "PATCH",
|
||||||
headers: this.getHeaders(customHeaders),
|
headers: this.getHeaders(customHeaders),
|
||||||
body: data ? JSON.stringify(data) : undefined,
|
body: data ? JSON.stringify(data) : undefined,
|
||||||
})
|
});
|
||||||
|
|
||||||
return this.handleResponse<T>(response)
|
return this.handleResponse<T>(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* DELETE request
|
* DELETE request
|
||||||
*/
|
*/
|
||||||
async delete<T>(endpoint: string, customHeaders?: Record<string, string>): Promise<T> {
|
async delete<T>(
|
||||||
const url = `${this.baseURL}${endpoint}`
|
endpoint: string,
|
||||||
|
customHeaders?: Record<string, string>,
|
||||||
|
): Promise<T> {
|
||||||
|
const url = `${this.baseURL}${endpoint}`;
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
method: 'DELETE',
|
method: "DELETE",
|
||||||
headers: this.getHeaders(customHeaders),
|
headers: this.getHeaders(customHeaders),
|
||||||
})
|
});
|
||||||
|
|
||||||
return this.handleResponse<T>(response)
|
return this.handleResponse<T>(response);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Export singleton instance
|
// Export singleton instance
|
||||||
export const apiClient = new ApiClient()
|
export const apiClient = new ApiClient();
|
||||||
|
|
||||||
|
|||||||
+22
-21
@@ -2,25 +2,26 @@
|
|||||||
* API Services Export
|
* API Services Export
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export * from './client'
|
export * from "./client";
|
||||||
export * from './types'
|
export * from "./types";
|
||||||
export {
|
export {
|
||||||
type RequestOTPRequest,
|
|
||||||
type RequestOTPResponse,
|
|
||||||
type VerifyOTPRequest,
|
|
||||||
type AuthUser,
|
type AuthUser,
|
||||||
type VerifyOTPResponse,
|
type AuthTokens,
|
||||||
|
type LoginRequest,
|
||||||
|
type RegisterRequest,
|
||||||
|
type AuthResponse,
|
||||||
|
type RefreshTokenResponse,
|
||||||
type UpdateProfilePayload,
|
type UpdateProfilePayload,
|
||||||
type UpdateProfileResponse,
|
type UpdateProfileResponse,
|
||||||
authService
|
authService,
|
||||||
} from './services/authService'
|
} from "./services/authService";
|
||||||
export * from './services/taskService'
|
export * from "./services/taskService";
|
||||||
export * from './services/eventService'
|
export * from "./services/eventService";
|
||||||
export * from './services/simulatorService'
|
export * from "./services/simulatorService";
|
||||||
export * from './services/chatService'
|
export * from "./services/chatService";
|
||||||
export * from './services/aiChatService'
|
export * from "./services/aiChatService";
|
||||||
export * from './services/kanbanService'
|
export * from "./services/kanbanService";
|
||||||
export * from './services/todoService'
|
export * from "./services/todoService";
|
||||||
export {
|
export {
|
||||||
type User,
|
type User,
|
||||||
type UserDetails,
|
type UserDetails,
|
||||||
@@ -29,12 +30,12 @@ export {
|
|||||||
type UpdateProfileRequest,
|
type UpdateProfileRequest,
|
||||||
type AddAccountRequest,
|
type AddAccountRequest,
|
||||||
type UpdateAccountRequest,
|
type UpdateAccountRequest,
|
||||||
userManagementService
|
userManagementService,
|
||||||
} from './services/userManagementService'
|
} from "./services/userManagementService";
|
||||||
export * from './services/rolesPermissionsService'
|
export * from "./services/rolesPermissionsService";
|
||||||
export * from './services/sensorHubService'
|
export * from "./services/sensorHubService";
|
||||||
export {
|
export {
|
||||||
type FarmDashboardConfigResponse,
|
type FarmDashboardConfigResponse,
|
||||||
type FarmDashboardCardsResponse,
|
type FarmDashboardCardsResponse,
|
||||||
farmDashboardService
|
farmDashboardService,
|
||||||
} from './services/farmDashboardService'
|
} from "./services/farmDashboardService";
|
||||||
|
|||||||
@@ -1,95 +1,132 @@
|
|||||||
/**
|
/**
|
||||||
* Authentication Service
|
* Authentication Service
|
||||||
* Handles OTP-based authentication with the backend
|
* Handles password-based authentication with the backend
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { apiClient } from '../client'
|
import { apiClient } from "../client";
|
||||||
|
|
||||||
export interface RequestOTPRequest {
|
|
||||||
phone_number: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RequestOTPResponse {
|
|
||||||
code: number
|
|
||||||
msg: string
|
|
||||||
token: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface VerifyOTPRequest {
|
|
||||||
token: string
|
|
||||||
otp_code: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AuthUser {
|
export interface AuthUser {
|
||||||
id: number
|
id: number;
|
||||||
username: string
|
username: string;
|
||||||
email: string
|
email: string;
|
||||||
first_name: string
|
first_name: string;
|
||||||
last_name: string
|
last_name: string;
|
||||||
phone_number: string
|
phone_number: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface VerifyOTPResponse {
|
export interface AuthTokens {
|
||||||
code: number
|
access: string;
|
||||||
msg: string
|
refresh: string;
|
||||||
data: AuthUser
|
}
|
||||||
token: string
|
|
||||||
|
export interface LoginRequest {
|
||||||
|
identifier: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RegisterRequest {
|
||||||
|
username: string;
|
||||||
|
email: string;
|
||||||
|
phone_number: string;
|
||||||
|
password: string;
|
||||||
|
first_name?: string;
|
||||||
|
last_name?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthResponse {
|
||||||
|
code: number;
|
||||||
|
msg: string;
|
||||||
|
data: AuthUser;
|
||||||
|
token: AuthTokens;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RefreshTokenResponse {
|
||||||
|
access: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateProfilePayload {
|
export interface UpdateProfilePayload {
|
||||||
first_name: string
|
first_name?: string;
|
||||||
last_name: string
|
last_name?: string;
|
||||||
email: string
|
email?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateProfileResponse {
|
export interface UpdateProfileResponse {
|
||||||
code: number
|
code: number;
|
||||||
msg: string
|
msg: string;
|
||||||
data: AuthUser
|
data: AuthUser;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const authService = {
|
export const authService = {
|
||||||
/**
|
/**
|
||||||
* Request OTP for phone number authentication
|
* Login with username, email, or phone number
|
||||||
*/
|
*/
|
||||||
async requestOTP(phoneNumber: string): Promise<RequestOTPResponse> {
|
async login(payload: LoginRequest): Promise<AuthResponse> {
|
||||||
return apiClient.post<RequestOTPResponse>('/api/auth/request-otp/', {
|
const response = await apiClient.post<AuthResponse>(
|
||||||
phone_number: phoneNumber
|
"/api/auth/login/",
|
||||||
})
|
payload,
|
||||||
},
|
);
|
||||||
|
|
||||||
/**
|
if (response.token?.access) {
|
||||||
* Verify OTP code and get JWT token
|
apiClient.setAuthTokens(response.token);
|
||||||
*/
|
|
||||||
async verifyOTP(token: string, otpCode: string): Promise<VerifyOTPResponse> {
|
|
||||||
const response = await apiClient.post<VerifyOTPResponse>('/api/auth/verify-otp/', {
|
|
||||||
token,
|
|
||||||
otp_code: otpCode
|
|
||||||
})
|
|
||||||
|
|
||||||
if ( response.token) {
|
|
||||||
apiClient.setAuthToken(response.token)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return response
|
return response;
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update user profile (first name, last name, email)
|
* Register a new user
|
||||||
*/
|
*/
|
||||||
async updateProfile(payload: UpdateProfilePayload): Promise<UpdateProfileResponse> {
|
async register(payload: RegisterRequest): Promise<AuthResponse> {
|
||||||
const response = await apiClient.patch<UpdateProfileResponse>('/users/me', payload)
|
const response = await apiClient.post<AuthResponse>(
|
||||||
|
"/api/auth/register/",
|
||||||
|
payload,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.token?.access) {
|
||||||
|
apiClient.setAuthTokens(response.token);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refresh access token
|
||||||
|
*/
|
||||||
|
async refreshToken(refresh: string): Promise<RefreshTokenResponse> {
|
||||||
|
const response = await apiClient.post<RefreshTokenResponse>(
|
||||||
|
"/api/auth/token/refresh/",
|
||||||
|
{ refresh },
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.access) {
|
||||||
|
apiClient.setAuthToken(response.access);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update user profile
|
||||||
|
*/
|
||||||
|
async updateProfile(
|
||||||
|
payload: UpdateProfilePayload,
|
||||||
|
): Promise<UpdateProfileResponse> {
|
||||||
|
const response = await apiClient.patch<UpdateProfileResponse>(
|
||||||
|
"/api/account/profile/",
|
||||||
|
payload,
|
||||||
|
);
|
||||||
|
|
||||||
if (response.code !== 200 || !response.data) {
|
if (response.code !== 200 || !response.data) {
|
||||||
throw new Error(response.msg || 'Failed to update profile')
|
throw new Error(response.msg || "Failed to update profile");
|
||||||
}
|
}
|
||||||
return response
|
|
||||||
|
return response;
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Logout - clear authentication token
|
* Logout - clear authentication tokens
|
||||||
*/
|
*/
|
||||||
logout(): void {
|
logout(): void {
|
||||||
apiClient.clearAuthToken()
|
apiClient.clearAuthToken();
|
||||||
}
|
},
|
||||||
}
|
};
|
||||||
|
|
||||||
|
|||||||
+17
-16
@@ -3,43 +3,44 @@
|
|||||||
* Client-side authentication helpers
|
* Client-side authentication helpers
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { AuthUser } from './api/services/authService'
|
import type { AuthUser } from "./api/services/authService";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get authentication token from localStorage
|
* Get authentication token from localStorage
|
||||||
*/
|
*/
|
||||||
export const getAuthToken = (): string | null => {
|
export const getAuthToken = (): string | null => {
|
||||||
if (typeof window === 'undefined') return null
|
if (typeof window === "undefined") return null;
|
||||||
return localStorage.getItem('auth_token')
|
return localStorage.getItem("auth_token");
|
||||||
}
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get authenticated user from localStorage
|
* Get authenticated user from localStorage
|
||||||
*/
|
*/
|
||||||
export const getAuthUser = (): AuthUser | null => {
|
export const getAuthUser = (): AuthUser | null => {
|
||||||
if (typeof window === 'undefined') return null
|
if (typeof window === "undefined") return null;
|
||||||
const userStr = localStorage.getItem('auth_user')
|
const userStr = localStorage.getItem("auth_user");
|
||||||
if (!userStr) return null
|
if (!userStr) return null;
|
||||||
try {
|
try {
|
||||||
return JSON.parse(userStr)
|
return JSON.parse(userStr);
|
||||||
} catch {
|
} catch {
|
||||||
return null
|
return null;
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if user is authenticated
|
* Check if user is authenticated
|
||||||
*/
|
*/
|
||||||
export const isAuthenticated = (): boolean => {
|
export const isAuthenticated = (): boolean => {
|
||||||
return !!getAuthToken() && !!getAuthUser()
|
return !!getAuthToken() && !!getAuthUser();
|
||||||
}
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clear authentication data
|
* Clear authentication data
|
||||||
*/
|
*/
|
||||||
export const clearAuth = (): void => {
|
export const clearAuth = (): void => {
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== "undefined") {
|
||||||
localStorage.removeItem('auth_token')
|
localStorage.removeItem("auth_token");
|
||||||
localStorage.removeItem('auth_user')
|
localStorage.removeItem("auth_refresh_token");
|
||||||
|
localStorage.removeItem("auth_user");
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|||||||
+207
-266
@@ -1,311 +1,252 @@
|
|||||||
'use client'
|
"use client";
|
||||||
|
|
||||||
// React Imports
|
// React Imports
|
||||||
import { useMemo, useState } from 'react'
|
import { useMemo, useState } from "react";
|
||||||
|
|
||||||
// Next Imports
|
// Next Imports
|
||||||
import Link from 'next/link'
|
import Link from "next/link";
|
||||||
import { useRouter, useSearchParams } from 'next/navigation'
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
import { useTranslations } from 'next-intl'
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
// MUI Imports
|
// MUI Imports
|
||||||
import useMediaQuery from '@mui/material/useMediaQuery'
|
import Card from "@mui/material/Card";
|
||||||
import { styled, useTheme } from '@mui/material/styles'
|
import CardContent from "@mui/material/CardContent";
|
||||||
import Typography from '@mui/material/Typography'
|
import Typography from "@mui/material/Typography";
|
||||||
import Button from '@mui/material/Button'
|
import IconButton from "@mui/material/IconButton";
|
||||||
import Alert from '@mui/material/Alert'
|
import InputAdornment from "@mui/material/InputAdornment";
|
||||||
import CircularProgress from '@mui/material/CircularProgress'
|
import Checkbox from "@mui/material/Checkbox";
|
||||||
|
import Button from "@mui/material/Button";
|
||||||
|
import FormControlLabel from "@mui/material/FormControlLabel";
|
||||||
|
import Alert from "@mui/material/Alert";
|
||||||
|
import CircularProgress from "@mui/material/CircularProgress";
|
||||||
|
|
||||||
// Third-party Imports
|
// Third-party Imports
|
||||||
import { Controller, useForm } from 'react-hook-form'
|
import { Controller, useForm } from "react-hook-form";
|
||||||
import { valibotResolver } from '@hookform/resolvers/valibot'
|
import { valibotResolver } from "@hookform/resolvers/valibot";
|
||||||
import { object, minLength, maxLength, string, pipe, nonEmpty, custom } from 'valibot'
|
import { object, minLength, string, pipe, nonEmpty } from "valibot";
|
||||||
import type { SubmitHandler } from 'react-hook-form'
|
import type { SubmitHandler } from "react-hook-form";
|
||||||
import classnames from 'classnames'
|
|
||||||
import { OTPInput } from 'input-otp'
|
|
||||||
|
|
||||||
// Type Imports
|
|
||||||
import type { SystemMode } from '@core/types'
|
|
||||||
|
|
||||||
// Component Imports
|
// Component Imports
|
||||||
import Logo from '@components/layout/shared/Logo'
|
import Logo from "@components/layout/shared/Logo";
|
||||||
import CustomTextField from '@core/components/mui/TextField'
|
import CustomTextField from "@core/components/mui/TextField";
|
||||||
|
import AuthIllustrationWrapper from "@views/pages/auth/AuthIllustrationWrapper";
|
||||||
|
|
||||||
// Context Imports
|
// Context Imports
|
||||||
import { useAuth } from '@/contexts/authContext'
|
import { useAuth } from "@/contexts/authContext";
|
||||||
|
|
||||||
// Config Imports
|
// Config Imports
|
||||||
import themeConfig from '@configs/themeConfig'
|
import themeConfig from "@configs/themeConfig";
|
||||||
|
|
||||||
// Hook Imports
|
type LoginFormData = {
|
||||||
import { useImageVariant } from '@core/hooks/useImageVariant'
|
identifier: string;
|
||||||
import { useSettings } from '@core/hooks/useSettings'
|
password: string;
|
||||||
|
};
|
||||||
|
|
||||||
// Styled Custom Components
|
const getErrorMessage = (error: unknown, fallbackMessage: string) => {
|
||||||
const LoginIllustration = styled('img')(({ theme }) => ({
|
if (error instanceof Error) return error.message;
|
||||||
zIndex: 2,
|
|
||||||
blockSize: 'auto',
|
if (typeof error === "object" && error !== null && "message" in error) {
|
||||||
maxBlockSize: 680,
|
return String(error.message);
|
||||||
maxInlineSize: '100%',
|
|
||||||
margin: theme.spacing(12),
|
|
||||||
[theme.breakpoints.down(1536)]: {
|
|
||||||
maxBlockSize: 550
|
|
||||||
},
|
|
||||||
[theme.breakpoints.down('lg')]: {
|
|
||||||
maxBlockSize: 450
|
|
||||||
}
|
}
|
||||||
}))
|
|
||||||
|
|
||||||
const MaskImg = styled('img')({
|
return fallbackMessage;
|
||||||
blockSize: 'auto',
|
};
|
||||||
maxBlockSize: 355,
|
|
||||||
inlineSize: '100%',
|
|
||||||
position: 'absolute',
|
|
||||||
insetBlockEnd: 0,
|
|
||||||
zIndex: -1
|
|
||||||
})
|
|
||||||
|
|
||||||
const OtpContainer = styled('div')({
|
const Login = () => {
|
||||||
display: 'flex',
|
const t = useTranslations("login");
|
||||||
justifyContent: 'center',
|
const router = useRouter();
|
||||||
gap: '8px',
|
const searchParams = useSearchParams();
|
||||||
marginTop: '16px'
|
const { login } = useAuth();
|
||||||
})
|
|
||||||
|
|
||||||
type ErrorType = {
|
const [isPasswordShown, setIsPasswordShown] = useState(false);
|
||||||
message: string
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||||
}
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
type PhoneFormData = {
|
const loginSchema = useMemo(
|
||||||
phone_number: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const Login = ({ mode }: { mode: SystemMode }) => {
|
|
||||||
const t = useTranslations('login')
|
|
||||||
|
|
||||||
const phoneSchema = useMemo(
|
|
||||||
() =>
|
() =>
|
||||||
object({
|
object({
|
||||||
phone_number: pipe(
|
identifier: pipe(
|
||||||
string(),
|
string(),
|
||||||
nonEmpty(String(t('validation.phoneRequired'))),
|
nonEmpty(String(t("validation.identifierRequired"))),
|
||||||
minLength(10, String(t('validation.phoneMinLength'))),
|
),
|
||||||
maxLength(15, String(t('validation.phoneMaxLength'))),
|
password: pipe(
|
||||||
custom((input) => /^[0-9]+$/.test(String(input)), String(t('validation.phoneDigitsOnly')))
|
string(),
|
||||||
)
|
nonEmpty(String(t("validation.passwordRequired"))),
|
||||||
|
minLength(8, String(t("validation.passwordMinLength"))),
|
||||||
|
),
|
||||||
}),
|
}),
|
||||||
[t]
|
[t],
|
||||||
)
|
);
|
||||||
// States
|
|
||||||
const [step, setStep] = useState<'phone' | 'otp'>('phone')
|
|
||||||
const [otp, setOtp] = useState('')
|
|
||||||
const [tempToken, setTempToken] = useState<string>('')
|
|
||||||
const [errorState, setErrorState] = useState<ErrorType | null>(null)
|
|
||||||
const [isLoading, setIsLoading] = useState(false)
|
|
||||||
|
|
||||||
// Vars
|
|
||||||
const darkImg = '/images/pages/auth-mask-dark.png'
|
|
||||||
const lightImg = '/images/pages/auth-mask-light.png'
|
|
||||||
const darkIllustration = '/images/illustrations/auth/v2-login-dark.png'
|
|
||||||
const lightIllustration = '/images/illustrations/auth/v2-login-light.png'
|
|
||||||
const borderedDarkIllustration = '/images/illustrations/auth/v2-login-dark-border.png'
|
|
||||||
const borderedLightIllustration = '/images/illustrations/auth/v2-login-light-border.png'
|
|
||||||
|
|
||||||
// Hooks
|
|
||||||
const router = useRouter()
|
|
||||||
const searchParams = useSearchParams()
|
|
||||||
const { settings } = useSettings()
|
|
||||||
const theme = useTheme()
|
|
||||||
const hidden = useMediaQuery(theme.breakpoints.down('md'))
|
|
||||||
const authBackground = useImageVariant(mode, lightImg, darkImg)
|
|
||||||
const { requestOTP, login } = useAuth()
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
control,
|
control,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
getValues,
|
formState: { errors },
|
||||||
formState: { errors }
|
} = useForm<LoginFormData>({
|
||||||
} = useForm<PhoneFormData>({
|
resolver: valibotResolver(loginSchema) as never,
|
||||||
resolver: valibotResolver(phoneSchema) as any,
|
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
phone_number: ''
|
identifier: "",
|
||||||
}
|
password: "",
|
||||||
})
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const characterIllustration = useImageVariant(
|
const handleClickShowPassword = () => setIsPasswordShown((show) => !show);
|
||||||
mode,
|
|
||||||
lightIllustration,
|
|
||||||
darkIllustration,
|
|
||||||
borderedLightIllustration,
|
|
||||||
borderedDarkIllustration
|
|
||||||
)
|
|
||||||
|
|
||||||
const onPhoneSubmit: SubmitHandler<PhoneFormData> = async (data: PhoneFormData) => {
|
const onSubmit: SubmitHandler<LoginFormData> = async (data) => {
|
||||||
setIsLoading(true)
|
setIsSubmitting(true);
|
||||||
setErrorState(null)
|
setErrorMessage(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const token = await requestOTP(data.phone_number)
|
await login(data.identifier.trim(), data.password);
|
||||||
setTempToken(token)
|
|
||||||
setStep('otp')
|
const redirectURL = searchParams.get("redirectTo") ?? "/";
|
||||||
} catch (error: any) {
|
|
||||||
setErrorState({ message: error.message || t('errors.sendOtpFailed') })
|
router.replace(redirectURL);
|
||||||
|
} catch (error) {
|
||||||
|
setErrorMessage(getErrorMessage(error, String(t("errors.loginFailed"))));
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false)
|
setIsSubmitting(false);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
const onOtpSubmit = async (otpValue?: string) => {
|
|
||||||
const code = otpValue ?? otp
|
|
||||||
if (code.length !== 6) {
|
|
||||||
setErrorState({ message: t('errors.incompleteOtp') })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsLoading(true)
|
|
||||||
setErrorState(null)
|
|
||||||
|
|
||||||
try {
|
|
||||||
const phoneNumber = getValues('phone_number')
|
|
||||||
await login(phoneNumber, code, tempToken)
|
|
||||||
|
|
||||||
// Redirect on successful login
|
|
||||||
const redirectURL = searchParams.get('redirectTo') ?? '/'
|
|
||||||
router.replace(redirectURL)
|
|
||||||
} catch (error: any) {
|
|
||||||
setErrorState({ message: error.message || t('errors.otpVerificationFailed') })
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleBackToPhone = () => {
|
|
||||||
setStep('phone')
|
|
||||||
setOtp('')
|
|
||||||
setErrorState(null)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='flex bs-full justify-center'>
|
<AuthIllustrationWrapper>
|
||||||
<div
|
<Card className="flex flex-col sm:is-[450px]">
|
||||||
className={classnames(
|
<CardContent className="sm:!p-12">
|
||||||
'flex bs-full items-center justify-center flex-1 min-bs-[100dvh] relative p-6 max-md:hidden',
|
<Link href="/" className="flex justify-center mbe-6">
|
||||||
{
|
<Logo />
|
||||||
'border-ie': settings.skin === 'bordered'
|
</Link>
|
||||||
}
|
<div className="flex flex-col gap-1 mbe-6">
|
||||||
)}
|
<Typography variant="h4">
|
||||||
>
|
{t("welcome", { templateName: themeConfig.templateName })}
|
||||||
<LoginIllustration src={characterIllustration} alt='character-illustration' />
|
|
||||||
{!hidden && <MaskImg alt='mask' src={authBackground} />}
|
|
||||||
</div>
|
|
||||||
<div className='flex justify-center items-center bs-full bg-backgroundPaper !min-is-full p-6 md:!min-is-[unset] md:p-12 md:is-[480px]'>
|
|
||||||
<div className='absolute block-start-5 sm:block-start-[33px] inline-start-6 sm:inline-start-[38px]'>
|
|
||||||
<Logo />
|
|
||||||
</div>
|
|
||||||
<div className='flex flex-col gap-6 is-full sm:is-auto md:is-full sm:max-is-[400px] md:max-is-[unset] mbs-8 sm:mbs-11 md:mbs-0'>
|
|
||||||
<div className='flex flex-col gap-1'>
|
|
||||||
<Typography variant='h4'>{t('welcome', { templateName: themeConfig.templateName })}</Typography>
|
|
||||||
<Typography>
|
|
||||||
{step === 'phone' ? t('phoneStep') : t('otpStep')}
|
|
||||||
</Typography>
|
</Typography>
|
||||||
|
<Typography>{t("subtitle")}</Typography>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{errorState && (
|
{errorMessage && (
|
||||||
<Alert severity='error' onClose={() => setErrorState(null)}>
|
<Alert
|
||||||
{errorState.message}
|
severity="error"
|
||||||
|
onClose={() => setErrorMessage(null)}
|
||||||
|
className="mbe-6"
|
||||||
|
>
|
||||||
|
{errorMessage}
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{step === 'phone' ? (
|
<form
|
||||||
<form
|
noValidate
|
||||||
noValidate
|
autoComplete="off"
|
||||||
autoComplete='off'
|
onSubmit={handleSubmit(onSubmit)}
|
||||||
action={() => {}}
|
className="flex flex-col gap-6"
|
||||||
onSubmit={handleSubmit(onPhoneSubmit)}
|
>
|
||||||
className='flex flex-col gap-6'
|
<Controller
|
||||||
>
|
name="identifier"
|
||||||
<Controller
|
control={control}
|
||||||
name='phone_number'
|
render={({ field }) => (
|
||||||
control={control}
|
<CustomTextField
|
||||||
rules={{ required: true }}
|
{...field}
|
||||||
render={({ field }) => (
|
autoFocus
|
||||||
<CustomTextField
|
fullWidth
|
||||||
{...field}
|
label={t("identifier")}
|
||||||
autoFocus
|
placeholder={t("placeholderIdentifier")}
|
||||||
fullWidth
|
onChange={(event) => {
|
||||||
type='tel'
|
field.onChange(event.target.value);
|
||||||
label={t('phoneNumber')}
|
if (errorMessage) setErrorMessage(null);
|
||||||
placeholder={t('placeholderPhone')}
|
}}
|
||||||
onChange={e => {
|
{...(errors.identifier && {
|
||||||
field.onChange(e.target.value.replace(/\D/g, '')) // Only allow digits
|
error: true,
|
||||||
errorState !== null && setErrorState(null)
|
helperText: errors.identifier.message,
|
||||||
}}
|
})}
|
||||||
{...(errors.phone_number && {
|
/>
|
||||||
error: true,
|
)}
|
||||||
helperText: errors.phone_number.message
|
/>
|
||||||
})}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<Button fullWidth variant='contained' type='submit' disabled={isLoading}>
|
|
||||||
{isLoading ? <CircularProgress size={24} /> : t('sendOtp')}
|
|
||||||
</Button>
|
|
||||||
<div className='flex justify-center items-center flex-wrap gap-2'>
|
|
||||||
<Typography>{t('newUser')}</Typography>
|
|
||||||
<Typography component={Link} href='/register' color='primary.main'>
|
|
||||||
{t('createAccount')}
|
|
||||||
</Typography>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
) : (
|
|
||||||
<div className='flex flex-col gap-6'>
|
|
||||||
<div className='flex flex-col gap-4'>
|
|
||||||
<Typography variant='body2' color='text.secondary'>
|
|
||||||
{t('otpSent', { phone: getValues('phone_number') })}
|
|
||||||
</Typography>
|
|
||||||
<OtpContainer>
|
|
||||||
<OTPInput
|
|
||||||
value={otp}
|
|
||||||
onChange={(value) => {
|
|
||||||
setOtp(value)
|
|
||||||
if (value.length === 6) onOtpSubmit(value)
|
|
||||||
}}
|
|
||||||
maxLength={6}
|
|
||||||
containerClassName='flex gap-2 ltr:flex-row rtl:flex-row-reverse'
|
|
||||||
render={({ slots }) => (
|
|
||||||
<>
|
|
||||||
{slots.map((slot, idx) => (
|
|
||||||
<div
|
|
||||||
key={idx}
|
|
||||||
className={classnames(
|
|
||||||
'flex items-center justify-center w-12 h-12 text-2xl font-semibold border rounded',
|
|
||||||
{
|
|
||||||
'border-primary': slot.isActive,
|
|
||||||
'border-defaultBorder': !slot.isActive && slot.char !== null,
|
|
||||||
'border-error': errorState !== null
|
|
||||||
}
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{slot.char}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</OtpContainer>
|
|
||||||
</div>
|
|
||||||
<Button fullWidth variant='contained' onClick={() => onOtpSubmit()} disabled={isLoading || otp.length !== 6}>
|
|
||||||
{isLoading ? <CircularProgress size={24} /> : t('verifyOtp')}
|
|
||||||
</Button>
|
|
||||||
<Button fullWidth variant='text' onClick={handleBackToPhone} disabled={isLoading}>
|
|
||||||
{t('backToPhone')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default Login
|
<Controller
|
||||||
|
name="password"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<CustomTextField
|
||||||
|
{...field}
|
||||||
|
fullWidth
|
||||||
|
label={t("password")}
|
||||||
|
placeholder="············"
|
||||||
|
type={isPasswordShown ? "text" : "password"}
|
||||||
|
onChange={(event) => {
|
||||||
|
field.onChange(event.target.value);
|
||||||
|
if (errorMessage) setErrorMessage(null);
|
||||||
|
}}
|
||||||
|
slotProps={{
|
||||||
|
input: {
|
||||||
|
endAdornment: (
|
||||||
|
<InputAdornment position="end">
|
||||||
|
<IconButton
|
||||||
|
edge="end"
|
||||||
|
onClick={handleClickShowPassword}
|
||||||
|
onMouseDown={(event) => event.preventDefault()}
|
||||||
|
>
|
||||||
|
<i
|
||||||
|
className={
|
||||||
|
isPasswordShown
|
||||||
|
? "tabler-eye-off"
|
||||||
|
: "tabler-eye"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</IconButton>
|
||||||
|
</InputAdornment>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
{...(errors.password && {
|
||||||
|
error: true,
|
||||||
|
helperText: errors.password.message,
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex justify-between items-center gap-x-3 gap-y-1 flex-wrap">
|
||||||
|
<FormControlLabel
|
||||||
|
control={<Checkbox />}
|
||||||
|
label={t("rememberMe")}
|
||||||
|
/>
|
||||||
|
<Typography
|
||||||
|
className="text-end"
|
||||||
|
color="primary.main"
|
||||||
|
component={Link}
|
||||||
|
href="/forgot-password"
|
||||||
|
>
|
||||||
|
{t("forgotPassword")}
|
||||||
|
</Typography>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
fullWidth
|
||||||
|
variant="contained"
|
||||||
|
type="submit"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
{isSubmitting ? (
|
||||||
|
<CircularProgress size={24} color="inherit" />
|
||||||
|
) : (
|
||||||
|
t("submit")
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="flex justify-center items-center flex-wrap gap-2">
|
||||||
|
<Typography>{t("newUser")}</Typography>
|
||||||
|
<Typography
|
||||||
|
component={Link}
|
||||||
|
href="/register"
|
||||||
|
color="primary.main"
|
||||||
|
>
|
||||||
|
{t("createAccount")}
|
||||||
|
</Typography>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</AuthIllustrationWrapper>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Login;
|
||||||
|
|||||||
+312
-143
@@ -1,173 +1,342 @@
|
|||||||
'use client'
|
"use client";
|
||||||
|
|
||||||
// React Imports
|
// React Imports
|
||||||
import { useState } from 'react'
|
import { useMemo, useState } from "react";
|
||||||
|
|
||||||
// Next Imports
|
// Next Imports
|
||||||
import Link from 'next/link'
|
import Link from "next/link";
|
||||||
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
|
|
||||||
// MUI Imports
|
// MUI Imports
|
||||||
import useMediaQuery from '@mui/material/useMediaQuery'
|
import Card from "@mui/material/Card";
|
||||||
import { styled, useTheme } from '@mui/material/styles'
|
import CardContent from "@mui/material/CardContent";
|
||||||
import Typography from '@mui/material/Typography'
|
import Typography from "@mui/material/Typography";
|
||||||
import IconButton from '@mui/material/IconButton'
|
import IconButton from "@mui/material/IconButton";
|
||||||
import InputAdornment from '@mui/material/InputAdornment'
|
import InputAdornment from "@mui/material/InputAdornment";
|
||||||
import Checkbox from '@mui/material/Checkbox'
|
import Button from "@mui/material/Button";
|
||||||
import Button from '@mui/material/Button'
|
import Alert from "@mui/material/Alert";
|
||||||
import FormControlLabel from '@mui/material/FormControlLabel'
|
import CircularProgress from "@mui/material/CircularProgress";
|
||||||
import Divider from '@mui/material/Divider'
|
|
||||||
|
|
||||||
// Third-party Imports
|
// Third-party Imports
|
||||||
import classnames from 'classnames'
|
import { Controller, useForm } from "react-hook-form";
|
||||||
|
import { valibotResolver } from "@hookform/resolvers/valibot";
|
||||||
// Type Imports
|
import {
|
||||||
import type { SystemMode } from '@core/types'
|
object,
|
||||||
|
string,
|
||||||
|
pipe,
|
||||||
|
nonEmpty,
|
||||||
|
minLength,
|
||||||
|
maxLength,
|
||||||
|
email,
|
||||||
|
custom,
|
||||||
|
} from "valibot";
|
||||||
|
import type { SubmitHandler } from "react-hook-form";
|
||||||
|
|
||||||
// Component Imports
|
// Component Imports
|
||||||
import Logo from '@components/layout/shared/Logo'
|
import Logo from "@components/layout/shared/Logo";
|
||||||
import CustomTextField from '@core/components/mui/TextField'
|
import CustomTextField from "@core/components/mui/TextField";
|
||||||
|
import AuthIllustrationWrapper from "@views/pages/auth/AuthIllustrationWrapper";
|
||||||
|
|
||||||
// Hook Imports
|
// Context Imports
|
||||||
import { useImageVariant } from '@core/hooks/useImageVariant'
|
import { useAuth } from "@/contexts/authContext";
|
||||||
import { useSettings } from '@core/hooks/useSettings'
|
|
||||||
|
|
||||||
// Styled Custom Components
|
type RegisterFormData = {
|
||||||
const RegisterIllustration = styled('img')(({ theme }) => ({
|
username: string;
|
||||||
zIndex: 2,
|
email: string;
|
||||||
blockSize: 'auto',
|
phone_number: string;
|
||||||
maxBlockSize: 600,
|
password: string;
|
||||||
maxInlineSize: '100%',
|
first_name: string;
|
||||||
margin: theme.spacing(12),
|
last_name: string;
|
||||||
[theme.breakpoints.down(1536)]: {
|
};
|
||||||
maxBlockSize: 550
|
|
||||||
},
|
const getErrorMessage = (error: unknown, fallbackMessage: string) => {
|
||||||
[theme.breakpoints.down('lg')]: {
|
if (error instanceof Error) return error.message;
|
||||||
maxBlockSize: 450
|
|
||||||
|
if (typeof error === "object" && error !== null && "message" in error) {
|
||||||
|
return String(error.message);
|
||||||
}
|
}
|
||||||
}))
|
|
||||||
|
|
||||||
const MaskImg = styled('img')({
|
return fallbackMessage;
|
||||||
blockSize: 'auto',
|
};
|
||||||
maxBlockSize: 345,
|
|
||||||
inlineSize: '100%',
|
|
||||||
position: 'absolute',
|
|
||||||
insetBlockEnd: 0,
|
|
||||||
zIndex: -1
|
|
||||||
})
|
|
||||||
|
|
||||||
const Register = ({ mode }: { mode: SystemMode }) => {
|
const Register = () => {
|
||||||
// States
|
const router = useRouter();
|
||||||
const [isPasswordShown, setIsPasswordShown] = useState(false)
|
const searchParams = useSearchParams();
|
||||||
|
const { register } = useAuth();
|
||||||
|
|
||||||
// Vars
|
const [isPasswordShown, setIsPasswordShown] = useState(false);
|
||||||
const darkImg = '/images/pages/auth-mask-dark.png'
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||||
const lightImg = '/images/pages/auth-mask-light.png'
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const darkIllustration = '/images/illustrations/auth/v2-register-dark.png'
|
|
||||||
const lightIllustration = '/images/illustrations/auth/v2-register-light.png'
|
|
||||||
const borderedDarkIllustration = '/images/illustrations/auth/v2-register-dark-border.png'
|
|
||||||
const borderedLightIllustration = '/images/illustrations/auth/v2-register-light-border.png'
|
|
||||||
|
|
||||||
// Hooks
|
const registerSchema = useMemo(
|
||||||
const { settings } = useSettings()
|
() =>
|
||||||
const theme = useTheme()
|
object({
|
||||||
const hidden = useMediaQuery(theme.breakpoints.down('md'))
|
first_name: string(),
|
||||||
const authBackground = useImageVariant(mode, lightImg, darkImg)
|
last_name: string(),
|
||||||
|
username: pipe(string(), nonEmpty("نام کاربری الزامی است")),
|
||||||
|
email: pipe(
|
||||||
|
string(),
|
||||||
|
nonEmpty("ایمیل الزامی است"),
|
||||||
|
email("ایمیل معتبر وارد کنید"),
|
||||||
|
),
|
||||||
|
phone_number: pipe(
|
||||||
|
string(),
|
||||||
|
nonEmpty("شماره موبایل الزامی است"),
|
||||||
|
minLength(10, "شماره موبایل باید حداقل ۱۰ رقم باشد"),
|
||||||
|
maxLength(15, "شماره موبایل باید حداکثر ۱۵ رقم باشد"),
|
||||||
|
custom(
|
||||||
|
(input) => /^[0-9]+$/.test(String(input)),
|
||||||
|
"شماره موبایل باید فقط شامل عدد باشد",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
password: pipe(
|
||||||
|
string(),
|
||||||
|
nonEmpty("رمز عبور الزامی است"),
|
||||||
|
minLength(8, "رمز عبور باید حداقل ۸ کاراکتر باشد"),
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
const characterIllustration = useImageVariant(
|
const {
|
||||||
mode,
|
control,
|
||||||
lightIllustration,
|
handleSubmit,
|
||||||
darkIllustration,
|
formState: { errors },
|
||||||
borderedLightIllustration,
|
} = useForm<RegisterFormData>({
|
||||||
borderedDarkIllustration
|
resolver: valibotResolver(registerSchema) as never,
|
||||||
)
|
defaultValues: {
|
||||||
|
first_name: "",
|
||||||
|
last_name: "",
|
||||||
|
username: "",
|
||||||
|
email: "",
|
||||||
|
phone_number: "",
|
||||||
|
password: "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const handleClickShowPassword = () => setIsPasswordShown(show => !show)
|
const handleClickShowPassword = () => setIsPasswordShown((show) => !show);
|
||||||
|
|
||||||
|
const onSubmit: SubmitHandler<RegisterFormData> = async (data) => {
|
||||||
|
setIsSubmitting(true);
|
||||||
|
setErrorMessage(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await register({
|
||||||
|
first_name: data.first_name.trim(),
|
||||||
|
last_name: data.last_name.trim(),
|
||||||
|
username: data.username.trim(),
|
||||||
|
email: data.email.trim(),
|
||||||
|
phone_number: data.phone_number.trim(),
|
||||||
|
password: data.password,
|
||||||
|
});
|
||||||
|
|
||||||
|
const redirectURL = searchParams.get("redirectTo") ?? "/";
|
||||||
|
|
||||||
|
router.replace(redirectURL);
|
||||||
|
} catch (error) {
|
||||||
|
setErrorMessage(getErrorMessage(error, "ثبت نام ناموفق بود"));
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='flex bs-full justify-center'>
|
<AuthIllustrationWrapper>
|
||||||
<div
|
<Card className="flex flex-col sm:is-[480px]">
|
||||||
className={classnames(
|
<CardContent className="sm:!p-12">
|
||||||
'flex bs-full items-center justify-center flex-1 min-bs-[100dvh] relative p-6 max-md:hidden',
|
<Link href="/" className="flex justify-center mbe-6">
|
||||||
{
|
<Logo />
|
||||||
'border-ie': settings.skin === 'bordered'
|
</Link>
|
||||||
}
|
<div className="flex flex-col gap-1 mbe-6">
|
||||||
)}
|
<Typography variant="h4">ایجاد حساب کاربری</Typography>
|
||||||
>
|
<Typography>برای شروع، اطلاعات حساب خود را وارد کنید.</Typography>
|
||||||
<RegisterIllustration src={characterIllustration} alt='character-illustration' />
|
|
||||||
{!hidden && <MaskImg alt='mask' src={authBackground} />}
|
|
||||||
</div>
|
|
||||||
<div className='flex justify-center items-center bs-full bg-backgroundPaper !min-is-full p-6 md:!min-is-[unset] md:p-12 md:is-[480px]'>
|
|
||||||
<Link
|
|
||||||
href='/login'
|
|
||||||
className='absolute block-start-5 sm:block-start-[33px] inline-start-6 sm:inline-start-[38px]'
|
|
||||||
>
|
|
||||||
<Logo />
|
|
||||||
</Link>
|
|
||||||
<div className='flex flex-col gap-6 is-full sm:is-auto md:is-full sm:max-is-[400px] md:max-is-[unset] mbs-8 sm:mbs-11 md:mbs-0'>
|
|
||||||
<div className='flex flex-col gap-1'>
|
|
||||||
<Typography variant='h4'>ماجراجویی از اینجا شروع میشود 🚀</Typography>
|
|
||||||
<Typography>مدیریت اپلیکیشن خود را آسان و لذتبخش کنید!</Typography>
|
|
||||||
</div>
|
</div>
|
||||||
<form noValidate autoComplete='off' onSubmit={e => e.preventDefault()} className='flex flex-col gap-6'>
|
|
||||||
<CustomTextField autoFocus fullWidth label='نام کاربری' placeholder='نام کاربری خود را وارد کنید' />
|
{errorMessage && (
|
||||||
<CustomTextField fullWidth label='ایمیل' placeholder='ایمیل خود را وارد کنید' />
|
<Alert
|
||||||
<CustomTextField
|
severity="error"
|
||||||
|
onClose={() => setErrorMessage(null)}
|
||||||
|
className="mbe-6"
|
||||||
|
>
|
||||||
|
{errorMessage}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form
|
||||||
|
noValidate
|
||||||
|
autoComplete="off"
|
||||||
|
onSubmit={handleSubmit(onSubmit)}
|
||||||
|
className="flex flex-col gap-6"
|
||||||
|
>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
<Controller
|
||||||
|
name="first_name"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<CustomTextField
|
||||||
|
{...field}
|
||||||
|
autoFocus
|
||||||
|
fullWidth
|
||||||
|
label="نام"
|
||||||
|
placeholder="نام خود را وارد کنید"
|
||||||
|
onChange={(event) => {
|
||||||
|
field.onChange(event.target.value);
|
||||||
|
if (errorMessage) setErrorMessage(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Controller
|
||||||
|
name="last_name"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<CustomTextField
|
||||||
|
{...field}
|
||||||
|
fullWidth
|
||||||
|
label="نام خانوادگی"
|
||||||
|
placeholder="نام خانوادگی خود را وارد کنید"
|
||||||
|
onChange={(event) => {
|
||||||
|
field.onChange(event.target.value);
|
||||||
|
if (errorMessage) setErrorMessage(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Controller
|
||||||
|
name="username"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<CustomTextField
|
||||||
|
{...field}
|
||||||
|
fullWidth
|
||||||
|
label="نام کاربری"
|
||||||
|
placeholder="نام کاربری خود را وارد کنید"
|
||||||
|
onChange={(event) => {
|
||||||
|
field.onChange(event.target.value);
|
||||||
|
if (errorMessage) setErrorMessage(null);
|
||||||
|
}}
|
||||||
|
{...(errors.username && {
|
||||||
|
error: true,
|
||||||
|
helperText: errors.username.message,
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Controller
|
||||||
|
name="email"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<CustomTextField
|
||||||
|
{...field}
|
||||||
|
fullWidth
|
||||||
|
type="email"
|
||||||
|
label="ایمیل"
|
||||||
|
placeholder="ایمیل خود را وارد کنید"
|
||||||
|
onChange={(event) => {
|
||||||
|
field.onChange(event.target.value);
|
||||||
|
if (errorMessage) setErrorMessage(null);
|
||||||
|
}}
|
||||||
|
{...(errors.email && {
|
||||||
|
error: true,
|
||||||
|
helperText: errors.email.message,
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Controller
|
||||||
|
name="phone_number"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<CustomTextField
|
||||||
|
{...field}
|
||||||
|
fullWidth
|
||||||
|
type="tel"
|
||||||
|
label="شماره موبایل"
|
||||||
|
placeholder="شماره موبایل خود را وارد کنید"
|
||||||
|
onChange={(event) => {
|
||||||
|
field.onChange(event.target.value.replace(/\D/g, ""));
|
||||||
|
if (errorMessage) setErrorMessage(null);
|
||||||
|
}}
|
||||||
|
{...(errors.phone_number && {
|
||||||
|
error: true,
|
||||||
|
helperText: errors.phone_number.message,
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Controller
|
||||||
|
name="password"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<CustomTextField
|
||||||
|
{...field}
|
||||||
|
fullWidth
|
||||||
|
label="رمز عبور"
|
||||||
|
placeholder="············"
|
||||||
|
type={isPasswordShown ? "text" : "password"}
|
||||||
|
onChange={(event) => {
|
||||||
|
field.onChange(event.target.value);
|
||||||
|
if (errorMessage) setErrorMessage(null);
|
||||||
|
}}
|
||||||
|
slotProps={{
|
||||||
|
input: {
|
||||||
|
endAdornment: (
|
||||||
|
<InputAdornment position="end">
|
||||||
|
<IconButton
|
||||||
|
edge="end"
|
||||||
|
onClick={handleClickShowPassword}
|
||||||
|
onMouseDown={(event) => event.preventDefault()}
|
||||||
|
>
|
||||||
|
<i
|
||||||
|
className={
|
||||||
|
isPasswordShown
|
||||||
|
? "tabler-eye-off"
|
||||||
|
: "tabler-eye"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</IconButton>
|
||||||
|
</InputAdornment>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
{...(errors.password && {
|
||||||
|
error: true,
|
||||||
|
helperText: errors.password.message,
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button
|
||||||
fullWidth
|
fullWidth
|
||||||
label='رمز عبور'
|
variant="contained"
|
||||||
placeholder='············'
|
type="submit"
|
||||||
type={isPasswordShown ? 'text' : 'password'}
|
disabled={isSubmitting}
|
||||||
slotProps={{
|
>
|
||||||
input: {
|
{isSubmitting ? (
|
||||||
endAdornment: (
|
<CircularProgress size={24} color="inherit" />
|
||||||
<InputAdornment position='end'>
|
) : (
|
||||||
<IconButton edge='end' onClick={handleClickShowPassword} onMouseDown={e => e.preventDefault()}>
|
"ثبت نام"
|
||||||
<i className={isPasswordShown ? 'tabler-eye-off' : 'tabler-eye'} />
|
)}
|
||||||
</IconButton>
|
|
||||||
</InputAdornment>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<FormControlLabel
|
|
||||||
control={<Checkbox />}
|
|
||||||
label={
|
|
||||||
<>
|
|
||||||
<span>موافقم با </span>
|
|
||||||
<Link className='text-primary' href='/' onClick={e => e.preventDefault()}>
|
|
||||||
حریم خصوصی و شرایط استفاده
|
|
||||||
</Link>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Button fullWidth variant='contained' type='submit'>
|
|
||||||
ثبت نام
|
|
||||||
</Button>
|
</Button>
|
||||||
<div className='flex justify-center items-center flex-wrap gap-2'>
|
|
||||||
|
<div className="flex justify-center items-center flex-wrap gap-2">
|
||||||
<Typography>قبلاً حساب کاربری دارید؟</Typography>
|
<Typography>قبلاً حساب کاربری دارید؟</Typography>
|
||||||
<Typography component={Link} href='/login' color='primary.main'>
|
<Typography component={Link} href="/login" color="primary.main">
|
||||||
وارد شوید
|
وارد شوید
|
||||||
</Typography>
|
</Typography>
|
||||||
</div>
|
</div>
|
||||||
<Divider className='gap-2'>یا</Divider>
|
|
||||||
<div className='flex justify-center items-center gap-1.5'>
|
|
||||||
<IconButton className='text-facebook' size='small'>
|
|
||||||
<i className='tabler-brand-facebook-filled' />
|
|
||||||
</IconButton>
|
|
||||||
<IconButton className='text-twitter' size='small'>
|
|
||||||
<i className='tabler-brand-twitter-filled' />
|
|
||||||
</IconButton>
|
|
||||||
<IconButton className='text-textPrimary' size='small'>
|
|
||||||
<i className='tabler-brand-github-filled' />
|
|
||||||
</IconButton>
|
|
||||||
<IconButton className='text-error' size='small'>
|
|
||||||
<i className='tabler-brand-google-filled' />
|
|
||||||
</IconButton>
|
|
||||||
</div>
|
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</CardContent>
|
||||||
</div>
|
</Card>
|
||||||
</div>
|
</AuthIllustrationWrapper>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
export default Register
|
export default Register;
|
||||||
|
|||||||
Reference in New Issue
Block a user