diff --git a/.gitignore b/.gitignore index 600e365..dc6213a 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ -**/node_modules \ No newline at end of file +**/node_modules +.history/ diff --git a/frontend/package.json b/frontend/package.json index bada143..3a5ea05 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -30,6 +30,7 @@ "@radix-ui/react-tooltip": "^1.1.8", "@tanstack/react-query": "^5.66.0", "@tanstack/react-query-devtools": "^5.75.7", + "agentation": "^3.0.2", "antd": "^5.24.0", "axios": "^1.7.9", "class-variance-authority": "^0.7.1", diff --git a/frontend/src/assets/images/main-login.png b/frontend/src/assets/images/main-login.png new file mode 100644 index 0000000..b4ff2f4 Binary files /dev/null and b/frontend/src/assets/images/main-login.png differ diff --git a/frontend/src/components/auth/protected-route.tsx b/frontend/src/components/auth/protected-route.tsx index cb1502b..2cb0cf6 100644 --- a/frontend/src/components/auth/protected-route.tsx +++ b/frontend/src/components/auth/protected-route.tsx @@ -3,7 +3,7 @@ import { type ReactNode } from 'react' import { Navigate, Outlet, useLocation } from 'react-router-dom' import { ROUTE } from '@/core/constants/path' -import { useAuth } from '@/hooks/auth/use-auth' +import { useAuthStore } from '@/core/store/features/auth/authStore' interface ProtectedRouteProps { children?: ReactNode @@ -11,12 +11,12 @@ interface ProtectedRouteProps { } const ProtectedRoute = ({ children, redirectPath = ROUTE.PUBLIC.LOGIN }: ProtectedRouteProps) => { - const { isAuthenticated } = useAuth() + const isAuthenticated = useAuthStore((state) => state.isAuthenticated) const location = useLocation() - // if (!isAuthenticated) { - // return - // } + if (!isAuthenticated) { + return + } return children ? <>{children} : } diff --git a/frontend/src/components/ui/input-password.tsx b/frontend/src/components/ui/input-password.tsx new file mode 100644 index 0000000..c8e5f90 --- /dev/null +++ b/frontend/src/components/ui/input-password.tsx @@ -0,0 +1,25 @@ +import { forwardRef, useState } from 'react' + +import { Eye, EyeOff } from 'lucide-react' + +import { PASSWORD_TYPE, TEXT_TYPE } from '@/core/configs/consts' + +import { Input, type InputProps } from './input' + +export const InputPassword = forwardRef(({ iconLabel, ...props }, ref) => { + const [isVisible, setIsVisible] = useState(false) + const ToggleIcon = isVisible ? EyeOff : Eye + + return ( + } + iconLabel={iconLabel || (isVisible ? 'Ẩn mật khẩu' : 'Hiện mật khẩu')} + iconOnClick={() => setIsVisible((current) => !current)} + {...props} + /> + ) +}) + +InputPassword.displayName = 'InputPassword' diff --git a/frontend/src/contexts/SocketContext.tsx b/frontend/src/contexts/SocketContext.tsx index 8a857a9..c2d3f3d 100644 --- a/frontend/src/contexts/SocketContext.tsx +++ b/frontend/src/contexts/SocketContext.tsx @@ -1,8 +1,6 @@ import { type ReactNode } from 'react' import config from '@/core/configs/env' -import { getAccessTokenFromLS } from '@/core/shared/storage' -import { useAuthStore } from '@/core/store/features/auth/authStore' import { useSocket } from '@/hooks/socket/useSocket' import { SocketContext } from './socket-context' @@ -12,11 +10,8 @@ interface SocketProviderProps { } export const SocketProvider = ({ children }: SocketProviderProps) => { - const storeToken = useAuthStore((state) => state.access_token) - const token = storeToken || getAccessTokenFromLS() const socketState = useSocket({ - token, - enabled: Boolean(token) && !config.useMockChat + enabled: !config.useMockChat }) return {children} diff --git a/frontend/src/core/helpers/auth-route.ts b/frontend/src/core/helpers/auth-route.ts new file mode 100644 index 0000000..b63d9de --- /dev/null +++ b/frontend/src/core/helpers/auth-route.ts @@ -0,0 +1,20 @@ +import { ROLE_ADMIN, ROLE_EMPLOYEE } from '@/core/configs/consts' +import { ROUTE } from '@/core/constants/path' + +export const getDashboardRouteByRole = (role?: string | null) => { + const normalizedRole = role?.trim().toLowerCase() + + if (!normalizedRole) { + return ROUTE.DISABILITY.DASHBOARD + } + + if ([ROLE_ADMIN, ROLE_EMPLOYEE].some((adminRole) => adminRole.toLowerCase() === normalizedRole)) { + return `${ROUTE.ADMIN.ROOT}/${ROUTE.ADMIN.DASHBOARD}` + } + + if (['business', 'recruiter', 'employer', 'company'].includes(normalizedRole)) { + return ROUTE.BUSINESS.DASHBOARD + } + + return ROUTE.DISABILITY.DASHBOARD +} diff --git a/frontend/src/core/helpers/key-tanstack.ts b/frontend/src/core/helpers/key-tanstack.ts index 821254f..e8e459a 100644 --- a/frontend/src/core/helpers/key-tanstack.ts +++ b/frontend/src/core/helpers/key-tanstack.ts @@ -1,6 +1,7 @@ export const MUTATION_KEYS = { register: 'register', login: 'login', + forgotPassword: 'forgotPassword', updateProfile: 'updateProfile', verifyEmail: 'verifyEmail', resendCode: 'resendCode' diff --git a/frontend/src/core/services/auth.service.ts b/frontend/src/core/services/auth.service.ts index 6b2b089..a7ec07c 100644 --- a/frontend/src/core/services/auth.service.ts +++ b/frontend/src/core/services/auth.service.ts @@ -3,10 +3,13 @@ import { type AxiosInstance } from 'axios' import axiosClient from '@/core/services/axios-client' import { type VerifyEmailReq, - type Account, type LoginResponse, type RegisterReponse, - type VerifyEmailRes + type VerifyEmailRes, + type LoginRequest, + type RegisterRequest, + type ForgotPasswordRequest, + type ForgotPasswordResponse } from '@/models/interface/auth.interfaces' const API_AUTH_BASE_URL = '/api/v1/auth' @@ -14,36 +17,41 @@ const API_LOGIN_URL = `${API_AUTH_BASE_URL}/login` const API_REGISTER_URL = `${API_AUTH_BASE_URL}/register` const API_REFRESH_TOKEN_URL = `${API_AUTH_BASE_URL}/refresh` const API_VERIFY_EMAIL_URL = `${API_AUTH_BASE_URL}/verify-email` +const API_FORGOT_PASSWORD_URL = `${API_AUTH_BASE_URL}/forgot-password` const API_RESEND_CODE_URL = `${API_AUTH_BASE_URL}/resend-verification-email` const API_LOGOUT_URL = `${API_AUTH_BASE_URL}/logout` export type AuthApi = { - login: (params: Account) => Promise - register: (params: Account) => Promise + login: (params: LoginRequest) => Promise + register: (params: RegisterRequest) => Promise refreshToken: (refreshToken: string) => Promise verifyEmail: (params: VerifyEmailReq) => Promise + forgotPassword: (params: ForgotPasswordRequest) => Promise resendVerificationCode: (email: string) => Promise<{ message: string }> - logout: (refresh_token: string) => Promise + logout: () => Promise } export const createAuthApi = (client: AxiosInstance): AuthApi => ({ login(params) { - return client.post(API_LOGIN_URL, params) + return client.post(API_LOGIN_URL, params, { withCredentials: true }) as Promise }, register(params) { - return client.post(API_REGISTER_URL, params) + return client.post(API_REGISTER_URL, params) as Promise }, refreshToken(refreshToken) { - return client.post(API_REFRESH_TOKEN_URL, { refresh_token: refreshToken }) + return client.post(API_REFRESH_TOKEN_URL, { refresh_token: refreshToken }) as Promise }, verifyEmail(params) { - return client.post(API_VERIFY_EMAIL_URL, params) + return client.post(API_VERIFY_EMAIL_URL, params) as Promise + }, + forgotPassword(params) { + return client.post(API_FORGOT_PASSWORD_URL, params) as Promise }, resendVerificationCode(email) { - return client.post(API_RESEND_CODE_URL, { email }) + return client.post(API_RESEND_CODE_URL, { email }) as Promise<{ message: string }> }, - logout(refresh_token) { - return client.post(API_LOGOUT_URL, { refresh_token }) + logout() { + return client.post(API_LOGOUT_URL, undefined, { withCredentials: true }) as Promise } }) diff --git a/frontend/src/core/services/axios-client.ts b/frontend/src/core/services/axios-client.ts index b01055d..0c1cb9b 100644 --- a/frontend/src/core/services/axios-client.ts +++ b/frontend/src/core/services/axios-client.ts @@ -1,146 +1,51 @@ -import axios, { HttpStatusCode } from 'axios' +import axios from 'axios' -import { AUTH_ENDPOINTS } from '@/core/configs/consts' import config from '@/core/configs/env' -import isEqual from '@/core/configs/is-equal' -import { - getAccessTokenFromLS, - getRefreshTokenFromLS, - removeAccessTokenFromLS, - removeRefreshTokenFromLS, - setAccessTokenToLS -} from '@/core/shared/storage' -import { type LoginResponse } from '@/models/interface/auth.interfaces' const controllers = new Map() -let isRefreshing = false -let failedQueue: { resolve: (value?: unknown) => void; reject: (reason?: unknown) => void }[] = [] - -const processQueue = (error: unknown, token: string | null = null) => { - failedQueue.forEach((prom) => { - if (error) { - prom.reject(error) - } else { - prom.resolve(token) - } - }) - failedQueue = [] -} const axiosClient = axios.create({ baseURL: config.baseUrl, + withCredentials: true, headers: { 'Content-Type': 'application/json' } }) -const refreshClient = axios.create({ - baseURL: config.baseUrl, - headers: { - 'Content-Type': 'application/json' - }, - withCredentials: true -}) axiosClient.interceptors.request.use( - (config) => { - if (config.url) { - const prevController = controllers.get(config.url) + (requestConfig) => { + if (requestConfig.url) { + const prevController = controllers.get(requestConfig.url) if (prevController) { prevController.abort() } } const controller = new AbortController() - config.signal = controller.signal - - if (config.url) { - controllers.set(config.url, controller) - } + requestConfig.signal = controller.signal - const token = getAccessTokenFromLS() - if (token) { - config.headers.Authorization = `Bearer ${token}` + if (requestConfig.url) { + controllers.set(requestConfig.url, controller) } - return config + return requestConfig }, - (error) => { - return Promise.reject(error) - } + (error) => Promise.reject(error) ) -// Response interceptor axiosClient.interceptors.response.use( (response) => { if (response.config.url) { controllers.delete(response.config.url) } + return response.data }, - async (error) => { - const originalRequest = error.config - - // Check if the request is an auth request (login, register...) - const isAuthRequest = AUTH_ENDPOINTS.some( - (endpoint) => originalRequest.url && originalRequest.url.includes(endpoint) - ) - - // Only refresh token if it's not an auth request - if ( - error.response && - isEqual(error.response.status, HttpStatusCode.Unauthorized) && - !originalRequest._retry && - !isAuthRequest - ) { - if (isRefreshing) { - return new Promise((resolve, reject) => { - failedQueue.push({ resolve, reject }) - }) - .then((token) => { - originalRequest.headers.Authorization = `Bearer ${token}` - return axiosClient(originalRequest) - }) - .catch((err) => { - return Promise.reject(err) - }) - } - - originalRequest._retry = true - isRefreshing = true - - try { - const refresh_token = getRefreshTokenFromLS() - if (!refresh_token) { - removeAccessTokenFromLS() - removeRefreshTokenFromLS() - processQueue(new Error('Phiên đăng nhập hết hạn, vui lòng đăng nhập lại'), null) - return Promise.reject(error) - } - - const { data: refreshResponse } = await refreshClient.post('/api/v1/auth/refresh', { - refresh_token - }) - const access_token = refreshResponse?.data?.access_token || refreshResponse?.access_token - - if (!access_token) { - throw new Error('Refresh response is missing access_token') - } - setAccessTokenToLS(access_token) - originalRequest.headers.Authorization = `Bearer ${access_token}` - processQueue(null, access_token) - return axiosClient(originalRequest) - } catch (refreshError) { - processQueue(refreshError, null) - removeAccessTokenFromLS() - removeRefreshTokenFromLS() - return Promise.reject(error) - } finally { - isRefreshing = false - } - } + (error) => { + const requestUrl = error.config?.url - if (originalRequest?.url) { - controllers.delete(originalRequest.url) + if (requestUrl) { + controllers.delete(requestUrl) } return Promise.reject(error) diff --git a/frontend/src/core/shared/auth.ts b/frontend/src/core/shared/auth.ts index ba2e4ad..f00ff78 100644 --- a/frontend/src/core/shared/auth.ts +++ b/frontend/src/core/shared/auth.ts @@ -1,13 +1,7 @@ -import { getAccessTokenFromLS, getRefreshTokenFromLS, getUserFromLocalStorage } from '@/core/shared/storage' import { type AuthState } from '@/core/store/features/auth/types' -export const getPersistedAuth = (): Partial => { - const access_token = getAccessTokenFromLS() - const refresh_token = getRefreshTokenFromLS() - const user = getUserFromLocalStorage() +export const getPersistedAuth = (): Partial => ({}) - return access_token ? { access_token, refresh_token, user, isAuthenticated: true } : {} -} +export const isAuthenticated = (): boolean => false -export const isAuthenticated = (): boolean => !!getPersistedAuth().access_token -export const getCurrentUser = () => getPersistedAuth().user +export const getCurrentUser = () => null diff --git a/frontend/src/core/shared/storage.ts b/frontend/src/core/shared/storage.ts index 3158d38..c00db7e 100644 --- a/frontend/src/core/shared/storage.ts +++ b/frontend/src/core/shared/storage.ts @@ -3,48 +3,14 @@ import { REFRESH_TOKEN_LOCAL_STORAGE_KEY, USER_LOCAL_STORAGE_KEY } from '@/core/helpers/common' -import { type UserResponseType } from '@/models/interface/user.interfaces' export const LocalStorageEventTarget = new EventTarget() -export const setAccessTokenToLS = (access_token: string) => - localStorage.setItem(ACCESS_TOKEN_LOCAL_STORAGE_KEY, access_token) -export const setRefreshTokenToLS = (refresh_token: string) => - localStorage.setItem(REFRESH_TOKEN_LOCAL_STORAGE_KEY, refresh_token) - -export const setToken = (access_token: string, refresh_token: string) => { - localStorage.setItem(ACCESS_TOKEN_LOCAL_STORAGE_KEY, access_token) - localStorage.setItem(REFRESH_TOKEN_LOCAL_STORAGE_KEY, refresh_token) -} - -export const clearLS = () => { +export const clearAuthClientState = () => { localStorage.removeItem(ACCESS_TOKEN_LOCAL_STORAGE_KEY) localStorage.removeItem(REFRESH_TOKEN_LOCAL_STORAGE_KEY) localStorage.removeItem(USER_LOCAL_STORAGE_KEY) + const clearLSEvent = new Event('clearLS') LocalStorageEventTarget.dispatchEvent(clearLSEvent) } - -export const getAccessTokenFromLS = () => localStorage.getItem(ACCESS_TOKEN_LOCAL_STORAGE_KEY) || '' - -export const getRefreshTokenFromLS = () => localStorage.getItem(REFRESH_TOKEN_LOCAL_STORAGE_KEY) || '' - -export const getUserFromLocalStorage = (): UserResponseType | null => { - const user = localStorage.getItem(USER_LOCAL_STORAGE_KEY) - if (!user || user === 'undefined' || user === 'null') { - return null - } - try { - return JSON.parse(user) - } catch (error) { - localStorage.removeItem(USER_LOCAL_STORAGE_KEY) - return null - } -} - -export const removeAccessTokenFromLS = () => localStorage.removeItem(ACCESS_TOKEN_LOCAL_STORAGE_KEY) - -export const setUserToLS = (user: { id: string; name: string; email: string; role: string }) => - localStorage.setItem(USER_LOCAL_STORAGE_KEY, JSON.stringify(user)) - -export const removeRefreshTokenFromLS = () => localStorage.removeItem(REFRESH_TOKEN_LOCAL_STORAGE_KEY) diff --git a/frontend/src/core/store/features/auth/authStore.ts b/frontend/src/core/store/features/auth/authStore.ts index 59ae4af..510cba6 100644 --- a/frontend/src/core/store/features/auth/authStore.ts +++ b/frontend/src/core/store/features/auth/authStore.ts @@ -1,15 +1,14 @@ import { create } from 'zustand' +import { authApi } from '@/core/services/auth.service' import { getPersistedAuth } from '@/core/shared/auth' -import { clearLS } from '@/core/shared/storage' +import { clearAuthClientState } from '@/core/shared/storage' import { type LoginResponse } from '@/models/interface/auth.interfaces' import { type AuthState, type AuthStore } from './types' const initialState: AuthState = { user: null, - access_token: null, - refresh_token: null, isAuthenticated: false, isLoading: false, error: null @@ -30,9 +29,10 @@ export const useAuthStore = create((set) => ({ set({ isLoading: false, isAuthenticated: true, - user: data?.user, - access_token: data?.access_token, - refresh_token: data?.refresh_token, + user: { + ...data.user, + name: data.user.full_name || data.user.email + }, error: null }) }, @@ -45,9 +45,11 @@ export const useAuthStore = create((set) => ({ }, logout: () => { - clearLS() - set({ - ...initialState + void authApi.logout().finally(() => { + clearAuthClientState() + set({ + ...initialState + }) }) }, diff --git a/frontend/src/core/store/features/auth/types.ts b/frontend/src/core/store/features/auth/types.ts index 98cfbc7..9f09afe 100644 --- a/frontend/src/core/store/features/auth/types.ts +++ b/frontend/src/core/store/features/auth/types.ts @@ -2,8 +2,6 @@ import { type LoginResponse } from '@/models/interface/auth.interfaces' export interface AuthState { user: LoginResponse['user'] | null - access_token: string | null - refresh_token: string | null isAuthenticated: boolean isLoading: boolean error: string | null diff --git a/frontend/src/core/zod/forgot-password.zod.ts b/frontend/src/core/zod/forgot-password.zod.ts new file mode 100644 index 0000000..c95c70d --- /dev/null +++ b/frontend/src/core/zod/forgot-password.zod.ts @@ -0,0 +1,8 @@ +import { z } from 'zod' + +export const ForgotPasswordSchema = z.object({ + email: z + .string() + .min(1, { message: 'Vui lòng nhập email.' }) + .email({ message: 'Email không đúng định dạng.' }) +}) diff --git a/frontend/src/core/zod/index.ts b/frontend/src/core/zod/index.ts index 11c6406..12cf11f 100644 --- a/frontend/src/core/zod/index.ts +++ b/frontend/src/core/zod/index.ts @@ -2,3 +2,4 @@ export * from './cv.zod' export * from './login.zod' export * from './register.zod' export * from './verify-account-email.zod' +export * from './forgot-password.zod' diff --git a/frontend/src/core/zod/login.zod.ts b/frontend/src/core/zod/login.zod.ts index f4d6933..0c005c6 100644 --- a/frontend/src/core/zod/login.zod.ts +++ b/frontend/src/core/zod/login.zod.ts @@ -1,12 +1,24 @@ import { z } from 'zod' -import { numberConstants } from '@/core/configs/consts' - export const LoginSchema = z.object({ - email: z.string().min(numberConstants.TWO, { - message: 'Email is valid.' - }), - password: z.string().min(numberConstants.SIX, { - message: 'Password must be at least 6 characters.' + email: z.string().min(1, { message: 'Vui lòng nhập email.' }).email({ message: 'Email không đúng định dạng.' }), + password: z.string().min(1, { message: 'Vui lòng nhập mật khẩu.' }) +}) + +export const LoginResponseSchema = z.object({ + access_token: z.string().min(1), + refresh_token: z.string().min(1), + expires_in: z.number(), + user: z.object({ + id: z.string().min(1), + email: z.string().email(), + role: z.string().min(1), + full_name: z.string().nullable() }) }) + +export const ApiErrorSchema = z.object({ + status: z.number(), + code: z.string(), + message: z.string() +}) diff --git a/frontend/src/core/zod/register.zod.ts b/frontend/src/core/zod/register.zod.ts index 3cdbb6b..4a561c5 100644 --- a/frontend/src/core/zod/register.zod.ts +++ b/frontend/src/core/zod/register.zod.ts @@ -4,30 +4,33 @@ import { numberConstants } from '@/core/configs/consts' import { validator } from '../helpers/validator' -export const RegisterSchema = z.object({ - name: z.string().min(numberConstants.TWO, { - message: 'Name is valid.' - }), - email: z.string().min(numberConstants.TWO, { - message: 'Email is valid.' - }), - password: z - .string() - .min(numberConstants.ONE, { - message: 'Password is required' - }) - .regex(validator.passwordRegex, { - message: 'Password must be at least 5 characters long, contain at least one uppercase letter and one number' +export const RegisterSchema = z + .object({ + email: z.string().email({ + message: 'Email không hợp lệ.' }), - confirmPassword: z - .string() - .min(numberConstants.ONE, { - message: 'Password is required' - }) - .regex(validator.passwordRegex, { - message: 'Password must be at least 5 characters long, contain at least one uppercase letter and one number' + phone: z.string().min(numberConstants.TEN, { + message: 'Số điện thoại phải có ít nhất 10 ký tự.' + }), + password: z + .string() + .min(numberConstants.ONE, { + message: 'Vui lòng nhập mật khẩu.' + }) + .regex(validator.passwordRegex, { + message: 'Mật khẩu phải có ít nhất 5 ký tự, một chữ in hoa và một số.' + }), + confirmPassword: z.string().min(numberConstants.ONE, { + message: 'Vui lòng nhập lại mật khẩu.' }), - phone: z.string().min(numberConstants.TEN, { - message: 'Phone number must be at least 10 characters.' + role: z.enum(['candidate', 'educator', 'business'], { + required_error: 'Vui lòng chọn vai trò.' + }), + full_name: z.string().min(numberConstants.TWO, { + message: 'Họ và tên phải có ít nhất 2 ký tự.' + }) + }) + .refine((data) => data.password === data.confirmPassword, { + message: 'Mật khẩu nhập lại không khớp.', + path: ['confirmPassword'] }) -}) diff --git a/frontend/src/hooks/auth/use-auth-redirect.ts b/frontend/src/hooks/auth/use-auth-redirect.ts index 2bf1079..990103e 100644 --- a/frontend/src/hooks/auth/use-auth-redirect.ts +++ b/frontend/src/hooks/auth/use-auth-redirect.ts @@ -2,16 +2,18 @@ import { useEffect } from 'react' import { useNavigate } from 'react-router-dom' -import { ROUTE } from '@/core/constants/path' +import { getDashboardRouteByRole } from '@/core/helpers/auth-route' +import { useAuthStore } from '@/core/store/features/auth/authStore' import { useAuth } from '@/hooks/auth/use-auth' export const useAuthRedirect = () => { const { isAuthenticated } = useAuth() + const user = useAuthStore((state) => state.user) const navigate = useNavigate() useEffect(() => { if (isAuthenticated) { - navigate(ROUTE.PUBLIC.HOME) + navigate(getDashboardRouteByRole(user?.role), { replace: true }) } - }, [isAuthenticated, navigate]) + }, [isAuthenticated, navigate, user?.role]) } diff --git a/frontend/src/hooks/routes/use-router-element.tsx b/frontend/src/hooks/routes/use-router-element.tsx index f040c09..de5df05 100644 --- a/frontend/src/hooks/routes/use-router-element.tsx +++ b/frontend/src/hooks/routes/use-router-element.tsx @@ -2,7 +2,6 @@ import { lazy } from 'react' import { Navigate, Route, Routes, useLocation } from 'react-router-dom' -import DisabilityLayout from '@/app/layout/disability-layout' import LayoutClient from '@/app/layout/layout-client' import LayoutMain from '@/app/layout/layout-main' import SuspenseProvider from '@/app/providers/suspense-provider' @@ -15,6 +14,7 @@ const HomePage = lazy(() => import('@/pages/home')) const Login = lazy(() => import('@/pages/auth/login')) const Register = lazy(() => import('@/pages/auth/register')) const VerifyAccountEmail = lazy(() => import('@/pages/auth/verify-account-email')) +const ForgotPassword = lazy(() => import('@/pages/auth/forgot-password')) const AccountSettingsPage = lazy(() => import('@/pages/account/settings')) const ChatPage = lazy(() => import('@/pages/communication/chat/ChatPageRefactored')) const CallPage = lazy(() => import('@/pages/communication/call')) @@ -33,7 +33,6 @@ const DisabilityProfilePage = lazy(() => import('@/pages/disability/profile')) const DisabilityProfileUpdatePage = lazy(() => import('@/pages/disability/profile/update')) const DisabilityCvPage = lazy(() => import('@/pages/disability/cv')) const DisabilityCvEditPage = lazy(() => import('@/pages/disability/cv/edit')) -const DisabilityCvPreviewPage = lazy(() => import('@/pages/disability/cv/preview')) const BusinessDashboardPage = lazy(() => import('@/pages/business/dashboard')) const BusinessMessagesPage = lazy(() => import('@/pages/business/messages')) const BusinessCandidatesPage = lazy(() => import('@/pages/business/candidates')) @@ -50,7 +49,9 @@ const PageNotFound = lazy(() => import('@/pages/404')) export default function useRoutesElements() { const location = useLocation() - const isAuthPath = [ROUTE.PUBLIC.LOGIN, ROUTE.PUBLIC.REGISTER].some((path) => path === location.pathname) + const isAuthPath = [ROUTE.PUBLIC.LOGIN, ROUTE.PUBLIC.REGISTER, ROUTE.PUBLIC.FORGOT_PASSWORD].some( + (path) => path === location.pathname + ) const isAdminPath = location.pathname.startsWith('/admin') const routeElements = ( @@ -60,6 +61,7 @@ export default function useRoutesElements() { } /> } /> } /> + } /> } /> {/* }> }> diff --git a/frontend/src/hooks/socket/useSocket.ts b/frontend/src/hooks/socket/useSocket.ts index 095c396..2b62601 100644 --- a/frontend/src/hooks/socket/useSocket.ts +++ b/frontend/src/hooks/socket/useSocket.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useState } from 'react' +import { useCallback, useEffect, useState } from 'react' import { io, type Socket } from 'socket.io-client' @@ -11,7 +11,6 @@ type ClientToServerEvents = Record export type AppSocket = Socket export interface UseSocketOptions { - token?: string | null url?: string enabled?: boolean path?: string @@ -31,7 +30,6 @@ const DEFAULT_SOCKET_PATH = '/socket.io' const DEFAULT_TRANSPORTS: ('websocket' | 'polling')[] = ['websocket', 'polling'] export const useSocket = ({ - token, url = config.socketUrl, enabled = true, path = DEFAULT_SOCKET_PATH, @@ -42,13 +40,6 @@ export const useSocket = ({ const [isConnecting, setIsConnecting] = useState(false) const [error, setError] = useState(null) - const socketAuth = useMemo( - () => ({ - ...(token ? { token, authorization: `Bearer ${token}` } : {}) - }), - [token] - ) - useEffect(() => { if (!enabled || !url) { setSocket(null) @@ -64,7 +55,7 @@ export const useSocket = ({ reconnection: true, reconnectionAttempts: 5, reconnectionDelay: 1000, - auth: socketAuth + withCredentials: true }) const handleConnect = () => { @@ -112,7 +103,7 @@ export const useSocket = ({ setIsConnected(false) setIsConnecting(false) } - }, [enabled, path, socketAuth, transports, url]) + }, [enabled, path, transports, url]) const connect = useCallback(() => { if (!socket || socket.connected) { diff --git a/frontend/src/hooks/tanstack-query/auth/use-query-auth.ts b/frontend/src/hooks/tanstack-query/auth/use-query-auth.ts index a49832e..fc65d8e 100644 --- a/frontend/src/hooks/tanstack-query/auth/use-query-auth.ts +++ b/frontend/src/hooks/tanstack-query/auth/use-query-auth.ts @@ -3,38 +3,23 @@ import { type AxiosError } from 'axios' import { useNavigate } from 'react-router-dom' import { type z } from 'zod' -import { ROLE_ADMIN, ROLE_EMPLOYEE } from '@/core/configs/consts' -import isEqual from '@/core/configs/is-equal' import { ROUTE } from '@/core/constants/path' import { handleError } from '@/core/helpers/error-handler' import { MUTATION_KEYS } from '@/core/helpers/key-tanstack' import toastifyCommon from '@/core/lib/toastify-common' import { authApi } from '@/core/services/auth.service' -import { setToken, setUserToLS } from '@/core/shared/storage' -import { type LoginSchema } from '@/core/zod/login.zod' +import { type ForgotPasswordSchema } from '@/core/zod/forgot-password.zod' +import { LoginResponseSchema, type LoginSchema } from '@/core/zod/login.zod' import { type RegisterSchema } from '@/core/zod/register.zod' import { type VerifyAccountEmailSchema } from '@/core/zod/verify-account-email.zod' -import { type LoginResponse } from '@/models/interface/auth.interfaces' const RESEND_COUNTDOWN = 60 export const useLoginAuth = () => { - const navigate = useNavigate() return useMutation({ mutationKey: [MUTATION_KEYS.login], - mutationFn: (data: z.infer) => authApi.login(data), - onSuccess: (response: LoginResponse) => { - const { access_token, refresh_token, user } = response - setToken(access_token, refresh_token) - setUserToLS(user) - navigate( - isEqual(user.role, ROLE_ADMIN) || isEqual(user.role, ROLE_EMPLOYEE) - ? `${ROUTE.ADMIN.ROOT}/${ROUTE.ADMIN.DASHBOARD}` - : ROUTE.PUBLIC.HOME - ) - toastifyCommon.success('Đăng nhập thành công') - }, - onError: (error: AxiosError) => { - handleError(error, 'Đăng nhập thất bại') + mutationFn: async (data: z.infer) => { + const response = await authApi.login(data) + return LoginResponseSchema.parse(response) } }) } @@ -43,10 +28,11 @@ export const useRegisterAuth = () => { const navigate = useNavigate() return useMutation({ mutationKey: [MUTATION_KEYS.register], - mutationFn: (data: z.infer) => authApi.register(data), - onSuccess: (_, variables) => { - navigate(ROUTE.PUBLIC.VERIFY_ACCOUNT_EMAIL, { state: { email: variables.email } }) - toastifyCommon.success('Đăng ký thành công') + mutationFn: ({ confirmPassword: _confirmPassword, ...data }: z.infer) => + authApi.register(data), + onSuccess: (_) => { + navigate(ROUTE.PUBLIC.LOGIN) + toastifyCommon.success('Đăng ký thành công, vui lòng đăng nhập!') }, onError: (error: AxiosError) => { handleError(error, 'Đăng ký thất bại') @@ -60,10 +46,18 @@ export const useVerifyAccountEmail = () => { mutationKey: [MUTATION_KEYS.verifyEmail], mutationFn: (data: z.infer) => authApi.verifyEmail(data), onSuccess: () => { - toastifyCommon.success('Email verified successfully! 🎉') + toastifyCommon.success('Xác thực email thành công') navigate(ROUTE.PUBLIC.LOGIN) }, - onError: (error: AxiosError) => handleError(error, 'Failed to verify email') + onError: (error: AxiosError) => handleError(error, 'Xác thực email thất bại') + }) +} + +export const useForgotPassword = () => { + return useMutation({ + mutationKey: [MUTATION_KEYS.forgotPassword], + mutationFn: (data: z.infer) => authApi.forgotPassword(data), + onError: (error: AxiosError) => handleError(error, 'Gá»­i yêu cầu khôi phục mật khẩu thất bại') }) } @@ -78,10 +72,10 @@ export const useResendVerificationCode = ({ mutationKey: [MUTATION_KEYS.resendCode], mutationFn: (email: string) => authApi.resendVerificationCode(email), onSuccess: () => { - toastifyCommon.success('Verification code resent! 📧') + toastifyCommon.success('Đã gửi lại mã xác thực') setCountdown(RESEND_COUNTDOWN) setCanResend(false) }, - onError: (error: AxiosError) => handleError(error, 'Failed to resend verification code') + onError: (error: AxiosError) => handleError(error, 'Gửi lại mã xác thực thất bại') }) } diff --git a/frontend/src/models/interface/auth.interfaces.ts b/frontend/src/models/interface/auth.interfaces.ts index 18d3524..3b33e13 100644 --- a/frontend/src/models/interface/auth.interfaces.ts +++ b/frontend/src/models/interface/auth.interfaces.ts @@ -1,4 +1,4 @@ -import { User } from "./user.interfaces" +import { type User } from './user.interfaces' export interface TokenResponse { access_token: string @@ -10,10 +10,41 @@ export interface LoginRequest { password: string } +export interface AuthUser { + id: string + email: string + role: string + full_name: string | null + name?: string +} + +export interface LoginResponse { + access_token: string + refresh_token: string + expires_in: number + user: AuthUser +} + +export interface ApiError { + status: number + code: string + message: string +} + export interface RegisterRequest { email: string + phone: string password: string - confirm_password: string + role: string + full_name: string +} + +export interface ForgotPasswordRequest { + email: string +} + +export interface ForgotPasswordResponse { + message: string } export interface AuthState { @@ -29,31 +60,11 @@ export interface APIResponse { success?: boolean } -export interface LoginApiResponse { - data: LoginResponse - message: string -} - -export interface LoginResponse { - user: { id: string; name: string; email: string; role: string } - access_token: string - refresh_token: string -} - -export interface Account { - email?: string - password?: string - confirmPassword?: string - name?: string - phone?: string -} - export interface RegisterReponse { - name: string + full_name: string email: string - password: string - confirmPassword: string - phone?: string + phone: string + role: string } export interface VerifyEmailReq { @@ -64,9 +75,3 @@ export interface VerifyEmailReq { export interface VerifyEmailRes { message: string } - -export interface RememberMeData { - email: string - password: string - isRemembered: boolean -} diff --git a/frontend/src/models/interface/user.interfaces.ts b/frontend/src/models/interface/user.interfaces.ts index a085f01..dc60ba1 100644 --- a/frontend/src/models/interface/user.interfaces.ts +++ b/frontend/src/models/interface/user.interfaces.ts @@ -1,4 +1,4 @@ -import { UserRole } from "../type/user.types" +import { type UserRole } from '../type/user.types' export interface User { id: string diff --git a/frontend/src/pages/auth/components/auth-brand-panel.tsx b/frontend/src/pages/auth/components/auth-brand-panel.tsx new file mode 100644 index 0000000..62f7c1b --- /dev/null +++ b/frontend/src/pages/auth/components/auth-brand-panel.tsx @@ -0,0 +1,32 @@ +import { motion } from 'framer-motion' + +import mainLoginImage from '@/assets/images/main-login.png' + +export const AuthBrandPanel = () => ( +
+ + + Trao cơ hội - Nhận giá trị + + +
+) diff --git a/frontend/src/pages/auth/components/auth-divider.tsx b/frontend/src/pages/auth/components/auth-divider.tsx new file mode 100644 index 0000000..01680df --- /dev/null +++ b/frontend/src/pages/auth/components/auth-divider.tsx @@ -0,0 +1,7 @@ +export const AuthDivider = () => ( +
+ + Hoặc + +
+) diff --git a/frontend/src/pages/auth/components/auth-footer.tsx b/frontend/src/pages/auth/components/auth-footer.tsx new file mode 100644 index 0000000..c4e9219 --- /dev/null +++ b/frontend/src/pages/auth/components/auth-footer.tsx @@ -0,0 +1,18 @@ +import { Link } from 'react-router-dom' + +export const AuthFooter = () => ( +
+

© 2026 D-SHIFTIFY. Xây dựng cho khả năng tiếp cận.

+
+ + Chính sách bảo mật + + + Điều khoản + + + Hỗ trợ + +
+
+) diff --git a/frontend/src/pages/auth/components/auth-header.tsx b/frontend/src/pages/auth/components/auth-header.tsx new file mode 100644 index 0000000..ee884b0 --- /dev/null +++ b/frontend/src/pages/auth/components/auth-header.tsx @@ -0,0 +1,41 @@ +import { ArrowLeft, HelpCircle, Settings } from 'lucide-react' +import { useNavigate } from 'react-router-dom' + +import Logo from '@/components/logo/logo' + +export const AuthHeader = () => { + const navigate = useNavigate() + + return ( +
+
+ + +
+ +
+ + +
+
+ ) +} diff --git a/frontend/src/pages/auth/components/decorative-wave.tsx b/frontend/src/pages/auth/components/decorative-wave.tsx new file mode 100644 index 0000000..d73777c --- /dev/null +++ b/frontend/src/pages/auth/components/decorative-wave.tsx @@ -0,0 +1,37 @@ +import { motion } from 'framer-motion' + +export const DecorativeWave = () => ( + +) diff --git a/frontend/src/pages/auth/forgot-password.tsx b/frontend/src/pages/auth/forgot-password.tsx new file mode 100644 index 0000000..22d17e9 --- /dev/null +++ b/frontend/src/pages/auth/forgot-password.tsx @@ -0,0 +1,195 @@ +import { useEffect, useState } from 'react' + +import { zodResolver } from '@hookform/resolvers/zod' +import { AnimatePresence, motion } from 'framer-motion' +import { ArrowLeft, CheckCircle2, Loader2, Mail } from 'lucide-react' +import { useForm } from 'react-hook-form' +import { Link, useNavigate } from 'react-router-dom' +import { type z } from 'zod' + +import { Button } from '@/components/ui/button' +import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form' +import { Input } from '@/components/ui/input' +import { ROUTE } from '@/core/constants/path' +import { ForgotPasswordSchema } from '@/core/zod' +import { useForgotPassword } from '@/hooks/tanstack-query/auth/use-query-auth' + +import { AuthFooter } from './components/auth-footer' +import { AuthHeader } from './components/auth-header' +import { DecorativeWave } from './components/decorative-wave' + +type ForgotPasswordFormValues = z.infer + +export default function ForgotPassword() { + const navigate = useNavigate() + const [isSuccess, setIsSuccess] = useState(false) + const [submittedEmail, setSubmittedEmail] = useState('') + const [countdown, setCountdown] = useState(0) + const forgotPasswordMutation = useForgotPassword() + const isPending = forgotPasswordMutation.isPending + + const form = useForm({ + resolver: zodResolver(ForgotPasswordSchema), + defaultValues: { + email: '' + } + }) + + useEffect(() => { + if (countdown > 0) { + const timer = setTimeout(() => setCountdown(countdown - 1), 1000) + return () => clearTimeout(timer) + } + }, [countdown]) + + const onSubmit = (values: ForgotPasswordFormValues) => { + forgotPasswordMutation.mutate(values, { + onSuccess: () => { + setIsSuccess(true) + setSubmittedEmail(values.email) + setCountdown(60) + } + }) + } + + const handleResend = () => { + if (countdown > 0 || isPending) return + forgotPasswordMutation.mutate( + { email: submittedEmail }, + { + onSuccess: () => { + setCountdown(60) + } + } + ) + } + + return ( +
+ + + +
+ + {!isSuccess ? ( + +
+

Quên mật khẩu

+ + + +
+ +

+ Nhập địa chỉ email tài khoản của bạn để nhận liên kết xác thực đặt lại mật khẩu mới từ hệ thống. +

+ +
+ + ( + + Địa chỉ email + +
+ + +
+
+ +
+ )} + /> + + + + +
+ ) : ( + + + + + +

Kiểm tra hộp thư của bạn

+

+ Chúng tôi đã gửi hướng dẫn khôi phục mật khẩu đến địa chỉ email:
+ {submittedEmail} +

+ +
+ + +
+ Không nhận được email?{' '} + {countdown > 0 ? ( + Gửi lại sau {countdown}s + ) : ( + + )} +
+
+
+ )} +
+
+ + +
+ ) +} diff --git a/frontend/src/pages/auth/login.tsx b/frontend/src/pages/auth/login.tsx deleted file mode 100644 index 9ba026e..0000000 --- a/frontend/src/pages/auth/login.tsx +++ /dev/null @@ -1,255 +0,0 @@ -import { useCallback, useEffect, useState } from 'react' - -import { zodResolver } from '@hookform/resolvers/zod' -import { motion } from 'framer-motion' -import { useForm } from 'react-hook-form' -import { Link } from 'react-router-dom' -import { type z } from 'zod' - -import { IconEye, IconNonEye } from '@/assets/icons' -import Logo from '@/components/logo/logo' -import { Button } from '@/components/ui/button' -import { Checkbox } from '@/components/ui/checkbox' -import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form' -import { Input } from '@/components/ui/input' -import { Label } from '@/components/ui/label' -import { REMEMBER_ME, PASSWORD_TYPE, TEXT_TYPE } from '@/core/configs/consts' -import { ROUTE } from '@/core/constants/path' -import { containerVariants, itemVariants } from '@/core/lib/variant/style-variant' -import { useAuthStore } from '@/core/store/features/auth/authStore' -import { LoginSchema } from '@/core/zod' -import { useAuthRedirect } from '@/hooks/auth/use-auth-redirect' -import { useLoginAuth } from '@/hooks/tanstack-query/auth/use-query-auth' -import { type RememberMeData } from '@/models/interface/auth.interfaces' - -const techStack = [ - { name: 'React', icon: '⚛️' }, - { name: 'TypeScript', icon: '📘' }, - { name: 'TailwindCSS', icon: '🎨' }, - { name: 'Vite', icon: '⚡' }, - { name: 'React Query', icon: '🔄' }, - { name: 'Zod', icon: '✨' } -] - -export default function Login() { - const { loginStart, loginSuccess, loginFailure, isLoading } = useAuthStore() - const [isPasswordVisible, setIsPasswordVisible] = useState(false) - const [rememberMe, setRememberMe] = useState(() => { - const savedData = localStorage.getItem(REMEMBER_ME) - if (savedData) { - const parsedData = JSON.parse(savedData) as RememberMeData - return parsedData.isRemembered - } - return false - }) - - useAuthRedirect() - - const form = useForm>({ - resolver: zodResolver(LoginSchema), - defaultValues: { - email: '', - password: '' - } - }) - - const { mutate: mutationLogin } = useLoginAuth() - - const onSubmit = useCallback( - (data: z.infer) => { - loginStart() - mutationLogin(data, { - onSuccess: (response) => { - loginSuccess(response.data) - }, - onError: (error) => { - loginFailure(error.message) - } - }) - }, - [mutationLogin, loginStart, loginSuccess, loginFailure] - ) - - const togglePasswordVisibility = () => setIsPasswordVisible((prev) => !prev) - - const handleChangeRememberMe = (event: boolean) => { - setRememberMe(event) - const loginData = form.getValues() - - if (event) { - const rememberMeData: RememberMeData = { - email: loginData.email, - password: loginData.password, - isRemembered: true - } - localStorage.setItem(REMEMBER_ME, JSON.stringify(rememberMeData)) - } else { - localStorage.removeItem(REMEMBER_ME) - } - } - - useEffect(() => { - const savedData = localStorage.getItem(REMEMBER_ME) - if (savedData) { - const parsedData = JSON.parse(savedData) as RememberMeData - if (parsedData.isRemembered) { - form.setValue('email', parsedData.email) - form.setValue('password', parsedData.password) - } - } - }, [form]) - - return ( -
-
- - - - - - -

