From fbbbfb8f67d0ee9bd74e261c78af5fd4e9960828 Mon Sep 17 00:00:00 2001 From: Prakash Maity Date: Wed, 7 Aug 2024 15:09:33 +0530 Subject: [PATCH 1/4] login changes --- package.json | 10 +++- src/app/(auth)/log-in/page.tsx | 56 ++++++++++++++++-- src/services/api/loginApi.ts | 24 ++++++++ src/services/axios/axiosInstance.ts | 88 +++++++++++++++++++++++++++++ src/ui/CustomTextField.tsx | 35 ++++++++++++ src/ui/CustomizedInputsStyled.tsx | 34 ++++++++--- src/ui/customizedButtons.tsx | 6 +- 7 files changed, 236 insertions(+), 17 deletions(-) create mode 100644 src/services/api/loginApi.ts create mode 100644 src/services/axios/axiosInstance.ts create mode 100644 src/ui/CustomTextField.tsx diff --git a/package.json b/package.json index 3c355d1..8f5776f 100644 --- a/package.json +++ b/package.json @@ -12,19 +12,27 @@ "@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-nextjs": "^5.16.6", "next": "14.2.5", "react": "^18", "react-dom": "^18", - "sass": "^1.77.8" + "react-hook-form": "^7.52.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..d5cc86b 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,42 @@ 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"; + +const loginSchema = z.object({ + username: z.string(), + password: z.string(), +}); + +type LoginFormValues = z.infer; export default function LoginPage() { + + const { register, handleSubmit, formState: { errors }, control } = useForm({ + resolver: zodResolver(loginSchema), + }); + console.log("errors", errors) + const [error, setError] = useState(''); + + const onSubmit = async (data: LoginFormValues) => { + try { + const response = await loginApi(data); + localStorage.setItem('token', response.token); + localStorage.setItem('refreshToken', response.refreshToken); + console.log("🚀 ~ response @@@ @@@ @@@ @@@ @@@@:", response) + + // router.push('/dashboard'); + } catch (err) { + setError('Invalid credentials'); + } + }; + return (
@@ -40,7 +75,7 @@ export default function LoginPage() { paddingBlock={5} > logo - +
Welcome to Convexsol Sign in to your account - - + - + - +
=> { + 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/ui/CustomTextField.tsx b/src/ui/CustomTextField.tsx new file mode 100644 index 0000000..6610188 --- /dev/null +++ b/src/ui/CustomTextField.tsx @@ -0,0 +1,35 @@ +// components/CustomTextField.tsx +import React from 'react'; +import TextField from '@mui/material/TextField'; +import { Controller } from 'react-hook-form'; + +interface CustomTextFieldProps { + name: string; + control: any; + label: string; + type?: string; + error?: boolean; + helperText?: string; +} + +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..11af0e5 100644 --- a/src/ui/CustomizedInputsStyled.tsx +++ b/src/ui/CustomizedInputsStyled.tsx @@ -5,6 +5,7 @@ import { styled } from "@mui/material/styles"; import TextField from "@mui/material/TextField"; import Visibility from "@mui/icons-material/Visibility"; import VisibilityOff from "@mui/icons-material/VisibilityOff"; +import { Controller } from 'react-hook-form'; // Define your styled component const CssTextField = styled(TextField)({ @@ -32,6 +33,10 @@ type InputType = { label: string; type: string; endAdornmentBoolean?: React.ReactNode; + name: string; + control: any; + error?: boolean; + helperText?: string; }; // Client component @@ -39,15 +44,28 @@ export default function CustomizedInputsStyled({ label, type, endAdornmentBoolean, + name, + control, + error, + helperText, }: InputType) { return ( - <> - }} - /> - + ( + }} + id="custom-css-outlined-input" + {...field} + type={type} + error={error} + helperText={helperText} + variant="outlined" + fullWidth + /> + )} + /> ); } 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} From 1dec1b625d80206fddc19b0ab35a4a9642a24ee7 Mon Sep 17 00:00:00 2001 From: Prakash Maity Date: Wed, 7 Aug 2024 16:06:54 +0530 Subject: [PATCH 2/4] fix(loginPage): login page hooks form intregation --- src/app/(auth)/log-in/page.tsx | 6 +++--- src/ui/CustomTextField.tsx | 25 +++++++++++++++++++++- src/ui/CustomizedInputsStyled.tsx | 35 ++++++++----------------------- 3 files changed, 36 insertions(+), 30 deletions(-) diff --git a/src/app/(auth)/log-in/page.tsx b/src/app/(auth)/log-in/page.tsx index d5cc86b..5116a68 100644 --- a/src/app/(auth)/log-in/page.tsx +++ b/src/app/(auth)/log-in/page.tsx @@ -15,6 +15,7 @@ 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"; const loginSchema = z.object({ username: z.string(), @@ -24,7 +25,7 @@ const loginSchema = z.object({ type LoginFormValues = z.infer; export default function LoginPage() { - + const router = useRouter() const { register, handleSubmit, formState: { errors }, control } = useForm({ resolver: zodResolver(loginSchema), }); @@ -36,9 +37,8 @@ export default function LoginPage() { const response = await loginApi(data); localStorage.setItem('token', response.token); localStorage.setItem('refreshToken', response.refreshToken); - console.log("🚀 ~ response @@@ @@@ @@@ @@@ @@@@:", response) - // router.push('/dashboard'); + router.push('/home'); } catch (err) { setError('Invalid credentials'); } diff --git a/src/ui/CustomTextField.tsx b/src/ui/CustomTextField.tsx index 6610188..ad201a3 100644 --- a/src/ui/CustomTextField.tsx +++ b/src/ui/CustomTextField.tsx @@ -2,6 +2,7 @@ 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; @@ -12,13 +13,35 @@ interface CustomTextFieldProps { 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 ( ( - ( - }} - id="custom-css-outlined-input" - {...field} - type={type} - error={error} - helperText={helperText} - variant="outlined" - fullWidth - /> - )} - /> + <> + }} + + /> + ); } From 4ef0aa47a4e10112055309be9a625c37050985fd Mon Sep 17 00:00:00 2001 From: Prakash Maity Date: Wed, 7 Aug 2024 17:18:59 +0530 Subject: [PATCH 3/4] auth guard changes --- src/app/(dashboard)/layout.tsx | 6 +++- src/components/wrapper/dashboardWrapper.tsx | 20 ++++++------- src/hoc/authGuard/authGuard.tsx | 31 +++++++++++++++++++++ 3 files changed, 45 insertions(+), 12 deletions(-) create mode 100644 src/hoc/authGuard/authGuard.tsx diff --git a/src/app/(dashboard)/layout.tsx b/src/app/(dashboard)/layout.tsx index 364a871..f823e4b 100644 --- a/src/app/(dashboard)/layout.tsx +++ b/src/app/(dashboard)/layout.tsx @@ -1,10 +1,14 @@ +"use client" import DashboardWrapper from "@/components/wrapper/dashboardWrapper"; +import AuthGuard from "@/hoc/authGuard/authGuard"; interface RootLayoutProps { children: React.ReactNode; } -export default function RootLayout(props: RootLayoutProps): JSX.Element { +function RootLayout(props: RootLayoutProps): JSX.Element { const { children } = props; return {children}; } + +export default AuthGuard(RootLayout); \ No newline at end of file diff --git a/src/components/wrapper/dashboardWrapper.tsx b/src/components/wrapper/dashboardWrapper.tsx index 0f45def..b3c6176 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'; +} From 23d349625d81c026e7c82377c4efe9e26419a946 Mon Sep 17 00:00:00 2001 From: Prakash Maity Date: Wed, 7 Aug 2024 17:47:30 +0530 Subject: [PATCH 4/4] fix(reduxSetup) --- package.json | 2 ++ src/app/(auth)/log-in/page.tsx | 8 +++-- src/app/layout.tsx | 11 +++++-- src/services/api/loginApi.ts | 2 +- src/services/store/authSlice.ts | 57 +++++++++++++++++++++++++++++++++ src/services/store/index.tsx | 15 +++++++++ 6 files changed, 89 insertions(+), 6 deletions(-) create mode 100644 src/services/store/authSlice.ts create mode 100644 src/services/store/index.tsx diff --git a/package.json b/package.json index aa35261..eadb499 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "@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", @@ -23,6 +24,7 @@ "react": "^18", "react-dom": "^18", "react-hook-form": "^7.52.2", + "react-redux": "^9.1.2", "sass": "^1.77.8", "zod": "^3.23.8" }, diff --git a/src/app/(auth)/log-in/page.tsx b/src/app/(auth)/log-in/page.tsx index 5116a68..01b1cbf 100644 --- a/src/app/(auth)/log-in/page.tsx +++ b/src/app/(auth)/log-in/page.tsx @@ -16,6 +16,9 @@ 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(), @@ -29,14 +32,15 @@ export default function LoginPage() { const { register, handleSubmit, formState: { errors }, control } = useForm({ resolver: zodResolver(loginSchema), }); - console.log("errors", errors) 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) { 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/services/api/loginApi.ts b/src/services/api/loginApi.ts index 7837ff9..59bb92e 100644 --- a/src/services/api/loginApi.ts +++ b/src/services/api/loginApi.ts @@ -1,7 +1,7 @@ // api.ts import axiosInstance from '../axios/axiosInstance'; -interface LoginResponse { +export interface LoginResponse { id: number; username: string; email: string; 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;