Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
PORT=3005
DB_HOST=localhost
DB_USER=postgres
DB_PASSWORD=
DB_DATABASE=postgres
CLIENT_HOST=http://localhost:5173
JWT_KEY=
JWT_REFRESH_KEY=
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_USER=
SMTP_PASSWORD=
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
File renamed without changes.
2 changes: 1 addition & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ jobs:

strategy:
matrix:
node-version: [12.x]
node-version: [20.x]

steps:
- uses: actions/checkout@v2
Expand Down
23 changes: 23 additions & 0 deletions .github/workflows/test.yml-template
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
name: Test

on:
pull_request:
branches: [ master ]

jobs:
build:

runs-on: ubuntu-latest

strategy:
matrix:
node-version: [20.x]

steps:
- uses: actions/checkout@v2
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v1
with:
node-version: ${{ matrix.node-version }}
- run: npm install
- run: npm test
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,6 @@ node_modules

# MacOS
.DS_Store
.env
backend/.env

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good job ignoring the environment file for the backend. To ensure frontend secrets are also kept safe, you should add an entry for the frontend's environment file as well, for example frontend/.env.


2 changes: 2 additions & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
/node_modules
/dist
9 changes: 9 additions & 0 deletions .prettierrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"tabWidth": 2,
"useTabs": false,
"semi": true,
"singleQuote": true,
"trailingComma": "all",
"printWidth": 80,
"endOfLine": "auto"
}
6 changes: 6 additions & 0 deletions backend/setup.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import 'dotenv/config';
import { client } from './backend/src/utils/db.js';
import './backend/src/models/user.js';
import './backend/src/models/token.js';
Comment on lines +2 to +4

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These import paths are incorrect. This script is located in the backend directory, so the paths to the modules should be relative to it (e.g., './src/utils/db.js'). The current paths will resolve to backend/backend/... which will cause the script to fail.


await client.sync({ alter: true });
174 changes: 174 additions & 0 deletions backend/src/controllers/auth.controller.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
import { ApiError } from '../exeptions/api.error.js';
import { User } from '../models/user.js';
import { jwtService } from '../services/jwt.service.js';
import { tokenService } from '../services/token.service.js';
import { userService } from '../services/user.service.js';
import { emailService } from '../services/email.service.js';
import { v4 as uuidv4 } from 'uuid';
import bcrypt from 'bcrypt';

function validateEmail(value) {
if (!value) {
return 'Email is required';
}

const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;

if (!emailPattern.test(value)) {
return 'Email is not valid';
}
}

function validatePassword(value) {
if (!value) {
return 'Password is required';
}

if (value.length < 6) {
return 'At least 6 characters';
}
}

const register = async (req, res) => {
const { email, password, name } = req.body;

const errors = {
email: validateEmail(email),
password: validatePassword(password),
};

if (errors.email || errors.password) {
throw ApiError.badRequest('Bad request', errors);
}

const hashedPass = await bcrypt.hash(password, 10);

await userService.register({ email, password: hashedPass, name });
res.send({ message: 'OK' });
};

const activate = async (req, res) => {
const { activationToken } = req.params;
const user = await User.findOne({ where: { activationToken } });

if (!user) {
return res.status(404).send({ message: 'User not found' });
}

user.activationToken = null;
await user.save();

const { accessToken, user: normalizedUser } = await generateTokens(res, user);

res.json({ accessToken, user: normalizedUser });
};

const login = async (req, res) => {
const { email, password } = req.body;
const user = await userService.findByEmail(email);

if (!user) {
throw ApiError.badRequest('No such user');
}

if (user.activationToken) {
throw ApiError.badRequest('Please activate your email');
}

const isPasswordValid = await bcrypt.compare(password, user.password);

if (!isPasswordValid) {
throw ApiError.badRequest('Wrong password');
}

const { accessToken, user: normalizedUser } = await generateTokens(res, user);

res.json({ accessToken, user: normalizedUser });
};

const refresh = async (req, res) => {
const { refreshToken } = req.cookies;
const userData = await jwtService.verifyRefresh(refreshToken);
const token = await tokenService.getByToken(refreshToken);

if (!userData || !token) {
throw ApiError.unAuthorized();
}

const user = await userService.findByEmail(userData.email);
const { accessToken, user: normalizedUser } = await generateTokens(res, user);

res.json({ accessToken, user: normalizedUser });
};

export async function generateTokens(res, user) {
const normalizedUser = userService.normalize(user);
const refreshToken = jwtService.signRefresh(normalizedUser);
const accessToken = jwtService.sign(normalizedUser);

await tokenService.save(normalizedUser.id, refreshToken);

res.cookie('refreshToken', refreshToken, {
maxAge: 30 * 24 * 60 * 60 * 1000,
httpOnly: true,
});

return { accessToken, user: normalizedUser };
}

const logout = async (req, res) => {
const { refreshToken } = req.cookies;
const userData = await jwtService.verifyRefresh(refreshToken);

if (!userData || !refreshToken) {
throw ApiError.unAuthorized();
}

await tokenService.remove(userData.id);

res.sendStatus(204);
};

