This commit is contained in:
Amit Roy 2024-08-07 17:50:58 +05:30
commit b8523bea88
13 changed files with 365 additions and 24 deletions

View File

@ -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"
}
}

View File

@ -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<typeof loginSchema>;
export default function LoginPage() {
const router = useRouter()
const { register, handleSubmit, formState: { errors }, control } = useForm<LoginFormValues>({
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 (
<main className={styles.loginPage}>
<Grid container sx={{ height: "100%" }}>
@ -40,7 +79,7 @@ export default function LoginPage() {
paddingBlock={5}
>
<img src={UTILITY_CONSTANT.IMAGES.AUTH.HEADER_LOG} alt="logo" />
<Box width={"100%"}>
<form onSubmit={handleSubmit(onSubmit)}>
<Typography variant="h4">Welcome to Convexsol</Typography>
<Typography variant="h4">Sign in to your account</Typography>
<Box
@ -51,17 +90,26 @@ export default function LoginPage() {
flexDirection={"column"}
gap={3}
>
<CustomizedInputsStyled label="User Name" type="text" />
<CustomizedInputsStyled
<CustomTextField
name="username"
control={control}
label="Username"
error={!!errors.username}
helperText={errors.username?.message}
/>
<CustomTextField
name="password"
control={control}
label="Password"
type="password"
endAdornmentBoolean
error={!!errors.password}
helperText={errors.password?.message}
/>
</Box>
<Box width={"100%"} marginBlockStart={"20px"}>
<CustomizedButtons label={"Sign In"} />
</Box>
<CustomizedButtons btnType="submit" label={"Sign In"} />
</Box>
</form>
<Box
width={"100%"}
display={"flex"}

View File

@ -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 <DashboardWrapper>{children}</DashboardWrapper>;
}
export default AuthGuard(RootLayout);

View File

@ -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 (
<html lang="en">
<body>
<Provider store={store}>
<AppRouterCacheProvider>
<ThemeProvider theme={theme}>{children}</ThemeProvider>
</AppRouterCacheProvider>
</Provider>
</body>
</html>
);

View File

@ -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 (
<Box
sx={{ display: "flex" }}
@ -176,15 +181,8 @@ export default function DashboardWrapper(props: Props) {
alt="Remy Sharp"
src="/static/images/avatar/1.jpg"
/>
{/* <GEAR
sx={{
color: "var(--primary_Active_text)",
fontSize: "1.8rem",
cursor: "pointer",
}}
/> */}
<IconButton aria-label="log out" size="medium" color="error">
<LogoutIcon fontSize="small" />
<LogoutIcon fontSize="small" onClick={logoutHandler} />
</IconButton>
</Box>
</Box>

View File

@ -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 <WrappedComponent {...props} />;
};
// 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';
}

View File

@ -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<LoginResponse> => {
const response = await axiosInstance.post<LoginResponse>('/auth/login', data);
return response.data;
};

View File

@ -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;

View File

@ -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<LoginResponse>) => {
state = action.payload;
},
},
});
export const { setAuthTokens, clearAuthTokens, setUserDetails } =
authSlice.actions;
export default authSlice.reducer;

View File

@ -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<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
export default store;

View File

@ -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<CustomTextFieldProps> = ({ name, control, label, type = 'text', error, helperText }) => {
return (
<Controller
name={name}
control={control}
render={({ field }) => (
<CssTextField
{...field}
label={label}
type={type}
error={error}
helperText={helperText}
variant="outlined"
fullWidth
/>
)}
/>
);
};
export default CustomTextField;

View File

@ -47,6 +47,7 @@ export default function CustomizedInputsStyled({
type={type}
id="custom-css-outlined-input"
InputProps={{ endAdornment: endAdornmentBoolean && <VisibilityOff /> }}
/>
</>
);

View File

@ -16,12 +16,14 @@ const ColorButton = styled(Button)<ButtonProps>(() => ({
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 (
<>
<ColorButton variant="contained">
<ColorButton type={btnType} onClick={onPress} variant="contained">
{statIcon && statIcon}
{label}
</ColorButton>