Chào mừng trở lại!

-

Đăng nhập để tiếp tục trải nghiệm

-
- -
- - - ( - - Email - - - - - - )} - /> - - - - ( - - Mật khẩu - - : } - iconOnClick={togglePasswordVisibility} - /> - - - - )} - /> - - - -
- - -
- - Quên mật khẩu? - -
- - - - - - - Chưa có tài khoản?{' '} - - Đăng ký ngay - - -
-
-
- - {/* Right side - Tech Stack */} - -
-

Công nghệ hiện đại

-

Được xây dựng với những công nghệ mới nhất

-
- -
- {techStack.map((tech, index) => ( - - {tech.icon} - {tech.name} - - ))} -
- -
-

Tính năng nổi bật

-
    -
  • ✨ Giao diện hiện đại, thân thiện
  • -
  • 🚀 Hiệu suất tối ưu
  • -
  • 🔒 Bảo mật cao cấp
  • -
  • 📱 Responsive trên mọi thiết bị
  • -
-
-
-
-
- ) -} diff --git a/frontend/src/pages/auth/login/components/google-login-button.tsx b/frontend/src/pages/auth/login/components/google-login-button.tsx new file mode 100644 index 0000000..becb968 --- /dev/null +++ b/frontend/src/pages/auth/login/components/google-login-button.tsx @@ -0,0 +1,19 @@ +import { Button } from '@/components/ui/button' + +interface GoogleLoginButtonProps { + label?: string +} + +export const GoogleLoginButton = ({ label = 'Đăng nhập bằng Google' }: GoogleLoginButtonProps) => ( + +) + diff --git a/frontend/src/pages/auth/login/components/login-form-card.tsx b/frontend/src/pages/auth/login/components/login-form-card.tsx new file mode 100644 index 0000000..3688336 --- /dev/null +++ b/frontend/src/pages/auth/login/components/login-form-card.tsx @@ -0,0 +1,120 @@ +import { motion } from 'framer-motion' +import { type UseFormReturn } from 'react-hook-form' +import { Link } from 'react-router-dom' + +import { Button } from '@/components/ui/button' +import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form' +import { Input } from '@/components/ui/input' +import { InputPassword } from '@/components/ui/input-password' +import { ROUTE } from '@/core/constants/path' +import { cn } from '@/core/lib/utils' + +import { AuthDivider } from '../../components/auth-divider' +import { type LoginFormValues } from '../types' + +import { GoogleLoginButton } from './google-login-button' + +interface LoginFormCardProps { + form: UseFormReturn + formError: string | null + isPending: boolean + onSubmit: (values: LoginFormValues) => void +} + +export const LoginFormCard = ({ form, formError, isPending, onSubmit }: LoginFormCardProps) => ( + +

