Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
**/node_modules
**/node_modules
.history/
1 change: 1 addition & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Binary file added frontend/src/assets/images/main-login.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
10 changes: 5 additions & 5 deletions frontend/src/components/auth/protected-route.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,20 +3,20 @@ 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
redirectPath?: string
}

const ProtectedRoute = ({ children, redirectPath = ROUTE.PUBLIC.LOGIN }: ProtectedRouteProps) => {
const { isAuthenticated } = useAuth()
const isAuthenticated = useAuthStore((state) => state.isAuthenticated)
const location = useLocation()

// if (!isAuthenticated) {
// return <Navigate to={redirectPath} state={{ from: location }} replace />
// }
if (!isAuthenticated) {
return <Navigate to={redirectPath} state={{ from: location }} replace />
}

return children ? <>{children}</> : <Outlet />
}
Expand Down
25 changes: 25 additions & 0 deletions frontend/src/components/ui/input-password.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLInputElement, InputProps>(({ iconLabel, ...props }, ref) => {
const [isVisible, setIsVisible] = useState(false)
const ToggleIcon = isVisible ? EyeOff : Eye

return (
<Input
ref={ref}
type={isVisible ? TEXT_TYPE : PASSWORD_TYPE}
icon={<ToggleIcon className='size-5' />}
iconLabel={iconLabel || (isVisible ? 'Ẩn mật khẩu' : 'Hiện mật khẩu')}
iconOnClick={() => setIsVisible((current) => !current)}
{...props}
/>
)
})

InputPassword.displayName = 'InputPassword'
7 changes: 1 addition & 6 deletions frontend/src/contexts/SocketContext.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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 <SocketContext.Provider value={socketState}>{children}</SocketContext.Provider>
Expand Down
20 changes: 20 additions & 0 deletions frontend/src/core/helpers/auth-route.ts
Original file line number Diff line number Diff line change
@@ -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
}
1 change: 1 addition & 0 deletions frontend/src/core/helpers/key-tanstack.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
export const MUTATION_KEYS = {
register: 'register',
login: 'login',
forgotPassword: 'forgotPassword',
updateProfile: 'updateProfile',
verifyEmail: 'verifyEmail',
resendCode: 'resendCode'
Expand Down
32 changes: 20 additions & 12 deletions frontend/src/core/services/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,47 +3,55 @@ 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'
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<LoginResponse>
register: (params: Account) => Promise<RegisterReponse>
login: (params: LoginRequest) => Promise<LoginResponse>
register: (params: RegisterRequest) => Promise<RegisterReponse>
refreshToken: (refreshToken: string) => Promise<LoginResponse>
verifyEmail: (params: VerifyEmailReq) => Promise<VerifyEmailRes>
forgotPassword: (params: ForgotPasswordRequest) => Promise<ForgotPasswordResponse>
resendVerificationCode: (email: string) => Promise<{ message: string }>
logout: (refresh_token: string) => Promise<void>
logout: () => Promise<void>
}

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<LoginResponse>
},
register(params) {
return client.post(API_REGISTER_URL, params)
return client.post(API_REGISTER_URL, params) as Promise<RegisterReponse>
},
refreshToken(refreshToken) {
return client.post(API_REFRESH_TOKEN_URL, { refresh_token: refreshToken })
return client.post(API_REFRESH_TOKEN_URL, { refresh_token: refreshToken }) as Promise<LoginResponse>
},
verifyEmail(params) {
return client.post(API_VERIFY_EMAIL_URL, params)
return client.post(API_VERIFY_EMAIL_URL, params) as Promise<VerifyEmailRes>
},
forgotPassword(params) {
return client.post(API_FORGOT_PASSWORD_URL, params) as Promise<ForgotPasswordResponse>
},
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<void>
}
})

Expand Down
125 changes: 15 additions & 110 deletions frontend/src/core/services/axios-client.ts
Original file line number Diff line number Diff line change
@@ -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<string, AbortController>()
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)
Expand Down
12 changes: 3 additions & 9 deletions frontend/src/core/shared/auth.ts
Original file line number Diff line number Diff line change
@@ -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<AuthState> => {
const access_token = getAccessTokenFromLS()
const refresh_token = getRefreshTokenFromLS()
const user = getUserFromLocalStorage()
export const getPersistedAuth = (): Partial<AuthState> => ({})

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
Loading