Merge pull request #43 from amhsirak/develop

feat: auth ui
This commit is contained in:
Karishma Shukla
2024-09-25 21:20:46 +05:30
committed by GitHub
13 changed files with 367 additions and 61 deletions

View File

@@ -15,6 +15,7 @@
"@testing-library/react": "^13.1.1", "@testing-library/react": "^13.1.1",
"@testing-library/user-event": "^13.5.0", "@testing-library/user-event": "^13.5.0",
"@types/bcrypt": "^5.0.2", "@types/bcrypt": "^5.0.2",
"@types/body-parser": "^1.19.5",
"@types/csurf": "^1.11.5", "@types/csurf": "^1.11.5",
"@types/jest": "^27.4.1", "@types/jest": "^27.4.1",
"@types/jsonwebtoken": "^9.0.7", "@types/jsonwebtoken": "^9.0.7",
@@ -25,6 +26,7 @@
"@wbr-project/wbr-interpret": "^0.9.3-marketa.1", "@wbr-project/wbr-interpret": "^0.9.3-marketa.1",
"axios": "^0.26.0", "axios": "^0.26.0",
"bcrypt": "^5.1.1", "bcrypt": "^5.1.1",
"body-parser": "^1.20.3",
"buffer": "^6.0.3", "buffer": "^6.0.3",
"bullmq": "^5.12.15", "bullmq": "^5.12.15",
"cookie-parser": "^1.4.6", "cookie-parser": "^1.4.6",

5
server/src/api/index.ts Normal file
View File

@@ -0,0 +1,5 @@
const genAPIKey = () => {
return [...Array(30)]
.map((e) => ((Math.random() * 36) | 0).toString(36))
.join('');
};

View File

