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