Đăng nhập

+ +
+ + ( + + Email + + + + + + )} + /> + + ( + + Mật khẩu + + + + + + )} + /> + +
+ + Quên mật khẩu? + +
+ + {formError ? ( + + ) : null} + + + + + + + +

+ Bạn chưa có tài khoản?{' '} + + Đăng ký ngay + +

+ + +
+) diff --git a/frontend/src/pages/auth/login/index.tsx b/frontend/src/pages/auth/login/index.tsx new file mode 100644 index 0000000..45fe5c9 --- /dev/null +++ b/frontend/src/pages/auth/login/index.tsx @@ -0,0 +1,97 @@ +import { useCallback, useState } from 'react' + +import { zodResolver } from '@hookform/resolvers/zod' +import { motion } from 'framer-motion' +import { useForm } from 'react-hook-form' +import { useLocation, useNavigate } from 'react-router-dom' + +import { ROUTE } from '@/core/constants/path' +import { getDashboardRouteByRole } from '@/core/helpers/auth-route' +import { useAuthStore } from '@/core/store/features/auth/authStore' +import { LoginSchema } from '@/core/zod' +import { useAuthRedirect } from '@/hooks/auth/use-auth-redirect' +import { useLoginAuth } from '@/hooks/tanstack-query/auth/use-query-auth' + +import { AuthBrandPanel } from '../components/auth-brand-panel' +import { AuthFooter } from '../components/auth-footer' +import { AuthHeader } from '../components/auth-header' +import { DecorativeWave } from '../components/decorative-wave' + +import { LoginFormCard } from './components/login-form-card' +import { getLoginErrorMessage } from './login-error' +import { type LoginFormValues, type LoginRouteState } from './types' + +export default function Login() { + const [formError, setFormError] = useState(null) + const { loginStart, loginSuccess, loginFailure } = useAuthStore() + const location = useLocation() + const navigate = useNavigate() + const { mutate: loginMutation, isPending } = useLoginAuth() + + useAuthRedirect() + + const form = useForm({ + resolver: zodResolver(LoginSchema), + defaultValues: { + email: '', + password: '' + } + }) + + const attemptedPath = (location.state as LoginRouteState | null)?.from?.pathname + + const handleSubmit = useCallback( + (values: LoginFormValues) => { + if (isPending) { + return + } + + setFormError(null) + loginStart() + loginMutation(values, { + onSuccess: (response) => { + loginSuccess(response) + const dashboardRoute = getDashboardRouteByRole(response.user.role) + const destination = attemptedPath && attemptedPath !== ROUTE.PUBLIC.LOGIN ? attemptedPath : dashboardRoute + + navigate(destination, { replace: true }) + }, + onError: (error) => { + const errorMessage = getLoginErrorMessage(error) + loginFailure(errorMessage) + setFormError(errorMessage) + } + }) + }, + [attemptedPath, isPending, loginFailure, loginMutation, loginStart, loginSuccess, navigate] + ) + + return ( +
+ + + +
+ + + +
+ +
+
+
+ + +
+ ) +} diff --git a/frontend/src/pages/auth/login/login-error.ts b/frontend/src/pages/auth/login/login-error.ts new file mode 100644 index 0000000..4f75541 --- /dev/null +++ b/frontend/src/pages/auth/login/login-error.ts @@ -0,0 +1,30 @@ +import { AxiosError } from 'axios' + +import { ApiErrorSchema } from '@/core/zod' + +export const LOGIN_NETWORK_ERROR = 'Không thể kết nối đến máy chủ. Vui lòng kiểm tra kết nối và thử lại.' +export const LOGIN_SERVER_ERROR = 'Hiện chưa thể đăng nhập. Vui lòng thử lại sau.' +export const LOGIN_UNAUTHORIZED_ERROR = 'Email hoặc mật khẩu không chính xác.' + +export const getLoginErrorMessage = (error: unknown) => { + if (error instanceof AxiosError) { + const status = error.response?.status + const parsedError = ApiErrorSchema.safeParse(error.response?.data) + + if (status === 401) { + return LOGIN_UNAUTHORIZED_ERROR + } + + if (status === 400 && parsedError.success) { + return parsedError.data.message + } + + if (status === 500) { + return LOGIN_SERVER_ERROR + } + + return LOGIN_NETWORK_ERROR + } + + return LOGIN_NETWORK_ERROR +} diff --git a/frontend/src/pages/auth/login/types.ts b/frontend/src/pages/auth/login/types.ts new file mode 100644 index 0000000..1bddbca --- /dev/null +++ b/frontend/src/pages/auth/login/types.ts @@ -0,0 +1,11 @@ +import { type z } from 'zod' + +import { type LoginSchema } from '@/core/zod' + +export type LoginFormValues = z.infer + +export type LoginRouteState = { + from?: { + pathname?: string + } +} diff --git a/frontend/src/pages/auth/register.tsx b/frontend/src/pages/auth/register.tsx deleted file mode 100644 index 676b584..0000000 --- a/frontend/src/pages/auth/register.tsx +++ /dev/null @@ -1,266 +0,0 @@ -import { useCallback, useState } from 'react' - -import { zodResolver } from '@hookform/resolvers/zod' -import { motion } from 'framer-motion' -import { useForm } from 'react-hook-form' -import { Link } from 'react-router-dom' -import { type z } from 'zod' - -import { IconEye, IconNonEye } from '@/assets/icons' -import Logo from '@/components/logo/logo' -import { Button } from '@/components/ui/button' -import { Checkbox } from '@/components/ui/checkbox' -import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form' -import { Input } from '@/components/ui/input' -import { Label } from '@/components/ui/label' -import { PASSWORD_TYPE, TEXT_TYPE } from '@/core/configs/consts' -import { ROUTE } from '@/core/constants/path' -import { containerVariants, itemVariants } from '@/core/lib/variant/style-variant' -import { RegisterSchema } from '@/core/zod' -import { useAuthRedirect } from '@/hooks/auth/use-auth-redirect' -import { useRegisterAuth } from '@/hooks/tanstack-query/auth/use-query-auth' - -const features = [ - { title: 'Tài khoản cá nhân', description: 'Quản lý thông tin và cài đặt của bạn' }, - { title: 'Bảo mật cao cấp', description: 'Bảo vệ dữ liệu của bạn với mã hóa tiên tiến' }, - { title: 'Hỗ trợ 24/7', description: 'Đội ngũ hỗ trợ luôn sẵn sàng giúp đỡ bạn' }, - { title: 'Cập nhật thường xuyên', description: 'Luôn được cập nhật những tính năng mới nhất' } -] - -export default function Register() { - const [isPasswordVisible, setIsPasswordVisible] = useState(false) - const [isConfirmPasswordVisible, setIsConfirmPasswordVisible] = useState(false) - - useAuthRedirect() - - const form = useForm>({ - resolver: zodResolver(RegisterSchema), - defaultValues: { - email: '', - password: '', - confirmPassword: '', - name: '', - phone: '' - } - }) - - const { mutate: mutationRegister, isPending } = useRegisterAuth() - - const handleRegister = useCallback( - (values: z.infer) => { - mutationRegister(values) - }, - [mutationRegister] - ) - - const togglePasswordVisibility = () => setIsPasswordVisible((prev) => !prev) - const toggleConfirmPasswordVisibility = () => setIsConfirmPasswordVisible((prev) => !prev) - - return ( -
-
- {/* Left side - Features */} - - -

