diff --git a/package.json b/package.json index fa553e2..eadb499 100644 --- a/package.json +++ b/package.json @@ -12,23 +12,33 @@ "@emotion/cache": "^11.13.1", "@emotion/react": "^11.13.0", "@emotion/styled": "^11.13.0", + "@hookform/resolvers": "^3.9.0", "@mui/icons-material": "^5.16.6", "@mui/material": "^5.16.6", "@mui/material-nextjs": "^5.16.6", + "@reduxjs/toolkit": "^2.2.7", "@tanstack/react-table": "^8.20.1", "axios": "^1.7.3", "moment": "^2.30.1", "next": "14.2.5", "react": "^18", "react-dom": "^18", - "sass": "^1.77.8" + "react-hook-form": "^7.52.2", + "react-redux": "^9.1.2", + "sass": "^1.77.8", + "zod": "^3.23.8" }, "devDependencies": { + "@testing-library/jest-dom": "^6.4.8", + "@testing-library/react": "^16.0.0", + "@testing-library/user-event": "^14.5.2", "@types/node": "^20", "@types/react": "^18", "@types/react-dom": "^18", + "axios-mock-adapter": "^2.0.0", "eslint": "^8", "eslint-config-next": "14.2.5", + "jest": "^29.7.0", "typescript": "^5" } } diff --git a/src/app/(auth)/log-in/page.tsx b/src/app/(auth)/log-in/page.tsx index b2a5ffa..01b1cbf 100644 --- a/src/app/(auth)/log-in/page.tsx +++ b/src/app/(auth)/log-in/page.tsx @@ -1,3 +1,4 @@ +"use client" import React from "react"; import styles from "./loginPage.module.scss"; import Box from "@mui/material/Box"; @@ -7,8 +8,46 @@ import { UTILITY_CONSTANT } from "@/utilities/utilityConstant"; import Link from "next/link"; import CustomizedInputsStyled from "@/ui/CustomizedInputsStyled"; import CustomizedButtons from "@/ui/customizedButtons"; +import { useForm } from 'react-hook-form'; +import { z } from 'zod'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { useState } from 'react'; +import { loginApi } from "@/services/api/loginApi"; +import { FormControl } from '@mui/material'; +import CustomTextField from "@/ui/CustomTextField"; +import { useRouter } from "next/navigation"; +import { useDispatch, useSelector } from "react-redux"; +import { RootState } from "@/services/store"; +import { setAuthTokens, setUserDetails } from "@/services/store/authSlice"; + +const loginSchema = z.object({ + username: z.string(), + password: z.string(), +}); + +type LoginFormValues = z.infer; export default function LoginPage() { + const router = useRouter() + const { register, handleSubmit, formState: { errors }, control } = useForm({ + resolver: zodResolver(loginSchema), + }); + const [error, setError] = useState(''); + const dispatch = useDispatch(); + const onSubmit = async (data: LoginFormValues) => { + try { + const response = await loginApi(data); + localStorage.setItem('token', response.token); + localStorage.setItem('refreshToken', response.refreshToken); + dispatch(setAuthTokens({ token: response.token, refreshToken: response.refreshToken })); + dispatch(setUserDetails(response)); + + router.push('/home'); + } catch (err) { + setError('Invalid credentials'); + } + }; + return (
@@ -40,7 +79,7 @@ export default function LoginPage() { paddingBlock={5} > logo - +
Welcome to Convexsol Sign in to your account - - + - + - +
{children}; } + +export default AuthGuard(RootLayout); \ No newline at end of file diff --git a/src/app/layout.tsx b/src/app/layout.tsx index a6980ec..9c4b1a8 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,7 +1,10 @@ +"use client" import { AppRouterCacheProvider } from "@mui/material-nextjs/v13-appRouter"; import { ThemeProvider } from "@mui/material/styles"; import theme from "../theme"; import "./globals.scss"; +import { Provider } from 'react-redux'; +import store from "@/services/store"; interface RootLayoutProps { children: React.ReactNode; @@ -12,9 +15,11 @@ export default function RootLayout(props: RootLayoutProps): JSX.Element { return ( - - {children} - + + + {children} + + ); diff --git a/src/components/wrapper/dashboardWrapper.tsx b/src/components/wrapper/dashboardWrapper.tsx index dea2beb..0e5f4fa 100644 --- a/src/components/wrapper/dashboardWrapper.tsx +++ b/src/components/wrapper/dashboardWrapper.tsx @@ -1,5 +1,4 @@ "use client"; // This ensures the file is a client component - import * as React from "react"; import AppBar, { AppBarProps } from "@mui/material/AppBar"; import Box from "@mui/material/Box"; @@ -12,7 +11,6 @@ import ListItem from "@mui/material/ListItem"; import ListItemButton from "@mui/material/ListItemButton"; import ListItemIcon from "@mui/material/ListItemIcon"; import ListItemText from "@mui/material/ListItemText"; -import MailIcon from "@mui/icons-material/Mail"; import MenuIcon from "@mui/icons-material/Menu"; import Toolbar from "@mui/material/Toolbar"; import Typography from "@mui/material/Typography"; @@ -22,6 +20,7 @@ import styles from "./DashboardWrapper.module.scss"; import styled from "@emotion/styled"; import { BUILDING, GEAR } from "@/utilities/svgConstant"; import LogoutIcon from "@mui/icons-material/Logout"; +import { useRouter } from "next/navigation"; const drawerWidth = 260; @@ -75,7 +74,7 @@ export default function DashboardWrapper(props: Props) { const { children: Children } = props; const [mobileOpen, setMobileOpen] = useState(false); const [isClosing, setIsClosing] = useState(false); - + const router = useRouter() const handleDrawerClose = () => { setIsClosing(true); setMobileOpen(false); @@ -125,6 +124,12 @@ export default function DashboardWrapper(props: Props) { const container = window !== undefined ? () => window().document.body : undefined; + const logoutHandler = () => { + localStorage.removeItem('token'); + localStorage.removeItem('refreshToken'); + router.push('/log-in'); + } + return ( - {/* */} - + diff --git a/src/hoc/authGuard/authGuard.tsx b/src/hoc/authGuard/authGuard.tsx new file mode 100644 index 0000000..e575a16 --- /dev/null +++ b/src/hoc/authGuard/authGuard.tsx @@ -0,0 +1,31 @@ +"use client" +// hoc/withAuth.tsx +import { useRouter } from 'next/navigation'; +import { useEffect } from 'react'; + +const AuthGuard = (WrappedComponent: any) => { + const AuthGuardComponent = (props: any) => { + const router = useRouter(); + + useEffect(() => { + const token = localStorage.getItem('token'); + if (!token) { + router.push('/log-in'); + } + }, [router]); + + return ; + }; + + // Set display name for the component + AuthGuardComponent.displayName = `AuthGuard(${getDisplayName(WrappedComponent)})`; + + return AuthGuardComponent; +}; + +export default AuthGuard; + +// Helper function to get the display name of a component +function getDisplayName(WrappedComponent: any) { + return WrappedComponent.displayName || WrappedComponent.name || 'Component'; +} diff --git a/src/services/api/loginApi.ts b/src/services/api/loginApi.ts new file mode 100644 index 0000000..59bb92e --- /dev/null +++ b/src/services/api/loginApi.ts @@ -0,0 +1,24 @@ +// api.ts +import axiosInstance from '../axios/axiosInstance'; + +export interface LoginResponse { + id: number; + username: string; + email: string; + firstName: string; + lastName: string; + gender: string; + image: string; + token: string; + refreshToken: string; +} + +interface LoginData { + username: string; + password: string; +} + +export const loginApi = async (data: LoginData): Promise => { + const response = await axiosInstance.post('/auth/login', data); + return response.data; +}; diff --git a/src/services/axios/axiosInstance.ts b/src/services/axios/axiosInstance.ts new file mode 100644 index 0000000..3fe3d38 --- /dev/null +++ b/src/services/axios/axiosInstance.ts @@ -0,0 +1,88 @@ +// axiosInstance.ts +import axios from 'axios'; + +const axiosInstance = axios.create({ + baseURL: process.env.NEXT_PUBLIC_API_URL, + headers: { + 'Content-Type': 'application/json', + }, +}); + +let isRefreshing = false; +let failedQueue: any[] = []; + +const processQueue = (error: any, token: string | null = null) => { + failedQueue.forEach((prom) => { + if (token) { + prom.resolve(token); + } else { + prom.reject(error); + } + }); + + failedQueue = []; +}; + +axiosInstance.interceptors.request.use( + (config) => { + const token = localStorage.getItem('token'); + if (token) { + config.headers['Authorization'] = `Bearer ${token}`; + } + return config; + }, + (error) => Promise.reject(error) +); + +axiosInstance.interceptors.response.use( + (response) => response, + async (error) => { + const originalRequest = error.config; + if (error.response.status === 401 && !originalRequest._retry) { + if (isRefreshing) { + return new Promise(function (resolve, reject) { + failedQueue.push({ resolve, reject }); + }) + .then((token) => { + originalRequest.headers['Authorization'] = 'Bearer ' + token; + return axiosInstance(originalRequest); + }) + .catch((err) => Promise.reject(err)); + } + + originalRequest._retry = true; + isRefreshing = true; + + const refreshToken = localStorage.getItem('refreshToken'); + if (!refreshToken) { + window.location.href = '/log-in'; + return Promise.reject(error); + } + + try { + const { data } = await axios.post( + `${process.env.NEXT_PUBLIC_API_URL}/auth/refresh`, + { token: refreshToken } + ); + localStorage.setItem('token', data.token); + localStorage.setItem('refreshToken', data.refreshToken); + axiosInstance.defaults.headers['Authorization'] = + 'Bearer ' + data.token; + processQueue(null, data.token); + return axiosInstance(originalRequest); + } catch (err) { + processQueue(err, null); + localStorage.removeItem('token'); + localStorage.removeItem('refreshToken'); + window.location.href = '/login'; + return Promise.reject(err); + } finally { + isRefreshing = false; + } + } + + return Promise.reject(error); + } +); + +export default axiosInstance; diff --git a/src/services/store/authSlice.ts b/src/services/store/authSlice.ts new file mode 100644 index 0000000..e5511b1 --- /dev/null +++ b/src/services/store/authSlice.ts @@ -0,0 +1,57 @@ +// store/slices/authSlice.ts +import { createSlice, PayloadAction } from '@reduxjs/toolkit'; +import { LoginResponse } from '../api/loginApi'; + +interface AuthState { + token: string | null; + refreshToken: string | null; + id: number; + username: string; + email: string; + firstName: string; + lastName: string; + gender: string; + image: string; +} + +const initialState: AuthState = { + token: null, + refreshToken: null, + id: 0, + username: '', + email: '', + firstName: '', + lastName: '', + gender: '', + image: '', +}; + +const authSlice = createSlice({ + name: 'auth', + initialState, + reducers: { + setAuthTokens: ( + state, + action: PayloadAction<{ token: string; refreshToken: string }> + ) => { + state.token = action.payload.token; + state.refreshToken = action.payload.refreshToken; + localStorage.setItem('token', action.payload.token); + localStorage.setItem('refreshToken', action.payload.refreshToken); + }, + clearAuthTokens: (state) => { + state.token = null; + state.refreshToken = null; + localStorage.removeItem('token'); + localStorage.removeItem('refreshToken'); + }, + setUserDetails: (state, action: PayloadAction) => { + state = action.payload; + }, + }, +}); + +export const { setAuthTokens, clearAuthTokens, setUserDetails } = + authSlice.actions; + +export default authSlice.reducer; diff --git a/src/services/store/index.tsx b/src/services/store/index.tsx new file mode 100644 index 0000000..b3f906d --- /dev/null +++ b/src/services/store/index.tsx @@ -0,0 +1,15 @@ + +// store/index.ts +import { configureStore } from '@reduxjs/toolkit'; +import authReducer from './authSlice'; + +const store = configureStore({ + reducer: { + auth: authReducer, + }, +}); + +export type RootState = ReturnType; +export type AppDispatch = typeof store.dispatch; + +export default store; diff --git a/src/ui/CustomTextField.tsx b/src/ui/CustomTextField.tsx new file mode 100644 index 0000000..ad201a3 --- /dev/null +++ b/src/ui/CustomTextField.tsx @@ -0,0 +1,58 @@ +// components/CustomTextField.tsx +import React from 'react'; +import TextField from '@mui/material/TextField'; +import { Controller } from 'react-hook-form'; +import { styled } from "@mui/material/styles"; + +interface CustomTextFieldProps { + name: string; + control: any; + label: string; + type?: string; + error?: boolean; + helperText?: string; +} + +// Define your styled component +const CssTextField = styled(TextField)({ + width: "100%", + "& label.Mui-focused": { + color: "var(--primary)", + }, + "& .MuiInputBase-root": { + borderRadius: 8, + }, + "& .MuiOutlinedInput-root": { + "& fieldset": { + borderColor: "var(--input-border-default)", + }, + "&:hover fieldset": { + borderColor: "var(--input-border-hover)", + }, + "&.Mui-focused fieldset": { + borderColor: "var(--primary)", + }, + }, +}); + +const CustomTextField: React.FC = ({ name, control, label, type = 'text', error, helperText }) => { + return ( + ( + + )} + /> + ); +}; + +export default CustomTextField; diff --git a/src/ui/CustomizedInputsStyled.tsx b/src/ui/CustomizedInputsStyled.tsx index 68e6f23..fddd3c5 100644 --- a/src/ui/CustomizedInputsStyled.tsx +++ b/src/ui/CustomizedInputsStyled.tsx @@ -47,6 +47,7 @@ export default function CustomizedInputsStyled({ type={type} id="custom-css-outlined-input" InputProps={{ endAdornment: endAdornmentBoolean && }} + /> ); diff --git a/src/ui/customizedButtons.tsx b/src/ui/customizedButtons.tsx index 33e7efc..b722dd7 100644 --- a/src/ui/customizedButtons.tsx +++ b/src/ui/customizedButtons.tsx @@ -16,12 +16,14 @@ const ColorButton = styled(Button)(() => ({ type InputType = { label: string; statIcon?: React.ReactNode; + onPress?: () => void; + btnType?: "submit" | "button"; }; -export default function CustomizedButtons({ label, statIcon }: InputType) { +export default function CustomizedButtons({ label, statIcon, onPress, btnType = "button" }: InputType) { return ( <> - + {statIcon && statIcon} {label}