diff --git a/e2e-tests/cypress/tests/integration/channels/enterprise/guest_accounts/guest_invitation_ui_more_spec.ts b/e2e-tests/cypress/tests/integration/channels/enterprise/guest_accounts/guest_invitation_ui_more_spec.ts index 069bd3be632..4de28ab1aef 100644 --- a/e2e-tests/cypress/tests/integration/channels/enterprise/guest_accounts/guest_invitation_ui_more_spec.ts +++ b/e2e-tests/cypress/tests/integration/channels/enterprise/guest_accounts/guest_invitation_ui_more_spec.ts @@ -134,9 +134,9 @@ describe('Guest Account - Guest User Invitation Flow', () => { cy.findByText('Update email').should('be.visible').click(); // * Update email outside whitelisted domain and verify error message - cy.findByTestId('resetEmailModal').should('be.visible').within(() => { - cy.findByTestId('resetEmailForm').should('be.visible').get('input').type(email); - cy.findByTestId('resetEmailButton').click(); + cy.get('#resetEmailModal').should('be.visible').within(() => { + cy.get('input[type="email"]').type(email); + cy.get('button.btn-primary.confirm').click(); cy.get('.error').should('be.visible').and('have.text', 'The email you provided does not belong to an accepted domain for guest accounts. Please contact your administrator or sign up with a different email.'); cy.get('.close').click(); }); diff --git a/e2e-tests/cypress/tests/integration/channels/enterprise/guest_accounts/system_console_manage_guest_spec.ts b/e2e-tests/cypress/tests/integration/channels/enterprise/guest_accounts/system_console_manage_guest_spec.ts index adc4adf47d3..b03d5a7251b 100644 --- a/e2e-tests/cypress/tests/integration/channels/enterprise/guest_accounts/system_console_manage_guest_spec.ts +++ b/e2e-tests/cypress/tests/integration/channels/enterprise/guest_accounts/system_console_manage_guest_spec.ts @@ -85,9 +85,9 @@ describe('Guest Account - Verify Manage Guest Users', () => { // * Update email of Guest User const email = `temp-${getRandomId()}@mattermost.com`; - cy.findByTestId('resetEmailModal').should('be.visible').within(() => { - cy.findByTestId('resetEmailForm').should('be.visible').get('input').type(email); - cy.findByTestId('resetEmailButton').click(); + cy.get('#resetEmailModal').should('be.visible').within(() => { + cy.get('input[type="email"]').type(email); + cy.get('button.btn-primary.confirm').click(); }); // * Verify if Guest's email was updated diff --git a/e2e-tests/cypress/tests/integration/channels/system_console/user_management/users_spec.js b/e2e-tests/cypress/tests/integration/channels/system_console/user_management/users_spec.js index e6041c4f6d1..2263ccf3e91 100644 --- a/e2e-tests/cypress/tests/integration/channels/system_console/user_management/users_spec.js +++ b/e2e-tests/cypress/tests/integration/channels/system_console/user_management/users_spec.js @@ -78,7 +78,7 @@ describe('System Console > User Management > Users', () => { // # Type new password and submit. cy.get('input[type=password]').type('new' + testUser.password); - cy.get('button[type=submit]').should('contain', 'Reset').click().wait(TIMEOUTS.HALF_SEC); + cy.get('button.btn-primary.confirm').should('contain', 'Reset').click().wait(TIMEOUTS.HALF_SEC); // # Log out. cy.apiLogout(); @@ -137,10 +137,10 @@ describe('System Console > User Management > Users', () => { cy.get('input[type=password]').eq(1).type('new' + otherAdmin.password); // # Click the 'Reset' button. - cy.get('button[type=submit] span').should('contain', 'Reset').click().wait(TIMEOUTS.HALF_SEC); + cy.get('button.btn-primary.confirm').should('contain', 'Reset').click().wait(TIMEOUTS.HALF_SEC); - // * Verify the appropriate error is returned. - cy.get('form.form-horizontal').find('.has-error p.error').should('be.visible'). + // * Verify the appropriate error is returned (current password error shows in modal header area). + cy.get('.genericModalError .error').should('be.visible'). and('contain', 'The "Current Password" you entered is incorrect. Please check that Caps Lock is off and try again.'); }); @@ -160,11 +160,10 @@ describe('System Console > User Management > Users', () => { cy.get('input[type=password]').eq(1).type('new'); // # Click the 'Reset' button. - cy.get('button[type=submit] span').should('contain', 'Reset').click().wait(TIMEOUTS.HALF_SEC); + cy.get('button.btn-primary.confirm').should('contain', 'Reset').click().wait(TIMEOUTS.HALF_SEC); - // * Verify the appropriate error is returned. - cy.get('form.form-horizontal').find('.has-error p.error').should('be.visible'). - and('contain', 'Your password must be 5-72 characters long.'); + // * Verify the appropriate error is returned (new password error shows under the input). + cy.get('.Input___error').should('be.visible').and('contain', 'characters long'); }); it('MM-T936 Users - System admin changes own password - Blank fields', () => { @@ -179,21 +178,20 @@ describe('System Console > User Management > Users', () => { cy.findByText('Reset password').click(); // # Click the 'Reset' button. - cy.get('button[type=submit] span').should('contain', 'Reset').click().wait(TIMEOUTS.HALF_SEC); + cy.get('button.btn-primary.confirm').should('contain', 'Reset').click().wait(TIMEOUTS.HALF_SEC); - // * Verify the appropriate error is returned. - cy.get('form.form-horizontal').find('.has-error p.error').should('be.visible'). + // * Verify the appropriate error is returned (current password missing). + cy.get('.genericModalError .error').should('be.visible'). and('contain', 'Please enter your current password.'); // # Type current password, leave new password blank. cy.get('input[type=password]').eq(0).type(otherAdmin.password); // # Click the 'Reset' button. - cy.get('button[type=submit] span').should('contain', 'Reset').click().wait(TIMEOUTS.HALF_SEC); + cy.get('button.btn-primary.confirm').should('contain', 'Reset').click().wait(TIMEOUTS.HALF_SEC); - // * Verify the appropriate error is returned. - cy.get('form.form-horizontal').find('.has-error p.error').should('be.visible'). - and('contain', 'Your password must be 5-72 characters long.'); + // * Verify the appropriate error is returned (new password error shows under the input). + cy.get('.Input___error').should('be.visible').and('contain', 'characters long'); }); it('MM-T937 Users - System admin changes own password - Successfully changed', () => { @@ -212,7 +210,7 @@ describe('System Console > User Management > Users', () => { cy.get('input[type=password]').eq(1).type('new' + otherAdmin.password); // # Click the 'Reset' button. - cy.get('button[type=submit] span').should('contain', 'Reset').click().wait(TIMEOUTS.HALF_SEC); + cy.get('button.btn-primary.confirm').should('contain', 'Reset').click().wait(TIMEOUTS.HALF_SEC); // # Log out. cy.apiLogout(); diff --git a/e2e-tests/cypress/tests/integration/channels/system_console/user_management_spec.js b/e2e-tests/cypress/tests/integration/channels/system_console/user_management_spec.js index e7ea290bd29..23d6cee0a19 100644 --- a/e2e-tests/cypress/tests/integration/channels/system_console/user_management_spec.js +++ b/e2e-tests/cypress/tests/integration/channels/system_console/user_management_spec.js @@ -195,7 +195,7 @@ describe('User Management', () => { // # Set new password. cy.get('input[type=password]').type('new' + testUser.password); - cy.get('button[type=submit]').should('contain', 'Reset').click().wait(TIMEOUTS.HALF_SEC); + cy.get('button.btn-primary.confirm').should('contain', 'Reset').click().wait(TIMEOUTS.HALF_SEC); // * Verify Update email option is visible. cy.get('#systemUsersTable-cell-0_actionsColumn').click().wait(TIMEOUTS.HALF_SEC); @@ -262,22 +262,22 @@ describe('User Management', () => { cy.findByText('Update email').click().wait(TIMEOUTS.HALF_SEC); // # Verify the modal opened. - cy.findByTestId('resetEmailModal').should('exist'); + cy.get('#resetEmailModal').should('exist'); // # Type the new e-mail address. if (newEmail.length > 0) { cy.get('input[type=email]').eq(0).clear().type(newEmail); } - // # Click the "Reset" button. - cy.findByTestId('resetEmailButton').click(); + // # Click the "Update" button. + cy.get('button.btn-primary.confirm').click(); // * Check for the error messages, if any. if (errorMsg.length > 0) { - cy.get('form.form-horizontal').find('.has-error p.error').should('be.visible').and('contain', errorMsg); + cy.get('.Input___error').should('be.visible').and('contain', errorMsg); // # Close the modal. - cy.findByLabelText('Close').click(); + cy.get('button.close').click(); } } diff --git a/e2e-tests/playwright/lib/src/ui/pages/system_console.ts b/e2e-tests/playwright/lib/src/ui/pages/system_console.ts index 1ef638e2872..576e47532a8 100644 --- a/e2e-tests/playwright/lib/src/ui/pages/system_console.ts +++ b/e2e-tests/playwright/lib/src/ui/pages/system_console.ts @@ -85,4 +85,8 @@ export default class SystemConsolePage { async clickResetButton() { await this.saveChangesModal.container.locator('button.btn-primary:has-text("Reset")').click(); } + + async clickUpdateEmailButton() { + await this.saveChangesModal.container.locator('button.btn-primary:has-text("Update")').click(); + } } diff --git a/e2e-tests/playwright/specs/functional/system_console/system_users/actions.spec.ts b/e2e-tests/playwright/specs/functional/system_console/system_users/actions.spec.ts index fb2d661e31c..de11901b08a 100644 --- a/e2e-tests/playwright/specs/functional/system_console/system_users/actions.spec.ts +++ b/e2e-tests/playwright/specs/functional/system_console/system_users/actions.spec.ts @@ -172,10 +172,10 @@ test('MM-T5520-5 should change the users email', async ({pw}) => { const updateEmail = await systemConsolePage.systemUsersActionMenus[0].getMenuItem('Update email'); await updateEmail.click(); - // # Enter a random password and click Save + // # Enter new email and click Update const emailInput = systemConsolePage.page.locator('input[type="email"]'); await emailInput.fill(newEmail); - await systemConsolePage.clickResetButton(); + await systemConsolePage.clickUpdateEmailButton(); // * Verify that the modal closed await emailInput.waitFor({state: 'detached'}); diff --git a/webapp/channels/src/components/admin_console/admin_modal_with_input.scss b/webapp/channels/src/components/admin_console/admin_modal_with_input.scss new file mode 100644 index 00000000000..4aee7fd8a7b --- /dev/null +++ b/webapp/channels/src/components/admin_console/admin_modal_with_input.scss @@ -0,0 +1,57 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +// Shared styles for admin console modals that use the Input component +// Used by: ResetPasswordModal, ResetEmailModal + +.ResetPasswordModal, +.ResetEmailModal { + &__body { + display: flex; + flex-direction: column; + gap: 20px; + } + + // Override Input component styles to work properly in admin console modal + // The Input component uses a fieldset for the border, so we need to remove + // the default form-control border styling that GenericModal applies + .Input_container { + .Input_fieldset { + background-color: var(--center-channel-bg); + } + + .Input_legend { + background-color: var(--center-channel-bg); + } + + .Input_wrapper { + background-color: transparent; + } + + // Remove the GenericModal form-control border styling since Input uses fieldset for border + // Also remove any background color that might be inherited + .Input.form-control, + .form-control, + input.form-control, + input { + height: auto; + border: none !important; + background: none !important; + background-color: transparent !important; + box-shadow: none !important; + + &:focus { + border: none !important; + background: none !important; + background-color: transparent !important; + box-shadow: none !important; + } + + &:hover { + background: none !important; + background-color: transparent !important; + } + } + } +} + diff --git a/webapp/channels/src/components/admin_console/reset_email_modal/__snapshots__/reset_email_modal.test.tsx.snap b/webapp/channels/src/components/admin_console/reset_email_modal/__snapshots__/reset_email_modal.test.tsx.snap deleted file mode 100644 index dea75f5ceb8..00000000000 --- a/webapp/channels/src/components/admin_console/reset_email_modal/__snapshots__/reset_email_modal.test.tsx.snap +++ /dev/null @@ -1,252 +0,0 @@ -// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing - -exports[`components/admin_console/reset_email_modal/reset_email_modal.tsx should match snapshot when not the current user 1`] = ` - - - - - - -
- -
-
-
- - - - -
-
-
-
- - - - -
-
-`; - -exports[`components/admin_console/reset_email_modal/reset_email_modal.tsx should match snapshot when the current user 1`] = ` - - - - - - -
- -
-
-
- - - - -
-
- - - - -
-
-
-
- - - - -
-
-`; - -exports[`components/admin_console/reset_email_modal/reset_email_modal.tsx should match snapshot when there is no user 1`] = `
`; diff --git a/webapp/channels/src/components/admin_console/reset_email_modal/reset_email_modal.test.tsx b/webapp/channels/src/components/admin_console/reset_email_modal/reset_email_modal.test.tsx index b983d0ed619..0b3d4203bce 100644 --- a/webapp/channels/src/components/admin_console/reset_email_modal/reset_email_modal.test.tsx +++ b/webapp/channels/src/components/admin_console/reset_email_modal/reset_email_modal.test.tsx @@ -1,113 +1,136 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {shallow} from 'enzyme'; import React from 'react'; -import {FormattedMessage} from 'react-intl'; import type {UserProfile} from '@mattermost/types/users'; -import {mountWithIntl} from 'tests/helpers/intl-test-helper'; +import {renderWithContext, screen, userEvent, waitFor} from 'tests/react_testing_utils'; import {TestHelper} from 'utils/test_helper'; import ResetEmailModal from './reset_email_modal'; describe('components/admin_console/reset_email_modal/reset_email_modal.tsx', () => { const user: UserProfile = TestHelper.getUserMock({ + id: 'user_id_1', email: 'arvin.darmawan@gmail.com', + first_name: 'Arvin', + last_name: 'Darmawan', }); const baseProps = { actions: {patchUser: jest.fn(() => Promise.resolve({}))}, user, currentUserId: 'random_user_id', - onHide: jest.fn(), onSuccess: jest.fn(), onExited: jest.fn(), }; - test('should match snapshot when not the current user', () => { - const wrapper = shallow(); - expect(wrapper).toMatchSnapshot(); + beforeEach(() => { + jest.clearAllMocks(); }); - test('should match snapshot when there is no user', () => { + test('should render modal with user name in title', () => { + renderWithContext(); + + expect(screen.getByText(/Update email for Arvin Darmawan/i)).toBeInTheDocument(); + expect(screen.getByRole('textbox')).toBeInTheDocument(); + }); + + test('should render null when there is no user', () => { const props = {...baseProps, user: undefined}; - const wrapper = shallow(); - expect(wrapper).toMatchSnapshot(); + const {container} = renderWithContext(); + + expect(container).toBeEmptyDOMElement(); }); - test('should match snapshot when the current user', () => { + test('should show password field when updating own email', () => { const props = {...baseProps, currentUserId: user.id}; - const wrapper = shallow(); - expect(wrapper).toMatchSnapshot(); + renderWithContext(); + + // Should have both email and password inputs + const inputs = screen.getAllByRole('textbox'); + expect(inputs.length).toBeGreaterThanOrEqual(1); + + // Password input won't have role="textbox", check by placeholder + expect(screen.getByPlaceholderText(/Current password/i)).toBeInTheDocument(); }); - test('should not update email since the email is empty', () => { - const wrapper = mountWithIntl(); + test('should not update email since the email is empty', async () => { + renderWithContext(); - (wrapper.find('input[type=\'email\']').first().instance() as unknown as HTMLInputElement).value = ''; - wrapper.find('button[type=\'submit\']').first().simulate('click', {preventDefault: jest.fn()}); + // Click submit without entering email + await userEvent.click(screen.getByRole('button', {name: /Update/i})); - expect(baseProps.actions.patchUser.mock.calls.length).toBe(0); - expect(wrapper.state('error')).toStrictEqual( - , - ); + expect(baseProps.actions.patchUser).not.toHaveBeenCalled(); + await waitFor(() => { + expect(screen.getByText(/Please enter a valid email address/i)).toBeInTheDocument(); + }); }); - test('should not update email since the email is invalid', () => { - const wrapper = mountWithIntl(); + test('should not update email since the email is invalid', async () => { + renderWithContext(); - (wrapper.find('input[type=\'email\']').first().instance() as unknown as HTMLInputElement).value = 'invalid-email'; - wrapper.find('button[type=\'submit\']').first().simulate('click', {preventDefault: jest.fn()}); + const emailInput = screen.getByPlaceholderText(/Enter new email address/i); + await userEvent.type(emailInput, 'invalid-email'); + await userEvent.click(screen.getByRole('button', {name: /Update/i})); - expect(baseProps.actions.patchUser.mock.calls.length).toBe(0); - expect(wrapper.state('error')).toStrictEqual( - , - ); + expect(baseProps.actions.patchUser).not.toHaveBeenCalled(); + await waitFor(() => { + expect(screen.getByText(/Please enter a valid email address/i)).toBeInTheDocument(); + }); }); - test('should require password when updating email of the current user', () => { + test('should require password when updating email of the current user', async () => { const props = {...baseProps, currentUserId: user.id}; - const wrapper = mountWithIntl(); + renderWithContext(); - (wrapper.find('input[type=\'email\']').first().instance() as unknown as HTMLInputElement).value = 'currentUser@test.com'; - wrapper.find('button[type=\'submit\']').first().simulate('click', {preventDefault: jest.fn()}); + const emailInput = screen.getByPlaceholderText(/Enter new email address/i); + await userEvent.type(emailInput, 'currentUser@test.com'); + await userEvent.click(screen.getByRole('button', {name: /Update/i})); - expect(baseProps.actions.patchUser.mock.calls.length).toBe(0); - expect(wrapper.state('error')).toStrictEqual( - , - ); + expect(baseProps.actions.patchUser).not.toHaveBeenCalled(); + await waitFor(() => { + expect(screen.getByText(/Please enter your current password/i)).toBeInTheDocument(); + }); }); - test('should update email since the email is valid of the another user', () => { - const wrapper = mountWithIntl(); + test('should update email since the email is valid for another user', async () => { + const patchUser = jest.fn(() => Promise.resolve({})); + const props = {...baseProps, actions: {patchUser}}; + renderWithContext(); - (wrapper.find('input[type=\'email\']').first().instance() as unknown as HTMLInputElement).value = 'user@test.com'; - wrapper.find('button[type=\'submit\']').first().simulate('click', {preventDefault: jest.fn()}); + const emailInput = screen.getByPlaceholderText(/Enter new email address/i); + await userEvent.type(emailInput, 'user@test.com'); + await userEvent.click(screen.getByRole('button', {name: /Update/i})); - expect(baseProps.actions.patchUser.mock.calls.length).toBe(1); - expect(wrapper.state('error')).toBeNull(); + await waitFor(() => { + expect(patchUser).toHaveBeenCalledTimes(1); + expect(patchUser).toHaveBeenCalledWith(expect.objectContaining({ + email: 'user@test.com', + })); + }); }); - test('should update email since the email is valid of the current user', () => { - const props = {...baseProps, currentUserId: user.id}; - const wrapper = mountWithIntl(); + test('should update email since the email is valid for the current user', async () => { + const patchUser = jest.fn(() => Promise.resolve({})); + const props = {...baseProps, currentUserId: user.id, actions: {patchUser}}; + renderWithContext(); - (wrapper.find('input[type=\'email\']').first().instance() as unknown as HTMLInputElement).value = 'currentUser@test.com'; - (wrapper.find('input[type=\'password\']').first().instance() as unknown as HTMLInputElement).value = 'password'; - wrapper.find('button[type=\'submit\']').first().simulate('click', {preventDefault: jest.fn()}); + const emailInput = screen.getByPlaceholderText(/Enter new email address/i); + await userEvent.type(emailInput, 'currentUser@test.com'); - expect(baseProps.actions.patchUser.mock.calls.length).toBe(1); - expect(wrapper.state('error')).toBeNull(); + const passwordInput = screen.getByPlaceholderText(/Current password/i); + await userEvent.type(passwordInput, 'password123'); + + await userEvent.click(screen.getByRole('button', {name: /Update/i})); + + await waitFor(() => { + expect(patchUser).toHaveBeenCalledTimes(1); + expect(patchUser).toHaveBeenCalledWith(expect.objectContaining({ + email: 'currentuser@test.com', // lowercase + password: 'password123', + })); + }); }); }); diff --git a/webapp/channels/src/components/admin_console/reset_email_modal/reset_email_modal.tsx b/webapp/channels/src/components/admin_console/reset_email_modal/reset_email_modal.tsx index 0d48203ac7c..9e7be184ab4 100644 --- a/webapp/channels/src/components/admin_console/reset_email_modal/reset_email_modal.tsx +++ b/webapp/channels/src/components/admin_console/reset_email_modal/reset_email_modal.tsx @@ -1,21 +1,20 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import React from 'react'; -import {Modal} from 'react-bootstrap'; -import {FormattedMessage} from 'react-intl'; +import React, {useCallback, useRef, useState} from 'react'; +import {useIntl} from 'react-intl'; +import {GenericModal} from '@mattermost/components'; import type {UserProfile} from '@mattermost/types/users'; import type {ActionResult} from 'mattermost-redux/types/actions'; import {isEmail} from 'mattermost-redux/utils/helpers'; -type State = { - show: boolean; - error: JSX.Element|string|null; - isEmailError: boolean; - isCurrentPasswordError: boolean; -} +import Input from 'components/widgets/inputs/input/input'; + +import {getFullName} from 'utils/utils'; + +import '../admin_modal_with_input.scss'; type Props = { user?: UserProfile; @@ -27,220 +26,159 @@ type Props = { }; } -export default class ResetEmailModal extends React.PureComponent { - private emailRef: React.RefObject; - private currentPasswordRef: React.RefObject; +export default function ResetEmailModal({ + user, + currentUserId, + onSuccess, + onExited, + actions, +}: Props) { + const {formatMessage} = useIntl(); - public constructor(props: Props) { - super(props); + const [show, setShow] = useState(true); + const [email, setEmail] = useState(''); + const [currentPassword, setCurrentPassword] = useState(''); + const [error, setError] = useState(null); + const [emailError, setEmailError] = useState(null); + const [passwordError, setPasswordError] = useState(null); - this.state = { - show: true, - error: null, - isEmailError: false, - isCurrentPasswordError: false, - }; + const emailRef = useRef(null); + const currentPasswordRef = useRef(null); - this.emailRef = React.createRef(); - this.currentPasswordRef = React.createRef(); - } + const isUpdatingOwnEmail = user?.id === currentUserId; - private isEmailValid = (): boolean => { - if (!this.emailRef.current || !this.emailRef.current.value || !isEmail(this.emailRef.current.value)) { - const errMsg = ( - - ); - this.setState({error: errMsg, isEmailError: true}); - return false; - } + const handleCancel = useCallback(() => { + setShow(false); + }, []); - this.setState({error: null, isEmailError: false}); - return true; - }; - - private isCurrentPasswordValid = (): boolean => { - if (!this.currentPasswordRef.current || !this.currentPasswordRef.current.value) { - const errMsg = ( - - ); - - this.setState({error: errMsg, isCurrentPasswordError: true}); - return false; - } - this.setState({error: null, isCurrentPasswordError: false}); - return true; - }; - - private doSubmit = async (e: React.MouseEvent) => { - e.preventDefault(); - if (!this.props.user) { - return; - } - - if (!this.isEmailValid()) { - return; - } - - const user = { - ...this.props.user, - email: (this.emailRef.current as HTMLInputElement).value.trim().toLowerCase(), - }; - - if (this.props.user?.id === this.props.currentUserId) { - if (!this.isCurrentPasswordValid()) { - return; - } - user.password = (this.currentPasswordRef.current as HTMLInputElement).value; - } - - const result = await this.props.actions.patchUser(user); - if ('error' in result) { - this.setState({ - error: result.error.message, - isEmailError: result.error.server_error_id === 'app.user.save.email_exists.app_error', - isCurrentPasswordError: result.error.server_error_id === 'api.user.check_user_password.invalid.app_error', - }); - return; - } - - this.props.onSuccess(user.email); - this.setState({show: false}); - }; - - private doCancel = (): void => { - this.setState({ - show: false, - error: null, - }); - }; - - public render(): JSX.Element { - const user = this.props.user; + const handleConfirm = useCallback(async () => { if (!user) { - return
; + return; } - const groupClass = 'input-group input-group--limit mb-5'; + // Clear previous errors + setError(null); + setEmailError(null); + setPasswordError(null); - const title = ( - - ); + // Validate email + if (!email || !isEmail(email)) { + setEmailError(formatMessage({ + id: 'user.settings.general.validEmail', + defaultMessage: 'Please enter a valid email address.', + })); + return; + } - return ( - - - - {title} - - -
- -
-
-
- - - - -
+ // Validate current password if updating own email + if (isUpdatingOwnEmail && !currentPassword) { + setPasswordError(formatMessage({ + id: 'admin.reset_email.missing_current_password', + defaultMessage: 'Please enter your current password.', + })); + return; + } - {this.props.user?.id === this.props.currentUserId && ( -
- - - - -
- )} + const updatedUser: UserProfile = { + ...user, + email: email.trim().toLowerCase(), + }; - {this.state.error && ( -
-

- {this.state.error} -

-
- )} -
-
-
- - - - -
-
- ); + if (isUpdatingOwnEmail) { + updatedUser.password = currentPassword; + } + + const result = await actions.patchUser(updatedUser); + + if ('error' in result) { + const isEmailError = result.error.server_error_id === 'app.user.save.email_exists.app_error'; + const isPasswordError = result.error.server_error_id === 'api.user.check_user_password.invalid.app_error'; + + if (isEmailError) { + setEmailError(result.error.message); + } else if (isPasswordError) { + setPasswordError(result.error.message); + } else { + setError(result.error.message); + } + return; + } + + onSuccess(updatedUser.email); + setShow(false); + }, [user, email, currentPassword, isUpdatingOwnEmail, actions, onSuccess, formatMessage]); + + if (!user) { + return null; } + + const displayName = getFullName(user) || user.username; + + const title = formatMessage({ + id: 'admin.reset_email.titleResetFor', + defaultMessage: 'Update email for {name}', + }, {name: displayName}); + + return ( + {error} : undefined} + dataTestId='resetEmailModal' + > +
+ } + type='email' + name='newEmail' + autoComplete='off' + label={formatMessage({ + id: 'admin.reset_email.newEmail', + defaultMessage: 'New email', + })} + placeholder={formatMessage({ + id: 'admin.reset_email.enterNewEmail', + defaultMessage: 'Enter new email address', + })} + value={email} + onChange={(e) => setEmail(e.target.value)} + autoFocus={true} + maxLength={128} + customMessage={emailError ? {type: 'error', value: emailError} : undefined} + /> + {isUpdatingOwnEmail && ( + } + type='password' + name='currentPassword' + autoComplete='current-password' + label={formatMessage({ + id: 'admin.reset_email.currentPassword', + defaultMessage: 'Current password', + })} + placeholder={formatMessage({ + id: 'admin.reset_email.enterCurrentPassword', + defaultMessage: 'Enter current password', + })} + value={currentPassword} + onChange={(e) => setCurrentPassword(e.target.value)} + customMessage={passwordError ? {type: 'error', value: passwordError} : undefined} + /> + )} +
+
+ ); } diff --git a/webapp/channels/src/components/admin_console/reset_password_modal/__snapshots__/reset_password_modal.test.tsx.snap b/webapp/channels/src/components/admin_console/reset_password_modal/__snapshots__/reset_password_modal.test.tsx.snap deleted file mode 100644 index 941eeca048b..00000000000 --- a/webapp/channels/src/components/admin_console/reset_password_modal/__snapshots__/reset_password_modal.test.tsx.snap +++ /dev/null @@ -1,137 +0,0 @@ -// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing - -exports[`components/admin_console/reset_password_modal/reset_password_modal.tsx should match snapshot 1`] = ` - - - - - - -
- -
-
-
- - - - -
-
-
-
- - - - -
-
-
-
- - - - -
-
-`; - -exports[`components/admin_console/reset_password_modal/reset_password_modal.tsx should match snapshot when there is no user 1`] = `
`; diff --git a/webapp/channels/src/components/admin_console/reset_password_modal/reset_password_modal.test.tsx b/webapp/channels/src/components/admin_console/reset_password_modal/reset_password_modal.test.tsx index 6b8de10cf88..935994b57b7 100644 --- a/webapp/channels/src/components/admin_console/reset_password_modal/reset_password_modal.test.tsx +++ b/webapp/channels/src/components/admin_console/reset_password_modal/reset_password_modal.test.tsx @@ -1,13 +1,11 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {shallow} from 'enzyme'; import React from 'react'; -import {FormattedMessage} from 'react-intl'; import type {UserNotifyProps, UserProfile} from '@mattermost/types/users'; -import {mountWithIntl} from 'tests/helpers/intl-test-helper'; +import {renderWithContext, screen, userEvent, waitFor} from 'tests/react_testing_utils'; import {TestHelper} from 'utils/test_helper'; import ResetPasswordModal from './reset_password_modal'; @@ -27,16 +25,20 @@ describe('components/admin_console/reset_password_modal/reset_password_modal.tsx push: 'default', push_status: 'ooo', }; + const user: UserProfile = TestHelper.getUserMock({ - auth_service: 'test', + id: 'user_id_1', + auth_service: '', notify_props: notifyProps, + first_name: 'Test', + last_name: 'User', + username: 'testuser', }); const baseProps = { actions: {updateUserPassword: jest.fn(() => Promise.resolve({data: ''}))}, currentUserId: user.id, user, - onHide: jest.fn(), onExited: jest.fn(), passwordConfig: { minimumLength: 10, @@ -47,65 +49,119 @@ describe('components/admin_console/reset_password_modal/reset_password_modal.tsx }, }; - test('should match snapshot', () => { - const wrapper = shallow( - , - ); - expect(wrapper).toMatchSnapshot(); + beforeEach(() => { + jest.clearAllMocks(); }); - test('should match snapshot when there is no user', () => { + test('should render modal with user name in title', () => { + renderWithContext(); + + expect(screen.getByText(/Reset password for Test User/i)).toBeInTheDocument(); + }); + + test('should render null when there is no user', () => { const props = {...baseProps, user: undefined}; - const wrapper = shallow( - , - ); - expect(wrapper).toMatchSnapshot(); + const {container} = renderWithContext(); + + expect(container).toBeEmptyDOMElement(); }); - test('should call updateUserPassword', () => { + test('should show switch account title when user has auth_service', () => { + const authUser = TestHelper.getUserMock({ + ...user, + auth_service: 'ldap', + }); + const props = {...baseProps, user: authUser}; + renderWithContext(); + + expect(screen.getByText(/Switch account to Email\/Password/i)).toBeInTheDocument(); + }); + + test('should show current password field when resetting own password', () => { + renderWithContext(); + + expect(screen.getByPlaceholderText(/Current password/i)).toBeInTheDocument(); + expect(screen.getByPlaceholderText(/New password/i)).toBeInTheDocument(); + }); + + test('should not show current password field when resetting another user password', () => { + const props = {...baseProps, currentUserId: 'different_user_id'}; + renderWithContext(); + + expect(screen.queryByPlaceholderText(/Current password/i)).not.toBeInTheDocument(); + expect(screen.getByPlaceholderText(/New password/i)).toBeInTheDocument(); + }); + + test('should call updateUserPassword with both passwords when resetting own password', async () => { const updateUserPassword = jest.fn(() => Promise.resolve({data: ''})); - const oldPassword = 'oldPassword123!'; - const newPassword = 'newPassword123!'; const props = {...baseProps, actions: {updateUserPassword}}; - const wrapper = mountWithIntl(); + renderWithContext(); - (wrapper.find('input[type=\'password\']').first().instance() as unknown as HTMLInputElement).value = oldPassword; - (wrapper.find('input[type=\'password\']').last().instance() as unknown as HTMLInputElement).value = newPassword; - wrapper.find('button[type=\'submit\']').first().simulate('click', {preventDefault: jest.fn()}); + const currentPasswordInput = screen.getByPlaceholderText(/Current password/i); + const newPasswordInput = screen.getByPlaceholderText(/New password/i); - expect(updateUserPassword.mock.calls.length).toBe(1); - expect(wrapper.state('serverErrorCurrentPass')).toBeNull(); - expect(wrapper.state('serverErrorNewPass')).toBeNull(); + await userEvent.type(currentPasswordInput, 'oldPassword123!'); + await userEvent.type(newPasswordInput, 'newPassword123!'); + await userEvent.click(screen.getByRole('button', {name: /Reset/i})); + + await waitFor(() => { + expect(updateUserPassword).toHaveBeenCalledTimes(1); + expect(updateUserPassword).toHaveBeenCalledWith( + user.id, + 'oldPassword123!', + 'newPassword123!', + ); + }); }); - test('should not call updateUserPassword when the old password is not provided', () => { + test('should not call updateUserPassword when the current password is not provided', async () => { const updateUserPassword = jest.fn(() => Promise.resolve({data: ''})); - const newPassword = 'newPassword123!'; const props = {...baseProps, actions: {updateUserPassword}}; - const wrapper = mountWithIntl(); + renderWithContext(); - (wrapper.find('input[type=\'password\']').last().instance() as unknown as HTMLInputElement).value = newPassword; - wrapper.find('button[type=\'submit\']').first().simulate('click', {preventDefault: jest.fn()}); + const newPasswordInput = screen.getByPlaceholderText(/New password/i); + await userEvent.type(newPasswordInput, 'newPassword123!'); + await userEvent.click(screen.getByRole('button', {name: /Reset/i})); - expect(updateUserPassword.mock.calls.length).toBe(0); - expect(wrapper.state('serverErrorCurrentPass')).toStrictEqual( - ); - expect(wrapper.state('serverErrorNewPass')).toBeNull(); + expect(updateUserPassword).not.toHaveBeenCalled(); + await waitFor(() => { + expect(screen.getByText(/Please enter your current password/i)).toBeInTheDocument(); + }); }); - test('should call updateUserPassword', () => { + test('should call updateUserPassword without current password when resetting another user', async () => { const updateUserPassword = jest.fn(() => Promise.resolve({data: ''})); - const password = 'Password123!'; + const props = {...baseProps, currentUserId: 'different_user_id', actions: {updateUserPassword}}; + renderWithContext(); - const props = {...baseProps, currentUserId: '2', actions: {updateUserPassword}}; - const wrapper = mountWithIntl(); + const newPasswordInput = screen.getByPlaceholderText(/New password/i); + await userEvent.type(newPasswordInput, 'Password123!'); + await userEvent.click(screen.getByRole('button', {name: /Reset/i})); - (wrapper.find('input[type=\'password\']').first().instance() as unknown as HTMLInputElement).value = password; - wrapper.find('button[type=\'submit\']').first().simulate('click', {preventDefault: jest.fn()}); + await waitFor(() => { + expect(updateUserPassword).toHaveBeenCalledTimes(1); + expect(updateUserPassword).toHaveBeenCalledWith( + user.id, + '', + 'Password123!', + ); + }); + }); - expect(updateUserPassword.mock.calls.length).toBe(1); + test('should show error when password does not meet requirements', async () => { + const updateUserPassword = jest.fn(() => Promise.resolve({data: ''})); + const props = {...baseProps, currentUserId: 'different_user_id', actions: {updateUserPassword}}; + renderWithContext(); + + const newPasswordInput = screen.getByPlaceholderText(/New password/i); + await userEvent.type(newPasswordInput, 'weak'); + await userEvent.click(screen.getByRole('button', {name: /Reset/i})); + + expect(updateUserPassword).not.toHaveBeenCalled(); + + // Password validation error should appear + await waitFor(() => { + expect(screen.getByText(/Must be 10-72 characters long/i)).toBeInTheDocument(); + }); }); }); diff --git a/webapp/channels/src/components/admin_console/reset_password_modal/reset_password_modal.tsx b/webapp/channels/src/components/admin_console/reset_password_modal/reset_password_modal.tsx index 30470c06417..4b3c8c7dda9 100644 --- a/webapp/channels/src/components/admin_console/reset_password_modal/reset_password_modal.tsx +++ b/webapp/channels/src/components/admin_console/reset_password_modal/reset_password_modal.tsx @@ -1,15 +1,20 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import React from 'react'; -import {Modal} from 'react-bootstrap'; -import {FormattedMessage} from 'react-intl'; +import React, {useCallback, useRef, useState} from 'react'; +import {useIntl} from 'react-intl'; +import {GenericModal} from '@mattermost/components'; import type {UserProfile} from '@mattermost/types/users'; import type {ActionResult} from 'mattermost-redux/types/actions'; +import Input from 'components/widgets/inputs/input/input'; + import {isValidPassword} from 'utils/password'; +import {getFullName} from 'utils/utils'; + +import '../admin_modal_with_input.scss'; interface PasswordConfig { minimumLength: number; @@ -19,12 +24,6 @@ interface PasswordConfig { requireUppercase: boolean; } -type State = { - show: boolean; - serverErrorNewPass: React.ReactNode; - serverErrorCurrentPass: React.ReactNode; -} - export type Props = { user?: UserProfile; currentUserId: string; @@ -36,215 +35,146 @@ export type Props = { }; } -export default class ResetPasswordModal extends React.PureComponent { - private currentPasswordRef: React.RefObject; - private passwordRef: React.RefObject; +export default function ResetPasswordModal({ + user, + currentUserId, + onSuccess, + onExited, + passwordConfig, + actions, +}: Props) { + const {formatMessage} = useIntl(); - public constructor(props: Props) { - super(props); + const [show, setShow] = useState(true); + const [currentPassword, setCurrentPassword] = useState(''); + const [newPassword, setNewPassword] = useState(''); + const [errorNewPass, setErrorNewPass] = useState(null); + const [errorCurrentPass, setErrorCurrentPass] = useState(null); - this.state = { - show: true, - serverErrorNewPass: null, - serverErrorCurrentPass: null, - }; + const currentPasswordRef = useRef(null); + const newPasswordRef = useRef(null); - this.currentPasswordRef = React.createRef(); - this.passwordRef = React.createRef(); - } + const isResettingOwnPassword = user?.id === currentUserId; - public componentWillUnmount(): void { - this.setState({ - serverErrorNewPass: null, - serverErrorCurrentPass: null, - }); - } + const handleCancel = useCallback(() => { + setShow(false); + }, []); - private doSubmit = async (e: React.MouseEvent) => { - e.preventDefault(); - if (!this.props.user) { + const handleConfirm = useCallback(async () => { + if (!user) { return; } - let currentPassword = ''; - if (this.currentPasswordRef.current) { - currentPassword = (this.currentPasswordRef.current as HTMLInputElement).value; - if (currentPassword === '') { - const errorMsg = ( - - ); - this.setState({serverErrorCurrentPass: errorMsg}); - return; - } + // Clear previous errors + setErrorNewPass(null); + setErrorCurrentPass(null); + + // Validate current password if resetting own password + if (isResettingOwnPassword && currentPassword === '') { + setErrorCurrentPass(formatMessage({ + id: 'admin.reset_password.missing_current', + defaultMessage: 'Please enter your current password.', + })); + return; } - const password = (this.passwordRef.current as HTMLInputElement).value; - - const {valid, error} = isValidPassword(password, this.props.passwordConfig); + // Validate new password + const {valid, error} = isValidPassword(newPassword, passwordConfig); if (!valid && error) { - this.setState({serverErrorNewPass: error}); + setErrorNewPass(error); return; } - this.setState({serverErrorNewPass: null}); - - const result = await this.props.actions.updateUserPassword(this.props.user.id, currentPassword, password); - if ('error' in result) { - this.setState({serverErrorCurrentPass: result.error.message}); - return; - } - this.props.onSuccess?.(); - this.setState({show: false}); - }; - - private doCancel = (): void => { - this.setState({ - show: false, - serverErrorNewPass: null, - serverErrorCurrentPass: null, - }); - }; - - public render(): JSX.Element { - const user = this.props.user; - if (user == null) { - return
; - } - - let urlClass = 'input-group input-group--limit'; - let serverErrorNewPass = null; - - if (this.state.serverErrorNewPass) { - urlClass += ' has-error'; - serverErrorNewPass =

{this.state.serverErrorNewPass}

; - } - - let title; - if (user.auth_service) { - title = ( - - ); - } else { - title = ( - - ); - } - - let currentPassword = null; - let serverErrorCurrentPass = null; - let newPasswordFocus = true; - if (this.props.currentUserId === user.id) { - newPasswordFocus = false; - let urlClassCurrentPass = 'input-group input-group--limit'; - if (this.state.serverErrorCurrentPass) { - urlClassCurrentPass += ' has-error'; - serverErrorCurrentPass =

{this.state.serverErrorCurrentPass}

; - } - currentPassword = ( -
-
- - - - -
-
- ); - } - - return ( - - - - {title} - - -
- -
- {currentPassword} -
-
- - - - -
- {serverErrorNewPass} - {serverErrorCurrentPass} -
-
-
- - - - -
-
+ const result = await actions.updateUserPassword( + user.id, + isResettingOwnPassword ? currentPassword : '', + newPassword, ); + + if ('error' in result) { + setErrorCurrentPass(result.error.message); + return; + } + + onSuccess?.(); + setShow(false); + }, [user, isResettingOwnPassword, currentPassword, newPassword, passwordConfig, actions, onSuccess, formatMessage]); + + if (!user) { + return null; } + + const displayName = getFullName(user) || user.username; + const isAuthUser = Boolean(user.auth_service); + + const title = isAuthUser ? + formatMessage({ + id: 'admin.reset_password.titleSwitchFor', + defaultMessage: 'Switch account to Email/Password for {name}', + }, {name: displayName}) : + formatMessage({ + id: 'admin.reset_password.titleResetFor', + defaultMessage: 'Reset password for {name}', + }, {name: displayName}); + + return ( + {errorCurrentPass} : undefined} + > +
+ {isResettingOwnPassword && ( + } + type='password' + name='currentPassword' + autoComplete='current-password' + label={formatMessage({ + id: 'admin.reset_password.currentPassword', + defaultMessage: 'Current password', + })} + placeholder={formatMessage({ + id: 'admin.reset_password.enterCurrentPassword', + defaultMessage: 'Enter current password', + })} + value={currentPassword} + onChange={(e) => setCurrentPassword(e.target.value)} + autoFocus={true} + /> + )} + } + type='password' + name='newPassword' + autoComplete='new-password' + label={formatMessage({ + id: 'admin.reset_password.newPassword', + defaultMessage: 'New password', + })} + placeholder={formatMessage({ + id: 'admin.reset_password.enterNewPassword', + defaultMessage: 'Enter new password', + })} + value={newPassword} + onChange={(e) => setNewPassword(e.target.value)} + autoFocus={!isResettingOwnPassword} + customMessage={errorNewPass ? {type: 'error', value: errorNewPass} : undefined} + /> +
+
+ ); } diff --git a/webapp/channels/src/i18n/en.json b/webapp/channels/src/i18n/en.json index e1ef6ce86bb..cefad218923 100644 --- a/webapp/channels/src/i18n/en.json +++ b/webapp/channels/src/i18n/en.json @@ -2454,19 +2454,21 @@ "admin.requestButton.loading": " Loading...", "admin.requestButton.requestFailure": "Test Failure: {error}", "admin.requestButton.requestSuccess": "Test Successful", - "admin.reset_email.cancel": "Cancel", "admin.reset_email.currentPassword": "Current Password", + "admin.reset_email.enterCurrentPassword": "Enter current password", + "admin.reset_email.enterNewEmail": "Enter new email address", "admin.reset_email.missing_current_password": "Please enter your current password.", "admin.reset_email.newEmail": "New Email", - "admin.reset_email.reset": "Reset", - "admin.reset_email.titleReset": "Update Email", - "admin.reset_password.cancel": "Cancel", - "admin.reset_password.curentPassword": "Current Password", + "admin.reset_email.titleResetFor": "Update email for {name}", + "admin.reset_email.update": "Update", + "admin.reset_password.currentPassword": "Current password", + "admin.reset_password.enterCurrentPassword": "Enter current password", + "admin.reset_password.enterNewPassword": "Enter new password", "admin.reset_password.missing_current": "Please enter your current password.", "admin.reset_password.newPassword": "New Password", "admin.reset_password.reset": "Reset", - "admin.reset_password.titleReset": "Reset Password", - "admin.reset_password.titleSwitch": "Switch Account to Email/Password", + "admin.reset_password.titleResetFor": "Reset password for {name}", + "admin.reset_password.titleSwitchFor": "Switch account to Email/Password for {name}", "admin.revoke_token_button.delete": "Delete", "admin.s3.connectionS3Test": "Test Connection", "admin.s3.s3Fail": "Connection unsuccessful: {error}", diff --git a/webapp/platform/components/src/generic_modal/generic_modal.scss b/webapp/platform/components/src/generic_modal/generic_modal.scss index 6064859567f..19d2be2848a 100644 --- a/webapp/platform/components/src/generic_modal/generic_modal.scss +++ b/webapp/platform/components/src/generic_modal/generic_modal.scss @@ -126,7 +126,8 @@ display: flex; box-sizing: border-box; flex: 1; - padding: 14px 32px; + margin: 0 32px; + padding: 14px 16px; border: 1px solid rgba(var(--dnd-indicator-rgb), 0.16); margin-bottom: 24px; background: rgba(var(--dnd-indicator-rgb), 0.08);