diff --git a/.env.example b/.env.example index 3c60df9..cc00e54 100644 --- a/.env.example +++ b/.env.example @@ -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 \ No newline at end of file +NEXT_PUBLIC_API_URL=http://node.crop-logic.ir diff --git a/AUTH_API.md b/AUTH_API.md new file mode 100644 index 0000000..1ba1c06 --- /dev/null +++ b/AUTH_API.md @@ -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 +``` + +- 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": "", + "refresh": "" + } +} +``` + +#### 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": "", + "refresh": "" + } +} +``` + +#### 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": "" +} +``` + +| فیلد | توضیح | +|---------------|--------------------------------------------------------------| +| `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_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": "", + "refresh": "" + } +} +``` + +> اگر کاربر با این شماره موبایل قبلاً ثبت‌نام نکرده باشد، حساب جدید به‌صورت خودکار ساخته می‌شود. + +#### 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": "" +} +``` + +#### Response موفق — `200 OK` + +```json +{ + "access": "" +} +``` + +--- + +## Account Endpoints + +Base URL: `/api/account/` + +--- + +### 1. آپدیت پروفایل + +**`PATCH /api/account/profile/`** + +> 🔒 نیاز به احراز هویت دارد — هدر `Authorization: Bearer ` الزامی است. + +ویرایش اطلاعات پروفایل کاربر لاگین‌شده. + +#### 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."] +> } +> ``` diff --git a/docker-compose-prod.yml b/docker-compose-prod.yml index 8cea684..53eab70 100644 --- a/docker-compose-prod.yml +++ b/docker-compose-prod.yml @@ -5,8 +5,6 @@ services: dockerfile: Dockerfile container_name: croplogic-frontend restart: unless-stopped - ports: - - "80:9031" env_file: - .env environment: diff --git a/messages/fa.json b/messages/fa.json index 098adbc..54c6afe 100644 --- a/messages/fa.json +++ b/messages/fa.json @@ -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": { diff --git a/src/app/(blank-layout-pages)/(guest-only)/login/page.tsx b/src/app/(blank-layout-pages)/(guest-only)/login/page.tsx index c6c2372..d8433f6 100644 --- a/src/app/(blank-layout-pages)/(guest-only)/login/page.tsx +++ b/src/app/(blank-layout-pages)/(guest-only)/login/page.tsx @@ -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 { - 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 ( +
+ +
+ ); +}; - return -} - -export default LoginPage +export default LoginPage; diff --git a/src/app/(blank-layout-pages)/(guest-only)/register/page.tsx b/src/app/(blank-layout-pages)/(guest-only)/register/page.tsx index 87e9f49..d1e2ddf 100644 --- a/src/app/(blank-layout-pages)/(guest-only)/register/page.tsx +++ b/src/app/(blank-layout-pages)/(guest-only)/register/page.tsx @@ -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 ( +
+ +
+ ); +}; - return -} - -export default RegisterPage +export default RegisterPage; diff --git a/src/contexts/authContext.tsx b/src/contexts/authContext.tsx index dab0a99..8450c35 100644 --- a/src/contexts/authContext.tsx +++ b/src/contexts/authContext.tsx @@ -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 - requestOTP: (phoneNumber: string) => Promise - logout: () => void + user: AuthUser | null; + isLoading: boolean; + isAuthenticated: boolean; + login: (identifier: string, password: string) => Promise; + register: (payload: RegisterPayload) => Promise; + logout: () => void; } -const AuthContext = createContext(undefined) +const AuthContext = createContext(undefined); export const useAuth = () => { - const context = useContext(AuthContext) + const context = useContext(AuthContext); + 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 { - 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(null) - const [isLoading, setIsLoading] = useState(true) + const [user, setUser] = useState(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 => { - 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') - } + setIsLoading(false); + }, []); - /** - * Normalize API user object to AuthUser (handles data vs user key and snake_case vs camelCase). - */ - const normalizeUser = (raw: Record | null | undefined): AuthUser => { - if (!raw || typeof raw !== 'object') { + const normalizeUser = ( + raw: Record | 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 login = async (phoneNumber: string, otpCode: string, tempToken: string): Promise => { - const response = await authService.verifyOTP(tempToken, otpCode) as { - code: number - msg?: string - data?: AuthUser | Record - user?: AuthUser | Record - token?: 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); } - if (response.code !== 200 || !response.token) { - throw new Error(response.msg || 'OTP verification failed') + const userData = normalizeUser( + response.data as unknown as Record, + ); + + persistUser(userData); + + return { user: userData }; + }; + + const login = async ( + identifier: string, + password: string, + ): Promise => { + const response = await authService.login({ identifier, password }); + + if (response.code !== 200) { + throw new Error(response.msg || "Login failed"); } - const rawUser = response.data ?? response.user - const partialUserRaw = - rawUser && typeof rawUser === 'object' && rawUser !== null - ? (rawUser as Record) - : undefined + return authenticate(response, "Login failed"); + }; - const userData: AuthUser = partialUserRaw - ? normalizeUser(partialUserRaw) - : normalizeUser(undefined) - setUser(userData) - if (typeof window !== 'undefined') { - localStorage.setItem(STORAGE_KEYS.authUser, JSON.stringify(userData)) + const register = async (payload: RegisterPayload): Promise => { + const response = await authService.register(payload); + + if (response.code !== 200 && response.code !== 201) { + throw new Error(response.msg || "Registration failed"); } - 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 {children} -} + register, + logout, + }; + return {children}; +}; diff --git a/src/libs/api/client.ts b/src/libs/api/client.ts index 75639fe..7b25874 100644 --- a/src/libs/api/client.ts +++ b/src/libs/api/client.ts @@ -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 + private baseURL: string; + private defaultHeaders: Record; 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): Record { - const headers = { ...this.defaultHeaders, ...customHeaders } - const token = this.getAuthToken() - + private getHeaders( + customHeaders?: Record, + ): Record { + 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(response: Response): Promise { 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(endpoint: string, customHeaders?: Record): Promise { - const url = `${this.baseURL}${endpoint}` + async get( + endpoint: string, + customHeaders?: Record, + ): Promise { + const url = `${this.baseURL}${endpoint}`; const response = await fetch(url, { - method: 'GET', + method: "GET", headers: this.getHeaders(customHeaders), - }) + }); - return this.handleResponse(response) + return this.handleResponse(response); } /** * POST request */ - async post(endpoint: string, data?: any, customHeaders?: Record): Promise { - const url = `${this.baseURL}${endpoint}` + async post( + endpoint: string, + data?: any, + customHeaders?: Record, + ): Promise { + 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(response) + return this.handleResponse(response); } /** * POST request with FormData (e.g. file upload). Does not set Content-Type so browser sets multipart/form-data. */ - async postFormData(endpoint: string, formData: FormData, customHeaders?: Record): Promise { - const url = `${this.baseURL}${endpoint}` - const headers = { ...this.getHeaders(customHeaders) } - delete headers['Content-Type'] + async postFormData( + endpoint: string, + formData: FormData, + customHeaders?: Record, + ): Promise { + 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(response) + return this.handleResponse(response); } /** * PUT request */ - async put(endpoint: string, data?: any, customHeaders?: Record): Promise { - const url = `${this.baseURL}${endpoint}` + async put( + endpoint: string, + data?: any, + customHeaders?: Record, + ): Promise { + 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(response) + return this.handleResponse(response); } /** * PATCH request */ - async patch(endpoint: string, data?: any, customHeaders?: Record): Promise { - const url = `${this.baseURL}${endpoint}` + async patch( + endpoint: string, + data?: any, + customHeaders?: Record, + ): Promise { + 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(response) + return this.handleResponse(response); } /** * DELETE request */ - async delete(endpoint: string, customHeaders?: Record): Promise { - const url = `${this.baseURL}${endpoint}` + async delete( + endpoint: string, + customHeaders?: Record, + ): Promise { + const url = `${this.baseURL}${endpoint}`; const response = await fetch(url, { - method: 'DELETE', + method: "DELETE", headers: this.getHeaders(customHeaders), - }) + }); - return this.handleResponse(response) + return this.handleResponse(response); } } // Export singleton instance -export const apiClient = new ApiClient() - +export const apiClient = new ApiClient(); diff --git a/src/libs/api/index.ts b/src/libs/api/index.ts index ffe49e8..f72dfdb 100644 --- a/src/libs/api/index.ts +++ b/src/libs/api/index.ts @@ -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"; diff --git a/src/libs/api/services/authService.ts b/src/libs/api/services/authService.ts index 82c0df3..c813c3d 100644 --- a/src/libs/api/services/authService.ts +++ b/src/libs/api/services/authService.ts @@ -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 { - return apiClient.post('/api/auth/request-otp/', { - phone_number: phoneNumber - }) - }, + async login(payload: LoginRequest): Promise { + const response = await apiClient.post( + "/api/auth/login/", + payload, + ); - /** - * Verify OTP code and get JWT token - */ - async verifyOTP(token: string, otpCode: string): Promise { - const response = await apiClient.post('/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 { - const response = await apiClient.patch('/users/me', payload) + async register(payload: RegisterRequest): Promise { + const response = await apiClient.post( + "/api/auth/register/", + payload, + ); + + if (response.token?.access) { + apiClient.setAuthTokens(response.token); + } + + return response; + }, + + /** + * Refresh access token + */ + async refreshToken(refresh: string): Promise { + const response = await apiClient.post( + "/api/auth/token/refresh/", + { refresh }, + ); + + if (response.access) { + apiClient.setAuthToken(response.access); + } + + return response; + }, + + /** + * Update user profile + */ + async updateProfile( + payload: UpdateProfilePayload, + ): Promise { + const response = await apiClient.patch( + "/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(); + }, +}; diff --git a/src/libs/auth.ts b/src/libs/auth.ts index 734e51b..6b79507 100644 --- a/src/libs/auth.ts +++ b/src/libs/auth.ts @@ -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"); } -} +}; diff --git a/src/views/Login.tsx b/src/views/Login.tsx index 0942482..1a98d64 100644 --- a/src/views/Login.tsx +++ b/src/views/Login.tsx @@ -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; + + if (typeof error === "object" && error !== null && "message" in error) { + return String(error.message); } -})) -const MaskImg = styled('img')({ - blockSize: 'auto', - maxBlockSize: 355, - inlineSize: '100%', - position: 'absolute', - insetBlockEnd: 0, - zIndex: -1 -}) + return fallbackMessage; +}; -const OtpContainer = styled('div')({ - display: 'flex', - justifyContent: 'center', - gap: '8px', - marginTop: '16px' -}) +const Login = () => { + const t = useTranslations("login"); + const router = useRouter(); + const searchParams = useSearchParams(); + const { login } = useAuth(); -type ErrorType = { - message: string -} + const [isPasswordShown, setIsPasswordShown] = useState(false); + const [errorMessage, setErrorMessage] = useState(null); + const [isSubmitting, setIsSubmitting] = useState(false); -type PhoneFormData = { - phone_number: string -} - -const Login = ({ mode }: { mode: SystemMode }) => { - const t = useTranslations('login') - - const phoneSchema = useMemo( + 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('') - const [errorState, setErrorState] = useState(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({ - resolver: valibotResolver(phoneSchema) as any, + formState: { errors }, + } = useForm({ + 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 = async (data: PhoneFormData) => { - setIsLoading(true) - setErrorState(null) + const onSubmit: SubmitHandler = 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) + 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 ( -
-
- - {!hidden && } -
-
-
- -
-
-
- {t('welcome', { templateName: themeConfig.templateName })} - - {step === 'phone' ? t('phoneStep') : t('otpStep')} + + + + + + +
+ + {t("welcome", { templateName: themeConfig.templateName })} + {t("subtitle")}
- {errorState && ( - setErrorState(null)}> - {errorState.message} + {errorMessage && ( + setErrorMessage(null)} + className="mbe-6" + > + {errorMessage} )} - {step === 'phone' ? ( -
{}} - onSubmit={handleSubmit(onPhoneSubmit)} - className='flex flex-col gap-6' - > - ( - { - field.onChange(e.target.value.replace(/\D/g, '')) // Only allow digits - errorState !== null && setErrorState(null) - }} - {...(errors.phone_number && { - error: true, - helperText: errors.phone_number.message - })} - /> - )} - /> - -
- {t('newUser')} - - {t('createAccount')} - -
- - ) : ( -
-
- - {t('otpSent', { phone: getValues('phone_number') })} - - - { - 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) => ( -
- {slot.char} -
- ))} - - )} - /> -
-
- - -
- )} -
-
-
- ) -} +
+ ( + { + field.onChange(event.target.value); + if (errorMessage) setErrorMessage(null); + }} + {...(errors.identifier && { + error: true, + helperText: errors.identifier.message, + })} + /> + )} + /> -export default Login \ No newline at end of file + ( + { + field.onChange(event.target.value); + if (errorMessage) setErrorMessage(null); + }} + slotProps={{ + input: { + endAdornment: ( + + event.preventDefault()} + > + + + + ), + }, + }} + {...(errors.password && { + error: true, + helperText: errors.password.message, + })} + /> + )} + /> + +
+ } + label={t("rememberMe")} + /> + + {t("forgotPassword")} + +
+ + + +
+ {t("newUser")} + + {t("createAccount")} + +
+ + + + + ); +}; + +export default Login; diff --git a/src/views/Register.tsx b/src/views/Register.tsx index aff80a2..65dacc6 100644 --- a/src/views/Register.tsx +++ b/src/views/Register.tsx @@ -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(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({ + 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 = 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 ( -
-
- - {!hidden && } -
-
- - - -
-
- ماجراجویی از اینجا شروع می‌شود 🚀 - مدیریت اپلیکیشن خود را آسان و لذت‌بخش کنید! + + + + + + +
+ ایجاد حساب کاربری + برای شروع، اطلاعات حساب خود را وارد کنید.
-
e.preventDefault()} className='flex flex-col gap-6'> - - - setErrorMessage(null)} + className="mbe-6" + > + {errorMessage} + + )} + + +
+ ( + { + field.onChange(event.target.value); + if (errorMessage) setErrorMessage(null); + }} + /> + )} + /> + + ( + { + field.onChange(event.target.value); + if (errorMessage) setErrorMessage(null); + }} + /> + )} + /> +
+ + ( + { + field.onChange(event.target.value); + if (errorMessage) setErrorMessage(null); + }} + {...(errors.username && { + error: true, + helperText: errors.username.message, + })} + /> + )} + /> + + ( + { + field.onChange(event.target.value); + if (errorMessage) setErrorMessage(null); + }} + {...(errors.email && { + error: true, + helperText: errors.email.message, + })} + /> + )} + /> + + ( + { + field.onChange(event.target.value.replace(/\D/g, "")); + if (errorMessage) setErrorMessage(null); + }} + {...(errors.phone_number && { + error: true, + helperText: errors.phone_number.message, + })} + /> + )} + /> + + ( + { + field.onChange(event.target.value); + if (errorMessage) setErrorMessage(null); + }} + slotProps={{ + input: { + endAdornment: ( + + event.preventDefault()} + > + + + + ), + }, + }} + {...(errors.password && { + error: true, + helperText: errors.password.message, + })} + /> + )} + /> + + -
+ +
قبلاً حساب کاربری دارید؟ - + وارد شوید
- یا -
- - - - - - - - - - - - -
-
-
-
- ) -} + + + + ); +}; -export default Register +export default Register;