diff --git a/package.json b/package.json index fe5a48f9..0daed06c 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/server/src/api/index.ts b/server/src/api/index.ts new file mode 100644 index 00000000..e1e4e5a4 --- /dev/null +++ b/server/src/api/index.ts @@ -0,0 +1,5 @@ +const genAPIKey = () => { + return [...Array(30)] + .map((e) => ((Math.random() * 36) | 0).toString(36)) + .join(''); +}; \ No newline at end of file diff --git a/server/src/db/config.ts b/server/src/db/config.ts index 9b2221f2..be1ee123 100644 --- a/server/src/db/config.ts +++ b/server/src/db/config.ts @@ -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, } diff --git a/server/src/models/User.ts b/server/src/models/User.ts index 67b944b9..d5a93a7a 100644 --- a/server/src/models/User.ts +++ b/server/src/models/User.ts @@ -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 implements User public id!: number; public email!: string; public password!: string; - - public async isValidPassword(password: string): Promise { - 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; - } - }, - }, } ); diff --git a/server/src/routes/auth.ts b/server/src/routes/auth.ts index e8d376e0..b1431c10 100644 --- a/server/src/routes/auth.ts +++ b/server/src/routes/auth.ts @@ -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}` }); } }); \ No newline at end of file diff --git a/server/src/server.ts b/server/src/server.ts index 849ef834..0d0512fc 100644 --- a/server/src/server.ts +++ b/server/src/server.ts @@ -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(); diff --git a/server/src/utils/auth.ts b/server/src/utils/auth.ts index 4cc45644..96e121f9 100644 --- a/server/src/utils/auth.ts +++ b/server/src/utils/auth.ts @@ -1,6 +1,6 @@ import bcrypt from "bcrypt"; -export const hashPassword = (password: any) => { +export const hashPassword = (password: string): Promise => { 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 => { return bcrypt.compare(password, hash) } \ No newline at end of file diff --git a/src/components/molecules/NavBar.tsx b/src/components/molecules/NavBar.tsx index 15508942..e42a615a 100644 --- a/src/components/molecules/NavBar.tsx +++ b/src/components/molecules/NavBar.tsx @@ -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 exit : null} + { + user !== null ? ( + + ) : "" + } diff --git a/src/context/auth.tsx b/src/context/auth.tsx index 1d410f0c..cb5c01df 100644 --- a/src/context/auth.tsx +++ b/src/context/auth.tsx @@ -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 ( - {children} + {children} ); }; -export { Context, Provider }; +export { AuthContext, AuthProvider }; diff --git a/src/pages/Login.tsx b/src/pages/Login.tsx new file mode 100644 index 00000000..53f3d01e --- /dev/null +++ b/src/pages/Login.tsx @@ -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 ( + + + Welcome Back! + + + + + + + + {/* Redirect to Register */} + + Don’t have an account?{' '} + + Register + + + + + ); +}; + +export default Login; diff --git a/src/pages/PageWrappper.tsx b/src/pages/PageWrappper.tsx index cf43defb..975db578 100644 --- a/src/pages/PageWrappper.tsx +++ b/src/pages/PageWrappper.tsx @@ -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 (
- - - - - } - /> - - - - } - /> - - - + + + + + + }> + } /> + + }> + + + + } /> + + } + /> + } + /> + + + + {isNotification() ? { + 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 ( + + + + Create an account + + + + + + + Already have an account?{' '} + + Login + + + + + + ); +}; + +export default Register; diff --git a/src/routes/userRoute.tsx b/src/routes/userRoute.tsx new file mode 100644 index 00000000..4e9bb7c2 --- /dev/null +++ b/src/routes/userRoute.tsx @@ -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 ? : ; +}; + +export default UserRoute;