const forgotPassword = async (req, res) => {
const { email } = req.body;
const user = await userService.findByEmail(email);

if (!user) {
throw ApiError.badRequest('No such user');
}

const resetToken = uuidv4();

user.resetToken = resetToken;
await user.save();

await emailService.sendResetEmail(email, resetToken);
res.send({ message: 'OK' });
};

const resetPassword = async (req, res) => {
const { token } = req.params;
const { password } = req.body;

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When resetting a password, it's important to validate the new password to ensure it meets the required criteria (e.g., minimum length). You can reuse the validatePassword function here to check the new password before hashing and saving it.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

According to the task requirements, the password reset functionality must validate that the password and confirmation fields are equal. You need to get the confirmation from the request body and add a check to ensure it matches the password before updating it.


const user = await User.findOne({ where: { resetToken: token } });

if (!user) {
throw ApiError.badRequest('Invalid token');
}

user.password = await bcrypt.hash(password, 10);
user.resetToken = null;
await user.save();

res.send({ message: 'OK' });
};

export const authController = {
register,
activate,
login,
logout,
refresh,
resetPassword,
forgotPassword,
};
77 changes: 77 additions & 0 deletions backend/src/controllers/user.controller.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import bcrypt from 'bcrypt';
import { ApiError } from '../exeptions/api.error.js';
import { User } from '../models/user.js';
import { userService } from '../services/user.service.js';
import { emailService } from '../services/email.service.js';

const getAllActivated = async (req, res) => {
const users = await userService.getAllActivated();

res.send(users.map(userService.normalize));
};

const updateName = async (req, res) => {
const { name } = req.body;
const { id } = req.user;

const user = await User.findByPk(id);

user.name = name;
await user.save();

res.send(userService.normalize(user));
};

const changePassword = async (req, res) => {
const { password, newPassword } = req.body;

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The task requires the change password functionality to include fields for new password and confirmation. This function handles the old and new passwords but is missing the logic to check that the new password and its confirmation match. It's best practice to validate this on the server, not just the client.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The task requires a confirmation field for the new password to ensure the user typed it correctly. You're missing this check. Please update the function to receive the confirmation from the request body and throw an error if it doesn't match newPassword.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The task requires that changing a password includes a confirmation field for the new password. You need to get the confirmation from the request body and add a check to ensure it matches the newPassword before hashing and saving it.

const { id } = req.user;

const user = await User.findByPk(id);

if (!user.password) {
throw ApiError.badRequest('У вас немає пароля — ви увійшли через Google');
}

const isValid = await bcrypt.compare(password, user.password);

if (!isValid) {
throw ApiError.badRequest('Wrong password');
}

user.password = await bcrypt.hash(newPassword, 10);
await user.save();

res.send({ message: 'OK' });
};

const changeEmail = async (req, res) => {
const { password, newEmail } = req.body;

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

According to the requirements, changing an email should involve confirming the new email address. This implementation is missing that confirmation step. You should expect a confirmation field for the new email (e.g., newEmail and confirmNewEmail) and validate that they are the same before proceeding.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

According to the task description, the user must confirm their new email address. This implementation is missing a field for email confirmation. Please add a check to ensure the newEmail and its confirmation from the request body are identical before proceeding with the update.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The requirements state that to change an email, the user must "confirm the new email". This implies that you should expect a confirmation field for the newEmail in the request body and validate that they match before proceeding with the change.

const { id } = req.user;

const user = await User.findByPk(id);
const isValid = await bcrypt.compare(password, user.password);

if (!isValid) {
throw ApiError.badRequest('Wrong password');
}

const existUser = await userService.findByEmail(newEmail);

if (existUser) {
throw ApiError.badRequest('Email already in use');
}

await emailService.sendEmailChangeNotification(user.email, newEmail);

user.email = newEmail;
await user.save();

res.send(userService.normalize(user));
};

export const userController = {
getAllActivated,
updateName,
changePassword,
changeEmail,
};
32 changes: 32 additions & 0 deletions backend/src/exeptions/api.error.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
export class ApiError extends Error {
constructor({ message, status, errors = {} }) {
super(message);

this.status = status;
this.errors = errors;
}

static badRequest(message, errors) {
return new ApiError({
message,
errors,
status: 400,
});
}

static unAuthorized(errors) {
return new ApiError({
message: 'Unauthorized user',
errors,
status: 401,
});
}

static notFound(errors) {
return new ApiError({
message: 'Not found',
errors,
status: 404,
});
}
}
34 changes: 34 additions & 0 deletions backend/src/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
'use strict';
import 'dotenv/config';
import express from 'express';
import { authRouter } from './routes/auth.route.js';
import cors from 'cors';
import { userRouter } from './routes/user.route.js';
import { errorMiddleware } from './middlewares/errorMiddleware.js';
import cookieParser from 'cookie-parser';
import passport from './utils/passport.js';

const PORT = process.env.PORT || 3005;

const app = express();

app.use(express.json());
app.use(cookieParser());
app.use(passport.initialize());

app.use(
cors({
origin: process.env.CLIENT_HOST,
credentials: true,
}),
);
app.use('/auth', authRouter);
app.use('/users', userRouter);

app.get('/', (req, res) => {
res.send('Hello World!');
});

app.use(errorMiddleware);

app.listen(PORT);
Loading