@@ -2,13 +2,10 @@ import { Sequelize } from 'sequelize';
import dotenv from 'dotenv'; import dotenv from 'dotenv';
dotenv.config(); dotenv.config();
const sequelize = new Sequelize( const sequelize = new Sequelize(
process.env.DB_NAME as string, `postgresql://${process.env.DB_USER}:${process.env.DB_PASSWORD}@${process.env.DB_HOST}:${process.env.DB_PORT}/${process.env.DB_NAME}`,
process.env.DB_USER as string,
process.env.DB_PASSWORD as string,
{ {
host: process.env.DB_HOST, host: 'localhost',
dialect: 'postgres', dialect: 'postgres',
logging: false, logging: false,
} }

View File

@@ -1,6 +1,5 @@
import { DataTypes, Model, Optional } from 'sequelize'; import { DataTypes, Model, Optional } from 'sequelize';
import sequelize from '../db/config'; import sequelize from '../db/config';
import { hashPassword, comparePassword } from '../utils/auth';
interface UserAttributes { interface UserAttributes {
id: number; id: number;
@@ -15,16 +14,12 @@ class User extends Model<UserAttributes, UserCreationAttributes> implements User
public id!: number; public id!: number;
public email!: string; public email!: string;
public password!: string; public password!: string;
public async isValidPassword(password: string): Promise<boolean> {
return comparePassword(password, this.password);
}
} }
User.init( User.init(
{ {
id: { id: {
type: DataTypes.INTEGER.UNSIGNED, type: DataTypes.INTEGER,
autoIncrement: true, autoIncrement: true,
primaryKey: true, primaryKey: true,
}, },
@@ -44,13 +39,6 @@ User.init(
{ {
sequelize, sequelize,
tableName: 'user', tableName: 'user',
hooks: {
beforeCreate: async (user: User) => {
if (user.password) {
user.password = await hashPassword(user.password) as string;
}
},
},
} }
); );

View File

@@ -1,6 +1,8 @@
import { Router, Request, Response } from 'express'; import { Router, Request, Response } from 'express';
import User from '../models/User'; import User from '../models/User';
import jwt from 'jsonwebtoken'; import jwt from 'jsonwebtoken';
import { hashPassword, comparePassword } from '../utils/auth';
import { requireSignIn } from '../middlewares/auth';
export const router = Router(); export const router = Router();
interface AuthenticatedRequest extends Request { interface AuthenticatedRequest extends Request {
@@ -17,7 +19,9 @@ router.post('/register', async (req, res) => {
let userExist = await User.findOne({ where: { email } }); let userExist = await User.findOne({ where: { email } });
if (userExist) return res.status(400).send('User already exists') if (userExist) return res.status(400).send('User already exists')
const user = await User.create({ email, password }); const hashedPassword = await hashPassword(password)
const user = await User.create({ email, password: hashedPassword });
const token = jwt.sign({ id: user.id }, process.env.JWT_SECRET as string, { expiresIn: '1h' }); const token = jwt.sign({ id: user.id }, process.env.JWT_SECRET as string, { expiresIn: '1h' });
user.password = undefined as unknown as string user.password = undefined as unknown as string
@@ -36,8 +40,10 @@ router.post('/login', async (req, res) => {
if (!email || !password) return res.status(400).send('Email and password are required') if (!email || !password) return res.status(400).send('Email and password are required')
if (password.length < 6) return res.status(400).send('Password must be at least 6 characters') if (password.length < 6) return res.status(400).send('Password must be at least 6 characters')
let user = await User.findOne({ where: { email } }); let user = await User.findOne({ raw: true, where: { email } });
const match = await user?.isValidPassword(password); if (!user) return res.status(400).send('User does not exist');
const match = await comparePassword(password, user.password)
if (!match) return res.status(400).send('Invalid email or password') if (!match) return res.status(400).send('Invalid email or password')
const token = jwt.sign({ id: user?.id }, process.env.JWT_SECRET as string, { expiresIn: '1h' }); const token = jwt.sign({ id: user?.id }, process.env.JWT_SECRET as string, { expiresIn: '1h' });
@@ -52,6 +58,7 @@ router.post('/login', async (req, res) => {
res.json(user) res.json(user)
} catch (error: any) { } catch (error: any) {
res.status(400).send(`Could not login user - ${error.message}`) res.status(400).send(`Could not login user - ${error.message}`)
console.log(`Could not login user - ${error}`)
} }
}) })
@@ -64,16 +71,21 @@ router.get('/logout', async (req, res) => {
} }
}) })
router.get('/current-user', async (req: AuthenticatedRequest, res) => { router.get('/current-user', requireSignIn, async (req: AuthenticatedRequest, res) => {
try { try {
if (!req.user) { if (!req.user) {
return res.status(401).send('Unauthorized'); return res.status(401).json({ ok: false, error: 'Unauthorized' });
} }
const user = await User.findByPk(req.user.id, { const user = await User.findByPk(req.user.id, {
attributes: { exclude: ['password'] }, attributes: { exclude: ['password'] },
}); });
return res.status(200).json({ ok: true }); if (!user) {
return res.status(404).json({ ok: false, error: 'User not found' });
} else {
return res.status(200).json({ ok: true, user: user });
}
} catch (error: any) { } catch (error: any) {
return res.status(500).send(`Could not fetch current user : ${error.message}.`); console.error('Error in current-user route:', error);
return res.status(500).json({ ok: false, error: `Could not fetch current user: ${error.message}` });
} }
}); });

View File

@@ -1,11 +1,13 @@
import express from 'express'; import express from 'express';
import http from 'http'; import http from 'http';
import cors from 'cors'; import cors from 'cors';
import 'dotenv/config'; import dotenv from 'dotenv';
dotenv.config();
import { record, workflow, storage, auth, integration } from './routes'; import { record, workflow, storage, auth, integration } from './routes';
import { BrowserPool } from "./browser-management/classes/BrowserPool"; import { BrowserPool } from "./browser-management/classes/BrowserPool";
import logger from './logger'; import logger from './logger';
import { connectDB, syncDB } from './db/config'; import { connectDB, syncDB } from './db/config';
import bodyParser from 'body-parser';
import cookieParser from 'cookie-parser'; import cookieParser from 'cookie-parser';
import csrf from 'csurf'; import csrf from 'csurf';
import { SERVER_PORT } from "./constants/config"; import { SERVER_PORT } from "./constants/config";
@@ -14,7 +16,10 @@ import { Server } from "socket.io";
const csrfProtection = csrf({ cookie: true }) const csrfProtection = csrf({ cookie: true })
const app = express(); const app = express();
app.use(cors()); app.use(cors({
origin: 'http://localhost:3000',
credentials: true,
}));
app.use(express.json()); app.use(express.json());
const server = http.createServer(app); const server = http.createServer(app);
@@ -30,6 +35,8 @@ export const io = new Server(server);
*/ */
export const browserPool = new BrowserPool(); export const browserPool = new BrowserPool();
app.use(bodyParser.json({ limit: '10mb' }))
app.use(bodyParser.urlencoded({ extended: true, limit: '10mb', parameterLimit: 10000 }));
// parse cookies - "cookie" is true in csrfProtection // parse cookies - "cookie" is true in csrfProtection
app.use(cookieParser()) app.use(cookieParser())
app.use(csrfProtection) app.use(csrfProtection)
@@ -41,12 +48,12 @@ app.use('/auth', auth);
app.use('/integration', integration); app.use('/integration', integration);
app.get('/', function (req, res) { app.get('/', function (req, res) {
return res.send('Maxun server started 🚀'); return res.send('Maxun server started 🚀');
}); });
app.get('/csrf-token', (req, res) => { app.get('/csrf-token', (req, res) => {
res.json({ csrfToken: req.csrfToken() }) res.json({ csrfToken: req.csrfToken() })
}) })
server.listen(SERVER_PORT, async () => { server.listen(SERVER_PORT, async () => {
await connectDB(); await connectDB();

View File

@@ -1,6 +1,6 @@
import bcrypt from "bcrypt"; import bcrypt from "bcrypt";
export const hashPassword = (password: any) => { export const hashPassword = (password: string): Promise<string> => {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
bcrypt.genSalt(12, (err, salt) => { bcrypt.genSalt(12, (err, salt) => {
if (err) { if (err) {
@@ -17,6 +17,6 @@ export const hashPassword = (password: any) => {
} }
// password from frontend and hash from database // password from frontend and hash from database
export const comparePassword = (password: any, hash: any) => { export const comparePassword = (password: string, hash: string): Promise<boolean> => {
return bcrypt.compare(password, hash) return bcrypt.compare(password, hash)
} }

View File

@@ -1,4 +1,5 @@
import React from 'react'; import React, { useState, useContext } from 'react';
import axios from 'axios';
import styled from "styled-components"; import styled from "styled-components";
import { stopRecording } from "../../api/recording"; import { stopRecording } from "../../api/recording";
import { useGlobalInfoStore } from "../../context/globalInfo"; import { useGlobalInfoStore } from "../../context/globalInfo";
@@ -7,7 +8,8 @@ import { RecordingIcon } from "../atoms/RecorderIcon";
import { SaveRecording } from "./SaveRecording"; import { SaveRecording } from "./SaveRecording";
import { Circle } from "@mui/icons-material"; import { Circle } from "@mui/icons-material";
import MeetingRoomIcon from '@mui/icons-material/MeetingRoom'; import MeetingRoomIcon from '@mui/icons-material/MeetingRoom';
import { useNavigate } from "react-router-dom"; import { Link, useLocation, useNavigate } from 'react-router-dom';
import { AuthContext } from '../../context/auth';
interface NavBarProps { interface NavBarProps {
newRecording: () => void; newRecording: () => void;
@@ -18,8 +20,18 @@ interface NavBarProps {
export const NavBar = ({ newRecording, recordingName, isRecording }: NavBarProps) => { export const NavBar = ({ newRecording, recordingName, isRecording }: NavBarProps) => {
const { notify, browserId, setBrowserId, recordingLength } = useGlobalInfoStore(); const { notify, browserId, setBrowserId, recordingLength } = useGlobalInfoStore();
const { state, dispatch } = useContext(AuthContext);
const { user } = state;
const navigate = useNavigate(); const navigate = useNavigate();
const logout = async () => {
dispatch({ type: 'LOGOUT' });
window.localStorage.removeItem('user');
const { data } = await axios.get('http://localhost:8080/auth/logout');
notify('success', data.message);
navigate('/login');
};
// If recording is in progress, the resources and change page view by setting browserId to null // If recording is in progress, the resources and change page view by setting browserId to null
// else it won't affect the page // else it won't affect the page
const goToMainMenu = async () => { const goToMainMenu = async () => {
@@ -94,6 +106,23 @@ export const NavBar = ({ newRecording, recordingName, isRecording }: NavBarProps
<MeetingRoomIcon sx={{ marginRight: '5px' }} /> <MeetingRoomIcon sx={{ marginRight: '5px' }} />
exit</Button> exit</Button>
: null} : null}
{
user !== null ? (
<Button sx={{
width: '100px',
background: '#fff',
color: 'rgba(25, 118, 210, 0.7)',
padding: '9px',
marginRight: '19px',
'&:hover': {
background: 'white',
color: 'rgb(25, 118, 210)',
}
}} onClick={logout}>
<MeetingRoomIcon sx={{ marginRight: '5px' }} />
logout</Button>
) : ""
}
</div> </div>
</NavBarWrapper> </NavBarWrapper>

View File

@@ -2,7 +2,7 @@ import { useReducer, createContext, useEffect } from 'react';
import axios from 'axios'; import axios from 'axios';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
interface ProviderProps { interface AuthProviderProps {
children: React.ReactNode; children: React.ReactNode;
} }
@@ -19,7 +19,7 @@ const initialState = {
user: null, user: null,
}; };
const Context = createContext<{ const AuthContext = createContext<{
state: InitialStateType; state: InitialStateType;
dispatch: any; dispatch: any;
}>({ }>({
@@ -44,10 +44,11 @@ const reducer = (state: InitialStateType, action: ActionType) => {
} }
}; };
const Provider = ({ children }: ProviderProps) => { const AuthProvider = ({ children }: AuthProviderProps) => {
const [state, dispatch] = useReducer(reducer, initialState); const [state, dispatch] = useReducer(reducer, initialState);
const navigate = useNavigate(); const navigate = useNavigate();
axios.defaults.withCredentials = true;
// get user info from local storage // get user info from local storage
useEffect(() => { useEffect(() => {
@@ -88,16 +89,24 @@ const Provider = ({ children }: ProviderProps) => {
// csrf - include tokens in the axios header every time a request is made // csrf - include tokens in the axios header every time a request is made
useEffect(() => { useEffect(() => {
const getCsrfToken = async () => { const getCsrfToken = async () => {
const { data } = await axios.get('http://localhost:8080/csrf-token'); try {
console.log('CSRFFFFF =>>>>', data); const { data } = await axios.get('http://localhost:8080/csrf-token');
(axios.defaults.headers as any)['X-CSRF-TOKEN'] = data.getCsrfToken; console.log('CSRF Token Response:', data);
if (data && data.csrfToken) {
(axios.defaults.headers as any)['X-CSRF-TOKEN'] = data.csrfToken;
} else {
console.error('CSRF token not found in the response');
}
} catch (error) {
console.error('Error fetching CSRF token:', error);
}
}; };
getCsrfToken(); getCsrfToken();
}, []); }, []);
return ( return (
<Context.Provider value={{ state, dispatch }}>{children}</Context.Provider> <AuthContext.Provider value={{ state, dispatch }}>{children}</AuthContext.Provider>
); );
}; };
export { Context, Provider }; export { AuthContext, AuthProvider };

119
src/pages/Login.tsx Normal file
View File

@@ -0,0 +1,119 @@
import axios from 'axios';
import { useState, useContext, useEffect } from 'react';
import { useNavigate, Link } from 'react-router-dom';
import { AuthContext } from '../context/auth';
import {
Box,
Typography,
TextField,
Button,
CircularProgress,
} from '@mui/material';
import { useGlobalInfoStore } from "../context/globalInfo";
const Login = () => {
const [form, setForm] = useState({
email: '',
password: '',
});
const [loading, setLoading] = useState(false);
const { notify } = useGlobalInfoStore();
const { email, password } = form;
const { state, dispatch } = useContext(AuthContext);
const { user } = state;
const navigate = useNavigate();
useEffect(() => {
if (user) {
navigate('/');
}
}, [user, navigate]);
const handleChange = (e: any) => {
const { name, value } = e.target;
setForm({ ...form, [name]: value });
};
const submitForm = async (e: any) => {
e.preventDefault();
setLoading(true);
try {
const { data } = await axios.post(`http://localhost:8080/auth/login`, { email, password });
dispatch({ type: 'LOGIN', payload: data });
notify('success', 'Welcome to Maxun!');
window.localStorage.setItem('user', JSON.stringify(data));
navigate('/');
} catch (err: any) {
notify('error', err.response.data || 'Login Failed. Please try again.');
setLoading(false);
}
};
return (
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
mt: 5,
}}
>
<Typography variant="h4" gutterBottom>
Welcome Back!
</Typography>
<Box component="form" onSubmit={submitForm} sx={{ maxWidth: 400, width: '100%' }}>
<TextField
fullWidth
label="Email"
name="email"
value={email}
onChange={handleChange}
margin="normal"
variant="outlined"
required
/>
<TextField
fullWidth
label="Password"
name="password"
type="password"
value={password}
onChange={handleChange}
margin="normal"
variant="outlined"
required
/>
<Button
type="submit"
fullWidth
variant="contained"
color="primary"
sx={{ mt: 2, mb: 2 }}
disabled={loading || !email || !password}
>
{loading ? (
<>
<CircularProgress size={20} sx={{ mr: 2 }} />
Loading
</>
) : (
'Login'
)}
</Button>
{/* Redirect to Register */}
<Typography variant="body2" align="center">
Dont have an account?{' '}
<Link to="/register" style={{ textDecoration: 'none', color: '#1976d2' }}>
Register
</Link>
</Typography>
</Box>
</Box>
);
};
export default Login;

View File

@@ -2,11 +2,15 @@ import React, { useEffect, useState } from 'react';
import { NavBar } from "../components/molecules/NavBar"; import { NavBar } from "../components/molecules/NavBar";
import { SocketProvider } from "../context/socket"; import { SocketProvider } from "../context/socket";
import { BrowserDimensionsProvider } from "../context/browserDimensions"; import { BrowserDimensionsProvider } from "../context/browserDimensions";
import { AuthProvider } from '../context/auth';
import { RecordingPage } from "./RecordingPage"; import { RecordingPage } from "./RecordingPage";
import { MainPage } from "./MainPage"; import { MainPage } from "./MainPage";
import { useGlobalInfoStore } from "../context/globalInfo"; import { useGlobalInfoStore } from "../context/globalInfo";
import { getActiveBrowserId } from "../api/recording"; import { getActiveBrowserId } from "../api/recording";
import { AlertSnackbar } from "../components/atoms/AlertSnackbar"; import { AlertSnackbar } from "../components/atoms/AlertSnackbar";
import Login from './Login';
import Register from './Register';
import UserRoute from '../routes/userRoute';
import { Routes, Route, useNavigate } from 'react-router-dom'; import { Routes, Route, useNavigate } from 'react-router-dom';
export const PageWrapper = () => { export const PageWrapper = () => {
@@ -50,25 +54,33 @@ export const PageWrapper = () => {
return ( return (
<div> <div>
<SocketProvider> <AuthProvider>
<React.Fragment> <SocketProvider>
<NavBar newRecording={handleNewRecording} recordingName={recordingName} isRecording={!!browserId} /> <React.Fragment>
<Routes> <NavBar newRecording={handleNewRecording} recordingName={recordingName} isRecording={!!browserId} />
<Route <Routes>
path="/" <Route element={<UserRoute />}>
element={<MainPage handleEditRecording={handleEditRecording} />} <Route path="/" element={<MainPage handleEditRecording={handleEditRecording} />} />
/> </Route>
<Route <Route element={<UserRoute />}>
path="/recording" <Route path="/recording" element={
element={ <BrowserDimensionsProvider>
<BrowserDimensionsProvider> <RecordingPage recordingName={recordingName} />
<RecordingPage recordingName={recordingName} /> </BrowserDimensionsProvider>
</BrowserDimensionsProvider> } />
} </Route>
/> <Route
</Routes> path="/login"
</React.Fragment> element={<Login />}
</SocketProvider> />
<Route
path="/register"
element={<Register />}
/>
</Routes>
</React.Fragment>
</SocketProvider>
</AuthProvider>
{isNotification() ? {isNotification() ?
<AlertSnackbar severity={notification.severity} <AlertSnackbar severity={notification.severity}
message={notification.message} message={notification.message}

114
src/pages/Register.tsx Normal file
View File

@@ -0,0 +1,114 @@
import { useState, useContext, useEffect } from 'react';
import { useNavigate, Link } from 'react-router-dom';
import axios from 'axios';
import { AuthContext } from '../context/auth';
import { TextField, Button, CircularProgress, Typography, Box, Container } from '@mui/material';
import { useGlobalInfoStore } from "../context/globalInfo";
const Register = () => {
const [form, setForm] = useState({
email: '',
password: '',
});
const [loading, setLoading] = useState(false);
const { notify } = useGlobalInfoStore();
const { email, password } = form;
const { state, dispatch } = useContext(AuthContext);
const { user } = state;
const navigate = useNavigate();
useEffect(() => {
if (user !== null) navigate('/');
}, [user, navigate]);
const handleChange = (e: any) => {
const { name, value } = e.target;
setForm({ ...form, [name]: value });
};
const submitForm = async (e: any) => {
e.preventDefault();
setLoading(true);
try {
const { data } = await axios.post('http://localhost:8080/auth/register', {
email,
password,
});
dispatch({
type: 'LOGIN',
payload: data,
});
notify('success', 'Welcome to Maxun!');
window.localStorage.setItem('user', JSON.stringify(data));
navigate('/');
} catch (err: any) {
notify('error', err.response.data || 'Registration Failed. Please try again.');
} finally {
setLoading(false);
}
};
return (
<Container maxWidth="sm" sx={{ mt: 8 }}>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
}}
>
<Typography component="h1" variant="h5">
Create an account
</Typography>
<Box component="form" onSubmit={submitForm} sx={{ mt: 1 }}>
<TextField
margin="normal"
required
fullWidth
id="email"
label="Email Address"
name="email"
value={email}
onChange={handleChange}
autoComplete="email"
/>
<TextField
margin="normal"
required
fullWidth
name="password"
label="Password"
type="password"
id="password"
value={password}
onChange={handleChange}
autoComplete="current-password"
/>
<Button
type="submit"
fullWidth
variant="contained"
color="primary"
sx={{ mt: 3, mb: 2 }}
disabled={loading || !email || !password}
>
{loading ? (
<CircularProgress size={24} sx={{ color: '#fff' }} />
) : (
'Register'
)}
</Button>
<Typography variant="body2" align="center">
Already have an account?{' '}
<Link to="/login" style={{ textDecoration: 'none', color: '#1976d2' }}>
Login
</Link>
</Typography>
</Box>
</Box>
</Container>
);
};
export default Register;

12
src/routes/userRoute.tsx Normal file
View File

@@ -0,0 +1,12 @@
import React from 'react';
import { Navigate, Outlet } from 'react-router-dom';
import { useContext } from 'react';
import { AuthContext } from '../context/auth';
const UserRoute = () => {
const { state } = useContext(AuthContext);
return state.user ? <Outlet /> : <Navigate to="/login" />;
};
export default UserRoute;