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
Binary file added frontend/public/bg.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { useTranslation } from 'react-i18next'

type Props = {
password: string
}

const getPasswordStrength = (password: string) => {
let score = 0
if (password.length >= 5) score++
if (/[A-Z]/.test(password)) score++
if (/[0-9]/.test(password)) score++
return score
}

export default function PasswordStrengthBar({ password }: Props) {
const { t } = useTranslation('auth')

const strength = getPasswordStrength(password || '')

const getLabel = () => {
if (!password) return t('password.empty')
if (strength < 2) return t('password.weak')
if (strength < 3) return t('password.medium')
return t('password.strong')
}

const getColor = () => {
if (!password) return 'bg-background-tertiary'
if (strength < 2) return 'bg-error-primary'
if (strength < 3) return 'bg-warning-primary'
return 'bg-success-primary'
}

return (
<div className='mt-2'>
<div className='flex gap-1'>
{[0, 1, 2].map((i) => (
<div key={i} className={`h-0.5 flex-1 rounded ${i < strength ? getColor() : 'bg-background-tertiary'}`} />
))}
</div>

<div className='text-[10px] font-semibold text-red-700 text-right mt-1'>{getLabel()}</div>
</div>
)
}
4 changes: 2 additions & 2 deletions frontend/src/components/header-nav/header-nav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -146,10 +146,10 @@ const Header = () => {
variant='outline'
className='px-4 py-2 font-medium text-gray-900 rounded-md border border-gray-200 transition-all duration-200 dark:border-gray-700 bg-white/80 dark:bg-gray-800/80 dark:text-gray-100 hover:bg-gray-100 dark:hover:bg-gray-700 focus-visible:ring-2 focus-visible:ring-blue-500'
>
<Link to={ROUTE.AUTH.LOGIN}>{tAuth('auth.login')}</Link>
<Link to={ROUTE.AUTH.LOGIN}>{tAuth('login')}</Link>
</Button>
<Button className='px-4 py-2 font-medium text-white bg-primary rounded-md transition-all duration-200 dark:bg-primary/80 hover:bg-primary/80 dark:hover:bg-primary/80 focus-visible:ring-2 focus-visible:ring-primary/80'>
<Link to={ROUTE.AUTH.REGISTER}>{tAuth('auth.register')}</Link>
<Link to={ROUTE.AUTH.REGISTER}>{tAuth('register')}</Link>
</Button>
</div>
)}
Expand Down
1 change: 1 addition & 0 deletions frontend/src/core/configs/consts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ export const numberConstants = {
EIGHT: 8,
NINE: 9,
TEN: 10,
fifty: 50,
ONE_HUNDRED: 100
}

