diff --git a/components/Account/ActionsDialog/index.js b/components/Account/ActionsDialog/index.js new file mode 100644 index 0000000..36f95e5 --- /dev/null +++ b/components/Account/ActionsDialog/index.js @@ -0,0 +1,112 @@ +import React, { useState } from 'react'; +import PropTypes from 'prop-types'; +import { + makeStyles, + Button, + DialogTitle, + Dialog, +} from '@material-ui/core'; + +import api from '../../../utils/api'; +import { useAuth } from '../../../src/contexts/auth'; +import SnackAlert from '../../shared/SnackAlert'; + +const useStyles = makeStyles({ + titleRoot: { + flex: '0 0 auto', + margin: '0', + padding: '16px 24px 0 24px', + fontWeight: '700', + }, +}); + +function ActionsDialog({ + open, onClose, id, +}) { + const [errorOpen, setErrorOpen] = useState(false); + const [successOpen, setSuccessOpen] = useState(false); + const classes = useStyles(); + const { logout } = useAuth(); + + const handleDelete = () => { + api.delete('/user') + .then(() => { + setSuccessOpen(true); + setTimeout(() => { logout(); }, 1000); + }) + .catch(() => { + setErrorOpen(true); + }); + }; + + const handleSnackClose = (_, reason) => { + if (reason === 'clickaway') { + return; + } + + setSuccessOpen(false); + setErrorOpen(false); + }; + + return ( + + + + + + Are you sure? + + +
+

+ This will permanently delete your account. All data will be lost forever. +

+ + + +
+
+ ); +} + +ActionsDialog.propTypes = { + onClose: PropTypes.func.isRequired, + open: PropTypes.bool.isRequired, + id: PropTypes.string.isRequired, +}; + +export default ActionsDialog; diff --git a/components/Account/Modal.js b/components/Account/Modal.js deleted file mode 100644 index 77d9676..0000000 --- a/components/Account/Modal.js +++ /dev/null @@ -1,105 +0,0 @@ -import { useState } from 'react'; -import PropTypes from 'prop-types'; -import { - Button, Modal, makeStyles, -} from '@material-ui/core'; - -import api from '../../utils/api'; -import { useAuth } from '../../src/contexts/auth'; -import SnackAlert from '../shared/SnackAlert'; - -function getModalStyle() { - const top = 50; - const left = 50; - - return { - top: `${top}%`, - left: `${left}%`, - transform: `translate(-${top}%, -${left}%)`, - }; -} - -const useStyles = makeStyles((theme) => ({ - paper: { - position: 'absolute', - width: 400, - backgroundColor: theme.palette.background.paper, - border: null, - boxShadow: theme.shadows[5], - padding: theme.spacing(2, 4, 3), - borderRadius: '8px', - }, -})); - -function DeleteAccountModal({ open, setOpen }) { - const classes = useStyles(); - const [modalStyle] = useState(getModalStyle); - const { logout } = useAuth(); - const [successOpen, setSuccessOpen] = useState(false); - const [errorOpen, setErrorOpen] = useState(false); - - const handleClose = () => { - setOpen(false); - }; - - const handleDelete = () => { - api.delete('/user') - .then(() => { - setSuccessOpen(true); - setTimeout(() => { logout(); }, 1000); - }) - .catch(() => { - setErrorOpen(true); - }); - }; - - const handleSnackClose = (_, reason) => { - if (reason === 'clickaway') { - return; - } - - setSuccessOpen(false); - setErrorOpen(false); - }; - - return ( - -
-

Are you sure?

-

- This will permanently delete your account. All data will be lost forever. -

- - - - -
-
- ); -} - -export default DeleteAccountModal; - -DeleteAccountModal.propTypes = { - open: PropTypes.bool.isRequired, - setOpen: PropTypes.func.isRequired, -}; diff --git a/components/shared/SnackAlert/index.js b/components/shared/SnackAlert/index.js index 8b0126f..ea0b29e 100644 --- a/components/shared/SnackAlert/index.js +++ b/components/shared/SnackAlert/index.js @@ -3,7 +3,7 @@ import { Snackbar } from '@material-ui/core'; import Alert from '@material-ui/lab/Alert'; function SnackAlert({ - open, severity, message, onClose, + open, severity, message, onClose, id, }) { return ( { message } @@ -26,6 +27,7 @@ SnackAlert.propTypes = { onClose: PropTypes.func, severity: PropTypes.string.isRequired, message: PropTypes.string.isRequired, + id: PropTypes.string.isRequired, }; SnackAlert.defaultProps = { diff --git a/pages/users/account.js b/pages/users/account.js index f1f4f84..873ab73 100644 --- a/pages/users/account.js +++ b/pages/users/account.js @@ -11,7 +11,7 @@ import { import { TextField, CheckboxWithLabel } from 'formik-material-ui'; import Navbar from '../../components/shared/dashboard/Navbar'; -import DeleteAccountModal from '../../components/Account/Modal'; +import DeleteAccountModal from '../../components/Account/ActionsDialog'; import emailValidator from '../../utils/emailValidator'; import ProtectRoute from '../../utils/ProtectRoute'; @@ -95,12 +95,13 @@ function Account({ user }) { return ( - + setOpen(false)} id="delete-account-modal" /> handleValidate(values)} onSubmit={(values, { setSubmitting }) => handleSubmit(values, setSubmitting)} + id="form" > {({ values, submitForm, isSubmitting, resetForm, @@ -122,6 +123,7 @@ function Account({ user }) { label="Email" value={values.email} style={{ marginBottom: '10px' }} + id="email-input" /> New Password: @@ -144,6 +147,7 @@ function Account({ user }) { name="newPassword" value={values.newPassword} style={{ marginBottom: '10px' }} + id="new-password-input" /> @@ -166,6 +171,7 @@ function Account({ user }) { Label={{ label: 'Email notification after each bridge event', }} + id="notifications-checkbox" /> Delete Account @@ -211,8 +218,20 @@ function Account({ user }) { )} - - + + ); } diff --git a/specs/fixtures/user.json b/specs/fixtures/user.json index 526604f..1e42c5b 100644 --- a/specs/fixtures/user.json +++ b/specs/fixtures/user.json @@ -1,4 +1,6 @@ { - "email": "demo@demo.com", - "password": "password" + "user": { + "email": "demo@demo.com", + "notifications": true + } } \ No newline at end of file diff --git a/specs/integration/bridgeapi/users/account.spec.js b/specs/integration/bridgeapi/users/account.spec.js new file mode 100644 index 0000000..bb7a8a9 --- /dev/null +++ b/specs/integration/bridgeapi/users/account.spec.js @@ -0,0 +1,202 @@ +/* eslint-disable cypress/no-unnecessary-waiting */ +/// + +import { + inputPassword, + submit, +} from '../../../support/utils/inputs'; + +const stubSuccessAccount = () => { + cy.stubRequest('/user', 'PATCH', 200, {}); +}; + +const stubFailAccount = () => { + const response = { + error: 'some error message', + }; + cy.stubRequest('/user', 'PATCH', 400, response); +}; + +const stubSuccessDeleteAccount = () => { + cy.stubRequest('/user', 'DELETE', 200, {}); +}; + +const stubFailDeleteAccount = () => { + const response = { + error: 'some error message', + }; + cy.stubRequest('/user', 'DELETE', 400, response); +}; + +const inputNewPassword = (pw) => { + const input = pw || 'password'; + + cy.get('#new-password-input') + .type(input).should('have.value', input); +}; + +const inputNewPasswordConfirmation = (pw) => { + const input = pw || 'password'; + + cy.get('#new-password-confirmation-input') + .type(input).should('have.value', input); +}; + +const inputNewPasswords = (pw, pwc) => { + inputNewPassword(pw); + inputNewPasswordConfirmation(pwc); +}; + +describe('Account page', () => { + afterEach(() => { + cy.clearCookies(); + }); + + it('redirects to login with bad token', () => { + cy.setBadToken(); + cy.visit('/users/account'); + + cy.location().should((location) => { + expect(location.pathname).to.eq('/users/login'); + }); + }); + + it('requires password for submition', () => { + cy.setToken(); + cy.visit('/users/account'); + + submit(); + cy.get('#password-input') + .parent() + .should('have.class', 'Mui-error'); + }); + + it('has an active checkbox', () => { + cy.setToken(); + cy.visit('/users/account'); + + cy.get('#notifications-checkbox') + .should('be.checked'); + }); + + it('can submit a new password', () => { + stubSuccessAccount(); + cy.setToken(); + cy.visit('/users/account'); + + inputPassword(); + inputNewPasswords(); + submit(); + + cy.wait(250); + + cy.get('#success-message') + .contains('Account info has been updated.') + .should('be.visible'); + }); + + it('can show error message on failed submittion', () => { + stubFailAccount(); + cy.setToken(); + cy.visit('/users/account'); + + inputPassword(); + inputNewPasswords(); + submit(); + + cy.wait(250); + + cy.get('#error-message') + .contains('Some error occurred. Please try again later.') + .should('be.visible'); + }); + + it('requires passwords to match', () => { + stubFailAccount(); + cy.setToken(); + cy.visit('/users/account'); + + inputPassword(); + inputNewPasswords('password', 'passowrd'); + submit(); + + cy.get('#new-password-input') + .parent() + .should('have.class', 'Mui-error'); + + cy.get('#new-password-confirmation-input') + .parent() + .should('have.class', 'Mui-error'); + }); + + it('can open and close modal', () => { + cy.setToken(); + cy.visit('/users/account'); + + cy.get('#open-modal-button') + .click(); + + cy.get('#delete-account-modal') + .should('be.visible'); + + cy.get('#cancel-button') + .click(); + + cy.wait(250); + + cy.get('#delete-account-modal') + .should('not.exist'); + }); + + it('can delete account', () => { + stubSuccessDeleteAccount(); + cy.setToken(); + cy.visit('/users/account'); + + cy.get('#open-modal-button') + .click(); + + cy.get('#delete-account-modal') + .should('be.visible'); + + cy.get('#delete-account-button') + .click(); + + cy.wait(250); + + cy.get('#modal-success-message') + .contains('Account has been deleted. Redirecting...') + .should('be.visible'); + + cy.wait(1000); + + cy.location().should((location) => { + expect(location.pathname).to.eq('/users/login'); + }); + }); + + it('can delete account', () => { + stubFailDeleteAccount(); + cy.setToken(); + cy.visit('/users/account'); + + cy.get('#open-modal-button') + .click(); + + cy.get('#delete-account-modal') + .should('be.visible'); + + cy.get('#delete-account-button') + .click(); + + cy.wait(250); + + cy.get('#modal-error-message') + .contains('Some error occurred. Please try again later.') + .should('be.visible'); + + cy.location().should((location) => { + expect(location.pathname).to.eq('/users/account'); + }); + }); +}); diff --git a/specs/support/mocks/handlers.js b/specs/support/mocks/handlers.js index 75955c1..a176d07 100644 --- a/specs/support/mocks/handlers.js +++ b/specs/support/mocks/handlers.js @@ -3,6 +3,7 @@ import { rest } from 'msw'; const bridges = require('../../fixtures/bridges.json'); const event = require('../../fixtures/event.json'); +const user = require('../../fixtures/user.json'); // msw doesn't give us a way to stub requests on a per spec basis. Because // of this, we need to make our own way. Use `cy.setToken` to create a @@ -38,6 +39,19 @@ const handlers = [ return res(ctx.json(event)); }), + + rest.get('http://localhost/user', (req, res, ctx) => { + if (invalidToken(req)) { + return res( + ctx.status(401), + ctx.json( + {}, + ), + ); + } + + return res(ctx.json(user)); + }), ]; export default handlers;