diff --git a/cypress/integration/memberProfile.spec.js b/cypress/integration/memberProfile.spec.js index 26f945cb5..69735fa5d 100644 --- a/cypress/integration/memberProfile.spec.js +++ b/cypress/integration/memberProfile.spec.js @@ -7,6 +7,10 @@ import { MEMBER_PROFILE_LANGUAGE_SWITCH_ID, MEMBER_PROFILE_INSCRIPTION_DATE_ID, MEMBER_PROFILE_MEMBER_ID_COPY_BUTTON_ID, + USER_NEW_PASSWORD_INPUT_ID, + USER_CONFIRM_PASSWORD_INPUT_ID, + USER_CURRENT_PASSWORD_INPUT_ID, + CONFIRM_CHANGE_PASSWORD_BUTTON_ID, } from '../../src/config/selectors'; import { CURRENT_USER } from '../fixtures/members'; import { formatDate } from '../../src/utils/date'; @@ -34,6 +38,10 @@ describe('Member Profile', () => { 'contain', langs[extra.lang], ); + cy.get(`#${USER_CURRENT_PASSWORD_INPUT_ID}`).should('be.visible'); + cy.get(`#${USER_NEW_PASSWORD_INPUT_ID}`).should('be.visible'); + cy.get(`#${USER_CONFIRM_PASSWORD_INPUT_ID}`).should('be.visible'); + cy.get(`#${CONFIRM_CHANGE_PASSWORD_BUTTON_ID}`).should('be.visible'); }); it('Changing Language edits user', () => { @@ -46,12 +54,81 @@ describe('Member Profile', () => { expect(body?.extra?.lang).to.equal('en'); }); }); - + it('Copy member ID to clipboard', () => { const { id } = CURRENT_USER; - + cy.get(`#${MEMBER_PROFILE_MEMBER_ID_COPY_BUTTON_ID}`).click(); cy.get('@copy').should('be.calledWithExactly', id); }); + + it('Throw error with empty password', () => { + + cy.get(`#${USER_NEW_PASSWORD_INPUT_ID}`).type('password1'); + + cy.get(`#${CONFIRM_CHANGE_PASSWORD_BUTTON_ID}`).click(); + cy.get('#confirmPasswordInput-helper-text').should('be.visible'); + cy.get(`#${USER_NEW_PASSWORD_INPUT_ID}`).clear(); + cy.get('#newPasswordInput-helper-text').should('be.visible'); + }); + + it('Not trigger request with same current password', () => { + Cypress.on('fail', (error) => { + if (error.message.indexOf('Timed out retrying') !== 0) throw error + }) + + cy.get(`#${USER_CURRENT_PASSWORD_INPUT_ID}`).type('password1'); + cy.get(`#${USER_NEW_PASSWORD_INPUT_ID}`).type('password1'); + + cy.get(`#${CONFIRM_CHANGE_PASSWORD_BUTTON_ID}`).click(); + cy.wait('@updatePassword', { + requestTimeout: 1000, + }).then((xhr) => { + expect.isNull(xhr.response.body); + }); }); + + it('Not trigger request with diffferent passwords', () => { + Cypress.on('fail', (error) => { + if (error.message.indexOf('Timed out retrying') !== 0) throw error + }) + + cy.get(`#${USER_NEW_PASSWORD_INPUT_ID}`).type('password1'); + cy.get(`#${USER_CONFIRM_PASSWORD_INPUT_ID}`).type('password2'); + + cy.get(`#${CONFIRM_CHANGE_PASSWORD_BUTTON_ID}`).click(); + cy.wait('@updatePassword', { + requestTimeout: 1000, + }).then((xhr) => { + expect.isNull(xhr.response.body); + }); + }); + + it('Not trigger request with weak password', () => { + Cypress.on('fail', (error) => { + if (error.message.indexOf('Timed out retrying') !== 0) throw error + }) + + cy.get(`#${USER_NEW_PASSWORD_INPUT_ID}`).type('password'); + cy.get(`#${USER_CONFIRM_PASSWORD_INPUT_ID}`).type('password'); + + cy.get(`#${CONFIRM_CHANGE_PASSWORD_BUTTON_ID}`).click(); + cy.wait('@updatePassword', { + requestTimeout: 1000, + }).then((xhr) => { + expect.isNull(xhr.response.body); + }); + }); + + it('Update user password correctly', () => { + + cy.get(`#${USER_NEW_PASSWORD_INPUT_ID}`).type('ASDasd123'); + cy.get(`#${USER_CONFIRM_PASSWORD_INPUT_ID}`).type('ASDasd123'); + + cy.get(`#${CONFIRM_CHANGE_PASSWORD_BUTTON_ID}`).click(); + cy.wait('@updatePassword').then(({ request: { body } }) => { + expect(body?.currentPassword).to.equal(''); + expect(body?.password).to.equal('ASDasd123'); + }); + }); }); diff --git a/cypress/support/commands.js b/cypress/support/commands.js index 1f22b1d24..27c3a6441 100644 --- a/cypress/support/commands.js +++ b/cypress/support/commands.js @@ -81,6 +81,7 @@ import { mockPatchInvitation, mockDeleteInvitation, mockPublishItem, + mockUpdatePassword, mockPostManyItemMemberships, } from './server'; import './commands/item'; @@ -150,6 +151,7 @@ Cypress.Commands.add( getItemInvitationsError = false, patchInvitationError = false, deleteInvitationError = false, + updatePasswordError = false, } = {}) => { const cachedItems = JSON.parse(JSON.stringify(items)); const cachedMembers = JSON.parse(JSON.stringify(members)); @@ -305,6 +307,8 @@ Cypress.Commands.add( mockDeleteInvitation(items, deleteInvitationError); mockPublishItem(items); + + mockUpdatePassword(members, updatePasswordError); }, ); diff --git a/cypress/support/server.js b/cypress/support/server.js index a6530ff10..fc1932a7e 100644 --- a/cypress/support/server.js +++ b/cypress/support/server.js @@ -90,7 +90,8 @@ const { buildDeleteInvitationRoute, buildPatchInvitationRoute, buildResendInvitationRoute, - buildItemPublishRoute, + buildItemPublishRoute, + buildUpdateMemberPassword } = API_ROUTES; const API_HOST = Cypress.env('API_HOST'); @@ -1603,3 +1604,19 @@ export const mockPublishItem = (items) => { }, ).as('publishItem'); }; + +export const mockUpdatePassword = (members, shouldThrowError) => { + cy.intercept( + { + method: DEFAULT_PATCH.method, + url: new RegExp(`${API_HOST}/${buildUpdateMemberPassword}`), + }, + ({ reply }) => { + if (shouldThrowError) { + return reply({ statusCode: StatusCodes.BAD_REQUEST }); + } + + return reply('update password'); + }, + ).as('updatePassword'); +}; diff --git a/src/components/member/AvatarSetting.js b/src/components/member/AvatarSetting.js index 06ddbf3fc..c8946f7e7 100644 --- a/src/components/member/AvatarSetting.js +++ b/src/components/member/AvatarSetting.js @@ -17,10 +17,15 @@ import { import { MEMBER_PROFILE_AVATAR_UPLOAD_BUTTON_CLASSNAME } from '../../config/selectors'; import StatusBar from '../file/StatusBar'; -const useStyles = makeStyles(() => ({ +const useStyles = makeStyles((theme) => ({ thumbnail: { textAlign: 'right', }, + mainContainer: { + flexDirection: 'column', + alignItems: 'flex-start', + margin: theme.spacing(1, 0), + }, })); const AvatarSetting = ({ user }) => { @@ -109,10 +114,10 @@ const AvatarSetting = ({ user }) => { {uppy && ( )} - + {t('Thumbnail')} - {t('Update thumbnail')} + {t('Update thumbnail')} { className={MEMBER_PROFILE_AVATAR_UPLOAD_BUTTON_CLASSNAME} /> - + ({ +const useStyles = makeStyles((theme) => ({ confirmDeleteButton: { color: 'red', }, deleteButton: { - color: 'red', + backgroundColor: 'red', + margin: theme.spacing(1, 0), + }, + mainContainer: { + flexDirection: 'column', + alignItems: 'flex-start', + margin: theme.spacing(1, 0), + }, + deleteAccountContainer: { + display: 'flex', + flexDirection: 'column', + justifyContent: 'flex-start', + alignItems: 'flex-start', + padding: theme.spacing(0, 1), + margin: theme.spacing(0, 0), }, })); @@ -64,16 +79,33 @@ const DeleteMemberDialog = ({ id }) => { - - + + + + {t('Delete this account')} + + + + + {t( + 'Once you delete an account, there is no going back. Please be certain.', + )} + + + + ); }; diff --git a/src/components/member/MemberProfileScreen.js b/src/components/member/MemberProfileScreen.js index eb5f61e17..b71aeaab6 100644 --- a/src/components/member/MemberProfileScreen.js +++ b/src/components/member/MemberProfileScreen.js @@ -22,6 +22,7 @@ import Main from '../main/Main'; import { CurrentUserContext } from '../context/CurrentUserContext'; import AvatarSetting from './AvatarSetting'; import DeleteMemberDialog from './DeleteMemberDialog'; +import PasswordSetting from './PasswordSetting'; const useStyles = makeStyles((theme) => ({ root: { @@ -131,6 +132,7 @@ const MemberProfileScreen = () => { + diff --git a/src/components/member/PasswordSetting.js b/src/components/member/PasswordSetting.js new file mode 100644 index 000000000..8b6c1875a --- /dev/null +++ b/src/components/member/PasswordSetting.js @@ -0,0 +1,206 @@ +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { toast } from 'react-toastify'; +import Typography from '@material-ui/core/Typography'; +import { makeStyles } from '@material-ui/core/styles'; +import Grid from '@material-ui/core/Grid'; +import { Button, TextField } from '@material-ui/core'; +import { MUTATION_KEYS } from '@graasp/query-client'; +import { useMutation } from '../../config/queryClient'; +import { + CONFIRM_CHANGE_PASSWORD_BUTTON_ID, + CONFIRM_RESET_PASSWORD_BUTTON_ID, + USER_CONFIRM_PASSWORD_INPUT_ID, + USER_CURRENT_PASSWORD_INPUT_ID, + USER_NEW_PASSWORD_INPUT_ID, +} from '../../config/selectors'; +import { + newPasswordValidator, + passwordValidator, + strengthValidator, +} from '../../utils/validation'; +import { PASSWORD_EMPTY_ERROR } from '../../config/messages'; + +const useStyles = makeStyles((theme) => ({ + mainContainer: { + flexDirection: 'column', + alignItems: 'flex-start', + margin: theme.spacing(1, 0), + }, + changePasswordContainer: { + display: 'flex', + flexDirection: 'row', + justifyContent: 'flex-start', + alignItems: 'center', + margin: theme.spacing(1, 0), + }, + firstRow: { + display: 'flex', + flexDirection: 'column', + justifyContent: 'flex-start', + alignItems: 'flex-start', + }, + buttonItem: { + display: 'flex', + flexDirection: 'column', + justifyContent: 'flex-end', + alignItems: 'flex-start', + }, + updateButton: { + margin: theme.spacing(1, 0), + }, +})); + +const PasswordSetting = () => { + const { t } = useTranslation(); + const classes = useStyles(); + + const [currentPassword, setCurrentPassword] = useState(''); + const [newPassword, setNewPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [newPasswordError, setNewPasswordError] = useState(null); + const [confirmPasswordError, setConfirmPasswordError] = useState(null); + const { mutate: updatePassword } = useMutation(MUTATION_KEYS.UPDATE_PASSWORD); + + const verifyEmptyPassword = () => { + const checkingNewPassword = passwordValidator(newPassword); + const checkingConfirmPassword = passwordValidator(confirmPassword); + setNewPasswordError(checkingNewPassword); + setConfirmPasswordError(checkingConfirmPassword); + // throw error if one of the password fields is empty + if (checkingNewPassword || checkingConfirmPassword) { + throw PASSWORD_EMPTY_ERROR; + } + }; + + const onClose = () => { + setCurrentPassword(''); + setNewPassword(''); + setConfirmPassword(''); + }; + + const handleChangePassword = () => { + try { + // verify there are no empty inputs + verifyEmptyPassword(); + // perform validation when all fields are filled in + newPasswordValidator(currentPassword, newPassword, confirmPassword); + // check password strength for new password + strengthValidator(newPassword); + // perform password update + updatePassword({ + password: newPassword, + currentPassword, + }); + onClose(); + } catch (err) { + toast.error(err); + } + }; + + const handleCurrentPasswordInput = (event) => { + setCurrentPassword(event.target.value); + }; + const handleNewPasswordInput = (event) => { + setNewPassword(event.target.value); + setNewPasswordError(passwordValidator(event.target.value)); + }; + const handleConfirmPasswordInput = (event) => { + setConfirmPassword(event.target.value); + setConfirmPasswordError(passwordValidator(event.target.value)); + }; + + return ( + <> + + + + {t('Change Password')} + + + + + + {t( + 'Leave this field empty if you do not already have a password set.', + )} + + + + + + + + + + + + {t( + 'Make sure it is at least 8 characters including a number, a lowercase letter and an uppercase letter.', + )} + + + + + + + + + + ); +}; + +PasswordSetting.propTypes = {}; + +export default PasswordSetting; diff --git a/src/config/messages.js b/src/config/messages.js index 3f59b0528..91c01c173 100644 --- a/src/config/messages.js +++ b/src/config/messages.js @@ -49,3 +49,10 @@ export const IMPORT_ZIP_PROGRESS_MESSAGE = 'The ZIP is being processed. Please wait a moment.'; export const EXPORT_ZIP_FAILURE_MESSAGE = 'An error occurred while downloading the item as ZIP archive. Please try again later.'; + +export const PASSWORD_EMPTY_ERROR = 'Please enter a valid password'; +export const PASSWORD_WEAK_ERROR = '"New Password" not strong enough'; +export const PASSWORD_EQUAL_ERROR = + 'Please enter a new password different from your current one'; +export const PASSWORD_CONFIRM_ERROR = + 'Please make sure "New Password" matches "Confirm password"'; diff --git a/src/config/notifier.js b/src/config/notifier.js index fbdd854ee..e8a6fe987 100644 --- a/src/config/notifier.js +++ b/src/config/notifier.js @@ -46,6 +46,7 @@ const { exportItemRoutine, postInvitationsRoutine, resendInvitationRoutine, + updatePasswordRoutine, shareItemRoutine, } = routines; @@ -78,6 +79,7 @@ export default ({ type, payload }) => { case importZipRoutine.FAILURE: case postInvitationsRoutine.FAILURE: case resendInvitationRoutine.FAILURE: + case updatePasswordRoutine.FAILURE: case shareItemRoutine.FAILURE: case exportItemRoutine.FAILURE: { message = getErrorMessageFromPayload(payload); @@ -104,6 +106,7 @@ export default ({ type, payload }) => { case createItemRoutine.SUCCESS: case postInvitationsRoutine.SUCCESS: case resendInvitationRoutine.SUCCESS: + case updatePasswordRoutine.SUCCESS: case editMemberRoutine.SUCCESS: { message = getSuccessMessageFromPayload(payload); break; diff --git a/src/config/selectors.js b/src/config/selectors.js index 5ac42b5bf..5e919fbac 100644 --- a/src/config/selectors.js +++ b/src/config/selectors.js @@ -217,6 +217,16 @@ export const CO_EDITOR_SETTINGS_RADIO_GROUP_ID = 'coEditorSettingsRadioGroup'; export const buildCoEditorSettingsRadioButtonId = (id) => `coEditorSettingsRadioButton-${id}`; export const EMAIL_NOTIFICATION_CHECKBOX = 'emailNotificationCheckbox'; + +export const MEMBER_CURRENT_PASSWORD_ID = 'memberCurrentPassword'; +export const MEMBER_NEW_PASSWORD_ID = 'memberNewPassword'; +export const MEMBER_NEW_PASSWORD_CONFIRMATION_ID = + 'memberNewPasswordConfirmation'; +export const CONFIRM_CHANGE_PASSWORD_BUTTON_ID = 'confirmChangePasswordButton'; +export const CONFIRM_RESET_PASSWORD_BUTTON_ID = 'confirmResetPasswordButton'; +export const USER_CURRENT_PASSWORD_INPUT_ID = 'currentPasswordInput'; +export const USER_NEW_PASSWORD_INPUT_ID = 'newPasswordInput'; +export const USER_CONFIRM_PASSWORD_INPUT_ID = 'confirmPasswordInput'; export const SHARE_ITEM_CSV_PARSER_BUTTON_ID = 'shareItemCsvParserButton'; export const SHARE_ITEM_CSV_PARSER_INPUT_BUTTON_ID = 'shareItemCsvParserInputButton'; diff --git a/src/utils/validation.js b/src/utils/validation.js new file mode 100644 index 000000000..f12cb5e28 --- /dev/null +++ b/src/utils/validation.js @@ -0,0 +1,45 @@ +import validator from 'validator'; + +import { + PASSWORD_CONFIRM_ERROR, + PASSWORD_EMPTY_ERROR, + PASSWORD_EQUAL_ERROR, + PASSWORD_WEAK_ERROR, +} from '../config/messages'; + +export const strengthValidator = (password) => { + if ( + !validator.isStrongPassword(password, { + minLength: 8, + minLowercase: 1, + minUppercase: 1, + minNumbers: 1, + minSymbols: 0, + }) + ) { + throw PASSWORD_WEAK_ERROR; + } + return true; +}; + +export const passwordValidator = (password) => { + let res = false; + if (validator.isEmpty(password)) { + res = PASSWORD_EMPTY_ERROR; + } + return res; +}; + +export const newPasswordValidator = ( + currentPassword, + newPassword, + confirmPassword, +) => { + if (currentPassword === newPassword) { + throw PASSWORD_EQUAL_ERROR; + } + if (newPassword !== confirmPassword) { + throw PASSWORD_CONFIRM_ERROR; + } + return true; +}; diff --git a/yarn.lock b/yarn.lock index 033b61871..76bd2afb9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1958,7 +1958,7 @@ __metadata: "@graasp/query-client@github:graasp/graasp-query-client.git": version: 0.1.0 - resolution: "@graasp/query-client@https://github.com/graasp/graasp-query-client.git#commit=8bbde7401761817c292322b593cfd3553b420958" + resolution: "@graasp/query-client@https://github.com/graasp/graasp-query-client.git#commit=bf294574db01495512d3e12e36264888b412a9d1" dependencies: "@graasp/translations": "github:graasp/graasp-translations.git" "@graasp/utils": "github:graasp/graasp-utils.git" @@ -1972,16 +1972,16 @@ __metadata: uuid: 8.3.2 peerDependencies: react: ^17.0.0 - checksum: b98fad580bbd2c682dc616f79496193dc8c04463a0e358378509fe59d14ecce059e92a3bedc9aed372c1fe8b70c6ac1238dc9a190e2381df3c5b7eb93081e4f0 + checksum: c211649786441d3a4fdbadff8c617249121ec81d503f73a7dae567273a1c3a6250536144bec2d13b052d547beb8997440c1938e8b27d04783b1d1ae37122f171 languageName: node linkType: hard "@graasp/translations@github:graasp/graasp-translations.git": version: 0.1.0 - resolution: "@graasp/translations@https://github.com/graasp/graasp-translations.git#commit=f6042354f3c929a0d35cf6478ddb6a3fe994f6e4" + resolution: "@graasp/translations@https://github.com/graasp/graasp-translations.git#commit=362af84f4980e05235b47f32dee259db32d907d7" dependencies: i18next: 21.6.11 - checksum: c91e9050a9e386b19dc04a24322c9df3c7fb5f747ade55d0c55d450f6ac78defa8c9e35df77856ec0783a6bd308da2778ae4b7209c6654126d4af8157fda0b09 + checksum: de03da287758e53bca6b14ca0b59045761ff14358034df604ae864765b180a763151fe61ab699834f414bf85ac4c104f52e4a5646528c59beeb7ff1c9814694d languageName: node linkType: hard