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