diff --git a/.env.example b/.env.example
index a95a221..04f5848 100644
--- a/.env.example
+++ b/.env.example
@@ -2,6 +2,7 @@ NODE_ENV=development
SERVER_PORT=8000
SERVER_DOMAIN=''
+SERVER_URL='http://locahost:8000'
DB_HOST=localhost
DB_PORT=5432
diff --git a/script.sql b/script.sql
index f54e6d1..cd76c9e 100644
--- a/script.sql
+++ b/script.sql
@@ -15,4 +15,12 @@ CREATE TABLE alerts (
min_amount float DEFAULT 0,
CONSTRAINT alerts_pk PRIMARY KEY (id),
FOREIGN KEY (user_id) REFERENCES users(id)
-);
\ No newline at end of file
+);
+
+CREATE TABLE user_confirmation_codes (
+ id VARCHAR(36) NOT NULL,
+ user_id VARCHAR(36) NOT NULL,
+ code VARCHAR(4) NOT NULL,
+ CONSTRAINT user_confirmation_codes_pk PRIMARY KEY (id),
+ FOREIGN KEY (user_id) REFERENCES users(id)
+);
diff --git a/src/application/pages/confirm-code.e2e.spec.ts b/src/application/pages/confirm-code.e2e.spec.ts
new file mode 100644
index 0000000..a561411
--- /dev/null
+++ b/src/application/pages/confirm-code.e2e.spec.ts
@@ -0,0 +1,89 @@
+import { faker } from '@faker-js/faker/locale/pt_BR';
+import { expect, test } from '@playwright/test';
+
+test.describe('Confirm Code Page', () => {
+ let user_id = '';
+
+ const user = {
+ name: faker.person.fullName(),
+ email: faker.internet.email(),
+ password: `${faker.string.alphanumeric(10)}!@#$`,
+ phone_number: faker.string.numeric(11),
+ };
+
+ test.beforeAll(async ({ request }) => {
+ const response = await request.post('/api/users', {
+ data: user,
+ headers: { 'Content-Type': 'application/json' },
+ });
+
+ const data = await response.json();
+
+ user_id = data.id;
+ });
+
+ test.afterAll(async ({ request }) => {
+ await request.delete(`/api/users/${user_id}`);
+ });
+
+ test('should validate the code field', async ({ page }) => {
+ await page.goto(`/pages/confirm-code?email=${user.email}`);
+
+ const invalid_code = faker.string.numeric(3);
+
+ const code_input = page.getByTestId('code');
+ await code_input.fill(invalid_code);
+ await code_input.blur();
+
+ const code_error_message = page.getByTestId('code-error-messages').first();
+
+ await expect(code_error_message).toContainText('O texto precisa ter pelo menos 4 caracteres.');
+ });
+
+ test('should not allow the user to go to /pages/confirm-code if captcha failed', async ({ page }) => {
+ await page.goto(`/pages/confirm-code?email=${user.email}`);
+
+ await page.route('*/**/api/auth/captcha', async (route) => {
+ await route.fulfill({ status: 403 });
+ });
+
+ const code_input = page.getByTestId('code');
+ await code_input.fill(faker.string.numeric(4));
+
+ const email_input = page.getByTestId('email');
+ const email_value = await email_input.inputValue();
+
+ const submit_button = page.getByTestId('confirm-code-submit');
+ await submit_button.click();
+
+ expect(email_value).toEqual(user.email);
+ expect(page).toHaveURL(`/pages/confirm-code?email=${user.email}`);
+ });
+
+ test("should alert the user if code doesn't exist", async ({ page, baseURL }) => {
+ await page.goto(`/pages/confirm-code?email=${user.email}`);
+
+ const code_input = page.getByTestId('code');
+ await code_input.fill(faker.string.numeric(4));
+
+ const email_input = page.getByTestId('email');
+ const email_value = await email_input.inputValue();
+
+ const submit_button = page.getByTestId('confirm-code-submit');
+ await submit_button.click();
+
+ await page.waitForResponse(`${baseURL}/forms/confirm-code`);
+
+ const alert_message = page.getByTestId('alert-message');
+ const text = await alert_message.innerText();
+
+ expect(email_value).toEqual(user.email);
+ expect(text).toBe('Código não encontrado.');
+ });
+
+ test("should redirect user to /pages/login if query param doesn't have email", async ({ page }) => {
+ await page.goto('/pages/confirm-code');
+
+ expect(page).toHaveURL('/pages/login');
+ });
+});
diff --git a/src/application/pages/confirm-code.html b/src/application/pages/confirm-code.html
index 2cadb2e..4bbb8a4 100644
--- a/src/application/pages/confirm-code.html
+++ b/src/application/pages/confirm-code.html
@@ -1,9 +1,29 @@
{{> _head}}
{{title}}
+
+
{{> _footer}}
\ No newline at end of file
diff --git a/src/application/pages/login.html b/src/application/pages/login.html
index 45ed8e8..35064b8 100644
--- a/src/application/pages/login.html
+++ b/src/application/pages/login.html
@@ -16,7 +16,7 @@
{{title}}
{{> _footer}}
\ No newline at end of file
diff --git a/src/application/pages/partials/_head.html b/src/application/pages/partials/_head.html
index 998d341..906dcb2 100644
--- a/src/application/pages/partials/_head.html
+++ b/src/application/pages/partials/_head.html
@@ -18,7 +18,6 @@
-
{{> components/links}}
{{> components/scripts}}
diff --git a/src/application/pages/signup.e2e.spec.ts b/src/application/pages/signup.e2e.spec.ts
index 627725f..f0a2b68 100644
--- a/src/application/pages/signup.e2e.spec.ts
+++ b/src/application/pages/signup.e2e.spec.ts
@@ -2,7 +2,6 @@ import { faker } from '@faker-js/faker/locale/pt_BR';
import { expect, test } from '@playwright/test';
test.describe('Signup Page', () => {
- let user_id = '';
const user = {
name: faker.person.fullName(),
email: faker.internet.email(),
@@ -11,18 +10,10 @@ test.describe('Signup Page', () => {
};
test.beforeAll(async ({ request }) => {
- const response = await request.post('/api/users', {
+ await request.post('/api/users', {
data: user,
headers: { 'Content-Type': 'application/json' },
});
-
- const data = await response.json();
-
- user_id = data.id;
- });
-
- test.afterAll(async ({ request }) => {
- await request.delete(`/api/users/${user_id}`);
});
test.beforeEach(async ({ page }) => {
@@ -88,7 +79,7 @@ test.describe('Signup Page', () => {
await expect(phone_number_error_message).toContainText('O campo precisa ser um telefone válido.');
});
- test('should not allow the user to go to /pages/index if captcha failed', async ({ page }) => {
+ test('should not allow the user to go to /pages/confirm-code if captcha failed', async ({ page }) => {
await page.route('*/**/api/auth/captcha', async (route) => {
await route.fulfill({ status: 403 });
});
@@ -111,7 +102,7 @@ test.describe('Signup Page', () => {
expect(page).toHaveURL('/pages/signup');
});
- test('should redirect the user to /pages/login if captcha succeed', async ({ page }) => {
+ test('should redirect the user to /pages/confirm-code if captcha succeed', async ({ page }) => {
const name_input = page.getByTestId('signup-name');
await name_input.fill(faker.person.fullName());
@@ -126,9 +117,9 @@ test.describe('Signup Page', () => {
const submit_button = page.getByTestId('signup-submit');
await submit_button.click();
- await page.waitForURL('**/pages/confirm-code');
+ await page.waitForTimeout(3000);
- expect(page).toHaveURL('/pages/confirm-code');
+ expect(page).toHaveTitle('Confirmar código!');
});
test('should not register the same email twice', async ({ page, baseURL }) => {
diff --git a/src/application/pages/signup.html b/src/application/pages/signup.html
index 58b1338..e0c207e 100644
--- a/src/application/pages/signup.html
+++ b/src/application/pages/signup.html
@@ -25,7 +25,7 @@ {{title}}
const phone_number_input = document.getElementById('phone-number');
const mask = IMask(phone_number_input, { mask: '(00)00000-0000' });
- new SignupForm(
+ new Form(
document.getElementById('signup-form'),
[
{
@@ -49,6 +49,6 @@ {{title}}
rules: ['required', 'min:8', 'password']
}
]
- ).init();
+ ).init(true);
{{> _footer}}
\ No newline at end of file
diff --git a/src/application/public/js/form.js b/src/application/public/js/form.js
index df45863..a9bc697 100644
--- a/src/application/public/js/form.js
+++ b/src/application/public/js/form.js
@@ -9,22 +9,26 @@ class Form {
fields = [];
/**
- * @property {Element} form
+ * @property {Element} form_element
*/
form_element;
/**
- * @param {Element} form
+ * @param {Element} form_element
* @param {Object[]} fields
* @param {Element} fields[].input_element
* @param {Element} fields[].error_message_element
*/
- constructor(form, fields) {
- this.form_element = form;
+ constructor(form_element, fields) {
+ this.form_element = form_element;
this.fields = fields;
}
- init() {
+ /**
+ *
+ * @param {boolean} captcha
+ */
+ init(captcha = false) {
this.fields.forEach(({ input_element, error_message_element, rules }) => {
input_element.addEventListener('blur', this.validateInput(
error_message_element,
@@ -33,6 +37,10 @@ class Form {
).bind(this));
});
+ if (captcha) {
+ this.form_element.addEventListener('submit', this.submitCaptcha.bind(this));
+ return;
+ }
this.form_element.addEventListener('submit', this.submit.bind(this));
}
@@ -49,8 +57,44 @@ class Form {
* @param {Event} event
*/
submit(event) {
- console.log(event);
- throw new Error('Not implemented');
+ event.preventDefault();
+
+ if (this.hasErrors()) {
+ return;
+ }
+
+ this.form_element.submit();
+ }
+
+ /**
+ *
+ * @param {Event} event
+ */
+ submitCaptcha(event) {
+ event.preventDefault();
+
+ if (this.hasErrors()) {
+ return;
+ }
+
+ grecaptcha.ready(async () => {
+ const token = await grecaptcha.execute(
+ '6LdAc2UpAAAAAObuHow9pOS5dy0coRW11AKKiWJA',
+ { action: 'submit' },
+ );
+
+ const response = await fetch('/api/auth/captcha', {
+ method: 'POST',
+ body: JSON.stringify({ token }),
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ });
+
+ if (response.status === 204) {
+ this.form_element.submit();
+ }
+ });
}
/**
diff --git a/src/application/public/js/login_form.js b/src/application/public/js/login_form.js
deleted file mode 100644
index 6d60aa7..0000000
--- a/src/application/public/js/login_form.js
+++ /dev/null
@@ -1,29 +0,0 @@
-// eslint-disable-next-line no-unused-vars
-class LoginForm extends Form {
- submit(e) {
- e.preventDefault();
-
- if (this.hasErrors()) {
- return;
- }
-
- grecaptcha.ready(async () => {
- const token = await grecaptcha.execute(
- '6LdAc2UpAAAAAObuHow9pOS5dy0coRW11AKKiWJA',
- { action: 'submit' },
- );
-
- const response = await fetch('/api/auth/captcha', {
- method: 'POST',
- body: JSON.stringify({ token }),
- headers: {
- 'Content-Type': 'application/json',
- },
- });
-
- if (response.status === 204) {
- this.form_element.submit();
- }
- });
- }
-}
diff --git a/src/application/public/js/signup_form.js b/src/application/public/js/signup_form.js
deleted file mode 100644
index 53800c4..0000000
--- a/src/application/public/js/signup_form.js
+++ /dev/null
@@ -1,29 +0,0 @@
-// eslint-disable-next-line no-unused-vars
-class SignupForm extends Form {
- submit(e) {
- e.preventDefault();
-
- if (this.hasErrors()) {
- return;
- }
-
- grecaptcha.ready(async () => {
- const token = await grecaptcha.execute(
- '6LdAc2UpAAAAAObuHow9pOS5dy0coRW11AKKiWJA',
- { action: 'submit' },
- );
-
- const response = await fetch('/api/auth/captcha', {
- method: 'POST',
- body: JSON.stringify({ token }),
- headers: {
- 'Content-Type': 'application/json',
- },
- });
-
- if (response.status === 204) {
- this.form_element.submit();
- }
- });
- }
-}
diff --git a/src/application/routers/forms.ts b/src/application/routers/forms.ts
index bcc1454..5c7beee 100644
--- a/src/application/routers/forms.ts
+++ b/src/application/routers/forms.ts
@@ -1,32 +1,38 @@
import CredentialError from '@shared/CredentialError';
import EmailAlreadyRegisteredError from '@shared/EmailAlreadyRegisteredError';
-import { Request, Response, Router } from 'express';
+import {
+ NextFunction, Request, Response, Router,
+} from 'express';
import { auth_service, user_service } from 'src/bootstrap';
const router = Router();
-router.post('/login', async (request: Request, response: Response) => {
- const { email, password } = request.body;
- const result = await auth_service.login(email, password);
- const isProd = process.env.NODE_ENV === 'production';
-
- if (result.data) {
- response.cookie('AT', result.data.token, {
- httpOnly: isProd,
- sameSite: isProd,
- secure: true,
- domain: isProd ? process.env.SERVER_DOMAIN : '',
- expires: result.data.expired_at,
- });
+router.post('/login', async (request: Request, response: Response, next: NextFunction) => {
+ try {
+ const { email, password } = request.body;
+ const result = await auth_service.login(email, password);
+ const isProd = process.env.NODE_ENV === 'production';
- return response.redirect('/');
- }
+ if (result.data) {
+ response.cookie('AT', result.data.token, {
+ httpOnly: isProd,
+ sameSite: isProd,
+ secure: true,
+ domain: isProd ? process.env.SERVER_DOMAIN : '',
+ expires: result.data.expired_at,
+ });
- if (result.error instanceof CredentialError) {
- return response.redirect(`/pages/login?error_message=${result.error.message}`);
- }
+ return response.redirect('/');
+ }
+
+ if (result.error instanceof CredentialError) {
+ return response.redirect(`/pages/login?error_message=${result.error.message}`);
+ }
- return response.render('/pages/login');
+ return response.render('/pages/login');
+ } catch (e) {
+ return next(e)
+ }
});
router.post('/logout', (_request: Request, response: Response) => {
@@ -34,28 +40,54 @@ router.post('/logout', (_request: Request, response: Response) => {
response.redirect('/pages/login');
});
-router.post('/signup', async (request: Request, response: Response) => {
- const {
- email,
- name,
- password,
- phone_number,
- } = request.body;
+router.post('/signup', async (request: Request, response: Response, next: NextFunction) => {
+ try {
+ const {
+ email,
+ name,
+ password,
+ phone_number,
+ } = request.body;
- console.log(request.body);
+ const result = await user_service.createUser({
+ email,
+ name,
+ password,
+ phone_number,
+ });
- const result = await user_service.createUser({
- email,
- name,
- password,
- phone_number,
- });
+ if (result.error instanceof EmailAlreadyRegisteredError) {
+ return response.redirect(`/pages/signup?error_message=${result.error.message}`);
+ }
- if (result.error instanceof EmailAlreadyRegisteredError) {
- return response.redirect(`/pages/signup?error_message=${result.error.message}`);
+ await auth_service.sendConfirmationCode(result.data!.email);
+
+ return response.redirect(`/pages/confirm-code?email=${result.data!.email}`);
+ } catch (e) {
+ return next(e);
}
+});
+
+router.post('/confirm-code', async (request: Request, response: Response, next: NextFunction) => {
+ try {
+ const { email, code } = request.body;
+
+ console.log(email, code);
- return response.redirect('/pages/confirm-code');
+ const result = await auth_service.confirmCode(email, code);
+
+ if (!result.data) {
+ const query_params = new URLSearchParams({
+ email,
+ error_message: 'Código não encontrado.',
+ }).toString();
+ return response.redirect(`/pages/confirm-code?${query_params}`);
+ }
+
+ return response.redirect('/pages/login');
+ } catch (e) {
+ return next(e);
+ }
});
router.post('/forgot-password', (request: Request, response: Response) => {
diff --git a/src/application/routers/pages.ts b/src/application/routers/pages.ts
index 83bea5e..3d7b7d1 100644
--- a/src/application/routers/pages.ts
+++ b/src/application/routers/pages.ts
@@ -1,5 +1,6 @@
import auth from '@app/middlewares/auth';
import { Request, Response, Router } from 'express';
+import QueryString from 'qs';
const router = Router();
@@ -8,8 +9,7 @@ const LINKS: Record = {};
const SCRIPTS: Record = {
captcha: 'https://www.google.com/recaptcha/api.js?render=6LdAc2UpAAAAAObuHow9pOS5dy0coRW11AKKiWJA',
validator: '/js/validator.js',
- login_form: '/js/login_form.js',
- signup_form: '/js/signup_form.js',
+ form: '/js/form.js',
imask: 'https://unpkg.com/imask',
};
@@ -23,13 +23,21 @@ function getScriptUrls(script_names: string[]) {
return script_keys.map((key) => ({ url: SCRIPTS[key] }));
}
+function getAlerts(query: QueryString.ParsedQs) {
+ const { error_message } = query;
+ const alerts = [];
+
+ if (error_message) {
+ alerts.push({ message: error_message });
+ }
+ return alerts;
+}
+
router.get('/index', auth, (_request: Request, response: Response) => {
response.render('index', { title: 'Hellow Mustache!' });
});
router.get('/login', (request: Request, response: Response) => {
- const { error_message } = request.query;
-
if (request.headers.cookie) {
const cookies = request.headers.cookie.split(';');
const has_access_token = cookies.find((cookie) => cookie.split('=')[0] === 'AT');
@@ -39,37 +47,31 @@ router.get('/login', (request: Request, response: Response) => {
}
}
- const alerts = [];
-
- if (error_message) {
- alerts.push({ message: error_message });
- }
-
return response.render('login', {
title: 'Login!',
- scripts: getScriptUrls(['captcha', 'validator', 'login_form']),
- alerts,
+ scripts: getScriptUrls(['captcha', 'validator', 'form']),
+ alerts: getAlerts(request.query),
});
});
router.get('/signup', (request: Request, response: Response) => {
- const { error_message } = request.query;
- const alerts = [];
-
- if (error_message) {
- alerts.push({ message: error_message });
- }
-
response.render('signup', {
title: 'Sign up!',
- scripts: getScriptUrls(['captcha', 'validator', 'signup_form', 'imask']),
- alerts,
+ scripts: getScriptUrls(['captcha', 'validator', 'form', 'imask']),
+ alerts: getAlerts(request.query),
});
});
-router.get('/confirm-code', (_request: Request, response: Response) => {
- response.render('confirm-code', {
+router.get('/confirm-code', (request: Request, response: Response) => {
+ if (!request.query.email) {
+ return response.redirect('/pages/login');
+ }
+
+ return response.render('confirm-code', {
title: 'Confirmar código!',
+ email: request.query.email,
+ scripts: getScriptUrls(['captcha', 'validator', 'form']),
+ alerts: getAlerts(request.query),
});
});
diff --git a/src/b3_stock_alerts/EmailGateway.ts b/src/b3_stock_alerts/EmailGateway.ts
index ff9c314..7009fff 100644
--- a/src/b3_stock_alerts/EmailGateway.ts
+++ b/src/b3_stock_alerts/EmailGateway.ts
@@ -9,7 +9,7 @@ export default class EmailGateway implements AlertNotification, ConfirmationCode
this.transporter = nodemailer.createTransport({
host: process.env.EMAIL_HOST,
port: parseInt(process.env.EMAIL_PORT!, 10),
- secure: true,
+ secure: process.env.NODE_ENV === 'production',
auth: {
user: process.env.EMAIL_USER,
pass: process.env.EMAIL_PASSWORD,
@@ -45,7 +45,7 @@ export default class EmailGateway implements AlertNotification, ConfirmationCode
to: params.email,
subject: 'Código de confirmação',
text: `Segue o código de confirmação ${params.code}. Acesse o link ${process.env.SERVER_URL}/pages/confirm-code?email=${params.email}.`,
- html: `Segue o código de confirmação ${params.code}.
Acesse o link ${process.env.SERVER_URL}/pages/confirm-code?email=${params.email}.
`,
+ html: `Segue o código de confirmação ${params.code}.
Acesse o link.
`,
};
await this.transporter.sendMail(message);
diff --git a/src/b3_stock_alerts/PgUserRepository.ts b/src/b3_stock_alerts/PgUserRepository.ts
index b39ad7e..17ce593 100644
--- a/src/b3_stock_alerts/PgUserRepository.ts
+++ b/src/b3_stock_alerts/PgUserRepository.ts
@@ -87,8 +87,8 @@ export default class PgUserRepository implements UserRepository {
async getConfirmationCode(email: string, code: string): Promise {
const result = await this.client.query(
- 'SELECT ucc.id, user_id, code FROM user_confirmation_codes ucc WHERE code = $1 JOIN users ON users.email = $2',
- [code, email],
+ 'SELECT ucc.id, user_id, code FROM user_confirmation_codes ucc JOIN users ON users.email = $1 WHERE code = $2',
+ [email, code],
);
if (result.rows[0] === undefined) {
diff --git a/src/b3_stock_alerts/PgUserRepository.unit.test.ts b/src/b3_stock_alerts/PgUserRepository.unit.test.ts
index 7d08956..f9653f1 100644
--- a/src/b3_stock_alerts/PgUserRepository.unit.test.ts
+++ b/src/b3_stock_alerts/PgUserRepository.unit.test.ts
@@ -206,8 +206,8 @@ describe('PgUserRepository', () => {
const result = await repository.getConfirmationCode(email, code);
expect(query_mock).toHaveBeenCalledWith(
- 'SELECT ucc.id, user_id, code FROM user_confirmation_codes ucc WHERE code = $1 JOIN users ON users.email = $2',
- [code, email],
+ 'SELECT ucc.id, user_id, code FROM user_confirmation_codes ucc JOIN users ON users.email = $1 WHERE code = $2',
+ [email, code],
);
expect(result).toEqual(confirmation_code);
});
@@ -223,8 +223,8 @@ describe('PgUserRepository', () => {
const result = await repository.getConfirmationCode(email, code);
expect(query_mock).toHaveBeenCalledWith(
- 'SELECT ucc.id, user_id, code FROM user_confirmation_codes ucc WHERE code = $1 JOIN users ON users.email = $2',
- [code, email],
+ 'SELECT ucc.id, user_id, code FROM user_confirmation_codes ucc JOIN users ON users.email = $1 WHERE code = $2',
+ [email, code],
);
expect(result).toBeNull();