From 850668fc786d36158c7f892e428b1aae3c256bd6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hermerson=20Ara=C3=BAjo?= Date: Sun, 11 Feb 2024 17:52:07 -0300 Subject: [PATCH] feat: finish confirm code page --- .env.example | 1 + script.sql | 10 +- .../pages/confirm-code.e2e.spec.ts | 89 +++++++++++++++ src/application/pages/confirm-code.html | 22 +++- src/application/pages/login.html | 4 +- src/application/pages/partials/_head.html | 1 - src/application/pages/signup.e2e.spec.ts | 19 +--- src/application/pages/signup.html | 4 +- src/application/public/js/form.js | 58 ++++++++-- src/application/public/js/login_form.js | 29 ----- src/application/public/js/signup_form.js | 29 ----- src/application/routers/forms.ts | 106 ++++++++++++------ src/application/routers/pages.ts | 48 ++++---- src/b3_stock_alerts/EmailGateway.ts | 4 +- src/b3_stock_alerts/PgUserRepository.ts | 4 +- .../PgUserRepository.unit.test.ts | 8 +- 16 files changed, 282 insertions(+), 154 deletions(-) create mode 100644 src/application/pages/confirm-code.e2e.spec.ts delete mode 100644 src/application/public/js/login_form.js delete mode 100644 src/application/public/js/signup_form.js 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();