UPDATE AUTH
This commit is contained in:
+4
-6
@@ -1,12 +1,10 @@
|
||||
# Server Configuration
|
||||
PORT=9031
|
||||
PORT=3000
|
||||
NODE_ENV=development
|
||||
|
||||
|
||||
# Next.js Configuration
|
||||
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://85.208.253.135:8000
|
||||
|
||||
# MAPBOX_ACCESS_TOKEN=your-mapbox-access-token
|
||||
NEXT_PUBLIC_API_URL=http://node.crop-logic.ir
|
||||
|
||||
+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
|
||||
container_name: croplogic-frontend
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "80:9031"
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
|
||||
+11
-15
@@ -9,26 +9,22 @@
|
||||
"title": "ورود",
|
||||
"description": "ورود به حساب کاربری",
|
||||
"welcome": "خوش آمدید به {templateName}! 👋🏻",
|
||||
"phoneStep": "شماره موبایل خود را برای دریافت کد OTP وارد کنید",
|
||||
"otpStep": "کد OTP ارسال شده به موبایل خود را وارد کنید",
|
||||
"phoneNumber": "شماره موبایل",
|
||||
"placeholderPhone": "شماره موبایل خود را وارد کنید",
|
||||
"sendOtp": "ارسال OTP",
|
||||
"verifyOtp": "تایید OTP",
|
||||
"backToPhone": "بازگشت به شماره موبایل",
|
||||
"subtitle": "برای ادامه وارد حساب کاربری خود شوید.",
|
||||
"identifier": "نام کاربری، ایمیل یا موبایل",
|
||||
"placeholderIdentifier": "نام کاربری، ایمیل یا شماره موبایل را وارد کنید",
|
||||
"password": "رمز عبور",
|
||||
"submit": "ورود",
|
||||
"rememberMe": "مرا به خاطر بسپار",
|
||||
"forgotPassword": "رمز عبور را فراموش کردهاید؟",
|
||||
"newUser": "جدید هستید؟",
|
||||
"createAccount": "ثبت نام کنید",
|
||||
"otpSent": "کد OTP به {phone} ارسال شد",
|
||||
"validation": {
|
||||
"phoneRequired": "شماره موبایل الزامی است",
|
||||
"phoneMinLength": "شماره موبایل باید حداقل ۱۰ رقم باشد",
|
||||
"phoneMaxLength": "شماره موبایل باید حداکثر ۱۵ رقم باشد",
|
||||
"phoneDigitsOnly": "شماره موبایل باید فقط عدد باشد"
|
||||
"identifierRequired": "نام کاربری، ایمیل یا موبایل الزامی است",
|
||||
"passwordRequired": "رمز عبور الزامی است",
|
||||
"passwordMinLength": "رمز عبور باید حداقل ۸ کاراکتر باشد"
|
||||
},
|
||||
"errors": {
|
||||
"sendOtpFailed": "ارسال OTP ناموفق بود",
|
||||
"incompleteOtp": "لطفاً کد ۶ رقمی OTP را کامل وارد کنید",
|
||||
"otpVerificationFailed": "تایید OTP ناموفق بود"
|
||||
"loginFailed": "ورود ناموفق بود"
|
||||
}
|
||||
},
|
||||
"navigation": {
|
||||
|
||||
@@ -1,27 +1,25 @@
|
||||
// Next Imports
|
||||
import type { Metadata } from 'next'
|
||||
import { getTranslations } from 'next-intl/server'
|
||||
import type { Metadata } from "next";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
|
||||
// Component Imports
|
||||
import Login from '@views/Login'
|
||||
|
||||
// Server Action Imports
|
||||
import { getServerMode } from '@core/utils/serverHelpers'
|
||||
import Login from "@views/Login";
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const t = await getTranslations('login')
|
||||
const t = await getTranslations("login");
|
||||
|
||||
return {
|
||||
title: t('title'),
|
||||
description: t('description')
|
||||
}
|
||||
title: t("title"),
|
||||
description: t("description"),
|
||||
};
|
||||
}
|
||||
|
||||
const LoginPage = async () => {
|
||||
// Vars
|
||||
const mode = await getServerMode()
|
||||
return (
|
||||
<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
|
||||
import type { Metadata } from 'next'
|
||||
import type { Metadata } from "next";
|
||||
|
||||
// Component Imports
|
||||
import Register from '@views/Register'
|
||||
|
||||
// Server Action Imports
|
||||
import { getServerMode } from '@core/utils/serverHelpers'
|
||||
import Register from "@views/Register";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Register',
|
||||
description: 'Register to your account'
|
||||
}
|
||||
title: "Register",
|
||||
description: "Register to your account",
|
||||
};
|
||||
|
||||
const RegisterPage = async () => {
|
||||
// Vars
|
||||
const mode = await getServerMode()
|
||||
return (
|
||||
<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;
|
||||
|
||||
+113
-94
@@ -1,147 +1,166 @@
|
||||
'use client'
|
||||
"use client";
|
||||
|
||||
// React Imports
|
||||
import { createContext, useContext, useEffect, useState, ReactNode } from 'react'
|
||||
import { createContext, useContext, useEffect, useState } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
// 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 {
|
||||
user: AuthUser | null
|
||||
isLoading: boolean
|
||||
isAuthenticated: boolean
|
||||
login: (phoneNumber: string, otpCode: string, tempToken: string) => Promise<LoginResult>
|
||||
requestOTP: (phoneNumber: string) => Promise<string>
|
||||
logout: () => void
|
||||
user: AuthUser | null;
|
||||
isLoading: boolean;
|
||||
isAuthenticated: boolean;
|
||||
login: (identifier: string, password: string) => Promise<LoginResult>;
|
||||
register: (payload: RegisterPayload) => Promise<LoginResult>;
|
||||
logout: () => void;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType | undefined>(undefined)
|
||||
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||
|
||||
export const useAuth = () => {
|
||||
const context = useContext(AuthContext)
|
||||
const context = useContext(AuthContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error('useAuth must be used within an AuthProvider')
|
||||
}
|
||||
return context
|
||||
throw new Error("useAuth must be used within an AuthProvider");
|
||||
}
|
||||
|
||||
return context;
|
||||
};
|
||||
|
||||
interface AuthProviderProps {
|
||||
children: ReactNode
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const STORAGE_KEYS = {
|
||||
authUser: 'auth_user',
|
||||
authToken: 'auth_token'
|
||||
} as const
|
||||
authUser: "auth_user",
|
||||
} as const;
|
||||
|
||||
export const AuthProvider = ({ children }: AuthProviderProps) => {
|
||||
const [user, setUser] = useState<AuthUser | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [user, setUser] = useState<AuthUser | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
// Check if user is authenticated on mount
|
||||
useEffect(() => {
|
||||
// Check for stored token and user data
|
||||
const token =
|
||||
typeof window !== 'undefined' ? localStorage.getItem(STORAGE_KEYS.authToken) : null
|
||||
typeof window !== "undefined" ? localStorage.getItem("auth_token") : null;
|
||||
const storedUser =
|
||||
typeof window !== 'undefined' ? localStorage.getItem(STORAGE_KEYS.authUser) : null
|
||||
typeof window !== "undefined"
|
||||
? localStorage.getItem(STORAGE_KEYS.authUser)
|
||||
: null;
|
||||
|
||||
if (token && storedUser) {
|
||||
try {
|
||||
const userData = JSON.parse(storedUser)
|
||||
setUser(userData)
|
||||
const userData = JSON.parse(storedUser) as AuthUser;
|
||||
|
||||
setUser(userData);
|
||||
} catch (error) {
|
||||
console.error('Error parsing stored user data:', error)
|
||||
// Clear invalid data
|
||||
localStorage.removeItem(STORAGE_KEYS.authToken)
|
||||
localStorage.removeItem(STORAGE_KEYS.authUser)
|
||||
console.error("Error parsing stored user data:", error);
|
||||
authService.logout();
|
||||
localStorage.removeItem(STORAGE_KEYS.authUser);
|
||||
}
|
||||
}
|
||||
setIsLoading(false)
|
||||
}, [])
|
||||
|
||||
const requestOTP = async (phoneNumber: string): Promise<string> => {
|
||||
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')
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize API user object to AuthUser (handles data vs user key and snake_case vs camelCase).
|
||||
*/
|
||||
const normalizeUser = (raw: Record<string, unknown> | null | undefined): AuthUser => {
|
||||
if (!raw || typeof raw !== 'object') {
|
||||
setIsLoading(false);
|
||||
}, []);
|
||||
|
||||
const normalizeUser = (
|
||||
raw: Record<string, unknown> | null | undefined,
|
||||
): AuthUser => {
|
||||
if (!raw || typeof raw !== "object") {
|
||||
return {
|
||||
id: 0,
|
||||
username: '',
|
||||
email: '',
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
phone_number: ''
|
||||
username: "",
|
||||
email: "",
|
||||
first_name: "",
|
||||
last_name: "",
|
||||
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 {
|
||||
id: Number(raw.id) || 0,
|
||||
username: (raw.username as string) ?? '',
|
||||
email: (raw.email as string) ?? '',
|
||||
first_name: first,
|
||||
last_name: last,
|
||||
phone_number: (raw.phone_number as string) ?? (raw.phoneNumber as string) ?? ''
|
||||
username: (raw.username as string) ?? "",
|
||||
email: (raw.email as string) ?? "",
|
||||
first_name: (raw.first_name as string) ?? (raw.firstName as string) ?? "",
|
||||
last_name: (raw.last_name as string) ?? (raw.lastName as string) ?? "",
|
||||
phone_number:
|
||||
(raw.phone_number as string) ?? (raw.phoneNumber as string) ?? "",
|
||||
};
|
||||
};
|
||||
|
||||
const persistUser = (userData: AuthUser) => {
|
||||
setUser(userData);
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
localStorage.setItem(STORAGE_KEYS.authUser, JSON.stringify(userData));
|
||||
}
|
||||
};
|
||||
|
||||
const authenticate = (
|
||||
response: AuthResponse,
|
||||
fallbackMessage: string,
|
||||
): LoginResult => {
|
||||
if (!response.data || !response.token?.access) {
|
||||
throw new Error(response.msg || fallbackMessage);
|
||||
}
|
||||
|
||||
const login = async (phoneNumber: string, otpCode: string, tempToken: string): Promise<LoginResult> => {
|
||||
const response = await authService.verifyOTP(tempToken, otpCode) as {
|
||||
code: number
|
||||
msg?: string
|
||||
data?: AuthUser | Record<string, unknown>
|
||||
user?: AuthUser | Record<string, unknown>
|
||||
token?: string
|
||||
const userData = normalizeUser(
|
||||
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");
|
||||
}
|
||||
|
||||
if (response.code !== 200 || !response.token) {
|
||||
throw new Error(response.msg || 'OTP verification failed')
|
||||
return authenticate(response, "Login failed");
|
||||
};
|
||||
|
||||
const register = async (payload: RegisterPayload): Promise<LoginResult> => {
|
||||
const response = await authService.register(payload);
|
||||
|
||||
if (response.code !== 200 && response.code !== 201) {
|
||||
throw new Error(response.msg || "Registration failed");
|
||||
}
|
||||
|
||||
const rawUser = response.data ?? response.user
|
||||
const partialUserRaw =
|
||||
rawUser && typeof rawUser === 'object' && rawUser !== null
|
||||
? (rawUser as Record<string, unknown>)
|
||||
: undefined
|
||||
|
||||
const userData: AuthUser = partialUserRaw
|
||||
? normalizeUser(partialUserRaw)
|
||||
: normalizeUser(undefined)
|
||||
setUser(userData)
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem(STORAGE_KEYS.authUser, JSON.stringify(userData))
|
||||
}
|
||||
return { user: userData }
|
||||
}
|
||||
return authenticate(response, "Registration failed");
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
authService.logout()
|
||||
setUser(null)
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.removeItem(STORAGE_KEYS.authUser)
|
||||
}
|
||||
authService.logout();
|
||||
setUser(null);
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
localStorage.removeItem(STORAGE_KEYS.authUser);
|
||||
}
|
||||
};
|
||||
|
||||
const value: AuthContextType = {
|
||||
user,
|
||||
isLoading,
|
||||
isAuthenticated: !!user,
|
||||
login,
|
||||
requestOTP,
|
||||
logout
|
||||
}
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
|
||||
}
|
||||
register,
|
||||
logout,
|
||||
};
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
};
|
||||
|
||||
+118
-66
@@ -2,39 +2,67 @@
|
||||
* 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 {
|
||||
message: string
|
||||
code?: number
|
||||
details?: any
|
||||
message: string;
|
||||
code?: number;
|
||||
details?: any;
|
||||
}
|
||||
|
||||
export class ApiClient {
|
||||
private baseURL: string
|
||||
private defaultHeaders: Record<string, string>
|
||||
private baseURL: string;
|
||||
private defaultHeaders: Record<string, string>;
|
||||
|
||||
constructor(baseURL: string = API_BASE_URL) {
|
||||
this.baseURL = baseURL.replace(/\/$/, '') // Remove trailing slash
|
||||
this.baseURL = baseURL.replace(/\/$/, ""); // Remove trailing slash
|
||||
this.defaultHeaders = {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get authorization token from localStorage
|
||||
*/
|
||||
private getAuthToken(): string | null {
|
||||
if (typeof window === 'undefined') return null
|
||||
return localStorage.getItem('auth_token')
|
||||
if (typeof window === "undefined") return null;
|
||||
return localStorage.getItem(AUTH_STORAGE_KEYS.accessToken);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set authorization token in localStorage
|
||||
*/
|
||||
setAuthToken(token: string): void {
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem('auth_token', token)
|
||||
if (typeof window !== "undefined") {
|
||||
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
|
||||
*/
|
||||
clearAuthToken(): void {
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.removeItem('auth_token')
|
||||
if (typeof window !== "undefined") {
|
||||
localStorage.removeItem(AUTH_STORAGE_KEYS.accessToken);
|
||||
localStorage.removeItem(AUTH_STORAGE_KEYS.refreshToken);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get headers with authentication token
|
||||
*/
|
||||
private getHeaders(customHeaders?: Record<string, string>): Record<string, string> {
|
||||
const headers = { ...this.defaultHeaders, ...customHeaders }
|
||||
const token = this.getAuthToken()
|
||||
private getHeaders(
|
||||
customHeaders?: Record<string, string>,
|
||||
): Record<string, string> {
|
||||
const headers = { ...this.defaultHeaders, ...customHeaders };
|
||||
const token = this.getAuthToken();
|
||||
|
||||
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> {
|
||||
if (!response.ok) {
|
||||
let errorData: any
|
||||
let errorData: any;
|
||||
try {
|
||||
errorData = await response.json()
|
||||
errorData = await response.json();
|
||||
} catch {
|
||||
errorData = { message: response.statusText }
|
||||
errorData = { message: response.statusText };
|
||||
}
|
||||
|
||||
const error: ApiError = {
|
||||
message: errorData.msg || errorData.message || 'An error occurred',
|
||||
message: errorData.msg || errorData.message || "An error occurred",
|
||||
code: errorData.code || response.status,
|
||||
details: errorData
|
||||
}
|
||||
details: errorData,
|
||||
};
|
||||
|
||||
// If unauthorized, clear token
|
||||
if (response.status === 401) {
|
||||
this.clearAuthToken()
|
||||
this.clearAuthToken();
|
||||
}
|
||||
|
||||
throw error
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Handle empty responses
|
||||
const contentType = response.headers.get('content-type')
|
||||
if (!contentType || !contentType.includes('application/json')) {
|
||||
return {} as T
|
||||
const contentType = response.headers.get("content-type");
|
||||
if (!contentType || !contentType.includes("application/json")) {
|
||||
return {} as T;
|
||||
}
|
||||
|
||||
return response.json()
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* GET request
|
||||
*/
|
||||
async get<T>(endpoint: string, customHeaders?: Record<string, string>): Promise<T> {
|
||||
const url = `${this.baseURL}${endpoint}`
|
||||
async get<T>(
|
||||
endpoint: string,
|
||||
customHeaders?: Record<string, string>,
|
||||
): Promise<T> {
|
||||
const url = `${this.baseURL}${endpoint}`;
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
method: "GET",
|
||||
headers: this.getHeaders(customHeaders),
|
||||
})
|
||||
});
|
||||
|
||||
return this.handleResponse<T>(response)
|
||||
return this.handleResponse<T>(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST request
|
||||
*/
|
||||
async post<T>(endpoint: string, data?: any, customHeaders?: Record<string, string>): Promise<T> {
|
||||
const url = `${this.baseURL}${endpoint}`
|
||||
async post<T>(
|
||||
endpoint: string,
|
||||
data?: any,
|
||||
customHeaders?: Record<string, string>,
|
||||
): Promise<T> {
|
||||
const url = `${this.baseURL}${endpoint}`;
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
method: "POST",
|
||||
headers: this.getHeaders(customHeaders),
|
||||
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.
|
||||
*/
|
||||
async postFormData<T>(endpoint: string, formData: FormData, customHeaders?: Record<string, string>): Promise<T> {
|
||||
const url = `${this.baseURL}${endpoint}`
|
||||
const headers = { ...this.getHeaders(customHeaders) }
|
||||
delete headers['Content-Type']
|
||||
async postFormData<T>(
|
||||
endpoint: string,
|
||||
formData: FormData,
|
||||
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, {
|
||||
method: 'POST',
|
||||
method: "POST",
|
||||
headers,
|
||||
body: formData,
|
||||
})
|
||||
});
|
||||
|
||||
return this.handleResponse<T>(response)
|
||||
return this.handleResponse<T>(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT request
|
||||
*/
|
||||
async put<T>(endpoint: string, data?: any, customHeaders?: Record<string, string>): Promise<T> {
|
||||
const url = `${this.baseURL}${endpoint}`
|
||||
async put<T>(
|
||||
endpoint: string,
|
||||
data?: any,
|
||||
customHeaders?: Record<string, string>,
|
||||
): Promise<T> {
|
||||
const url = `${this.baseURL}${endpoint}`;
|
||||
const response = await fetch(url, {
|
||||
method: 'PUT',
|
||||
method: "PUT",
|
||||
headers: this.getHeaders(customHeaders),
|
||||
body: data ? JSON.stringify(data) : undefined,
|
||||
})
|
||||
});
|
||||
|
||||
return this.handleResponse<T>(response)
|
||||
return this.handleResponse<T>(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH request
|
||||
*/
|
||||
async patch<T>(endpoint: string, data?: any, customHeaders?: Record<string, string>): Promise<T> {
|
||||
const url = `${this.baseURL}${endpoint}`
|
||||
async patch<T>(
|
||||
endpoint: string,
|
||||
data?: any,
|
||||
customHeaders?: Record<string, string>,
|
||||
): Promise<T> {
|
||||
const url = `${this.baseURL}${endpoint}`;
|
||||
const response = await fetch(url, {
|
||||
method: 'PATCH',
|
||||
method: "PATCH",
|
||||
headers: this.getHeaders(customHeaders),
|
||||
body: data ? JSON.stringify(data) : undefined,
|
||||
})
|
||||
});
|
||||
|
||||
return this.handleResponse<T>(response)
|
||||
return this.handleResponse<T>(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE request
|
||||
*/
|
||||
async delete<T>(endpoint: string, customHeaders?: Record<string, string>): Promise<T> {
|
||||
const url = `${this.baseURL}${endpoint}`
|
||||
async delete<T>(
|
||||
endpoint: string,
|
||||
customHeaders?: Record<string, string>,
|
||||
): Promise<T> {
|
||||
const url = `${this.baseURL}${endpoint}`;
|
||||
const response = await fetch(url, {
|
||||
method: 'DELETE',
|
||||
method: "DELETE",
|
||||
headers: this.getHeaders(customHeaders),
|
||||
})
|
||||
});
|
||||
|
||||
return this.handleResponse<T>(response)
|
||||
return this.handleResponse<T>(response);
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const apiClient = new ApiClient()
|
||||
|
||||
export const apiClient = new ApiClient();
|
||||
|
||||
+22
-21
@@ -2,25 +2,26 @@
|
||||
* API Services Export
|
||||
*/
|
||||
|
||||
export * from './client'
|
||||
export * from './types'
|
||||
export * from "./client";
|
||||
export * from "./types";
|
||||
export {
|
||||
type RequestOTPRequest,
|
||||
type RequestOTPResponse,
|
||||
type VerifyOTPRequest,
|
||||
type AuthUser,
|
||||
type VerifyOTPResponse,
|
||||
type AuthTokens,
|
||||
type LoginRequest,
|
||||
type RegisterRequest,
|
||||
type AuthResponse,
|
||||
type RefreshTokenResponse,
|
||||
type UpdateProfilePayload,
|
||||
type UpdateProfileResponse,
|
||||
authService
|
||||
} from './services/authService'
|
||||
export * from './services/taskService'
|
||||
export * from './services/eventService'
|
||||
export * from './services/simulatorService'
|
||||
export * from './services/chatService'
|
||||
export * from './services/aiChatService'
|
||||
export * from './services/kanbanService'
|
||||
export * from './services/todoService'
|
||||
authService,
|
||||
} from "./services/authService";
|
||||
export * from "./services/taskService";
|
||||
export * from "./services/eventService";
|
||||
export * from "./services/simulatorService";
|
||||
export * from "./services/chatService";
|
||||
export * from "./services/aiChatService";
|
||||
export * from "./services/kanbanService";
|
||||
export * from "./services/todoService";
|
||||
export {
|
||||
type User,
|
||||
type UserDetails,
|
||||
@@ -29,12 +30,12 @@ export {
|
||||
type UpdateProfileRequest,
|
||||
type AddAccountRequest,
|
||||
type UpdateAccountRequest,
|
||||
userManagementService
|
||||
} from './services/userManagementService'
|
||||
export * from './services/rolesPermissionsService'
|
||||
export * from './services/sensorHubService'
|
||||
userManagementService,
|
||||
} from "./services/userManagementService";
|
||||
export * from "./services/rolesPermissionsService";
|
||||
export * from "./services/sensorHubService";
|
||||
export {
|
||||
type FarmDashboardConfigResponse,
|
||||
type FarmDashboardCardsResponse,
|
||||
farmDashboardService
|
||||
} from './services/farmDashboardService'
|
||||
farmDashboardService,
|
||||
} from "./services/farmDashboardService";
|
||||
|
||||
@@ -1,95 +1,132 @@
|
||||
/**
|
||||
* Authentication Service
|
||||
* Handles OTP-based authentication with the backend
|
||||
* Handles password-based authentication with the backend
|
||||
*/
|
||||
|
||||
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
|
||||
}
|
||||
import { apiClient } from "../client";
|
||||
|
||||
export interface AuthUser {
|
||||
id: number
|
||||
username: string
|
||||
email: string
|
||||
first_name: string
|
||||
last_name: string
|
||||
phone_number: string
|
||||
id: number;
|
||||
username: string;
|
||||
email: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
phone_number: string;
|
||||
}
|
||||
|
||||
export interface VerifyOTPResponse {
|
||||
code: number
|
||||
msg: string
|
||||
data: AuthUser
|
||||
token: string
|
||||
export interface AuthTokens {
|
||||
access: string;
|
||||
refresh: 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 {
|
||||
first_name: string
|
||||
last_name: string
|
||||
email: string
|
||||
first_name?: string;
|
||||
last_name?: string;
|
||||
email?: string;
|
||||
}
|
||||
|
||||
export interface UpdateProfileResponse {
|
||||
code: number
|
||||
msg: string
|
||||
data: AuthUser
|
||||
code: number;
|
||||
msg: string;
|
||||
data: AuthUser;
|
||||
}
|
||||
|
||||
export const authService = {
|
||||
/**
|
||||
* Request OTP for phone number authentication
|
||||
* Login with username, email, or phone number
|
||||
*/
|
||||
async requestOTP(phoneNumber: string): Promise<RequestOTPResponse> {
|
||||
return apiClient.post<RequestOTPResponse>('/api/auth/request-otp/', {
|
||||
phone_number: phoneNumber
|
||||
})
|
||||
},
|
||||
async login(payload: LoginRequest): Promise<AuthResponse> {
|
||||
const response = await apiClient.post<AuthResponse>(
|
||||
"/api/auth/login/",
|
||||
payload,
|
||||
);
|
||||
|
||||
/**
|
||||
* Verify OTP code and get JWT 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)
|
||||
if (response.token?.access) {
|
||||
apiClient.setAuthTokens(response.token);
|
||||
}
|
||||
|
||||
return response
|
||||
return response;
|
||||
},
|
||||
|
||||
/**
|
||||
* Update user profile (first name, last name, email)
|
||||
* Register a new user
|
||||
*/
|
||||
async updateProfile(payload: UpdateProfilePayload): Promise<UpdateProfileResponse> {
|
||||
const response = await apiClient.patch<UpdateProfileResponse>('/users/me', payload)
|
||||
async register(payload: RegisterRequest): Promise<AuthResponse> {
|
||||
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) {
|
||||
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 {
|
||||
apiClient.clearAuthToken()
|
||||
}
|
||||
}
|
||||
|
||||
apiClient.clearAuthToken();
|
||||
},
|
||||
};
|
||||
|
||||
+17
-16
@@ -3,43 +3,44 @@
|
||||
* Client-side authentication helpers
|
||||
*/
|
||||
|
||||
import type { AuthUser } from './api/services/authService'
|
||||
import type { AuthUser } from "./api/services/authService";
|
||||
|
||||
/**
|
||||
* Get authentication token from localStorage
|
||||
*/
|
||||
export const getAuthToken = (): string | null => {
|
||||
if (typeof window === 'undefined') return null
|
||||
return localStorage.getItem('auth_token')
|
||||
}
|
||||
if (typeof window === "undefined") return null;
|
||||
return localStorage.getItem("auth_token");
|
||||
};
|
||||
|
||||
/**
|
||||
* Get authenticated user from localStorage
|
||||
*/
|
||||
export const getAuthUser = (): AuthUser | null => {
|
||||
if (typeof window === 'undefined') return null
|
||||
const userStr = localStorage.getItem('auth_user')
|
||||
if (!userStr) return null
|
||||
if (typeof window === "undefined") return null;
|
||||
const userStr = localStorage.getItem("auth_user");
|
||||
if (!userStr) return null;
|
||||
try {
|
||||
return JSON.parse(userStr)
|
||||
return JSON.parse(userStr);
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if user is authenticated
|
||||
*/
|
||||
export const isAuthenticated = (): boolean => {
|
||||
return !!getAuthToken() && !!getAuthUser()
|
||||
}
|
||||
return !!getAuthToken() && !!getAuthUser();
|
||||
};
|
||||
|
||||
/**
|
||||
* Clear authentication data
|
||||
*/
|
||||
export const clearAuth = (): void => {
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.removeItem('auth_token')
|
||||
localStorage.removeItem('auth_user')
|
||||
}
|
||||
if (typeof window !== "undefined") {
|
||||
localStorage.removeItem("auth_token");
|
||||
localStorage.removeItem("auth_refresh_token");
|
||||
localStorage.removeItem("auth_user");
|
||||
}
|
||||
};
|
||||
|
||||
+186
-245
@@ -1,311 +1,252 @@
|
||||
'use client'
|
||||
"use client";
|
||||
|
||||
// React Imports
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
// Next Imports
|
||||
import Link from 'next/link'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import Link from "next/link";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
// MUI Imports
|
||||
import useMediaQuery from '@mui/material/useMediaQuery'
|
||||
import { styled, useTheme } from '@mui/material/styles'
|
||||
import Typography from '@mui/material/Typography'
|
||||
import Button from '@mui/material/Button'
|
||||
import Alert from '@mui/material/Alert'
|
||||
import CircularProgress from '@mui/material/CircularProgress'
|
||||
import Card from "@mui/material/Card";
|
||||
import CardContent from "@mui/material/CardContent";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
import InputAdornment from "@mui/material/InputAdornment";
|
||||
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
|
||||
import { Controller, useForm } from 'react-hook-form'
|
||||
import { valibotResolver } from '@hookform/resolvers/valibot'
|
||||
import { object, minLength, maxLength, string, pipe, nonEmpty, custom } from 'valibot'
|
||||
import type { SubmitHandler } from 'react-hook-form'
|
||||
import classnames from 'classnames'
|
||||
import { OTPInput } from 'input-otp'
|
||||
|
||||
// Type Imports
|
||||
import type { SystemMode } from '@core/types'
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { valibotResolver } from "@hookform/resolvers/valibot";
|
||||
import { object, minLength, string, pipe, nonEmpty } from "valibot";
|
||||
import type { SubmitHandler } from "react-hook-form";
|
||||
|
||||
// Component Imports
|
||||
import Logo from '@components/layout/shared/Logo'
|
||||
import CustomTextField from '@core/components/mui/TextField'
|
||||
import Logo from "@components/layout/shared/Logo";
|
||||
import CustomTextField from "@core/components/mui/TextField";
|
||||
import AuthIllustrationWrapper from "@views/pages/auth/AuthIllustrationWrapper";
|
||||
|
||||
// Context Imports
|
||||
import { useAuth } from '@/contexts/authContext'
|
||||
import { useAuth } from "@/contexts/authContext";
|
||||
|
||||
// Config Imports
|
||||
import themeConfig from '@configs/themeConfig'
|
||||
import themeConfig from "@configs/themeConfig";
|
||||
|
||||
// Hook Imports
|
||||
import { useImageVariant } from '@core/hooks/useImageVariant'
|
||||
import { useSettings } from '@core/hooks/useSettings'
|
||||
type LoginFormData = {
|
||||
identifier: string;
|
||||
password: string;
|
||||
};
|
||||
|
||||
// Styled Custom Components
|
||||
const LoginIllustration = styled('img')(({ theme }) => ({
|
||||
zIndex: 2,
|
||||
blockSize: 'auto',
|
||||
maxBlockSize: 680,
|
||||
maxInlineSize: '100%',
|
||||
margin: theme.spacing(12),
|
||||
[theme.breakpoints.down(1536)]: {
|
||||
maxBlockSize: 550
|
||||
},
|
||||
[theme.breakpoints.down('lg')]: {
|
||||
maxBlockSize: 450
|
||||
}
|
||||
}))
|
||||
const getErrorMessage = (error: unknown, fallbackMessage: string) => {
|
||||
if (error instanceof Error) return error.message;
|
||||
|
||||
const MaskImg = styled('img')({
|
||||
blockSize: 'auto',
|
||||
maxBlockSize: 355,
|
||||
inlineSize: '100%',
|
||||
position: 'absolute',
|
||||
insetBlockEnd: 0,
|
||||
zIndex: -1
|
||||
})
|
||||
|
||||
const OtpContainer = styled('div')({
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
gap: '8px',
|
||||
marginTop: '16px'
|
||||
})
|
||||
|
||||
type ErrorType = {
|
||||
message: string
|
||||
if (typeof error === "object" && error !== null && "message" in error) {
|
||||
return String(error.message);
|
||||
}
|
||||
|
||||
type PhoneFormData = {
|
||||
phone_number: string
|
||||
}
|
||||
return fallbackMessage;
|
||||
};
|
||||
|
||||
const Login = ({ mode }: { mode: SystemMode }) => {
|
||||
const t = useTranslations('login')
|
||||
const Login = () => {
|
||||
const t = useTranslations("login");
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { login } = useAuth();
|
||||
|
||||
const phoneSchema = useMemo(
|
||||
const [isPasswordShown, setIsPasswordShown] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const loginSchema = useMemo(
|
||||
() =>
|
||||
object({
|
||||
phone_number: pipe(
|
||||
identifier: pipe(
|
||||
string(),
|
||||
nonEmpty(String(t('validation.phoneRequired'))),
|
||||
minLength(10, String(t('validation.phoneMinLength'))),
|
||||
maxLength(15, String(t('validation.phoneMaxLength'))),
|
||||
custom((input) => /^[0-9]+$/.test(String(input)), String(t('validation.phoneDigitsOnly')))
|
||||
)
|
||||
nonEmpty(String(t("validation.identifierRequired"))),
|
||||
),
|
||||
password: pipe(
|
||||
string(),
|
||||
nonEmpty(String(t("validation.passwordRequired"))),
|
||||
minLength(8, String(t("validation.passwordMinLength"))),
|
||||
),
|
||||
}),
|
||||
[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()
|
||||
[t],
|
||||
);
|
||||
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
getValues,
|
||||
formState: { errors }
|
||||
} = useForm<PhoneFormData>({
|
||||
resolver: valibotResolver(phoneSchema) as any,
|
||||
formState: { errors },
|
||||
} = useForm<LoginFormData>({
|
||||
resolver: valibotResolver(loginSchema) as never,
|
||||
defaultValues: {
|
||||
phone_number: ''
|
||||
}
|
||||
})
|
||||
identifier: "",
|
||||
password: "",
|
||||
},
|
||||
});
|
||||
|
||||
const characterIllustration = useImageVariant(
|
||||
mode,
|
||||
lightIllustration,
|
||||
darkIllustration,
|
||||
borderedLightIllustration,
|
||||
borderedDarkIllustration
|
||||
)
|
||||
const handleClickShowPassword = () => setIsPasswordShown((show) => !show);
|
||||
|
||||
const onPhoneSubmit: SubmitHandler<PhoneFormData> = async (data: PhoneFormData) => {
|
||||
setIsLoading(true)
|
||||
setErrorState(null)
|
||||
const onSubmit: SubmitHandler<LoginFormData> = async (data) => {
|
||||
setIsSubmitting(true);
|
||||
setErrorMessage(null);
|
||||
|
||||
try {
|
||||
const token = await requestOTP(data.phone_number)
|
||||
setTempToken(token)
|
||||
setStep('otp')
|
||||
} catch (error: any) {
|
||||
setErrorState({ message: error.message || t('errors.sendOtpFailed') })
|
||||
await login(data.identifier.trim(), data.password);
|
||||
|
||||
const redirectURL = searchParams.get("redirectTo") ?? "/";
|
||||
|
||||
router.replace(redirectURL);
|
||||
} catch (error) {
|
||||
setErrorMessage(getErrorMessage(error, String(t("errors.loginFailed"))));
|
||||
} finally {
|
||||
setIsLoading(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)
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='flex bs-full justify-center'>
|
||||
<div
|
||||
className={classnames(
|
||||
'flex bs-full items-center justify-center flex-1 min-bs-[100dvh] relative p-6 max-md:hidden',
|
||||
{
|
||||
'border-ie': settings.skin === 'bordered'
|
||||
}
|
||||
)}
|
||||
>
|
||||
<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]'>
|
||||
<AuthIllustrationWrapper>
|
||||
<Card className="flex flex-col sm:is-[450px]">
|
||||
<CardContent className="sm:!p-12">
|
||||
<Link href="/" className="flex justify-center mbe-6">
|
||||
<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')}
|
||||
</Link>
|
||||
<div className="flex flex-col gap-1 mbe-6">
|
||||
<Typography variant="h4">
|
||||
{t("welcome", { templateName: themeConfig.templateName })}
|
||||
</Typography>
|
||||
<Typography>{t("subtitle")}</Typography>
|
||||
</div>
|
||||
|
||||
{errorState && (
|
||||
<Alert severity='error' onClose={() => setErrorState(null)}>
|
||||
{errorState.message}
|
||||
{errorMessage && (
|
||||
<Alert
|
||||
severity="error"
|
||||
onClose={() => setErrorMessage(null)}
|
||||
className="mbe-6"
|
||||
>
|
||||
{errorMessage}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{step === 'phone' ? (
|
||||
<form
|
||||
noValidate
|
||||
autoComplete='off'
|
||||
action={() => {}}
|
||||
onSubmit={handleSubmit(onPhoneSubmit)}
|
||||
className='flex flex-col gap-6'
|
||||
autoComplete="off"
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
className="flex flex-col gap-6"
|
||||
>
|
||||
<Controller
|
||||
name='phone_number'
|
||||
name="identifier"
|
||||
control={control}
|
||||
rules={{ required: true }}
|
||||
render={({ field }) => (
|
||||
<CustomTextField
|
||||
{...field}
|
||||
autoFocus
|
||||
fullWidth
|
||||
type='tel'
|
||||
label={t('phoneNumber')}
|
||||
placeholder={t('placeholderPhone')}
|
||||
onChange={e => {
|
||||
field.onChange(e.target.value.replace(/\D/g, '')) // Only allow digits
|
||||
errorState !== null && setErrorState(null)
|
||||
label={t("identifier")}
|
||||
placeholder={t("placeholderIdentifier")}
|
||||
onChange={(event) => {
|
||||
field.onChange(event.target.value);
|
||||
if (errorMessage) setErrorMessage(null);
|
||||
}}
|
||||
{...(errors.phone_number && {
|
||||
{...(errors.identifier && {
|
||||
error: true,
|
||||
helperText: errors.phone_number.message
|
||||
helperText: errors.identifier.message,
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Button fullWidth variant='contained' type='submit' disabled={isLoading}>
|
||||
{isLoading ? <CircularProgress size={24} /> : t('sendOtp')}
|
||||
|
||||
<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')}
|
||||
|
||||
<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>
|
||||
)
|
||||
}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AuthIllustrationWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default Login
|
||||
export default Login;
|
||||
|
||||
+301
-132
@@ -1,173 +1,342 @@
|
||||
'use client'
|
||||
"use client";
|
||||
|
||||
// React Imports
|
||||
import { useState } from 'react'
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
// Next Imports
|
||||
import Link from 'next/link'
|
||||
import Link from "next/link";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
|
||||
// MUI Imports
|
||||
import useMediaQuery from '@mui/material/useMediaQuery'
|
||||
import { styled, useTheme } from '@mui/material/styles'
|
||||
import Typography from '@mui/material/Typography'
|
||||
import IconButton from '@mui/material/IconButton'
|
||||
import InputAdornment from '@mui/material/InputAdornment'
|
||||
import Checkbox from '@mui/material/Checkbox'
|
||||
import Button from '@mui/material/Button'
|
||||
import FormControlLabel from '@mui/material/FormControlLabel'
|
||||
import Divider from '@mui/material/Divider'
|
||||
import Card from "@mui/material/Card";
|
||||
import CardContent from "@mui/material/CardContent";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
import InputAdornment from "@mui/material/InputAdornment";
|
||||
import Button from "@mui/material/Button";
|
||||
import Alert from "@mui/material/Alert";
|
||||
import CircularProgress from "@mui/material/CircularProgress";
|
||||
|
||||
// Third-party Imports
|
||||
import classnames from 'classnames'
|
||||
|
||||
// Type Imports
|
||||
import type { SystemMode } from '@core/types'
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { valibotResolver } from "@hookform/resolvers/valibot";
|
||||
import {
|
||||
object,
|
||||
string,
|
||||
pipe,
|
||||
nonEmpty,
|
||||
minLength,
|
||||
maxLength,
|
||||
email,
|
||||
custom,
|
||||
} from "valibot";
|
||||
import type { SubmitHandler } from "react-hook-form";
|
||||
|
||||
// Component Imports
|
||||
import Logo from '@components/layout/shared/Logo'
|
||||
import CustomTextField from '@core/components/mui/TextField'
|
||||
import Logo from "@components/layout/shared/Logo";
|
||||
import CustomTextField from "@core/components/mui/TextField";
|
||||
import AuthIllustrationWrapper from "@views/pages/auth/AuthIllustrationWrapper";
|
||||
|
||||
// Hook Imports
|
||||
import { useImageVariant } from '@core/hooks/useImageVariant'
|
||||
import { useSettings } from '@core/hooks/useSettings'
|
||||
// Context Imports
|
||||
import { useAuth } from "@/contexts/authContext";
|
||||
|
||||
// Styled Custom Components
|
||||
const RegisterIllustration = styled('img')(({ theme }) => ({
|
||||
zIndex: 2,
|
||||
blockSize: 'auto',
|
||||
maxBlockSize: 600,
|
||||
maxInlineSize: '100%',
|
||||
margin: theme.spacing(12),
|
||||
[theme.breakpoints.down(1536)]: {
|
||||
maxBlockSize: 550
|
||||
},
|
||||
[theme.breakpoints.down('lg')]: {
|
||||
maxBlockSize: 450
|
||||
type RegisterFormData = {
|
||||
username: string;
|
||||
email: string;
|
||||
phone_number: string;
|
||||
password: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
};
|
||||
|
||||
const getErrorMessage = (error: unknown, fallbackMessage: string) => {
|
||||
if (error instanceof Error) return error.message;
|
||||
|
||||
if (typeof error === "object" && error !== null && "message" in error) {
|
||||
return String(error.message);
|
||||
}
|
||||
}))
|
||||
|
||||
const MaskImg = styled('img')({
|
||||
blockSize: 'auto',
|
||||
maxBlockSize: 345,
|
||||
inlineSize: '100%',
|
||||
position: 'absolute',
|
||||
insetBlockEnd: 0,
|
||||
zIndex: -1
|
||||
})
|
||||
return fallbackMessage;
|
||||
};
|
||||
|
||||
const Register = ({ mode }: { mode: SystemMode }) => {
|
||||
// States
|
||||
const [isPasswordShown, setIsPasswordShown] = useState(false)
|
||||
const Register = () => {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { register } = useAuth();
|
||||
|
||||
// Vars
|
||||
const darkImg = '/images/pages/auth-mask-dark.png'
|
||||
const lightImg = '/images/pages/auth-mask-light.png'
|
||||
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'
|
||||
const [isPasswordShown, setIsPasswordShown] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
// Hooks
|
||||
const { settings } = useSettings()
|
||||
const theme = useTheme()
|
||||
const hidden = useMediaQuery(theme.breakpoints.down('md'))
|
||||
const authBackground = useImageVariant(mode, lightImg, darkImg)
|
||||
const registerSchema = useMemo(
|
||||
() =>
|
||||
object({
|
||||
first_name: string(),
|
||||
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(
|
||||
mode,
|
||||
lightIllustration,
|
||||
darkIllustration,
|
||||
borderedLightIllustration,
|
||||
borderedDarkIllustration
|
||||
)
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
} = useForm<RegisterFormData>({
|
||||
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 (
|
||||
<div className='flex bs-full justify-center'>
|
||||
<div
|
||||
className={classnames(
|
||||
'flex bs-full items-center justify-center flex-1 min-bs-[100dvh] relative p-6 max-md:hidden',
|
||||
{
|
||||
'border-ie': settings.skin === 'bordered'
|
||||
}
|
||||
)}
|
||||
>
|
||||
<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]'
|
||||
>
|
||||
<AuthIllustrationWrapper>
|
||||
<Card className="flex flex-col sm:is-[480px]">
|
||||
<CardContent className="sm:!p-12">
|
||||
<Link href="/" className="flex justify-center mbe-6">
|
||||
<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 className="flex flex-col gap-1 mbe-6">
|
||||
<Typography variant="h4">ایجاد حساب کاربری</Typography>
|
||||
<Typography>برای شروع، اطلاعات حساب خود را وارد کنید.</Typography>
|
||||
</div>
|
||||
<form noValidate autoComplete='off' onSubmit={e => e.preventDefault()} className='flex flex-col gap-6'>
|
||||
<CustomTextField autoFocus fullWidth label='نام کاربری' placeholder='نام کاربری خود را وارد کنید' />
|
||||
<CustomTextField fullWidth label='ایمیل' placeholder='ایمیل خود را وارد کنید' />
|
||||
|
||||
{errorMessage && (
|
||||
<Alert
|
||||
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='············'
|
||||
type={isPasswordShown ? 'text' : 'password'}
|
||||
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={e => e.preventDefault()}>
|
||||
<i className={isPasswordShown ? 'tabler-eye-off' : 'tabler-eye'} />
|
||||
<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,
|
||||
})}
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={<Checkbox />}
|
||||
label={
|
||||
<>
|
||||
<span>موافقم با </span>
|
||||
<Link className='text-primary' href='/' onClick={e => e.preventDefault()}>
|
||||
حریم خصوصی و شرایط استفاده
|
||||
</Link>
|
||||
</>
|
||||
}
|
||||
)}
|
||||
/>
|
||||
<Button fullWidth variant='contained' type='submit'>
|
||||
ثبت نام
|
||||
|
||||
<Button
|
||||
fullWidth
|
||||
variant="contained"
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<CircularProgress size={24} color="inherit" />
|
||||
) : (
|
||||
"ثبت نام"
|
||||
)}
|
||||
</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 component={Link} href='/login' color='primary.main'>
|
||||
<Typography component={Link} href="/login" color="primary.main">
|
||||
وارد شوید
|
||||
</Typography>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AuthIllustrationWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default Register
|
||||
export default Register;
|
||||
|
||||
Reference in New Issue
Block a user