Tại sao chọn chúng tôi?

-

Khám phá những lợi ích khi tham gia cùng chúng tôi

-
- - - {features.map((feature) => ( - -

{feature.title}

-

{feature.description}

-
- ))} -
- - -

Cam kết của chúng tôi

-
    -
  • ✨ Trải nghiệm người dùng tốt nhất
  • -
  • 🚀 Hiệu suất vượt trội
  • -
  • 🔒 Bảo mật tuyệt đối
  • -
  • 💡 Đổi mới liên tục
  • -
-
-
- - {/* Right side - Register Form */} - - - - - - -

Tạo tài khoản

-

Tham gia cùng chúng tôi ngay hôm nay

-
- -
- - - ( - - Email - - - - - - )} - /> - - - - ( - - Họ và tên - - - - - - )} - /> - ( - - Số điện thoại - - - - - - )} - /> - - - - ( - - Mật khẩu - - : } - iconOnClick={togglePasswordVisibility} - /> - - - - )} - /> - - - - ( - - Xác nhận mật khẩu - - : } - iconOnClick={toggleConfirmPasswordVisibility} - /> - - - - )} - /> - - - - - - - - - - - - - Đã có tài khoản?{' '} - - Đăng nhập ngay - - - -
-
-
-
- ) -} diff --git a/frontend/src/pages/auth/register/components/register-form-card.tsx b/frontend/src/pages/auth/register/components/register-form-card.tsx new file mode 100644 index 0000000..ead0be0 --- /dev/null +++ b/frontend/src/pages/auth/register/components/register-form-card.tsx @@ -0,0 +1,223 @@ +import { motion } from 'framer-motion' +import { ArrowLeft } from 'lucide-react' +import { type UseFormReturn } from 'react-hook-form' +import { Link } from 'react-router-dom' + +import { Button } from '@/components/ui/button' +import { Checkbox } from '@/components/ui/checkbox' +import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form' +import { Input } from '@/components/ui/input' +import { InputPassword } from '@/components/ui/input-password' +import { Label } from '@/components/ui/label' +import { ROUTE } from '@/core/constants/path' +import { cn } from '@/core/lib/utils' + +import { AuthDivider } from '../../components/auth-divider' +import { GoogleLoginButton } from '../../login/components/google-login-button' +import { type RegisterFormValues, type RegisterRole } from '../types' + +interface RegisterFormCardProps { + form: UseFormReturn + formError: string | null + isPending: boolean + selectedRole: RegisterRole | null + onBackToRole: () => void + onSubmit: (values: RegisterFormValues) => void +} + +const roleLabels: Record = { + candidate: 'Ứng viên', + educator: 'Nhà đào tạo', + business: 'Doanh nghiệp' +} + +export const RegisterFormCard = ({ + form, + formError, + isPending, + selectedRole, + onBackToRole, + onSubmit +}: RegisterFormCardProps) => ( + + + +
+

