diff --git a/client/src/App.tsx b/client/src/App.tsx index 5fab494..5900ee6 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -1,25 +1,33 @@ -import { BrowserRouter, Switch, Route } from 'react-router-dom'; +import { Fragment, useContext, useEffect } from 'react'; +import { Switch, Route } from 'react-router-dom'; import { Navbar } from './components/Navigation/Navbar'; import { Editor, Home, Snippet, Snippets, Auth } from './containers'; -import { AuthContextProvider, SnippetsContextProvider } from './store'; import { ProtectedRoute } from './utils'; +import { AuthContext } from './store'; export const App = () => { + const { autoLogin } = useContext(AuthContext); + + useEffect(() => { + // autoLogin(); + // const checker = setInterval(() => { + // autoLogin(); + // console.log('cake'); + // }, 1000); + // return () => window.clearInterval(checker); + }, []); + return ( - - - - - - - - - - - - - - - + + + + + + + + + + + ); }; diff --git a/client/src/components/Auth/AuthForm.tsx b/client/src/components/Auth/AuthForm.tsx index cb4aec1..8059d15 100644 --- a/client/src/components/Auth/AuthForm.tsx +++ b/client/src/components/Auth/AuthForm.tsx @@ -5,7 +5,7 @@ import { Button } from '../UI'; export const AuthForm = (): JSX.Element => { const [isInLogin, setIsInLogin] = useState(true); const [formData, setFormData] = useState({ email: '', password: '' }); - const { login } = useContext(AuthContext); + const { login, register } = useContext(AuthContext); const inputHandler = (e: ChangeEvent) => { const { name, value } = e.target; @@ -21,7 +21,7 @@ export const AuthForm = (): JSX.Element => { if (isInLogin) { login(formData); } else { - // register + register(formData); } }; diff --git a/client/src/containers/Auth.tsx b/client/src/containers/Auth.tsx index 83f3dd5..6646da9 100644 --- a/client/src/containers/Auth.tsx +++ b/client/src/containers/Auth.tsx @@ -4,7 +4,7 @@ import { Card, Layout } from '../components/UI'; export const Auth = (): JSX.Element => { return ( -
+
diff --git a/client/src/index.tsx b/client/src/index.tsx index 8458eb3..8e97138 100644 --- a/client/src/index.tsx +++ b/client/src/index.tsx @@ -1,11 +1,20 @@ import React from 'react'; import ReactDOM from 'react-dom'; import './styles/style.scss'; + +import { BrowserRouter } from 'react-router-dom'; +import { AuthContextProvider, SnippetsContextProvider } from './store'; import { App } from './App'; ReactDOM.render( - + + + + + + + , document.getElementById('root') ); diff --git a/client/src/store/AuthContext.tsx b/client/src/store/AuthContext.tsx deleted file mode 100644 index 04ca979..0000000 --- a/client/src/store/AuthContext.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import axios from 'axios'; -import { createContext, ReactNode } from 'react'; - -import { AuthContext as Context, Response } from '../typescript/interfaces'; -import { errorHandler } from '../utils'; - -export const AuthContext = createContext({ - isAuthenticated: false, - login: () => {} -}); - -interface Props { - children: ReactNode; -} - -export const AuthContextProvider = (props: Props): JSX.Element => { - const login = async (formData: { email: string; password: string }) => { - try { - const res = await axios.post>( - '/api/auth/login', - formData - ); - - localStorage.setItem('token', res.data.data.token); - - // get profile - // redirect to snippets? / home? - } catch (err) { - errorHandler(err); - } - }; - - const context: Context = { - isAuthenticated: false, - login - }; - - return ( - - {props.children} - - ); -}; diff --git a/client/src/store/auth/actions/index.ts b/client/src/store/auth/actions/index.ts new file mode 100644 index 0000000..f3d5fd7 --- /dev/null +++ b/client/src/store/auth/actions/index.ts @@ -0,0 +1,2 @@ +export * from './registerUser'; +export * from './loginUser'; diff --git a/client/src/store/auth/actions/loginUser.ts b/client/src/store/auth/actions/loginUser.ts new file mode 100644 index 0000000..2bd10c1 --- /dev/null +++ b/client/src/store/auth/actions/loginUser.ts @@ -0,0 +1,39 @@ +import { User, Response, UserWithRole } from '../../../typescript/interfaces'; +import { errorHandler } from '../../../utils'; +import axios from 'axios'; +import React from 'react'; + +interface Params { + formData: { + email: string; + password: string; + }; + setIsAuthenticated: (v: React.SetStateAction) => void; + setUser: (v: React.SetStateAction) => void; +} + +export const loginUser = async (params: Params) => { + const { formData, setIsAuthenticated, setUser } = params; + + try { + const res = await axios.post>( + '/api/auth/login', + formData + ); + + const { token: resToken, user: resUser } = res.data.data; + + setUser({ + ...resUser, + isAdmin: resUser.role === 'admin' + }); + + localStorage.setItem('token', resToken); + + setIsAuthenticated(true); + + // redirect to snippets? / home? + } catch (err) { + errorHandler(err); + } +}; diff --git a/client/src/store/auth/actions/registerUser.ts b/client/src/store/auth/actions/registerUser.ts new file mode 100644 index 0000000..deedbc9 --- /dev/null +++ b/client/src/store/auth/actions/registerUser.ts @@ -0,0 +1,37 @@ +import { User, Response, UserWithRole } from '../../../typescript/interfaces'; +import { errorHandler } from '../../../utils'; +import axios from 'axios'; +import React from 'react'; + +interface Params { + formData: { + email: string; + password: string; + }; + setIsAuthenticated: (v: React.SetStateAction) => void; + setUser: (v: React.SetStateAction) => void; +} + +export const registerUser = async (params: Params) => { + const { formData, setIsAuthenticated, setUser } = params; + + try { + const res = await axios.post>( + '/api/auth/register', + formData + ); + + const { token: resToken, user: resUser } = res.data.data; + + setUser({ + ...resUser, + isAdmin: resUser.role === 'admin' + }); + + localStorage.setItem('token', resToken); + + setIsAuthenticated(true); + } catch (err) { + errorHandler(err); + } +}; diff --git a/client/src/store/auth/index.tsx b/client/src/store/auth/index.tsx new file mode 100644 index 0000000..38f1876 --- /dev/null +++ b/client/src/store/auth/index.tsx @@ -0,0 +1,71 @@ +import { createContext, ReactNode, useState } from 'react'; +import axios from 'axios'; + +import { loginUser, registerUser } from './actions'; + +import { + AuthContext as Context, + Response, + User, + UserWithRole +} from '../../typescript/interfaces'; +import { errorHandler } from '../../utils'; + +export const AuthContext = createContext({ + isAuthenticated: false, + user: null, + autoLogin: () => {}, + login: () => {}, + register: () => {} +}); + +interface Props { + children: ReactNode; +} + +export const AuthContextProvider = (props: Props): JSX.Element => { + const [user, setUser] = useState(null); + const [isAuthenticated, setIsAuthenticated] = useState(false); + + const autoLogin = async () => { + if (localStorage.token) { + await getProfile(localStorage.token); + } + }; + + const login = async (formData: { email: string; password: string }) => { + await loginUser({ formData, setIsAuthenticated, setUser }); + }; + + const register = async (formData: { email: string; password: string }) => { + await registerUser({ formData, setIsAuthenticated, setUser }); + }; + + const getProfile = async (token: string) => { + try { + const res = await axios.get>('/api/auth/me', { + headers: { + Authorization: `Bearer ${token}` + } + }); + + console.log(res.data.data); + } catch (err) { + errorHandler(err); + } + }; + + const context: Context = { + isAuthenticated, + user, + autoLogin, + login, + register + }; + + return ( + + {props.children} + + ); +}; diff --git a/client/src/store/index.ts b/client/src/store/index.ts index efc489c..8442e7b 100644 --- a/client/src/store/index.ts +++ b/client/src/store/index.ts @@ -1,2 +1,2 @@ export * from './SnippetsContext'; -export * from './AuthContext'; +export * from './auth'; diff --git a/client/src/typescript/interfaces/Context.ts b/client/src/typescript/interfaces/Context.ts index 531ddb7..dac9dff 100644 --- a/client/src/typescript/interfaces/Context.ts +++ b/client/src/typescript/interfaces/Context.ts @@ -1,4 +1,4 @@ -import { TagCount, NewSnippet, Snippet, SearchQuery } from '.'; +import { TagCount, NewSnippet, Snippet, SearchQuery, UserWithRole } from '.'; export interface SnippetsContext { snippets: Snippet[]; @@ -18,5 +18,8 @@ export interface SnippetsContext { export interface AuthContext { isAuthenticated: boolean; + user: UserWithRole | null; + autoLogin: () => void; login: (formData: { email: string; password: string }) => void; + register: (formData: { email: string; password: string }) => void; } diff --git a/client/src/typescript/interfaces/User.ts b/client/src/typescript/interfaces/User.ts index b6a1093..0c88b00 100644 --- a/client/src/typescript/interfaces/User.ts +++ b/client/src/typescript/interfaces/User.ts @@ -2,4 +2,9 @@ import { Model } from '.'; export interface User extends Model { email: string; + role: string; +} + +export interface UserWithRole extends Model { + isAdmin: boolean; } diff --git a/src/controllers/auth/createUser.ts b/src/controllers/auth/createUser.ts new file mode 100644 index 0000000..27097dc --- /dev/null +++ b/src/controllers/auth/createUser.ts @@ -0,0 +1,51 @@ +import { Request, Response, NextFunction } from 'express'; +import { asyncWrapper } from '../../middleware'; +import { UserInstance, UserModel } from '../../models'; +import { hashPassword, signToken } from '../../utils'; + +interface RequestBody { + email: string; + password: string; +} + +interface ResponseBody { + data: { + user: Omit; + token: string; + }; +} + +/** + * @description Create new user + * @route /api/auth + * @request POST + * @access Public + */ +export const createUser = asyncWrapper( + async ( + req: Request<{}, {}, RequestBody>, + res: Response, + next: NextFunction + ): Promise => { + const password = await hashPassword(req.body.password); + + await UserModel.create({ + ...req.body, + password + }); + + const user = (await UserModel.findOne({ + where: { email: req.body.email }, + attributes: { exclude: ['password'] } + })) as UserInstance; + + const token = signToken({ email: req.body.email }); + + res.status(201).json({ + data: { + user, + token + } + }); + } +); diff --git a/src/controllers/auth/index.ts b/src/controllers/auth/index.ts new file mode 100644 index 0000000..42b1066 --- /dev/null +++ b/src/controllers/auth/index.ts @@ -0,0 +1,3 @@ +export * from './createUser'; +export * from './loginUser'; +export * from './getProfile'; diff --git a/src/controllers/auth/loginUser.ts b/src/controllers/auth/loginUser.ts new file mode 100644 index 0000000..6003a42 --- /dev/null +++ b/src/controllers/auth/loginUser.ts @@ -0,0 +1,60 @@ +import { Request, Response, NextFunction } from 'express'; +import { asyncWrapper } from '../../middleware'; +import { UserInstance, UserModel } from '../../models'; +import { ErrorResponse, signToken } from '../../utils'; +import { compare } from 'bcrypt'; + +interface RequestBody { + email: string; + password: string; +} + +interface ResponseBody { + data: { + user: Omit; + token: string; + }; +} + +/** + * @description User login + * @route /api/auth/login + * @request POST + * @access Public + */ +export const loginUser = asyncWrapper( + async ( + req: Request<{}, {}, RequestBody>, + res: Response, + next: NextFunction + ): Promise => { + const { email, password } = req.body; + + // Find user + let user = await UserModel.findOne({ where: { email }, raw: true }); + + if (user) { + // Check password + const passwordMatch = await compare(password, user.password); + + if (passwordMatch) { + // Sign token + const token = await signToken({ email }); + + user = (await UserModel.findOne({ + where: { email }, + attributes: { exclude: ['password'] }, + raw: true + })) as UserInstance; + + res.status(200).json({ + data: { token, user } + }); + } else { + return next(new ErrorResponse(400, 'Invalid credentials')); + } + } else { + return next(new ErrorResponse(400, 'Invalid credentials')); + } + } +); diff --git a/src/middleware/asyncWrapper.ts b/src/middleware/asyncWrapper.ts index ae23ab6..63be0ff 100644 --- a/src/middleware/asyncWrapper.ts +++ b/src/middleware/asyncWrapper.ts @@ -1,6 +1,6 @@ import { Request, Response, NextFunction } from 'express'; -type Foo = (req: Request, res: Response, next: NextFunction) => Promise; +type Foo = (req: any, res: Response, next: NextFunction) => Promise; export const asyncWrapper = (foo: Foo) => (req: Request, res: Response, next: NextFunction) => { diff --git a/src/middleware/authenticate.ts b/src/middleware/authenticate.ts new file mode 100644 index 0000000..4201d9c --- /dev/null +++ b/src/middleware/authenticate.ts @@ -0,0 +1,46 @@ +import { NextFunction, Request, Response } from 'express'; +import { asyncWrapper } from '.'; +import { ErrorResponse } from '../utils'; +import { verify } from 'jsonwebtoken'; +import { Token, UserInfoRequest } from '../typescript/interfaces'; +import { UserModel } from '../models'; + +export const authenticate = asyncWrapper( + async (req: UserInfoRequest, res: Response, next: NextFunction) => { + let token: string | null = null; + + // Check if token was provided + if (req.headers.authorization) { + if (req.headers.authorization.startsWith('Bearer ')) { + token = req.headers.authorization.split(' ')[1]; + } + } + + if (token) { + const secret = process.env.JWT_SECRET || 'secret'; + + // Decode token and extract data + const decoded = verify(token, secret) as Token; + + // Find user + const user = await UserModel.findOne({ + where: { email: decoded.email }, + attributes: { exclude: ['password'] }, + raw: true + }); + + if (user) { + req.user = { + ...user, + isAdmin: user.role == 'admin' + }; + + next(); + } else { + return next(new ErrorResponse(401, 'Not authorized')); + } + } else { + return next(new ErrorResponse(401, 'Not authorized')); + } + } +); diff --git a/src/middleware/index.ts b/src/middleware/index.ts index ebcbe86..c116d25 100644 --- a/src/middleware/index.ts +++ b/src/middleware/index.ts @@ -1,3 +1,4 @@ export * from './asyncWrapper'; export * from './requireBody'; export * from './errorHandler'; +export * from './authenticate'; diff --git a/src/routes/auth.ts b/src/routes/auth.ts new file mode 100644 index 0000000..21ea84e --- /dev/null +++ b/src/routes/auth.ts @@ -0,0 +1,13 @@ +import { Router } from 'express'; +import { createUser, getProfile, loginUser } from '../controllers/auth'; +import { authenticate, requireBody } from '../middleware'; + +export const authRouter = Router(); + +authRouter + .route('/register') + .post(requireBody('email', 'password'), createUser); + +authRouter.route('/login').post(requireBody('email', 'password'), loginUser); + +authRouter.route('/me').get(authenticate, getProfile); diff --git a/src/routes/index.ts b/src/routes/index.ts new file mode 100644 index 0000000..4829c7c --- /dev/null +++ b/src/routes/index.ts @@ -0,0 +1,2 @@ +export * from './snippets'; +export * from './auth'; diff --git a/src/server.ts b/src/server.ts index dfc64ea..e764b00 100644 --- a/src/server.ts +++ b/src/server.ts @@ -6,7 +6,7 @@ import { connectDB } from './db'; import { errorHandler } from './middleware'; // Routers -import { snippetRouter } from './routes/snippets'; +import { snippetRouter, authRouter } from './routes'; import { associateModels } from './db/associateModels'; // Env config @@ -27,6 +27,7 @@ app.get(/^\/(?!api)/, (req: Request, res: Response) => { // Routes app.use('/api/snippets', snippetRouter); +app.use('/api/auth', authRouter); // Error handler app.use(errorHandler); diff --git a/src/typescript/interfaces/Request.ts b/src/typescript/interfaces/Request.ts new file mode 100644 index 0000000..374513f --- /dev/null +++ b/src/typescript/interfaces/Request.ts @@ -0,0 +1,10 @@ +import { Request } from 'express'; + +export interface UserInfoRequest extends Request<{}, {}, body> { + user: { + id: number; + email: string; + role: string; + isAdmin: boolean; + }; +} diff --git a/src/typescript/interfaces/Token.ts b/src/typescript/interfaces/Token.ts new file mode 100644 index 0000000..fac1aed --- /dev/null +++ b/src/typescript/interfaces/Token.ts @@ -0,0 +1,3 @@ +export interface Token { + email: string; +} diff --git a/src/typescript/interfaces/index.ts b/src/typescript/interfaces/index.ts index 4f61f90..3ea2e45 100644 --- a/src/typescript/interfaces/index.ts +++ b/src/typescript/interfaces/index.ts @@ -5,3 +5,5 @@ export * from './Snippet_Tag'; export * from './Body'; export * from './SearchQuery'; export * from './User'; +export * from './Token'; +export * from './Request'; diff --git a/src/utils/signToken.ts b/src/utils/signToken.ts index 09cc0b4..b01ef00 100644 --- a/src/utils/signToken.ts +++ b/src/utils/signToken.ts @@ -1,10 +1,8 @@ import { sign } from 'jsonwebtoken'; +import { Token } from '../typescript/interfaces'; -type Data = string | object | Buffer; - -export const signToken = (data: Data): string => { - const secret = - process.env.JWT_SECRET || 'x7-joXEF89Q5hUx9Od5mibNVQb9vUuLr1091TMZSM-w'; +export const signToken = (data: Token): string => { + const secret = process.env.JWT_SECRET || 'secret'; const token = sign(data, secret, { expiresIn: '30d'