UPDATE AUTH

This commit is contained in:
2026-03-24 13:53:21 +03:30
parent 52e6a90319
commit 0f2b67ea4d
13 changed files with 1361 additions and 721 deletions
+4 -6
View File
@@ -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
View File
@@ -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."]
> }
> ```
-2
View File
@@ -5,8 +5,6 @@ services:
dockerfile: Dockerfile
container_name: croplogic-frontend
restart: unless-stopped
ports:
- "80:9031"
env_file:
- .env
environment:
+11 -15
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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";
+99 -62
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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;