+ {selectedRole ? roleLabels[selectedRole] : 'Tài khoản'} +

+

Đăng ký tài khoản

+
+ +
+ + ( + + Họ và tên + + + + + + )} + /> + + ( + + Số điện thoại + + + + + + )} + /> + + ( + + Email + + + + + + )} + /> + + ( + + Mật khẩu + + + + + + )} + /> + + ( + + Nhập lại mật khẩu + + + + + + )} + /> + + ( + + + + )} + /> + +
+ + +
+ + {formError ? ( + + ) : null} + + + + + + + +

+ Bạn đã có tài khoản?{' '} + + Đăng nhập ngay + +

+ + +
+) diff --git a/frontend/src/pages/auth/register/components/role-selection-card.tsx b/frontend/src/pages/auth/register/components/role-selection-card.tsx new file mode 100644 index 0000000..1a36be8 --- /dev/null +++ b/frontend/src/pages/auth/register/components/role-selection-card.tsx @@ -0,0 +1,108 @@ +import { type ReactNode } from 'react' + +import { motion } from 'framer-motion' +import { BriefcaseBusiness, GraduationCap, UserRound } from 'lucide-react' +import { Link } from 'react-router-dom' + +import { Button } from '@/components/ui/button' +import { ROUTE } from '@/core/constants/path' +import { cn } from '@/core/lib/utils' + +import { type RegisterRole } from '../types' + +interface RoleOption { + value: RegisterRole + title: string + description: string + icon: ReactNode +} + +const roleOptions: RoleOption[] = [ + { + value: 'candidate', + title: 'Ứng viên', + description: 'Tìm việc làm, tạo hồ sơ và ứng tuyển vào vị trí phù hợp.', + icon: + }, + + { + value: 'business', + title: 'Doanh nghiệp', + description: 'Đăng tuyển, tìm kiếm ứng viên và quản lý nhu cầu nhân sự.', + icon: + }, + { + value: 'educator', + title: 'Nhà đào tạo', + description: 'Kết nối học viên, quản lý chương trình đào tạo và định hướng nghề nghiệp.', + icon: + } +] + +interface RoleSelectionCardProps { + selectedRole: RegisterRole | null + onSelectRole: (role: RegisterRole) => void + onContinue: () => void +} + +export const RoleSelectionCard = ({ selectedRole, onSelectRole, onContinue }: RoleSelectionCardProps) => ( + +
+

Bước 1

+

Bạn là...

+
+ +
+ {roleOptions.map((option) => { + const isSelected = selectedRole === option.value + + return ( + + ) + })} +
+ + + +

+ Bạn đã có tài khoản?{' '} + + Đăng nhập ngay + +

+
+) diff --git a/frontend/src/pages/auth/register/index.tsx b/frontend/src/pages/auth/register/index.tsx new file mode 100644 index 0000000..ab49c1e --- /dev/null +++ b/frontend/src/pages/auth/register/index.tsx @@ -0,0 +1,124 @@ +import { useCallback, useState } from 'react' + +import { zodResolver } from '@hookform/resolvers/zod' +import { isAxiosError } from 'axios' +import { motion } from 'framer-motion' +import { useForm } from 'react-hook-form' + +import { RegisterSchema } from '@/core/zod' +import { useAuthRedirect } from '@/hooks/auth/use-auth-redirect' +import { useRegisterAuth } from '@/hooks/tanstack-query/auth/use-query-auth' + +import { AuthBrandPanel } from '../components/auth-brand-panel' +import { AuthFooter } from '../components/auth-footer' +import { AuthHeader } from '../components/auth-header' +import { DecorativeWave } from '../components/decorative-wave' + +import { RegisterFormCard } from './components/register-form-card' +import { RoleSelectionCard } from './components/role-selection-card' +import { type RegisterFormValues, type RegisterRole } from './types' + +const REGISTER_ERROR_MESSAGE = 'Đăng ký thất bại. Vui lòng thử lại.' + +const getRegisterErrorMessage = (error: unknown) => { + if (isAxiosError<{ message?: string }>(error)) { + return error.response?.data?.message || error.message || REGISTER_ERROR_MESSAGE + } + + if (error instanceof Error) { + return error.message + } + + return REGISTER_ERROR_MESSAGE +} + +export default function Register() { + const [formError, setFormError] = useState(null) + const [selectedRole, setSelectedRole] = useState(null) + const [step, setStep] = useState<'role' | 'form'>('role') + + const { mutate: registerMutation, isPending } = useRegisterAuth() + + useAuthRedirect() + + const form = useForm({ + resolver: zodResolver(RegisterSchema), + defaultValues: { + email: '', + phone: '', + password: '', + confirmPassword: '', + role: undefined, + full_name: '' + } + }) + + const handleRoleSelect = useCallback( + (role: RegisterRole) => { + setSelectedRole(role) + form.setValue('role', role, { shouldValidate: true }) + }, + [form] + ) + + const handleContinueToForm = useCallback(() => { + if (!selectedRole) { + return + } + + setStep('form') + }, [selectedRole]) + + const handleSubmit = useCallback( + (values: RegisterFormValues) => { + if (isPending) { + return + } + + setFormError(null) + registerMutation(values, { + onError: (error) => setFormError(getRegisterErrorMessage(error)) + }) + }, + [isPending, registerMutation] + ) + + return ( +
+ + + +
+ + + +
+ {step === 'role' ? ( + + ) : ( + setStep('role')} + onSubmit={handleSubmit} + /> + )} +
+
+
+ + +
+ ) +} diff --git a/frontend/src/pages/auth/register/types.ts b/frontend/src/pages/auth/register/types.ts new file mode 100644 index 0000000..1468e0d --- /dev/null +++ b/frontend/src/pages/auth/register/types.ts @@ -0,0 +1,6 @@ +import { type z } from 'zod' + +import { type RegisterSchema } from '@/core/zod' + +export type RegisterFormValues = z.infer +export type RegisterRole = RegisterFormValues['role'] diff --git a/frontend/src/pages/communication/chat/ChatPageRefactored.tsx b/frontend/src/pages/communication/chat/ChatPageRefactored.tsx index 7bff9f9..cd8b740 100644 --- a/frontend/src/pages/communication/chat/ChatPageRefactored.tsx +++ b/frontend/src/pages/communication/chat/ChatPageRefactored.tsx @@ -1,13 +1,15 @@ -import { useCallback, useEffect, useState } from 'react' +import { useCallback, useState } from 'react' + import { getCurrentUser } from '@/core/shared/auth' import { useAuthStore } from '@/core/store/features/auth/authStore' + import ChatSidebar, { type ConversationItem } from './components/ChatSidebar' import ChatWindow from './components/ChatWindow' -import TopNavigation from './components/TopNavigation' -import { useSocketMessages } from './useSocketMessages' -import { useAutoScroll } from './useAutoScroll' import { type Message } from './components/MessageListNew' +import TopNavigation from './components/TopNavigation' import { type SocketMessage } from './socket.types' +import { useAutoScroll } from './useAutoScroll' +import { useSocketMessages } from './useSocketMessages' import './chat.css' // Mock data - Replace with real data from your backend diff --git a/frontend/src/pages/communication/chat/SharedChatLayout.tsx b/frontend/src/pages/communication/chat/SharedChatLayout.tsx index 453ff3c..639ab9f 100644 --- a/frontend/src/pages/communication/chat/SharedChatLayout.tsx +++ b/frontend/src/pages/communication/chat/SharedChatLayout.tsx @@ -1,8 +1,6 @@ import { useCallback, useEffect, useMemo, useState } from 'react' import config from '@/core/configs/env' -import { getAccessTokenFromLS } from '@/core/shared/storage' -import { useAuthStore } from '@/core/store/features/auth/authStore' import ChatSidebar, { type ConversationItem } from './components/ChatSidebar' import ChatWindow from './components/ChatWindow' @@ -51,9 +49,7 @@ export default function SharedChatLayout({ const [messages, setMessages] = useState(initialMessages) const [socketError, setSocketError] = useState(null) const isMockChat = config.useMockChat - const storeToken = useAuthStore((state) => state.access_token) - const token = storeToken || getAccessTokenFromLS() - const isSocketEnabled = Boolean(token) && !isMockChat + const isSocketEnabled = !isMockChat // Auto-scroll useAutoScroll(messages.length)