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/user-event": "^13.5.0",
"@types/bcrypt": "^5.0.2",
"@types/body-parser": "^1.19.5",
"@types/csurf": "^1.11.5",
"@types/jest": "^27.4.1",
"@types/jsonwebtoken": "^9.0.7",
@@ -25,6 +26,7 @@
"@wbr-project/wbr-interpret": "^0.9.3-marketa.1",
"axios": "^0.26.0",
"bcrypt": "^5.1.1",
"body-parser": "^1.20.3",
"buffer": "^6.0.3",
"bullmq": "^5.12.15",
"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';
dotenv.config();
const sequelize = new Sequelize(
process.env.DB_NAME as string,
process.env.DB_USER as string,
process.env.DB_PASSWORD as string,
`postgresql://${process.env.DB_USER}:${process.env.DB_PASSWORD}@${process.env.DB_HOST}:${process.env.DB_PORT}/${process.env.DB_NAME}`,
{
host: process.env.DB_HOST,
host: 'localhost',
dialect: 'postgres',
logging: false,
}

View File

@@ -1,6 +1,5 @@
import { DataTypes, Model, Optional } from 'sequelize';
import sequelize from '../db/config';
import { hashPassword, comparePassword } from '../utils/auth';
interface UserAttributes {
id: number;
@@ -15,16 +14,12 @@ class User extends Model<UserAttributes, UserCreationAttributes> implements User
public id!: number;
public email!: string;
public password!: string;
public async isValidPassword(password: string): Promise<boolean> {
return comparePassword(password, this.password);
}
}
User.init(
{
id: {
type: DataTypes.INTEGER.UNSIGNED,
type: DataTypes.INTEGER,
autoIncrement: true,
primaryKey: true,
},
@@ -44,13 +39,6 @@ User.init(
{
sequelize,
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 User from '../models/User';
import jwt from 'jsonwebtoken';
import { hashPassword, comparePassword } from '../utils/auth';
import { requireSignIn } from '../middlewares/auth';
export const router = Router();
interface AuthenticatedRequest extends Request {
@@ -17,7 +19,9 @@ router.post('/register', async (req, res) => {
let userExist = await User.findOne({ where: { email } });
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' });
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 (password.length < 6) return res.status(400).send('Password must be at least 6 characters')
let user = await User.findOne({ where: { email } });
const match = await user?.isValidPassword(password);
let user = await User.findOne({ raw: true, where: { email } });
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')
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)
} catch (error: any) {
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 {
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, {
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) {
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 http from 'http';
import cors from 'cors';
import 'dotenv/config';
import dotenv from 'dotenv';
dotenv.config();
import { record, workflow, storage, auth, integration } from './routes';
import { BrowserPool } from "./browser-management/classes/BrowserPool";
import logger from './logger';
import { connectDB, syncDB } from './db/config';
import bodyParser from 'body-parser';
import cookieParser from 'cookie-parser';
import csrf from 'csurf';
import { SERVER_PORT } from "./constants/config";
@@ -14,7 +16,10 @@ import { Server } from "socket.io";
const csrfProtection = csrf({ cookie: true })
const app = express();
app.use(cors());
app.use(cors({
origin: 'http://localhost:3000',
credentials: true,
}));
app.use(express.json());
const server = http.createServer(app);
@@ -30,6 +35,8 @@ export const io = new Server(server);
*/
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
app.use(cookieParser())
app.use(csrfProtection)
@@ -41,12 +48,12 @@ app.use('/auth', auth);
app.use('/integration', integration);
app.get('/', function (req, res) {
return res.send('Maxun server started 🚀');
return res.send('Maxun server started 🚀');
});
app.get('/csrf-token', (req, res) => {
res.json({ csrfToken: req.csrfToken() })
})
res.json({ csrfToken: req.csrfToken() })
})
server.listen(SERVER_PORT, async () => {
await connectDB();

View File

@@ -1,6 +1,6 @@
import bcrypt from "bcrypt";
export const hashPassword = (password: any) => {
export const hashPassword = (password: string): Promise<string> => {
return new Promise((resolve, reject) => {
bcrypt.genSalt(12, (err, salt) => {
if (err) {
@@ -17,6 +17,6 @@ export const hashPassword = (password: any) => {
}
// 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)
}

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 { stopRecording } from "../../api/recording";
import { useGlobalInfoStore } from "../../context/globalInfo";
@@ -7,7 +8,8 @@ import { RecordingIcon } from "../atoms/RecorderIcon";
import { SaveRecording } from "./SaveRecording";
import { Circle } from "@mui/icons-material";
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 {
newRecording: () => void;
@@ -18,8 +20,18 @@ interface NavBarProps {
export const NavBar = ({ newRecording, recordingName, isRecording }: NavBarProps) => {
const { notify, browserId, setBrowserId, recordingLength } = useGlobalInfoStore();
const { state, dispatch } = useContext(AuthContext);
const { user } = state;
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
// else it won't affect the page
const goToMainMenu = async () => {
@@ -94,6 +106,23 @@ export const NavBar = ({ newRecording, recordingName, isRecording }: NavBarProps
<MeetingRoomIcon sx={{ marginRight: '5px' }} />
exit</Button>
: 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>
</NavBarWrapper>

View File

@@ -2,7 +2,7 @@ import { useReducer, createContext, useEffect } from 'react';
import axios from 'axios';
import { useNavigate } from 'react-router-dom';
interface ProviderProps {
interface AuthProviderProps {
children: React.ReactNode;
}
@@ -19,7 +19,7 @@ const initialState = {
user: null,
};
const Context = createContext<{
const AuthContext = createContext<{
state: InitialStateType;
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 navigate = useNavigate();
axios.defaults.withCredentials = true;
// get user info from local storage
useEffect(() => {
@@ -88,16 +89,24 @@ const Provider = ({ children }: ProviderProps) => {
// csrf - include tokens in the axios header every time a request is made
useEffect(() => {
const getCsrfToken = async () => {
const { data } = await axios.get('http://localhost:8080/csrf-token');
console.log('CSRFFFFF =>>>>', data);
(axios.defaults.headers as any)['X-CSRF-TOKEN'] = data.getCsrfToken;
try {
const { data } = await axios.get('http://localhost:8080/csrf-token');
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();
}, []);
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 { SocketProvider } from "../context/socket";
import { BrowserDimensionsProvider } from "../context/browserDimensions";
import { AuthProvider } from '../context/auth';
import { RecordingPage } from "./RecordingPage";
import { MainPage } from "./MainPage";
import { useGlobalInfoStore } from "../context/globalInfo";
import { getActiveBrowserId } from "../api/recording";
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';
export const PageWrapper = () => {
@@ -50,25 +54,33 @@ export const PageWrapper = () => {
return (
<div>
<SocketProvider>
<React.Fragment>
<NavBar newRecording={handleNewRecording} recordingName={recordingName} isRecording={!!browserId} />
<Routes>
<Route
path="/"
element={<MainPage handleEditRecording={handleEditRecording} />}
/>
<Route
path="/recording"
element={
<BrowserDimensionsProvider>
<RecordingPage recordingName={recordingName} />
</BrowserDimensionsProvider>
}
/>
</Routes>
</React.Fragment>
</SocketProvider>
<AuthProvider>
<SocketProvider>
<React.Fragment>
<NavBar newRecording={handleNewRecording} recordingName={recordingName} isRecording={!!browserId} />
<Routes>
<Route element={<UserRoute />}>
<Route path="/" element={<MainPage handleEditRecording={handleEditRecording} />} />
</Route>
<Route element={<UserRoute />}>
<Route path="/recording" element={
<BrowserDimensionsProvider>
<RecordingPage recordingName={recordingName} />
</BrowserDimensionsProvider>
} />
</Route>
<Route
path="/login"
element={<Login />}
/>
<Route
path="/register"
element={<Register />}
/>
</Routes>
</React.Fragment>
</SocketProvider>
</AuthProvider>
{isNotification() ?
<AlertSnackbar severity={notification.severity}
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;