diff --git a/frontend/public/bg.jpg b/frontend/public/bg.jpg new file mode 100644 index 0000000..ef547a4 Binary files /dev/null and b/frontend/public/bg.jpg differ diff --git a/frontend/src/components/PasswordStrengthBar/PasswordStrengthBar.tsx b/frontend/src/components/PasswordStrengthBar/PasswordStrengthBar.tsx new file mode 100644 index 0000000..457c254 --- /dev/null +++ b/frontend/src/components/PasswordStrengthBar/PasswordStrengthBar.tsx @@ -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 ( +
+
+ {[0, 1, 2].map((i) => ( +
+ ))} +
+ +
{getLabel()}
+
+ ) +} diff --git a/frontend/src/components/header-nav/header-nav.tsx b/frontend/src/components/header-nav/header-nav.tsx index dc3d15a..9a0b478 100644 --- a/frontend/src/components/header-nav/header-nav.tsx +++ b/frontend/src/components/header-nav/header-nav.tsx @@ -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' > - {tAuth('auth.login')} + {tAuth('login')}
)} diff --git a/frontend/src/core/configs/consts.ts b/frontend/src/core/configs/consts.ts index 33dc46e..0bde1ec 100644 --- a/frontend/src/core/configs/consts.ts +++ b/frontend/src/core/configs/consts.ts @@ -112,6 +112,7 @@ export const numberConstants = { EIGHT: 8, NINE: 9, TEN: 10, + fifty: 50, ONE_HUNDRED: 100 } diff --git a/frontend/src/core/constants/path.ts b/frontend/src/core/constants/path.ts index 31db4f3..b525804 100644 --- a/frontend/src/core/constants/path.ts +++ b/frontend/src/core/constants/path.ts @@ -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: { diff --git a/frontend/src/core/helpers/key-tanstack.ts b/frontend/src/core/helpers/key-tanstack.ts index 821254f..f6ae8fd 100644 --- a/frontend/src/core/helpers/key-tanstack.ts +++ b/frontend/src/core/helpers/key-tanstack.ts @@ -3,5 +3,6 @@ export const MUTATION_KEYS = { login: 'login', updateProfile: 'updateProfile', verifyEmail: 'verifyEmail', - resendCode: 'resendCode' + resendCode: 'resendCode', + resetPassword: 'resetPassword' } diff --git a/frontend/src/core/helpers/validator.ts b/frontend/src/core/helpers/validator.ts index ab0d221..b946c94 100644 --- a/frontend/src/core/helpers/validator.ts +++ b/frontend/src/core/helpers/validator.ts @@ -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,}$/ } diff --git a/frontend/src/core/services/auth.service.ts b/frontend/src/core/services/auth.service.ts index cbf567e..6e15cc5 100644 --- a/frontend/src/core/services/auth.service.ts +++ b/frontend/src/core/services/auth.service.ts @@ -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, @@ -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 = { @@ -23,6 +25,7 @@ export type AuthApi = { refreshToken: (refreshToken: string) => Promise verifyEmail: (params: VerifyEmailReq) => Promise resendVerificationCode: (email: string) => Promise<{ message: string }> + resetPassword: (params: ResetPasswordReq) => Promise<{ message: string }> logout: (refresh_token: string) => Promise } @@ -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 }) } diff --git a/frontend/src/core/zod/register.zod.ts b/frontend/src/core/zod/register.zod.ts index 3cdbb6b..c990903 100644 --- a/frontend/src/core/zod/register.zod.ts +++ b/frontend/src/core/zod/register.zod.ts @@ -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.' + }) + } + } }) -}) diff --git a/frontend/src/core/zod/reset-password.zod.ts b/frontend/src/core/zod/reset-password.zod.ts new file mode 100644 index 0000000..b4ef66c --- /dev/null +++ b/frontend/src/core/zod/reset-password.zod.ts @@ -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' + }) diff --git a/frontend/src/hooks/routes/use-router-element.tsx b/frontend/src/hooks/routes/use-router-element.tsx index d091b0f..06aee6d 100644 --- a/frontend/src/hooks/routes/use-router-element.tsx +++ b/frontend/src/hooks/routes/use-router-element.tsx @@ -15,6 +15,8 @@ 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')) @@ -22,7 +24,13 @@ 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 = ( @@ -31,7 +39,9 @@ export default function useRoutesElements() { } /> } /> } /> + } /> } /> + } /> {/* Client protected routes */} }> 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 27337f3..1b74bd1 100644 --- a/frontend/src/hooks/tanstack-query/auth/use-query-auth.ts +++ b/frontend/src/hooks/tanstack-query/auth/use-query-auth.ts @@ -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 = () => { @@ -55,11 +56,24 @@ export const useVerifyAccountEmail = () => { return useMutation({ mutationKey: [MUTATION_KEYS.verifyEmail], mutationFn: (data: z.infer) => 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) => 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') }) } diff --git a/frontend/src/locales/en/auth.json b/frontend/src/locales/en/auth.json index b97f27f..dcd9d38 100644 --- a/frontend/src/locales/en/auth.json +++ b/frontend/src/locales/en/auth.json @@ -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" } diff --git a/frontend/src/locales/vi/auth.json b/frontend/src/locales/vi/auth.json index a0ad742..4e5ad57 100644 --- a/frontend/src/locales/vi/auth.json +++ b/frontend/src/locales/vi/auth.json @@ -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ơ" } diff --git a/frontend/src/models/auth/interfaces.ts b/frontend/src/models/auth/interfaces.ts index 9669f10..9e27d55 100644 --- a/frontend/src/models/auth/interfaces.ts +++ b/frontend/src/models/auth/interfaces.ts @@ -14,6 +14,7 @@ export interface RegisterRequest { email: string password: string confirm_password: string + role: 'client' | 'lawyer' } export interface AuthState { diff --git a/frontend/src/models/interface/auth.interface.ts b/frontend/src/models/interface/auth.interface.ts index 378f930..8a3e2f2 100644 --- a/frontend/src/models/interface/auth.interface.ts +++ b/frontend/src/models/interface/auth.interface.ts @@ -43,6 +43,11 @@ export interface VerifyEmailRes { message: string } +export interface ResetPasswordReq { + email: string + password: string +} + export interface RememberMeData { email: string password: string diff --git a/frontend/src/pages/404/PageNotFound.tsx b/frontend/src/pages/404/PageNotFound.tsx index 60cc08f..6ba4e75 100644 --- a/frontend/src/pages/404/PageNotFound.tsx +++ b/frontend/src/pages/404/PageNotFound.tsx @@ -5,133 +5,55 @@ import { ROUTE } from '@/core/constants/path' const PageNotFound = () => { return ( -
- {/* Animated background elements */} - - {[...Array(5)].map((_, i) => ( - - ))} - - - {/* Floating particles */} - {[...Array(20)].map((_, i) => ( - - ))} +
+ {/* Header */} +
+
Your Logo
+
{/* Content */} -
- -

- 404 -

-
- - -

Oops! Page not found

-

- The page you're looking for doesn't exist or has been moved. -

-
- +
- - +

Quên mật khẩu

+

+ Nhập email của bạn. Chúng tôi sẽ gửi mã OTP để đặt lại mật khẩu. +

+
+ +
+
+ + +
+ +
+
+ + + +
+ + ← Quay lại đăng nhập + +
+
- - {/* Decorative elements */} -
) } diff --git a/frontend/src/pages/forgot-password/ForgotPassword.tsx b/frontend/src/pages/forgot-password/ForgotPassword.tsx new file mode 100644 index 0000000..daddf31 --- /dev/null +++ b/frontend/src/pages/forgot-password/ForgotPassword.tsx @@ -0,0 +1,112 @@ +import { useCallback } from 'react' +import { zodResolver } from '@hookform/resolvers/zod' +import { motion } from 'framer-motion' +import { useForm } from 'react-hook-form' +import { Link, useNavigate } from 'react-router-dom' +import { z } from 'zod' + +import Logo from '@/components/logo/logo' +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 { containerVariants, itemVariants } from '@/styles/variant/style-variant' + +const ForgotPasswordSchema = z.object({ + email: z.string().email({ message: 'Vui lòng nhập email hợp lệ' }) +}) + +type ForgotPasswordForm = z.infer + +export default function ForgotPassword() { + const navigate = useNavigate() + + const form = useForm({ + resolver: zodResolver(ForgotPasswordSchema), + defaultValues: { + email: '' + } + }) + + const onSubmit = useCallback( + (data: ForgotPasswordForm) => { + navigate(ROUTE.AUTH.VERIFY_ACCOUNT_EMAIL, { state: { email: data.email } }) + }, + [navigate] + ) + + return ( +
+
+
+ +
+ {/* + + + + + */} +

Quên mật khẩu

+

+ Nhập địa chỉ email liên kết với tài khoản của bạn. Chúng tôi sẽ gửi một mã xác thực (OTP) để thiết lập mật + khẩu mới. +

+
+ +
+ + + + ( + + EMAIL + + + + + + )} + /> + + + + + + + + + ← Quay lại đăng nhập + + + +
+ +
+
+
+ ) +} diff --git a/frontend/src/pages/login/Login.tsx b/frontend/src/pages/login/Login.tsx index 909fbc9..07b8238 100644 --- a/frontend/src/pages/login/Login.tsx +++ b/frontend/src/pages/login/Login.tsx @@ -7,7 +7,6 @@ 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' @@ -22,25 +21,13 @@ 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.interface' -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 [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 + const saved = localStorage.getItem(REMEMBER_ME) + return saved ? JSON.parse(saved).isRemembered : false }) useAuthRedirect() @@ -53,74 +40,69 @@ export default function Login() { } }) - const { mutate: mutationLogin } = useLoginAuth() + const { mutate: login } = useLoginAuth() const onSubmit = useCallback( (data: z.infer) => { loginStart() - mutationLogin(data, { - onSuccess: (response) => { - loginSuccess(response.data) - }, - onError: (error) => { - loginFailure(error.message) - } + login(data, { + onSuccess: (res) => loginSuccess(res.data), + onError: (err) => loginFailure(err.message) }) }, - [mutationLogin, loginStart, loginSuccess, loginFailure] + [login, 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)) + const togglePassword = () => setIsPasswordVisible((prev) => !prev) + + const handleRememberMe = (checked: boolean) => { + setRememberMe(checked) + const data = form.getValues() + + if (checked) { + localStorage.setItem( + REMEMBER_ME, + JSON.stringify({ + email: data.email, + password: data.password, + isRemembered: true + }) + ) } 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) + const saved = localStorage.getItem(REMEMBER_ME) + if (saved) { + const parsed: RememberMeData = JSON.parse(saved) + if (parsed.isRemembered) { + form.setValue('email', parsed.email) + form.setValue('password', parsed.password) } } }, [form]) return ( -
-
+
+
+ +
- - - - - -

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

-

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

-
+ {/* title */} +
+

Đăng nhập

+

Chào mừng trở lại

+
+ {/* EMAIL */} ( - Email + Email @@ -146,21 +129,21 @@ export default function Login() { /> + {/* PASSWORD */} ( - Mật khẩu + Mật khẩu : } - iconOnClick={togglePasswordVisibility} + iconOnClick={togglePassword} /> @@ -169,85 +152,52 @@ export default function Login() { /> - -
- - + {/* REMEMBER */} + +
+ +
- + + Quên mật khẩu?
+ {/* LOGIN BUTTON */} + + + + + {/* GOOGLE */} - + {/* REGISTER */} + Chưa có tài khoản?{' '} - - Đăng ký ngay + + Đăng ký - - {/* 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/register/Register.tsx b/frontend/src/pages/register/Register.tsx index 9b8bdfd..916a309 100644 --- a/frontend/src/pages/register/Register.tsx +++ b/frontend/src/pages/register/Register.tsx @@ -7,12 +7,10 @@ import { Link } from 'react-router-dom' import { type z } from 'zod' import { IconEye, IconNonEye } from '@/assets/icons' -import Logo from '@/components/logo/logo' +import PasswordStrengthBar from '@/components/PasswordStrengthBar/PasswordStrengthBar' 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' @@ -20,16 +18,9 @@ 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) + const [isPasswordVisible, setIsPasswordVisible] = useState(false) + const [isConfirmPasswordVisible, setIsConfirmPasswordVisible] = useState(false) useAuthRedirect() @@ -40,11 +31,18 @@ export default function Register() { password: '', confirmPassword: '', name: '', - phone: '' + phone: '', + role: 'client', + licenseNumber: '', + issuedDate: '', + issuedPlace: '', + certificate: null, + referralCode: '' } }) const { mutate: mutationRegister, isPending } = useRegisterAuth() + const role = form.watch('role') const handleRegister = () => { mutationRegister(form.getValues()) @@ -54,78 +52,23 @@ export default function Register() { 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

+ {/* HEADER */} + +

Tạo tài khoản

+

Tham gia mạng lưới AI pháp lí thông minh nhất

@@ -136,15 +79,39 @@ export default function Register() { onSubmit={form.handleSubmit(handleRegister)} className='space-y-6' > + {/* ROLE */} ( - Email - +
+
+ {[ + { value: 'client', label: 'Khách hàng' }, + { value: 'lawyer', label: 'Luật sư' } + ].map((option) => { + const isActive = field.value === option.value + + return ( + + ) + })} +
+
@@ -152,28 +119,33 @@ export default function Register() { />
- + {/* NAME */} + ( - Họ và tên + HỌ VÀ TÊN - + )} /> + + + {/* EMAIL */} + ( - Số điện thoại + EMAIL - + @@ -181,40 +153,43 @@ export default function Register() { /> + {/* PASSWORD */} ( - Mật khẩu + MẬT KHẨU : } iconOnClick={togglePasswordVisibility} /> + + + )} /> + {/* CONFIRM PASSWORD */} ( - Xác nhận mật khẩu + XÁC NHẬN MẬT KHẨU : } @@ -227,33 +202,50 @@ export default function Register() { /> - - - + {role === 'lawyer' && ( + + ( + + MÃ GIỚI THIỆU + + + + + + )} + /> + + )} + + {/* SUBMIT */} + + - - + Đã có tài khoản?{' '} - - Đăng nhập ngay + + Đăng nhập diff --git a/frontend/src/pages/reset-password/ResetPassword.tsx b/frontend/src/pages/reset-password/ResetPassword.tsx new file mode 100644 index 0000000..5438aec --- /dev/null +++ b/frontend/src/pages/reset-password/ResetPassword.tsx @@ -0,0 +1,154 @@ +import { useCallback, useEffect } from 'react' + +import { zodResolver } from '@hookform/resolvers/zod' +import { motion } from 'framer-motion' +import { useForm } from 'react-hook-form' +import { Link, useLocation } from 'react-router-dom' +import { type z } from 'zod' + +import PasswordStrengthBar from '@/components/PasswordStrengthBar/PasswordStrengthBar' +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 { ResetPasswordSchema } from '@/core/zod/reset-password.zod' +import { useResetPasswordAuth } from '@/hooks/tanstack-query/auth/use-query-auth' + +type ResetPasswordForm = z.infer + +export default function ResetPassword() { + const location = useLocation() + const emailFromState = location.state?.email as string | undefined + + const form = useForm({ + resolver: zodResolver(ResetPasswordSchema), + defaultValues: { + email: emailFromState ?? '', + password: '', + confirmPassword: '' + } + }) + + useEffect(() => { + if (emailFromState) { + form.setValue('email', emailFromState) + } + }, [emailFromState, form]) + + const { mutate: resetPassword, isPending: isResetting } = useResetPasswordAuth() + + const handleResetPassword = useCallback( + (data: ResetPasswordForm) => { + resetPassword(data) + }, + [resetPassword] + ) + + return ( +
+
+ +
+ +
+

Tạo mật khẩu mới

+

+ Vui lòng nhập mật khẩu mới của bạn bên dưới. Hãy đảm bảo mật khẩu này khác với các mật khẩu cũ để tăng + cường bảo mật. +

+
+ + + + {/* EMAIL */} + ( + + EMAIL + + + + + )} + /> + + {/* PASSWORD */} + ( + + MẬT KHẨU MỚI + + + + + + + + + + )} + /> + + {/* CONFIRM PASSWORD */} + ( + + XÁC NHẬN MẬT KHẨU + + + + + + )} + /> + + {/* BUTTON */} + + + {/* FOOTER */} +

+ Gặp vấn đề? Liên hệ tư vấn viên +

+ +
+ + ← Quay lại đăng nhập + +
+ + +
+
+
+ ) +} diff --git a/frontend/src/pages/verify-account-email/VerifyAcountEmail.tsx b/frontend/src/pages/verify-account-email/VerifyAcountEmail.tsx index 95c3c27..45751e9 100644 --- a/frontend/src/pages/verify-account-email/VerifyAcountEmail.tsx +++ b/frontend/src/pages/verify-account-email/VerifyAcountEmail.tsx @@ -3,24 +3,24 @@ 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, useLocation } from 'react-router-dom' +import { useLocation, useNavigate } from 'react-router-dom' import { type z } from 'zod' -import Logo from '@/components/logo/logo' import { Button } from '@/components/ui/button' -import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form' -import { Input } from '@/components/ui/input' +import { Form, FormControl, FormField, FormItem, FormMessage } from '@/components/ui/form' import { InputOTP, InputOTPGroup, InputOTPSlot } from '@/components/ui/input-otp' import { ROUTE } from '@/core/constants/path' import { VerifyAccountEmailSchema } from '@/core/zod/verify-account-email.zod' -import { useResendVerificationCode, useVerifyAccountEmail } from '@/hooks/tanstack-query/auth/use-query-auth' -import { containerVariants, itemVariants } from '@/styles/variant/style-variant' +import { useResendVerificationCode } from '@/hooks/tanstack-query/auth/use-query-auth' + const RESEND_COUNTDOWN = 60 export default function VerifyEmail() { const location = useLocation() - const [countdown, setCountdown] = useState(RESEND_COUNTDOWN) - const [canResend, setCanResend] = useState(false) + const navigate = useNavigate() + + const [countdown, setCountdown] = useState(RESEND_COUNTDOWN) + const [canResend, setCanResend] = useState(false) const form = useForm>({ resolver: zodResolver(VerifyAccountEmailSchema), @@ -30,166 +30,122 @@ export default function VerifyEmail() { } }) + // set email từ state useEffect(() => { const email = location.state?.email - if (email) { - form.setValue('email', email) - } - }, [form, location.state]) + if (email) form.setValue('email', email) + }, [location.state, form]) + // countdown resend useEffect(() => { - let timer: NodeJS.Timeout - if (countdown > 0 && !canResend) { - timer = setInterval(() => { + if (countdown === 0) { + setCanResend(true) + return + } + + if (!canResend) { + const timer = setInterval(() => { setCountdown((prev) => prev - 1) }, 1000) - } else if (countdown === 0) { - setCanResend(true) + + return () => clearInterval(timer) } - return () => clearInterval(timer) }, [countdown, canResend]) - const { mutate: verifyEmail, isPending: isVerifying } = useVerifyAccountEmail() - const { mutate: resendCode, isPending: isResending } = useResendVerificationCode({ setCountdown, setCanResend }) + // fake verify const handleVerify = useCallback( (data: z.infer) => { - verifyEmail(data) + setTimeout(() => { + navigate(ROUTE.AUTH.RESET_PASSWORD, { + state: { email: data.email } + }) + }, 800) }, - [verifyEmail] + [navigate] ) const handleResendCode = useCallback(() => { const email = form.getValues('email') - if (email) { - resendCode(email) - } + if (email) resendCode(email) }, [form, resendCode]) return ( -
- - Email Verification - -
+
+
+ +
- - - - - Verify Your Account - - - We've sent a verification code to your email address. Please enter it below to verify your account. - + {/* HEADER */} +
+

Xác nhận OTP

+

Mã OTP 6 chữ số đã được gửi tới email của bạn.

+
+
- - - ( - - Email - - - - - - )} - /> - - - ( - - Verification Code - - - - - - - - - - - - - - - )} - /> - - - - -
- Didn't receive the code? - -
-
- - - Already verified?  - + {/* OTP INPUT */} + ( + + + + + {Array.from({ length: 6 }).map((_, i) => ( + + ))} + + + + + + )} + /> + + {/* BUTTON */} + + + {/* RESEND */} +
+ +
diff --git a/frontend/src/utils/password-strength.ts b/frontend/src/utils/password-strength.ts new file mode 100644 index 0000000..3f383cc --- /dev/null +++ b/frontend/src/utils/password-strength.ts @@ -0,0 +1,26 @@ +export const getPasswordStrength = (password: string) => { + let score = 0 + + if (!password) return 0 + + if (password.length >= 6) score += 2 + if (password.length >= 10) score += 2 + + if (/[A-Z]/.test(password)) score += 2 + if (/[0-9]/.test(password)) score += 2 + if (/[^A-Za-z0-9]/.test(password)) score += 2 + + return score +} + +export const getPasswordStrengthLabel = (score: number, password: string) => { + if (!password) return 'Chưa nhập' + if (score < 5) return 'Yếu' + if (score < 8) return 'Trung bình' + return 'Mạnh' +} + +export const getPasswordStrengthColor = (score: number, password: string) => { + if (!password) return '' + return 'bg-red-500' +}