Expand Down
3 changes: 2 additions & 1 deletion frontend/src/core/constants/path.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ export const ROUTE = {
LOGIN: '/login',
REGISTER: '/register',
VERIFY_ACCOUNT_EMAIL: '/verify-account-email',
FORGOT_PASSWORD: '/forgot-password'
FORGOT_PASSWORD: '/forgot-password',
RESET_PASSWORD: '/reset-password'
},
BLOG: '/blog',
PROFILE: {
Expand Down
3 changes: 2 additions & 1 deletion frontend/src/core/helpers/key-tanstack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,6 @@ export const MUTATION_KEYS = {
login: 'login',
updateProfile: 'updateProfile',
verifyEmail: 'verifyEmail',
resendCode: 'resendCode'
resendCode: 'resendCode',
resetPassword: 'resetPassword'
}
3 changes: 2 additions & 1 deletion frontend/src/core/helpers/validator.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export const validator = {
email: /^[\w-.]+@([\w-]+\.)+[\w-]{2,4}$/,
password: /^(?=.*\d)(?=.*[a-z])(?=.*[A-Z])(?=.*[a-zA-Z]).{1,}$/,
passwordRegex: /^(?=.*[A-Za-z])(?=.*\d)[A-Za-z\d@$!%*?&]{5,}$/
passwordRegex: /^(?=.*[A-Za-z])(?=.*\d)[A-Za-z\d@$!%*?&]{5,}$/,
phone: /^[0-9]{10,}$/
}
6 changes: 6 additions & 0 deletions frontend/src/core/services/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { type AxiosInstance } from 'axios'
import axiosClient from '@/core/services/axios-client'
import {
type VerifyEmailReq,
type ResetPasswordReq,
type Account,
type LoginResponse,
type LoginApiResponse,
Expand All @@ -15,6 +16,7 @@ const API_REGISTER_URL = '/auth/register'
const API_REFRESH_TOKEN_URL = '/auth/refresh-token'
const API_VERIFY_EMAIL_URL = '/auth/verify-email'
const API_RESEND_CODE_URL = '/auth/resend-verification-email'
const API_RESET_PASSWORD_URL = '/auth/reset-password'
const API_LOGOUT_URL = '/auth/logout'

export type AuthApi = {
Expand All @@ -23,6 +25,7 @@ export type AuthApi = {
refreshToken: (refreshToken: string) => Promise<LoginResponse>
verifyEmail: (params: VerifyEmailReq) => Promise<VerifyEmailRes>
resendVerificationCode: (email: string) => Promise<{ message: string }>
resetPassword: (params: ResetPasswordReq) => Promise<{ message: string }>
logout: (refresh_token: string) => Promise<void>
}

Expand All @@ -42,6 +45,9 @@ export const createAuthApi = (client: AxiosInstance): AuthApi => ({
resendVerificationCode(email) {
return client.post(API_RESEND_CODE_URL, { email })
},
resetPassword(params) {
return client.post(API_RESET_PASSWORD_URL, params)
},
logout(refresh_token) {
return client.post(API_LOGOUT_URL, { refresh_token })
}
Expand Down
104 changes: 80 additions & 24 deletions frontend/src/core/zod/register.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,30 +4,86 @@ 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({
name: z
.string()
.trim()
.min(numberConstants.TWO, { message: 'Tên phải có ít nhất 2 ký tự.' })
.max(numberConstants.fifty, { message: 'Tên không được vượt quá 50 ký tự.' }),

email: z.string().min(numberConstants.TWO, { message: 'Email không hợp lệ.' }).regex(validator.email, {
message: 'Email không đúng định dạng.'
}),

password: z
.string()
.min(numberConstants.FIVE, {
message: 'Mật khẩu phải có ít nhất 5 ký tự.'
})
.regex(validator.passwordRegex, {
message: 'Mật khẩu phải có ít nhất 1 chữ hoa và 1 số.'
}),

confirmPassword: z.string().min(numberConstants.FIVE, {
message: 'Mật khẩu xác nhận phải có ít nhất 5 ký tự.'
}),
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().regex(validator.phone, {
message: 'Số điện thoại không hợp lệ.'
}),

role: z.enum(['client', 'lawyer'], {
message: 'Vui lòng chọn vai trò.'
}),
phone: z.string().min(numberConstants.TEN, {
message: 'Phone number must be at least 10 characters.'

licenseNumber: z.string().optional(),
issuedDate: z.string().optional(),
issuedPlace: z.string().optional(),
certificate: z.any().optional(),
referralCode: z.string().optional()
})

.superRefine((data, ctx) => {
if (data.password !== data.confirmPassword) {
ctx.addIssue({
path: ['confirmPassword'],
code: 'custom',
message: 'Mật khẩu xác nhận không khớp.'
})
}

if (data.role === 'lawyer') {
if (!data.licenseNumber) {
ctx.addIssue({
path: ['licenseNumber'],
code: 'custom',
message: 'Số chứng chỉ hành nghề là bắt buộc.'
})
}

if (!data.issuedDate) {
ctx.addIssue({
path: ['issuedDate'],
code: 'custom',
message: 'Ngày cấp là bắt buộc.'
})
}

if (!data.issuedPlace) {
ctx.addIssue({
path: ['issuedPlace'],
code: 'custom',
message: 'Nơi cấp là bắt buộc.'
})
}

if (!data.certificate) {
ctx.addIssue({
path: ['certificate'],
code: 'custom',
message: 'Chứng chỉ là bắt buộc.'
})
}
}
})
})
20 changes: 20 additions & 0 deletions frontend/src/core/zod/reset-password.zod.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { z } from 'zod'

import { numberConstants } from '@/core/configs/consts'

import { validator } from '../helpers/validator'
export const ResetPasswordSchema = z
.object({
email: z.string().email('Vui lòng nhập email hợp lệ').regex(validator.email, {
message: 'Email không đúng định dạng.'
}),

password: z.string().min(numberConstants.FIVE, 'Mật khẩu phải có ít nhất 5 ký tự').regex(validator.passwordRegex, {
message: 'Mật khẩu phải có ít nhất 1 chữ hoa và 1 số.'
}),
confirmPassword: z.string().min(numberConstants.FIVE, 'Vui lòng xác nhận mật khẩu')
})
.refine((data) => data.password === data.confirmPassword, {
path: ['confirmPassword'],
message: 'Mật khẩu xác nhận không khớp'
})
12 changes: 11 additions & 1 deletion frontend/src/hooks/routes/use-router-element.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,22 @@ const HomePage = lazy(() => import('@/pages/home/HomePage'))
const Login = lazy(() => import('@/pages/login/Login'))
const Register = lazy(() => import('@/pages/register/Register'))
const VerifyAcountEmail = lazy(() => import('@/pages/verify-account-email/VerifyAcountEmail'))
const ForgotPassword = lazy(() => import('@/pages/forgot-password/ForgotPassword'))
const ResetPassword = lazy(() => import('@/pages/reset-password/ResetPassword'))
const Dashboard = lazy(() => import('@/pages/admin/dashboard'))
const Users = lazy(() => import('@/pages/admin/users'))
const PageNotFound = lazy(() => import('@/pages/404/PageNotFound'))
const Profile = lazy(() => import('@/pages/profile/Profile'))

export default function useRoutesElements() {
const location = useLocation()
const isAuthPath = [ROUTE.AUTH.LOGIN, ROUTE.AUTH.REGISTER].includes(location.pathname)
const isAuthPath = [
ROUTE.AUTH.LOGIN,
ROUTE.AUTH.REGISTER,
ROUTE.AUTH.FORGOT_PASSWORD,
ROUTE.AUTH.VERIFY_ACCOUNT_EMAIL,
ROUTE.AUTH.RESET_PASSWORD
].includes(location.pathname)
const isAdminPath = location.pathname.startsWith('/admin')

const routeElements = (
Expand All @@ -31,7 +39,9 @@ export default function useRoutesElements() {
<Route path={ROUTE.HOME} element={<HomePage />} />
<Route path={ROUTE.AUTH.LOGIN} element={<Login />} />
<Route path={ROUTE.AUTH.REGISTER} element={<Register />} />
<Route path={ROUTE.AUTH.FORGOT_PASSWORD} element={<ForgotPassword />} />
<Route path={ROUTE.AUTH.VERIFY_ACCOUNT_EMAIL} element={<VerifyAcountEmail />} />
<Route path={ROUTE.AUTH.RESET_PASSWORD} element={<ResetPassword />} />

{/* Client protected routes */}
<Route element={<ProtectedRoute redirectPath={ROUTE.AUTH.LOGIN} />}>
Expand Down
20 changes: 17 additions & 3 deletions frontend/src/hooks/tanstack-query/auth/use-query-auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ import { setToken, setUserToLS } from '@/core/shared/storage'
import { 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 LoginApiResponse } from '@/models/interface/auth.interface'
import { type ResetPasswordSchema } from '@/core/zod/reset-password.zod'
import { type LoginApiResponse, type ResetPasswordReq } from '@/models/interface/auth.interface'
const RESEND_COUNTDOWN = 60

export const useLoginAuth = () => {
Expand Down Expand Up @@ -55,11 +56,24 @@ export const useVerifyAccountEmail = () => {
return useMutation({
mutationKey: [MUTATION_KEYS.verifyEmail],
mutationFn: (data: z.infer<typeof VerifyAccountEmailSchema>) => authApi.verifyEmail(data),
onSuccess: (response, variables) => {
toastifyCommon.success('OTP xác thực thành công!')
navigate(ROUTE.AUTH.RESET_PASSWORD, { state: { email: variables.email } })
},
onError: (error: AxiosError) => handleError(error, 'Failed to verify email')
})
}

export const useResetPasswordAuth = () => {
const navigate = useNavigate()
return useMutation({
mutationKey: [MUTATION_KEYS.resetPassword],
mutationFn: (data: z.infer<typeof ResetPasswordSchema>) => authApi.resetPassword(data as ResetPasswordReq),
onSuccess: () => {
toastifyCommon.success('Email verified successfully! 🎉')
toastifyCommon.success('Mật khẩu đã được cập nhật. Vui lòng đăng nhập lại.')
navigate(ROUTE.AUTH.LOGIN)
},
onError: (error: AxiosError) => handleError(error, 'Failed to verify email')
onError: (error: AxiosError) => handleError(error, 'Failed to reset password')
})
}

Expand Down
23 changes: 13 additions & 10 deletions frontend/src/locales/en/auth.json
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
{
"auth": {
"login": "Login",
"register": "Register",
"logout": "Logout",
"email": "Email",
"password": "Password",
"forgotPassword": "Forgot Password?",
"rememberMe": "Remember Me",
"profile": "Profile"
}
"login": "Login",
"register": "Register",
"logout": "Logout",
"email": "Email",
"password": {
"weak": "weak",
"medium": "MEDIUM",
"strong": "STRONG",
"empty": "EMPTY"
},
"forgotPassword": "Forgot Password?",
"rememberMe": "Remember Me",
"profile": "Profile"
}
23 changes: 13 additions & 10 deletions frontend/src/locales/vi/auth.json
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
{
"auth": {
"login": "Đăng nhập",
"register": "Đăng ký",
"logout": "Đăng xuất",
"email": "Email",
"password": "Mật khẩu",
"forgotPassword": "Quên mật khẩu?",
"rememberMe": "Ghi nhớ đăng nhập",
"profile": "Hồ sơ"
}
"login": "Đăng nhập",
"register": "Đăng ký",
"logout": "Đăng xuất",
"email": "Email",
"password": {
"weak": "YẾU",
"medium": "TRUNG BÌNH",
"strong": "MẠNH",
"empty": "CHƯA NHẬP"
},
"forgotPassword": "Quên mật khẩu?",
"rememberMe": "Ghi nhớ đăng nhập",
"profile": "Hồ sơ"
}
1 change: 1 addition & 0 deletions frontend/src/models/auth/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export interface RegisterRequest {
email: string
password: string
confirm_password: string
role: 'client' | 'lawyer'
}

export interface AuthState {
Expand Down
Loading