new release with reactjs
This commit is contained in:
parent
c9595ccb61
commit
20aae2dc2d
14
README.md
14
README.md
@ -48,24 +48,20 @@ This project uses `eslint` and `prettier`. all configurations for this project i
|
||||
|
||||
4- `GRANT ALL PRIVILEGES ON DATABASE authentication TO admin;`
|
||||
|
||||
**[2]** Second to install the node_modules run `npm install` or `yarn install`. After installation is done
|
||||
**[2]** Second to install the node_modules for the backend, navigate to the frontend directory and run `npm install` or `yarn install`. After installation is done
|
||||
migrate up the database table schema with `npm run migrate:up` or `yarn migrate:up`
|
||||
or run the `script.sh` in `sql` folder from root directory with `./script.sh`
|
||||
after that start the api in dev mode with `npm run dev` or `yarn dev`.
|
||||
|
||||
**[3]** Third, to install the node_modules for the frontend, navigate to the frontend directory and run `npm install` or `yarn install`. Once the installation is complete, start the frontend server in development mode by running `npm run dev` or `yarn dev`.
|
||||
|
||||
## Unit Tests
|
||||
|
||||
Unit test available using Jasmine with this command: `npm run test`
|
||||
|
||||
## Important Note
|
||||
|
||||
```
|
||||
To use refreshToken in the front end I provided a file called react.js in the documents folder.
|
||||
```
|
||||
Unit test available using Jasmine with this command: `npm run test` or `yarn test`
|
||||
|
||||
## Available Scripts
|
||||
|
||||
In the project directory, you can run:
|
||||
In the project backend directory, you can run:
|
||||
|
||||
##### `npm run dev` or `yarn dev`
|
||||
|
||||
|
||||
@ -29,4 +29,4 @@ JWT_REFRESH_TOKEN_EXPIRATION=86400
|
||||
|
||||
# URL
|
||||
BACKEND_HOST=http://127.0.0.1:8000
|
||||
FRONTEND_HOST=http://127.0.0.1:3000
|
||||
FRONTEND_HOST=http://127.0.0.1:5173
|
||||
@ -43,7 +43,6 @@ CREATE TABLE IF NOT EXISTS users (
|
||||
id SERIAL PRIMARY KEY,
|
||||
first_name VARCHAR(100) NOT NULL,
|
||||
last_name VARCHAR(100) NOT NULL,
|
||||
username VARCHAR(100) UNIQUE NOT NULL,
|
||||
email VARCHAR(255) UNIQUE NOT NULL,
|
||||
password TEXT NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
@ -1,6 +1,7 @@
|
||||
import { createUser } from './register';
|
||||
import { authUser } from './login';
|
||||
import { authMe } from '../../utils/token';
|
||||
import { updatePassword } from './updatePassword';
|
||||
import { refreshAccessToken } from './refreshAccessToken';
|
||||
|
||||
export { createUser, authUser, updatePassword, refreshAccessToken };
|
||||
export { createUser, authUser, authMe, updatePassword, refreshAccessToken };
|
||||
@ -28,14 +28,27 @@ export const authUser = async (req: Request, res: Response) => {
|
||||
maxAge: configs.refresh_expires
|
||||
});
|
||||
|
||||
/*
|
||||
Note:
|
||||
If you are not using frontend server like React, Angular.
|
||||
it's better to remove the refreshToken from the response body
|
||||
*/
|
||||
res.status(200).json({
|
||||
status: true,
|
||||
data: { user: { ...response }, accessToken },
|
||||
data: { user: { ...response }, accessToken, refreshToken },
|
||||
message: 'User authenticated successfully.'
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(400).json({
|
||||
message: (error as Error).message
|
||||
});
|
||||
if ((error as Error).message.includes('Password')) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ errors: [{ password: (error as Error).message }] });
|
||||
}
|
||||
if ((error as Error).message.includes('Email')) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ errors: [{ email: (error as Error).message }] });
|
||||
}
|
||||
res.status(400).json({ errors: (error as Error).message });
|
||||
}
|
||||
};
|
||||
@ -17,19 +17,17 @@ export const refreshAccessToken = async (req: Request, res: Response) => {
|
||||
// Verify the refresh token
|
||||
const decoded = await verifyRefreshToken(token);
|
||||
// Generate a new access and refresh tokens
|
||||
const { id, first_name, last_name, username, email } = decoded;
|
||||
const { id, first_name, last_name, email } = decoded;
|
||||
const accessToken = await setAccessToken({
|
||||
id,
|
||||
first_name,
|
||||
last_name,
|
||||
username,
|
||||
email
|
||||
});
|
||||
const refreshToken = await setRefreshToken({
|
||||
id,
|
||||
first_name,
|
||||
last_name,
|
||||
username,
|
||||
email
|
||||
});
|
||||
|
||||
@ -51,7 +49,7 @@ export const refreshAccessToken = async (req: Request, res: Response) => {
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(401).json({
|
||||
message: (error as Error).message
|
||||
error: (error as Error).message
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -13,7 +13,7 @@ export const deleteUser = async (req: Request, res: Response) => {
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(400).json({
|
||||
message: (error as Error).message
|
||||
error: (error as Error).message
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -13,7 +13,7 @@ export const getUser = async (req: Request, res: Response) => {
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(400).json({
|
||||
message: (error as Error).message
|
||||
error: (error as Error).message
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -13,7 +13,7 @@ export const updateUser = async (req: Request, res: Response) => {
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(400).json({
|
||||
message: (error as Error).message
|
||||
error: (error as Error).message
|
||||
});
|
||||
}
|
||||
};
|
||||
23
backend/src/middlewares/validation/auth/validateLogin.ts
Normal file
23
backend/src/middlewares/validation/auth/validateLogin.ts
Normal file
@ -0,0 +1,23 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { body, oneOf } from 'express-validator';
|
||||
import { validate } from '../validationResult';
|
||||
|
||||
export const validateLogin = [
|
||||
body('email')
|
||||
.exists()
|
||||
.withMessage('Email is missing from the body')
|
||||
.notEmpty()
|
||||
.withMessage('Email is empty')
|
||||
.isEmail()
|
||||
.withMessage('Email is not valid')
|
||||
.normalizeEmail()
|
||||
.withMessage('Email is not normalized'),
|
||||
body('password')
|
||||
.exists()
|
||||
.withMessage('password is missing from the body')
|
||||
.notEmpty()
|
||||
.withMessage('password is empty')
|
||||
.isLength({ min: 8 })
|
||||
.withMessage('password must be at least 8 characters long'),
|
||||
(req: Request, res: Response, next: NextFunction) => validate(req, res, next)
|
||||
];
|
||||
@ -5,27 +5,20 @@ import { validate } from '../validationResult';
|
||||
export const validateCreateUser = [
|
||||
body('first_name')
|
||||
.exists()
|
||||
.withMessage("first_name does'nt exists in the body.")
|
||||
.withMessage("First Name does'nt exists in the body.")
|
||||
.notEmpty()
|
||||
.withMessage('first_name is empty')
|
||||
.withMessage('First Name is empty')
|
||||
.isString()
|
||||
.isLength({ min: 5, max: 50 })
|
||||
.withMessage('first_name must be at least 5 and maximum 50 letters'),
|
||||
.withMessage('First Name must be at least 5 and maximum 50 letters'),
|
||||
body('last_name')
|
||||
.exists()
|
||||
.withMessage("last_name does'nt exists in the body.")
|
||||
.withMessage("Last Name does'nt exists in the body.")
|
||||
.notEmpty()
|
||||
.withMessage('last_name is empty')
|
||||
.withMessage('Last Name is empty')
|
||||
.isString()
|
||||
.isLength({ min: 5, max: 50 })
|
||||
.withMessage('last_name must be at least 5 and maximum 50 letters'),
|
||||
body('username')
|
||||
.exists()
|
||||
.withMessage('username is missing from the body')
|
||||
.notEmpty()
|
||||
.withMessage('username is empty')
|
||||
.isString()
|
||||
.withMessage('username must be string'),
|
||||
.withMessage('Last Name must be at least 5 and maximum 50 letters'),
|
||||
body('email')
|
||||
.exists()
|
||||
.withMessage('Email is missing from the body')
|
||||
@ -37,10 +30,10 @@ export const validateCreateUser = [
|
||||
.withMessage('Email is not normalized'),
|
||||
body('password')
|
||||
.exists()
|
||||
.withMessage('password is missing from the body')
|
||||
.withMessage('Password is missing from the body')
|
||||
.notEmpty()
|
||||
.withMessage('password is empty')
|
||||
.withMessage('Password is empty')
|
||||
.isLength({ min: 8 })
|
||||
.withMessage('password must be at least 8 characters long'),
|
||||
.withMessage('Password must be at least 8 characters long'),
|
||||
(req: Request, res: Response, next: NextFunction) => validate(req, res, next)
|
||||
];
|
||||
@ -24,13 +24,6 @@ export const validateUpdateUser = [
|
||||
.isString()
|
||||
.isLength({ min: 5, max: 50 })
|
||||
.withMessage('last_name must be at least 5 and maximum 50 letters'),
|
||||
body('username')
|
||||
.exists()
|
||||
.withMessage('username is missing from the body')
|
||||
.notEmpty()
|
||||
.withMessage('username is empty')
|
||||
.isString()
|
||||
.withMessage('username must be string'),
|
||||
body('email')
|
||||
.exists()
|
||||
.withMessage('Email is missing from the body')
|
||||
@ -10,7 +10,7 @@ export const validate = async (
|
||||
if (!errors.isEmpty()) {
|
||||
const errorArray = errors
|
||||
.array()
|
||||
.map((error) => ({ [error.param]: [error.msg] }));
|
||||
.map((error) => ({ [error.param]: error.msg }));
|
||||
return res.status(422).json({ errors: errorArray });
|
||||
}
|
||||
next();
|
||||
@ -37,7 +37,7 @@ class Auth {
|
||||
const check = await compare(u.password, hash);
|
||||
if (check) {
|
||||
const query = {
|
||||
text: 'SELECT id, first_name, last_name, username, email FROM users WHERE email=$1',
|
||||
text: 'SELECT id, first_name, last_name, email FROM users WHERE email=$1',
|
||||
values: [u.email]
|
||||
};
|
||||
const result = await connection.query(query);
|
||||
@ -51,7 +51,7 @@ class Auth {
|
||||
async authMe(id: string): Promise<UserType & AuthType> {
|
||||
return this.withConnection(async (connection: PoolClient) => {
|
||||
const query = {
|
||||
text: 'SELECT id, first_name, last_name, username, email FROM users WHERE id=$1',
|
||||
text: 'SELECT id, first_name, last_name, email FROM users WHERE id=$1',
|
||||
values: [id]
|
||||
};
|
||||
const result = await connection.query(query);
|
||||
@ -18,7 +18,6 @@ describe('User Model', () => {
|
||||
const newUser1 = {
|
||||
first_name: 'Adham',
|
||||
last_name: 'Haddad',
|
||||
username: 'adhamhaddad',
|
||||
email: 'adhamhaddad.dev@gmail.com',
|
||||
password: 'adham123'
|
||||
} as UserType;
|
||||
@ -26,7 +25,6 @@ describe('User Model', () => {
|
||||
const updateUser1 = {
|
||||
first_name: 'Adham',
|
||||
last_name: 'Ashraf',
|
||||
username: 'adhamhaddad1',
|
||||
email: 'adhamhaddad.dev@gmail.com'
|
||||
} as UserType;
|
||||
|
||||
@ -48,7 +46,6 @@ describe('User Model', () => {
|
||||
id: 1,
|
||||
first_name: 'Adham',
|
||||
last_name: 'Haddad',
|
||||
username: 'adhamhaddad',
|
||||
email: 'adhamhaddad.dev@gmail.com'
|
||||
} as UserType);
|
||||
});
|
||||
@ -59,7 +56,6 @@ describe('User Model', () => {
|
||||
id: 1,
|
||||
first_name: 'Adham',
|
||||
last_name: 'Haddad',
|
||||
username: 'adhamhaddad',
|
||||
email: 'adhamhaddad.dev@gmail.com'
|
||||
} as UserType);
|
||||
});
|
||||
@ -70,7 +66,6 @@ describe('User Model', () => {
|
||||
id: 1,
|
||||
first_name: 'Adham',
|
||||
last_name: 'Ashraf',
|
||||
username: 'adhamhaddad1',
|
||||
email: 'adhamhaddad.dev@gmail.com'
|
||||
} as UserType);
|
||||
});
|
||||
@ -118,7 +113,6 @@ describe('Auth Model', () => {
|
||||
id: 1,
|
||||
first_name: 'Adham',
|
||||
last_name: 'Ashraf',
|
||||
username: 'adhamhaddad1',
|
||||
email: 'adhamhaddad.dev@gmail.com'
|
||||
} as UserType);
|
||||
});
|
||||
@ -3,10 +3,9 @@ import { pgClient } from '../database';
|
||||
import { hash } from '../utils/password';
|
||||
|
||||
export type UserType = {
|
||||
id: number;
|
||||
id: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
username: string;
|
||||
email: string;
|
||||
password: string;
|
||||
created_at: Date;
|
||||
@ -31,11 +30,11 @@ class User {
|
||||
const password = await hash(u.password);
|
||||
const query = {
|
||||
text: `
|
||||
INSERT INTO users (first_name, last_name, username, email, password)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING id, first_name, last_name, username, email
|
||||
INSERT INTO users (first_name, last_name, email, password)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING id, first_name, last_name, email
|
||||
`,
|
||||
values: [u.first_name, u.last_name, u.username, u.email, password]
|
||||
values: [u.first_name, u.last_name, u.email, password]
|
||||
};
|
||||
const result = await connection.query(query);
|
||||
return result.rows[0];
|
||||
@ -44,7 +43,7 @@ class User {
|
||||
async getUser(id: string): Promise<UserType> {
|
||||
return this.withConnection(async (connection: PoolClient) => {
|
||||
const query = {
|
||||
text: 'SELECT id, first_name, last_name, username, email FROM users WHERE id=$1',
|
||||
text: 'SELECT id, first_name, last_name, email FROM users WHERE id=$1',
|
||||
values: [id]
|
||||
};
|
||||
const result = await connection.query(query);
|
||||
@ -55,11 +54,11 @@ class User {
|
||||
return this.withConnection(async (connection: PoolClient) => {
|
||||
const query = {
|
||||
text: `
|
||||
UPDATE users SET first_name=$2, last_name=$3, username=$4, email=$5, updated_at=CURRENT_TIMESTAMP
|
||||
UPDATE users SET first_name=$2, last_name=$3, email=$4, updated_at=CURRENT_TIMESTAMP
|
||||
WHERE id=$1
|
||||
RETURNING id, first_name, last_name, username, email
|
||||
RETURNING id, first_name, last_name, email
|
||||
`,
|
||||
values: [id, u.first_name, u.last_name, u.username, u.email]
|
||||
values: [id, u.first_name, u.last_name, u.email]
|
||||
};
|
||||
const result = await connection.query(query);
|
||||
return result.rows[0];
|
||||
@ -7,6 +7,7 @@ import {
|
||||
import {
|
||||
createUser,
|
||||
authUser,
|
||||
authMe,
|
||||
refreshAccessToken,
|
||||
updatePassword
|
||||
} from '../../controllers/auth';
|
||||
@ -18,6 +19,7 @@ router
|
||||
.post('/register', validateRegister, createUser)
|
||||
.post('/login', validateLogin, authUser)
|
||||
.patch('/reset-password', validateUpdatePassword, verifyToken, updatePassword)
|
||||
.post('/refresh-token', refreshAccessToken);
|
||||
.post('/refresh-token', refreshAccessToken)
|
||||
.get('/auth-me', authMe);
|
||||
|
||||
export default router;
|
||||
@ -22,6 +22,7 @@ const corsOptions = {
|
||||
'Content-Type',
|
||||
'Accept',
|
||||
'X-Access-Token',
|
||||
'X-Refresh-Token',
|
||||
'Authorization',
|
||||
'Access-Control-Allow-Origin',
|
||||
'Access-Control-Allow-Headers',
|
||||
@ -29,7 +30,8 @@ const corsOptions = {
|
||||
],
|
||||
methods: 'GET,HEAD,OPTIONS,PUT,PATCH,POST,DELETE',
|
||||
preflightContinue: true,
|
||||
origin: '*'
|
||||
origin: true,
|
||||
credentials: true
|
||||
};
|
||||
|
||||
// Middlewares
|
||||
11
backend/src/utils/password.ts
Normal file
11
backend/src/utils/password.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import bcrypt from 'bcrypt';
|
||||
import configs from '../configs';
|
||||
|
||||
export const hash = async (password: string) =>
|
||||
await bcrypt.hash(
|
||||
`${configs.pepper}${password}${configs.pepper}`,
|
||||
configs.salt
|
||||
);
|
||||
|
||||
export const compare = async (password: string, hash: string) =>
|
||||
await bcrypt.compare(`${configs.pepper}${password}${configs.pepper}`, hash);
|
||||
104
backend/src/utils/token/authMe.ts
Normal file
104
backend/src/utils/token/authMe.ts
Normal file
@ -0,0 +1,104 @@
|
||||
import { Request as ExpressRequest, Response } from 'express';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { redisClient } from '../../database';
|
||||
import configs from '../../configs';
|
||||
import { setAccessToken, verifyRefreshToken, DecodedToken, Payload } from '.';
|
||||
|
||||
const publicAccessKey = path.join(
|
||||
__dirname,
|
||||
'..',
|
||||
'..',
|
||||
'..',
|
||||
'keys',
|
||||
'accessToken',
|
||||
'public.key'
|
||||
);
|
||||
interface Request extends ExpressRequest {
|
||||
user?: DecodedToken;
|
||||
}
|
||||
|
||||
export const authMe = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const authorization = req.headers.authorization as string;
|
||||
if (!authorization) {
|
||||
return res.status(401).json({
|
||||
message: 'Not Authorized'
|
||||
});
|
||||
}
|
||||
const [bearer, token] = authorization.split(' ');
|
||||
if (bearer !== 'Bearer' || !token) {
|
||||
throw new Error(
|
||||
'Invalid Authorization header format. Format is "Bearer <token>".'
|
||||
);
|
||||
}
|
||||
try {
|
||||
const publicKey = await fs.promises.readFile(publicAccessKey, 'utf8');
|
||||
const decoded = jwt.verify(token, publicKey, {
|
||||
algorithms: ['RS256'],
|
||||
issuer: 'Nodejs-Refresh-Token'
|
||||
}) as DecodedToken;
|
||||
const cachedToken = await redisClient.get(`access_token:${decoded.id}`);
|
||||
if (!cachedToken || cachedToken !== token) {
|
||||
throw new Error('Access token not found or expired');
|
||||
}
|
||||
const { id, first_name, last_name, email } = decoded;
|
||||
|
||||
req.user = { id: id };
|
||||
|
||||
return res.status(200).json({
|
||||
data: {
|
||||
user: { id, first_name, last_name, email },
|
||||
accessToken: token
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
if ((err as Error).name !== 'TokenExpiredError') {
|
||||
throw new Error('Invalid access token');
|
||||
}
|
||||
// Get Refresh-Token
|
||||
const refreshToken = req.get('X-Refresh-Token') as string;
|
||||
|
||||
if (!refreshToken) {
|
||||
throw new Error('Refresh token missing');
|
||||
}
|
||||
const [bearer, token] = refreshToken.split(' ');
|
||||
if (bearer !== 'Bearer' || !token) {
|
||||
throw new Error(
|
||||
'Invalid Authorization header format. Format is "Bearer <token>".'
|
||||
);
|
||||
}
|
||||
const decoded = await verifyRefreshToken(token);
|
||||
const { id, first_name, last_name, email } = decoded;
|
||||
const newAccessToken = await setAccessToken({
|
||||
id,
|
||||
first_name,
|
||||
last_name,
|
||||
email
|
||||
});
|
||||
|
||||
// Attach user object to request and proceed with new access token
|
||||
req.user = { id: String(decoded.id) };
|
||||
res.cookie('accessToken', newAccessToken, {
|
||||
httpOnly: true,
|
||||
sameSite: 'strict',
|
||||
secure: false,
|
||||
maxAge: configs.access_expires
|
||||
});
|
||||
return res.status(200).json({
|
||||
data: {
|
||||
user: {
|
||||
id,
|
||||
first_name,
|
||||
last_name,
|
||||
email
|
||||
},
|
||||
accessToken: newAccessToken
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
res.status(401).json({ message: (err as Error).message });
|
||||
}
|
||||
};
|
||||
@ -2,16 +2,19 @@ import { setAccessToken } from './setAccessToken';
|
||||
import { setRefreshToken } from './setRefreshToken';
|
||||
import { verifyAccessToken } from './verifyAccessToken';
|
||||
import { verifyRefreshToken } from './verifyRefreshToken';
|
||||
import { authMe } from './authMe';
|
||||
|
||||
interface Payload {
|
||||
id: number;
|
||||
id: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
username: string;
|
||||
email: string;
|
||||
}
|
||||
interface DecodedToken {
|
||||
id: string;
|
||||
first_name?: string;
|
||||
last_name?: string;
|
||||
email?: string;
|
||||
}
|
||||
|
||||
export {
|
||||
@ -19,6 +22,7 @@ export {
|
||||
setRefreshToken,
|
||||
verifyAccessToken,
|
||||
verifyRefreshToken,
|
||||
authMe,
|
||||
Payload,
|
||||
DecodedToken
|
||||
};
|
||||
@ -3,9 +3,8 @@ import jwt from 'jsonwebtoken';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { redisClient } from '../../database';
|
||||
import { DecodedToken } from '.';
|
||||
import { verifyRefreshToken, setAccessToken } from '.';
|
||||
import configs from '../../configs';
|
||||
import { verifyRefreshToken, setAccessToken, DecodedToken } from '.';
|
||||
|
||||
const publicAccessKey = path.join(
|
||||
__dirname,
|
||||
@ -49,29 +48,35 @@ export const verifyAccessToken = async (
|
||||
throw new Error('Access token not found or expired');
|
||||
}
|
||||
req.user = { id: decoded.id };
|
||||
|
||||
return next();
|
||||
} catch (err) {
|
||||
if ((err as Error).name !== 'TokenExpiredError') {
|
||||
throw new Error('Invalid access token');
|
||||
}
|
||||
// Get Refresh-Token
|
||||
const refreshToken = req.get('X-Refresh-Token') as string;
|
||||
|
||||
const refreshToken = req.cookies.refreshToken;
|
||||
if (!refreshToken) {
|
||||
throw new Error('Refresh token missing');
|
||||
}
|
||||
|
||||
const decoded = await verifyRefreshToken(refreshToken);
|
||||
const { id, first_name, last_name, username, email } = decoded;
|
||||
const [bearer, token] = refreshToken.split(' ');
|
||||
if (bearer !== 'Bearer' || !token) {
|
||||
throw new Error(
|
||||
'Invalid Authorization header format. Format is "Bearer <token>".'
|
||||
);
|
||||
}
|
||||
const decoded = await verifyRefreshToken(token);
|
||||
const { id, first_name, last_name, email } = decoded;
|
||||
const newAccessToken = await setAccessToken({
|
||||
id,
|
||||
first_name,
|
||||
last_name,
|
||||
username,
|
||||
email
|
||||
});
|
||||
|
||||
// Attach user object to request and proceed with new access token
|
||||
req.user = { id: String(decoded.id) };
|
||||
req.user = { id };
|
||||
res.cookie('accessToken', newAccessToken, {
|
||||
httpOnly: true,
|
||||
sameSite: 'strict',
|
||||
@ -12,7 +12,6 @@ The request body should be a JSON object with the following properties:
|
||||
{
|
||||
"first_name": "Adham",
|
||||
"last_name": "Haddad",
|
||||
"username": "adhamhaddad",
|
||||
"email": "adhamhaddad.dev@gmail.com",
|
||||
"password": "secret-password"
|
||||
}
|
||||
@ -27,11 +26,12 @@ If the user is successfully created, the server will respond with a status code
|
||||
"id": 1,
|
||||
"first_name": "Adham",
|
||||
"last_name": "Haddad",
|
||||
"username": "adhamhaddad",
|
||||
"email": "adhamhaddad.dev@gmail.com"
|
||||
}
|
||||
```
|
||||
|
||||
<hr />
|
||||
|
||||
## POST /auth/login
|
||||
|
||||
Authenticate user.
|
||||
@ -57,23 +57,28 @@ If the user is exists and authenticated successfully, the server will respond wi
|
||||
"id": 1,
|
||||
"first_name": "Adham",
|
||||
"last_name": "Haddad",
|
||||
"username": "adhamhaddad",
|
||||
"email": "adhamhaddad.dev@gmail.com",
|
||||
"email": "adhamhaddad.dev@gmail.com"
|
||||
},
|
||||
"accessToken": "<Access-Token>"
|
||||
"accessToken": "<Access-Token>",
|
||||
"refreshToken": "<Refresh-Token>"
|
||||
}
|
||||
```
|
||||
|
||||
## POST /auth/refresh-token
|
||||
<hr />
|
||||
|
||||
## POST /auth/auth-me
|
||||
|
||||
Refresh the access and refresh tokens.
|
||||
|
||||
### Request Headers
|
||||
|
||||
The request headers should have a Cookies with the following properties:
|
||||
The request headers should have the following properties:
|
||||
|
||||
```json
|
||||
refreshToken="<Refresh-Token>"
|
||||
"headers": {
|
||||
"Authorization": "Bearer <Access-Token>",
|
||||
"X-Refresh-Token": "Bearer <Refresh-Token>"
|
||||
}
|
||||
```
|
||||
|
||||
### Response
|
||||
@ -82,21 +87,52 @@ If the refresh token is exists in redis and valid, the server will respond with
|
||||
|
||||
```json
|
||||
{
|
||||
"accessToken": "<Access-Token>",
|
||||
"accessToken": "<Access-Token>"
|
||||
}
|
||||
```
|
||||
|
||||
<hr />
|
||||
|
||||
## POST /auth/refresh-token
|
||||
|
||||
Refresh the access and refresh tokens.
|
||||
|
||||
### Request Headers
|
||||
|
||||
The request headers should have the following properties:
|
||||
|
||||
```json
|
||||
"headers": {
|
||||
"Authorization": "Bearer <Access-Token>",
|
||||
"X-Refresh-Token": "Bearer <Refresh-Token>"
|
||||
}
|
||||
```
|
||||
|
||||
### Response
|
||||
|
||||
If the refresh token is exists in redis and valid, the server will respond with a status code of 200 and a JSON object representing a new tokens:
|
||||
|
||||
```json
|
||||
{
|
||||
"accessToken": "<Access-Token>"
|
||||
}
|
||||
```
|
||||
|
||||
<hr />
|
||||
|
||||
## GET /users/:userId
|
||||
|
||||
Get a user by id.
|
||||
|
||||
### Request Headers
|
||||
|
||||
The request headers should have a Cookies with the following properties:
|
||||
The request headers should have the following properties:
|
||||
|
||||
```json
|
||||
accessToken="<Access-Token>"
|
||||
refreshToken="<Refresh-Token>"
|
||||
"headers": {
|
||||
"Authorization": "Bearer <Access-Token>",
|
||||
"X-Refresh-Token": "Bearer <Refresh-Token>"
|
||||
}
|
||||
```
|
||||
|
||||
### Response
|
||||
@ -108,47 +144,6 @@ If the user is exists, the server will respond with a status code of 200 and a J
|
||||
"id": 1,
|
||||
"first_name": "Adham",
|
||||
"last_name": "Haddad",
|
||||
"username": "adhamhaddad",
|
||||
"email": "adhamhaddad.dev@gmail.com"
|
||||
}
|
||||
```
|
||||
|
||||
## PATCH /users/:userId
|
||||
|
||||
Get a user by id.
|
||||
|
||||
### Request Headers
|
||||
|
||||
The request headers should have a Cookies with the following properties:
|
||||
|
||||
```json
|
||||
accessToken="<Access-Token>"
|
||||
refreshToken="<Refresh-Token>"
|
||||
```
|
||||
|
||||
### Request Body
|
||||
|
||||
The request body should be a JSON object with the following properties:
|
||||
|
||||
```json
|
||||
{
|
||||
"first_name": "Adham",
|
||||
"last_name": "Ashraf",
|
||||
"username": "adhamhaddad",
|
||||
"email": "adhamhaddad.dev@gmail.com"
|
||||
}
|
||||
```
|
||||
|
||||
### Response
|
||||
|
||||
If the user is exists and updated, the server will respond with a status code of 204 and a JSON object representing the received user:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 1,
|
||||
"first_name": "Adham",
|
||||
"last_name": "Ashraf",
|
||||
"username": "adhamhaddad",
|
||||
"email": "adhamhaddad.dev@gmail.com"
|
||||
}
|
||||
```
|
||||
67
documents/react.js
vendored
67
documents/react.js
vendored
@ -1,67 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
const Login = () => {
|
||||
const [accessToken, setAccessToken] = useState(null);
|
||||
const [refreshToken, setRefreshToken] = useState(null);
|
||||
const [values, setValues] = useState({
|
||||
email: '',
|
||||
password: ''
|
||||
});
|
||||
const handleChange = (prop) => (event) => {
|
||||
setValues((prev) => ({ ...prev, [prop]: event.target.value }));
|
||||
};
|
||||
const handleSubmit = async (event) => {
|
||||
event.preventDefault();
|
||||
|
||||
const response = await fetch('/auth/login', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ email, password })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
// The login was successful, extract the access token from the response
|
||||
const { accessToken } = data;
|
||||
|
||||
// Retrieve the refresh token from the HTTP-only cookie
|
||||
const cookies = document.cookie.split('; ');
|
||||
const refreshTokenCookie = cookies.find((cookie) =>
|
||||
cookie.startsWith('refreshToken=')
|
||||
);
|
||||
const refreshToken = refreshTokenCookie
|
||||
? refreshTokenCookie.split('=')[1]
|
||||
: null;
|
||||
|
||||
// Store the access token and refresh token in state
|
||||
setAccessToken(accessToken);
|
||||
setRefreshToken(refreshToken);
|
||||
} else {
|
||||
// The login failed, display the error message
|
||||
setError(data.message);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<input
|
||||
type='email'
|
||||
name='email'
|
||||
value={values.email}
|
||||
onChange={handleChange('email')}
|
||||
/>
|
||||
<input
|
||||
type='password'
|
||||
name='password'
|
||||
value={values.password}
|
||||
onChange={handleChange('password')}
|
||||
/>
|
||||
<button type='submit'>Login</button>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export default Login;
|
||||
15
frontend/.eslintrc.cjs
Normal file
15
frontend/.eslintrc.cjs
Normal file
@ -0,0 +1,15 @@
|
||||
module.exports = {
|
||||
env: { browser: true, es2020: true },
|
||||
extends: [
|
||||
'eslint:recommended',
|
||||
'plugin:react/recommended',
|
||||
'plugin:react/jsx-runtime',
|
||||
'plugin:react-hooks/recommended',
|
||||
],
|
||||
parserOptions: { ecmaVersion: 'latest', sourceType: 'module' },
|
||||
settings: { react: { version: '18.2' } },
|
||||
plugins: ['react-refresh'],
|
||||
rules: {
|
||||
'react-refresh/only-export-components': 'warn',
|
||||
},
|
||||
}
|
||||
24
frontend/.gitignore
vendored
Normal file
24
frontend/.gitignore
vendored
Normal file
@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Vite + React</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
29
frontend/package.json
Normal file
29
frontend/package.json
Normal file
@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"lint": "eslint src --ext js,jsx --report-unused-disable-directives --max-warnings 0",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.4.0",
|
||||
"js-cookie": "^3.0.5",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.0.28",
|
||||
"@types/react-dom": "^18.0.11",
|
||||
"@vitejs/plugin-react": "^4.0.0",
|
||||
"eslint": "^8.38.0",
|
||||
"eslint-plugin-react": "^7.32.2",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-react-refresh": "^0.3.4",
|
||||
"vite": "^4.3.2"
|
||||
}
|
||||
}
|
||||
1
frontend/public/vite.svg
Normal file
1
frontend/public/vite.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
33
frontend/src/App.jsx
Normal file
33
frontend/src/App.jsx
Normal file
@ -0,0 +1,33 @@
|
||||
import React from 'react';
|
||||
import { Switch, Route, Redirect } from 'react-router-dom';
|
||||
import { useAuth } from '@hooks';
|
||||
import RegisterPage from '@pages/register';
|
||||
import LoginPage from '@pages/login';
|
||||
import HomePage from '@pages/home';
|
||||
|
||||
function App() {
|
||||
const { isLogged, user } = useAuth();
|
||||
return user ? (
|
||||
<>
|
||||
<Switch>
|
||||
<Route path='/login' exact>
|
||||
<Redirect to='/' />
|
||||
</Route>
|
||||
<Route path='/register' exact>
|
||||
<Redirect to='/' />
|
||||
</Route>
|
||||
<Route path='/' exact>
|
||||
<HomePage />
|
||||
</Route>
|
||||
</Switch>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Route exact path='/login' component={LoginPage} />
|
||||
<Route exact path='/register' component={RegisterPage} />
|
||||
<Route exact path='*' render={() => <Redirect exact to='/login' />} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
34
frontend/src/components/UI/input/index.jsx
Normal file
34
frontend/src/components/UI/input/index.jsx
Normal file
@ -0,0 +1,34 @@
|
||||
import React from 'react';
|
||||
import styles from '@styles/input.module.css';
|
||||
|
||||
const Input = ({
|
||||
id,
|
||||
label,
|
||||
type,
|
||||
placeholder,
|
||||
value,
|
||||
style,
|
||||
isValid,
|
||||
onChange,
|
||||
onBlur
|
||||
}) => {
|
||||
return (
|
||||
<div style={style} className={styles['input-box']}>
|
||||
<label htmlFor={id} className={styles['input-box_label']}>
|
||||
{label}
|
||||
</label>
|
||||
<input
|
||||
className={`${styles['input-box_input']} ${
|
||||
isValid ? styles['invalid'] : null
|
||||
}`}
|
||||
type={type}
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onBlur={onBlur}
|
||||
/>
|
||||
{isValid && <p className={styles['input-error']}>{isValid}</p>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export default Input;
|
||||
69
frontend/src/config/api.js
Normal file
69
frontend/src/config/api.js
Normal file
@ -0,0 +1,69 @@
|
||||
import axios from 'axios';
|
||||
import { useState } from 'react';
|
||||
import { API_URL } from './env';
|
||||
import Cookies from 'js-cookie';
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: API_URL,
|
||||
headers: {
|
||||
Accept: 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
api.defaults.withCredentials = true;
|
||||
|
||||
api.interceptors.request.use((config) => {
|
||||
const accessToken = localStorage.getItem('accessToken');
|
||||
const refreshToken = Cookies.get('refreshToken');
|
||||
|
||||
if (accessToken) {
|
||||
config.headers.Authorization = `Bearer ${accessToken}`;
|
||||
}
|
||||
|
||||
if (refreshToken) {
|
||||
config.headers['X-Refresh-Token'] = `Bearer ${refreshToken}`;
|
||||
}
|
||||
|
||||
if (config.data instanceof FormData) {
|
||||
config.headers['Content-Type'] = 'multipart/form-data';
|
||||
}
|
||||
|
||||
return config;
|
||||
});
|
||||
|
||||
api.interceptors.response.use(
|
||||
(response) => response.data,
|
||||
(error) => Promise.reject(error)
|
||||
);
|
||||
|
||||
function useApi() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function get(url, options = {}) {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await api.get(url, options);
|
||||
setLoading(false);
|
||||
return response;
|
||||
} catch (error) {
|
||||
setLoading(false);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function post(url, data) {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await api.post(url, data);
|
||||
setLoading(false);
|
||||
return response;
|
||||
} catch (error) {
|
||||
setLoading(false);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
return { get, post, loading };
|
||||
}
|
||||
|
||||
export default useApi;
|
||||
1
frontend/src/config/env.js
Normal file
1
frontend/src/config/env.js
Normal file
@ -0,0 +1 @@
|
||||
export const API_URL = 'http://localhost:3000';
|
||||
2
frontend/src/config/index.js
Normal file
2
frontend/src/config/index.js
Normal file
@ -0,0 +1,2 @@
|
||||
export * from './env';
|
||||
export { default as useApi } from './api';
|
||||
129
frontend/src/context/AuthContext.jsx
Normal file
129
frontend/src/context/AuthContext.jsx
Normal file
@ -0,0 +1,129 @@
|
||||
import React, { useState, createContext, useEffect } from 'react';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import Cookies from 'js-cookie';
|
||||
import { useApi } from '@config';
|
||||
|
||||
const auth = {
|
||||
user: localStorage.getItem('user'),
|
||||
accessToken: null,
|
||||
refreshToken: null,
|
||||
isLogged: false,
|
||||
setLoading: () => {},
|
||||
register: () => {},
|
||||
login: () => {},
|
||||
logout: () => {}
|
||||
};
|
||||
|
||||
export const AuthContext = createContext(auth);
|
||||
|
||||
const AuthProvider = ({ children }) => {
|
||||
const [user, setUser] = useState(auth.user && JSON.parse(auth.user));
|
||||
const [accessToken, setAccessToken] = useState(null);
|
||||
const [refreshToken, setRefreshToken] = useState(null);
|
||||
const { get, post } = useApi();
|
||||
|
||||
const isLoggedIn = !!accessToken;
|
||||
|
||||
const history = useHistory();
|
||||
|
||||
useEffect(() => {
|
||||
const initAuth = async () => {
|
||||
const accessToken = window.localStorage.getItem('accessToken');
|
||||
if (accessToken) {
|
||||
await get('/auth/auth-me')
|
||||
.then(async (response) => {
|
||||
const { user: userData, accessToken: AccessToken } = response.data;
|
||||
if (accessToken) {
|
||||
window.localStorage.setItem('user', JSON.stringify(userData));
|
||||
window.localStorage.setItem('accessToken', AccessToken);
|
||||
}
|
||||
setUser(userData);
|
||||
setAccessToken(AccessToken);
|
||||
})
|
||||
.catch(() => {
|
||||
localStorage.removeItem('user');
|
||||
localStorage.removeItem('refreshToken');
|
||||
localStorage.removeItem('accessToken');
|
||||
setUser(null);
|
||||
setAccessToken(null);
|
||||
setRefreshToken(null);
|
||||
});
|
||||
}
|
||||
};
|
||||
initAuth();
|
||||
}, []);
|
||||
|
||||
const handleLogin = async (params, errorCallback) => {
|
||||
try {
|
||||
const response = await post('/auth/login', params);
|
||||
// Extract user, accessToken, refreshToken from the response body
|
||||
const { user, accessToken, refreshToken } = response.data;
|
||||
|
||||
// Store accessToken and refreshToken in state
|
||||
setAccessToken(accessToken);
|
||||
setRefreshToken(refreshToken);
|
||||
|
||||
// Store user in state
|
||||
setUser(user);
|
||||
|
||||
// Set user data in localStorage
|
||||
window.localStorage.setItem('user', JSON.stringify(user));
|
||||
// Set accessToken in localStorage
|
||||
window.localStorage.setItem('accessToken', accessToken);
|
||||
|
||||
// Set refreshToken in cookies
|
||||
Cookies.set('refreshToken', refreshToken, {
|
||||
expires: 30,
|
||||
secure: true,
|
||||
sameSite: 'strict'
|
||||
});
|
||||
history.replace('/');
|
||||
} catch (err) {
|
||||
if (errorCallback) errorCallback(err);
|
||||
}
|
||||
};
|
||||
const handleRegister = async (params, errorCallback) => {
|
||||
try {
|
||||
const response = await post('/auth/register', params);
|
||||
if (response.data.error) {
|
||||
if (errorCallback) errorCallback(response.data.error);
|
||||
} else {
|
||||
handleLogin({ email: params.email, password: params.password });
|
||||
}
|
||||
} catch (err) {
|
||||
if (errorCallback) errorCallback(err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
// Remove states
|
||||
setUser(null);
|
||||
setAccessToken(null);
|
||||
setRefreshToken(null);
|
||||
|
||||
// Remove user data from localStorage
|
||||
window.localStorage.removeItem('user');
|
||||
// Remove accessToken from localStorage
|
||||
window.localStorage.removeItem('accessToken');
|
||||
// Remove refreshToken from cookies
|
||||
Cookies.remove('refreshToken', {
|
||||
expires: 30,
|
||||
secure: true,
|
||||
sameSite: 'strict'
|
||||
});
|
||||
history.replace('/login');
|
||||
};
|
||||
|
||||
const values = {
|
||||
user: user,
|
||||
accessToken: accessToken,
|
||||
refreshToken: refreshToken,
|
||||
isLogged: isLoggedIn,
|
||||
login: handleLogin,
|
||||
register: handleRegister,
|
||||
logout: handleLogout
|
||||
};
|
||||
|
||||
return <AuthContext.Provider value={values}>{children}</AuthContext.Provider>;
|
||||
};
|
||||
export default AuthProvider;
|
||||
2
frontend/src/hooks/index.js
Normal file
2
frontend/src/hooks/index.js
Normal file
@ -0,0 +1,2 @@
|
||||
import { useAuth } from './useAuth';
|
||||
export { useAuth };
|
||||
4
frontend/src/hooks/useAuth.js
Normal file
4
frontend/src/hooks/useAuth.js
Normal file
@ -0,0 +1,4 @@
|
||||
import { useContext } from 'react';
|
||||
import { AuthContext } from '@context/AuthContext';
|
||||
|
||||
export const useAuth = () => useContext(AuthContext);
|
||||
16
frontend/src/main.jsx
Normal file
16
frontend/src/main.jsx
Normal file
@ -0,0 +1,16 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import AuthProvider from '@context/AuthContext.jsx';
|
||||
import App from './App.jsx';
|
||||
import '@styles/index.css';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<AuthProvider>
|
||||
<App />
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>
|
||||
);
|
||||
29
frontend/src/pages/home/index.jsx
Normal file
29
frontend/src/pages/home/index.jsx
Normal file
@ -0,0 +1,29 @@
|
||||
import React from 'react';
|
||||
import Cookies from 'js-cookie';
|
||||
import { useAuth } from '@hooks';
|
||||
import styles from '@styles/home.module.css';
|
||||
|
||||
const Home = () => {
|
||||
const { logout } = useAuth();
|
||||
return (
|
||||
<div className={styles['home-page']}>
|
||||
<h2>Welcome</h2>
|
||||
<hr />
|
||||
<p className={styles['localStorage-data']}>
|
||||
<strong>accessToken:</strong> {localStorage.getItem('accessToken')}
|
||||
</p>
|
||||
<p className={styles['localStorage-data']}>
|
||||
<strong>refreshToken:</strong> {Cookies.get('refreshToken')}
|
||||
</p>
|
||||
<p className={styles['localStorage-data']}>
|
||||
<strong>user:</strong>
|
||||
{localStorage.getItem('user')}
|
||||
</p>
|
||||
<hr />
|
||||
<button className={styles['logout-button']} onClick={logout}>
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export default Home;
|
||||
84
frontend/src/pages/login/index.jsx
Normal file
84
frontend/src/pages/login/index.jsx
Normal file
@ -0,0 +1,84 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useAuth } from '@hooks';
|
||||
import Input from '@UI/input';
|
||||
import styles from '@styles/form.module.css';
|
||||
|
||||
const Login = () => {
|
||||
const [values, setValues] = useState({
|
||||
email: '',
|
||||
password: ''
|
||||
});
|
||||
const [error, setError] = useState({
|
||||
email: null,
|
||||
password: null
|
||||
});
|
||||
|
||||
const { login } = useAuth();
|
||||
|
||||
const handleChange = (prop) => (event) => {
|
||||
if (error.email || error.password) {
|
||||
setError({
|
||||
email: null,
|
||||
password: null
|
||||
});
|
||||
}
|
||||
setValues((prev) => ({ ...prev, [prop]: event.target.value }));
|
||||
};
|
||||
|
||||
const onFormSubmit = (event) => {
|
||||
event.preventDefault();
|
||||
login(values, (err) => {
|
||||
const errors = err.response.data.errors;
|
||||
errors.forEach((error) => {
|
||||
if (error.email) {
|
||||
setError((prev) => ({ ...prev, email: error.email }));
|
||||
}
|
||||
if (error.password) {
|
||||
setError((prev) => ({ ...prev, password: error.password }));
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const Inputs = [
|
||||
{
|
||||
id: 'email',
|
||||
label: 'Email',
|
||||
type: 'email',
|
||||
value: values.email,
|
||||
isValid: error.email,
|
||||
onChange: handleChange('email')
|
||||
},
|
||||
{
|
||||
id: 'password',
|
||||
label: 'Password',
|
||||
type: 'password',
|
||||
value: values.password,
|
||||
isValid: error.password,
|
||||
onChange: handleChange('password')
|
||||
}
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
setValues({ email: '', password: '' });
|
||||
};
|
||||
}, []);
|
||||
return (
|
||||
<div className={styles['login-page']}>
|
||||
<h2>Login Page</h2>
|
||||
<form onSubmit={onFormSubmit} className={styles['form']}>
|
||||
{Inputs.map((input) => (
|
||||
<Input key={input.id} {...input} />
|
||||
))}
|
||||
<button type='submit'>Log In</button>
|
||||
</form>
|
||||
<p>
|
||||
Don't have account? <Link to='/register'>Register</Link>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Login;
|
||||
114
frontend/src/pages/register/index.jsx
Normal file
114
frontend/src/pages/register/index.jsx
Normal file
@ -0,0 +1,114 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useAuth } from '@hooks';
|
||||
import Input from '@UI/input';
|
||||
import styles from '@styles/form.module.css';
|
||||
|
||||
const Register = () => {
|
||||
const [values, setValues] = useState({
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
email: '',
|
||||
password: ''
|
||||
});
|
||||
const [error, setError] = useState({
|
||||
first_name: null,
|
||||
last_name: null,
|
||||
email: null,
|
||||
password: null
|
||||
});
|
||||
|
||||
const { register } = useAuth();
|
||||
|
||||
const handleChange = (prop) => (event) => {
|
||||
setValues((prev) => ({ ...prev, [prop]: event.target.value }));
|
||||
};
|
||||
|
||||
const handleSubmit = (event) => {
|
||||
event.preventDefault();
|
||||
register(values, (err) => {
|
||||
const errors = err.response.data.errors;
|
||||
errors.forEach((error) => {
|
||||
if (error.first_name) {
|
||||
setError((prev) => ({ ...prev, first_name: error.first_name }));
|
||||
}
|
||||
if (error.last_name) {
|
||||
setError((prev) => ({ ...prev, last_name: error.last_name }));
|
||||
}
|
||||
if (error.email) {
|
||||
setError((prev) => ({ ...prev, email: error.email }));
|
||||
}
|
||||
if (error.password) {
|
||||
setError((prev) => ({ ...prev, password: error.password }));
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const Inputs = [
|
||||
{
|
||||
id: 'first_name',
|
||||
label: 'First Name',
|
||||
type: 'text',
|
||||
value: values.first_name,
|
||||
isValid: error.first_name,
|
||||
onChange: handleChange('first_name')
|
||||
},
|
||||
{
|
||||
id: 'last_name',
|
||||
label: 'Last Name',
|
||||
type: 'text',
|
||||
value: values.last_name,
|
||||
isValid: error.last_name,
|
||||
onChange: handleChange('last_name')
|
||||
},
|
||||
{
|
||||
id: 'email',
|
||||
label: 'Email Address',
|
||||
type: 'email',
|
||||
value: values.email,
|
||||
isValid: error.email,
|
||||
onChange: handleChange('email')
|
||||
},
|
||||
{
|
||||
id: 'password',
|
||||
label: 'New Password',
|
||||
type: 'password',
|
||||
value: values.password,
|
||||
isValid: error.password,
|
||||
onChange: handleChange('password')
|
||||
}
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
setValues({
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
email: '',
|
||||
password: ''
|
||||
});
|
||||
setError({
|
||||
first_name: null,
|
||||
last_name: null,
|
||||
email: null,
|
||||
password: null
|
||||
});
|
||||
};
|
||||
}, []);
|
||||
return (
|
||||
<div className={styles['register-page']}>
|
||||
<h2>Register Page</h2>
|
||||
<form onSubmit={handleSubmit} className={styles['form']}>
|
||||
{Inputs.map((input) => (
|
||||
<Input key={input.id} {...input} />
|
||||
))}
|
||||
<button type='submit'>Register</button>
|
||||
</form>
|
||||
<p>
|
||||
Already have an account? <Link to='/login'>Login</Link>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export default Register;
|
||||
56
frontend/src/styles/form.module.css
Normal file
56
frontend/src/styles/form.module.css
Normal file
@ -0,0 +1,56 @@
|
||||
.login-page,
|
||||
.register-page {
|
||||
transform: translateY(20%);
|
||||
}
|
||||
|
||||
.register-page p,
|
||||
.register-page h2,
|
||||
.login-page p,
|
||||
.login-page h2 {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.form {
|
||||
width: 300px;
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
.form input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.form input:focus {
|
||||
border: none;
|
||||
outline: 2px solid #FFF;
|
||||
}
|
||||
|
||||
.form button {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
margin: 10px 0px;
|
||||
font-weight: bold;
|
||||
border-radius: 4px;
|
||||
border: none;
|
||||
background-color: #FFF;
|
||||
color: #000;
|
||||
cursor: pointer;
|
||||
transition: background .5s ease-in-out;
|
||||
}
|
||||
|
||||
.form button:hover {
|
||||
background-color: #ddd;
|
||||
transition: background .5s ease-in-out;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 480px) {
|
||||
|
||||
.register-page h2,
|
||||
.login-page h2 {
|
||||
margin-top: 10px;
|
||||
}
|
||||
.form {
|
||||
width: 100%;
|
||||
margin: none;
|
||||
}
|
||||
}
|
||||
21
frontend/src/styles/home.module.css
Normal file
21
frontend/src/styles/home.module.css
Normal file
@ -0,0 +1,21 @@
|
||||
.home-page {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.localStorage-data {
|
||||
font-size: 12px;
|
||||
margin: 10px 0px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.localStorage-data strong {
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.logout-button {
|
||||
display: block;
|
||||
width: 100px;
|
||||
height: 40px;
|
||||
margin: 10px 0px;
|
||||
}
|
||||
29
frontend/src/styles/index.css
Normal file
29
frontend/src/styles/index.css
Normal file
@ -0,0 +1,29 @@
|
||||
:root {
|
||||
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
color-scheme: light dark;
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
background-color: #242424;
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
}
|
||||
|
||||
* {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
-moz-box-sizing: border-box;
|
||||
-webkit-box-sizing: border-box;
|
||||
-ms-box-sizing: border-box;
|
||||
-o-box-sizing: border-box;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
a {
|
||||
font-weight: 500;
|
||||
color: inherit;
|
||||
text-decoration: inherit;
|
||||
}
|
||||
21
frontend/src/styles/input.module.css
Normal file
21
frontend/src/styles/input.module.css
Normal file
@ -0,0 +1,21 @@
|
||||
.input-box {
|
||||
margin: 4px 0px;
|
||||
}
|
||||
.input-box_label {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.input-box_input {
|
||||
height: 35px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #888;
|
||||
padding: 0px 10px;
|
||||
}
|
||||
|
||||
.input-box_input.invalid {
|
||||
border: 1px solid red
|
||||
}
|
||||
|
||||
.input-error {
|
||||
font-size: 12px;
|
||||
}
|
||||
19
frontend/vite.config.js
Normal file
19
frontend/vite.config.js
Normal file
@ -0,0 +1,19 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import path from 'path';
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@styles': path.resolve(__dirname, 'src/styles'),
|
||||
'@common': path.resolve(__dirname, 'src/components/common'),
|
||||
'@UI': path.resolve(__dirname, 'src/components/UI'),
|
||||
'@hooks': path.resolve(__dirname, 'src/hooks'),
|
||||
'@config': path.resolve(__dirname, 'src/config'),
|
||||
'@pages': path.resolve(__dirname, 'src/pages'),
|
||||
'@context': path.resolve(__dirname, 'src/context')
|
||||
}
|
||||
}
|
||||
})
|
||||
2022
frontend/yarn.lock
Normal file
2022
frontend/yarn.lock
Normal file
File diff suppressed because it is too large
Load Diff
@ -1,35 +0,0 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { body, oneOf } from 'express-validator';
|
||||
import { validate } from '../validationResult';
|
||||
|
||||
export const validateLogin = [
|
||||
oneOf(
|
||||
[
|
||||
body('username')
|
||||
.exists()
|
||||
.withMessage('username is missing from the body')
|
||||
.notEmpty()
|
||||
.withMessage('username is empty')
|
||||
.isString()
|
||||
.withMessage('username must be string'),
|
||||
body('email')
|
||||
.exists()
|
||||
.withMessage('Email is missing from the body')
|
||||
.notEmpty()
|
||||
.withMessage('Email is empty')
|
||||
.isEmail()
|
||||
.withMessage('Email is not valid')
|
||||
.normalizeEmail()
|
||||
.withMessage('Email is not normalized')
|
||||
],
|
||||
'Must be at least one of email or username'
|
||||
),
|
||||
body('password')
|
||||
.exists()
|
||||
.withMessage('password is missing from the body')
|
||||
.notEmpty()
|
||||
.withMessage('password is empty')
|
||||
.isLength({ min: 8 })
|
||||
.withMessage('password must be at least 8 characters long'),
|
||||
(req: Request, res: Response, next: NextFunction) => validate(req, res, next)
|
||||
];
|
||||
@ -1,8 +0,0 @@
|
||||
import bcrypt from 'bcrypt';
|
||||
import configs from '../configs';
|
||||
|
||||
export const hash = (password: string) =>
|
||||
bcrypt.hash(`${configs.pepper}${password}${configs.pepper}`, configs.salt);
|
||||
|
||||
export const compare = (password: string, hash: string) =>
|
||||
bcrypt.compare(`${configs.pepper}${password}${configs.pepper}`, hash);
|
||||
Loading…
Reference in New Issue
Block a user