diff --git a/app/views/ProfileView/index.test.tsx b/app/views/ProfileView/index.test.tsx
new file mode 100644
index 0000000000..8eea897201
--- /dev/null
+++ b/app/views/ProfileView/index.test.tsx
@@ -0,0 +1,172 @@
+import React from 'react';
+import { act, fireEvent, render, waitFor } from '@testing-library/react-native';
+import { useDispatch } from 'react-redux';
+import { sha256 } from 'js-sha256';
+
+import ProfileView from './index';
+import { useAppSelector } from '../../lib/hooks/useAppSelector';
+import { saveUserProfile } from '../../lib/services/restApi';
+import { twoFactor } from '../../lib/services/twoFactor';
+import handleSaveUserProfileError from '../../lib/methods/helpers/handleSaveUserProfileError';
+import EventEmitter from '../../lib/methods/helpers/events';
+import { setUser } from '../../actions/login';
+
+jest.mock('react-redux', () => ({
+ useDispatch: jest.fn()
+}));
+
+jest.mock('@react-navigation/native', () => ({
+ ...jest.requireActual('@react-navigation/native'),
+ useFocusEffect: jest.fn()
+}));
+
+jest.mock('../../lib/hooks/useAppSelector', () => ({
+ useAppSelector: jest.fn()
+}));
+
+jest.mock('../../lib/services/restApi', () => ({
+ saveUserProfile: jest.fn()
+}));
+
+jest.mock('../../lib/services/twoFactor', () => ({
+ twoFactor: jest.fn()
+}));
+
+jest.mock('../../lib/methods/helpers/handleSaveUserProfileError', () => jest.fn());
+
+const mockShowActionSheet = jest.fn();
+const mockHideActionSheet = jest.fn();
+jest.mock('../../containers/ActionSheet', () => ({
+ useActionSheet: () => ({ showActionSheet: mockShowActionSheet, hideActionSheet: mockHideActionSheet })
+}));
+
+jest.mock('react-native-keyboard-controller', () => {
+ const { View } = require('react-native');
+ return { KeyboardAvoidingView: View };
+});
+
+jest.mock('./components/DeleteAccountActionSheetContent', () => () => null);
+jest.mock('./components/ConfirmEmailChangeActionSheetContent', () => () => null);
+jest.mock('../../containers/CustomFields', () => () => null);
+jest.mock('../../containers/Avatar', () => ({ AvatarWithEdit: () => null }));
+
+const user = {
+ id: 'user-id',
+ name: 'John Doe',
+ username: 'john.doe',
+ emails: [{ address: 'john@rocket.chat', verified: true }],
+ bio: 'My bio',
+ nickname: 'johnny',
+ customFields: {}
+};
+
+const buildState = () => ({
+ login: { user },
+ app: { isMasterDetail: false },
+ server: { version: '7.0.0' },
+ settings: {
+ Accounts_AllowEmailChange: true,
+ Accounts_AllowPasswordChange: true,
+ Accounts_AllowRealNameChange: true,
+ Accounts_AllowUserAvatarChange: true,
+ Accounts_AllowUsernameChange: true,
+ Accounts_CustomFields: '',
+ Accounts_AllowDeleteOwnAccount: true
+ }
+});
+
+const navigation = {
+ setOptions: jest.fn(),
+ navigate: jest.fn()
+} as any;
+
+const dispatch = jest.fn();
+
+const renderProfile = () => {
+ (useAppSelector as jest.Mock).mockImplementation((selector: (state: any) => unknown) => selector(buildState()));
+ return render();
+};
+
+// Make the form dirty + valid so the Save button is enabled, then press it.
+const changeNameAndSubmit = (getByTestId: (id: string) => any, newName = 'Jane Doe') => {
+ fireEvent.changeText(getByTestId('profile-view-name'), newName);
+ fireEvent.press(getByTestId('profile-view-submit'));
+};
+
+beforeEach(() => {
+ jest.clearAllMocks();
+ (useDispatch as jest.Mock).mockReturnValue(dispatch);
+});
+
+describe('ProfileView submit', () => {
+ it('saves the changed fields and notifies the user on success', async () => {
+ (saveUserProfile as jest.Mock).mockResolvedValue(true);
+ const emitSpy = jest.spyOn(EventEmitter, 'emit');
+
+ const { getByTestId } = renderProfile();
+ changeNameAndSubmit(getByTestId);
+
+ await waitFor(() => expect(saveUserProfile).toHaveBeenCalledWith({ name: 'Jane Doe' }, {}));
+ expect(dispatch).toHaveBeenCalledWith(setUser({ ...user, name: 'Jane Doe', customFields: {} }));
+ expect(emitSpy).toHaveBeenCalled();
+ expect(handleSaveUserProfileError).not.toHaveBeenCalled();
+ });
+
+ it('asks for the current password before changing the email', async () => {
+ const { getByTestId } = renderProfile();
+
+ fireEvent.changeText(getByTestId('profile-view-email'), 'jane@rocket.chat');
+ fireEvent.press(getByTestId('profile-view-submit'));
+
+ await waitFor(() => expect(mockShowActionSheet).toHaveBeenCalled());
+ expect(saveUserProfile).not.toHaveBeenCalled();
+ });
+
+ it('retries the save through the 2FA challenge', async () => {
+ (saveUserProfile as jest.Mock)
+ .mockRejectedValueOnce({ error: 'totp-invalid', details: { method: 'totp' } })
+ .mockResolvedValueOnce(true);
+ (twoFactor as jest.Mock).mockResolvedValue({ twoFactorCode: '123456', twoFactorMethod: 'totp' });
+
+ const { getByTestId } = renderProfile();
+ changeNameAndSubmit(getByTestId);
+
+ await waitFor(() => expect(twoFactor).toHaveBeenCalledWith({ method: 'totp', invalid: false }));
+ await waitFor(() => expect(saveUserProfile).toHaveBeenCalledTimes(2));
+ expect(handleSaveUserProfileError).not.toHaveBeenCalled();
+ });
+
+ it('handles the save error after a cancelled/non-2FA failure', async () => {
+ const error = { error: 'some-other-error' };
+ (saveUserProfile as jest.Mock).mockRejectedValue(error);
+
+ const { getByTestId } = renderProfile();
+ changeNameAndSubmit(getByTestId);
+
+ await waitFor(() => expect(handleSaveUserProfileError).toHaveBeenCalledWith(error, 'saving_profile'));
+ expect(twoFactor).not.toHaveBeenCalled();
+ });
+
+ it('hashes the password supplied through the confirm-email action sheet', async () => {
+ (saveUserProfile as jest.Mock).mockResolvedValue(true);
+ // Capture the onSubmit handler passed to the confirm-email action sheet.
+ let confirmOnSubmit: ((password: string) => Promise) | undefined;
+ mockShowActionSheet.mockImplementation(({ children }: any) => {
+ confirmOnSubmit = children?.props?.onSubmit;
+ });
+
+ const { getByTestId } = renderProfile();
+ fireEvent.changeText(getByTestId('profile-view-email'), 'jane@rocket.chat');
+ fireEvent.press(getByTestId('profile-view-submit'));
+
+ await waitFor(() => expect(confirmOnSubmit).toBeDefined());
+ await act(async () => {
+ await confirmOnSubmit!('my-secret');
+ });
+
+ await waitFor(() =>
+ expect(saveUserProfile).toHaveBeenCalledWith({ email: 'jane@rocket.chat', currentPassword: sha256('my-secret') }, {})
+ );
+ expect(mockHideActionSheet).toHaveBeenCalled();
+ });
+});
diff --git a/app/views/ProfileView/index.tsx b/app/views/ProfileView/index.tsx
index e5206058ea..a36a555c6a 100644
--- a/app/views/ProfileView/index.tsx
+++ b/app/views/ProfileView/index.tsx
@@ -1,5 +1,4 @@
import { type NativeStackNavigationOptions, type NativeStackNavigationProp } from '@react-navigation/native-stack';
-import { sha256 } from 'js-sha256';
import React, { useCallback, useLayoutEffect, useRef, useState } from 'react';
import { Keyboard, ScrollView, View, type TextInput } from 'react-native';
import { useDispatch } from 'react-redux';
@@ -39,6 +38,7 @@ import CustomFields from '../../containers/CustomFields';
import ListSeparator from '../../containers/List/ListSeparator';
import handleSaveUserProfileError from '../../lib/methods/helpers/handleSaveUserProfileError';
import logoutOtherLocations from './methods/logoutOtherLocations';
+import buildProfileParams from './methods/buildProfileParams';
import ConfirmEmailChangeActionSheetContent from './components/ConfirmEmailChangeActionSheetContent';
// https://github.com/RocketChat/Rocket.Chat/blob/174c28d40b3d5a52023ee2dca2e81dd77ff33fa5/apps/meteor/app/lib/server/functions/saveUser.js#L24-L25
@@ -155,7 +155,70 @@ const ProfileView = ({ navigation }: IProfileViewProps): React.ReactElement => {
showActionSheet({ children: });
};
- // TODO: function is too long, split it
+ const showConfirmEmailChangeActionSheet = () => {
+ showActionSheet({
+ children: (
+ {
+ hideActionSheet();
+ setValue('currentPassword', p as any);
+ await submit();
+ }}
+ />
+ )
+ });
+ };
+
+ const resetSavingState = () => {
+ setValue('saving', false);
+ setValue('currentPassword', null);
+ setTwoFactorCode(null);
+ };
+
+ const applySaveSuccess = (params: IProfileParams) => {
+ logEvent(events.PROFILE_SAVE_CHANGES);
+
+ const updatedUser = { ...user, ...params };
+
+ reset({
+ name: updatedUser.name || '',
+ username: updatedUser.username || '',
+ email: updatedUser.emails?.[0]?.address || updatedUser.email || '',
+ currentPassword: null,
+ bio: updatedUser.bio || '',
+ nickname: updatedUser.nickname || '',
+ saving: false
+ });
+ dispatch(setUser({ ...user, ...params, customFields }));
+ EventEmitter.emit(LISTENER, { message: I18n.t('Profile_saved_successfully') });
+ };
+
+ const setFieldErrorsFromResponse = (e: any, email: string | null) => {
+ if (e?.error === 'error-could-not-save-identity') {
+ setError('username', { message: I18n.t('Username_not_available'), type: 'validate' });
+ }
+
+ if (email && e?.message?.startsWith(email) && e?.error === 'error-field-unavailable') {
+ setError('email', { message: I18n.t('Email_associated_with_another_user'), type: 'validate' });
+ }
+ };
+
+ // Returns true if a 2FA retry was issued and submit should yield to it.
+ const handleTwoFactorChallenge = async (e: any): Promise => {
+ if (e?.error !== 'totp-invalid' || e?.details.method === TwoFactorMethods.PASSWORD) {
+ return false;
+ }
+ try {
+ const code = await twoFactor({ method: e.details.method, invalid: e?.error === 'totp-invalid' && !!twoFactorCode });
+ setTwoFactorCode(code as any);
+ await submit();
+ return true;
+ } catch {
+ // cancelled twoFactor modal
+ return false;
+ }
+ };
+
const submit = async (): Promise => {
Keyboard.dismiss();
@@ -165,91 +228,30 @@ const ProfileView = ({ navigation }: IProfileViewProps): React.ReactElement => {
setValue('saving', true);
- const { name, username, email, currentPassword, bio, nickname } = getValues();
- const params = {} as IProfileParams;
-
- if (user.name !== name) params.name = name;
- if (user.username !== username) params.username = username;
- if (user.emails?.[0].address !== email) params.email = email;
- if (user.bio !== bio) params.bio = bio;
- if (user.nickname !== nickname) params.nickname = nickname;
- if (currentPassword) params.currentPassword = sha256(currentPassword);
-
+ const params = buildProfileParams(getValues(), user);
const requirePassword = !!params.email;
if (requirePassword && !params.currentPassword) {
setValue('saving', false);
- showActionSheet({
- children: (
- {
- hideActionSheet();
- setValue('currentPassword', p as any);
- submit();
- }}
- />
- )
- });
+ showConfirmEmailChangeActionSheet();
return;
}
try {
const result = await saveUserProfile(params, customFields);
-
if (result) {
- logEvent(events.PROFILE_SAVE_CHANGES);
- if (customFields) {
- dispatch(setUser({ customFields, ...params }));
- setCustomFields(customFields);
- } else {
- dispatch(setUser({ ...params }));
- const user = { ...getValues(), ...params };
- Object.entries(user).forEach(([key, value]) => setValue(key as any, value));
- }
-
- const updatedUser = {
- ...user,
- ...params
- };
-
- reset({
- name: updatedUser.name || '',
- username: updatedUser.username || '',
- email: updatedUser.emails?.[0]?.address || updatedUser.email || '',
- currentPassword: null,
- bio: updatedUser.bio || '',
- nickname: updatedUser.nickname || '',
- saving: false
- });
- dispatch(setUser({ ...user, ...params, customFields }));
- EventEmitter.emit(LISTENER, { message: I18n.t('Profile_saved_successfully') });
+ applySaveSuccess(params);
}
-
- setValue('saving', false);
- setValue('currentPassword', null);
- setTwoFactorCode(null);
+ resetSavingState();
} catch (e: any) {
- if (e?.error === 'error-could-not-save-identity') {
- setError('username', { message: I18n.t('Username_not_available'), type: 'validate' });
- }
+ const { email } = getValues();
+ setFieldErrorsFromResponse(e, email);
- if (e?.message.startsWith(email) && e?.error === 'error-field-unavailable') {
- setError('email', { message: I18n.t('Email_associated_with_another_user'), type: 'validate' });
- }
+ const retried = await handleTwoFactorChallenge(e);
+ if (retried) return;
- if (e?.error === 'totp-invalid' && e?.details.method !== TwoFactorMethods.PASSWORD) {
- try {
- const code = await twoFactor({ method: e.details.method, invalid: e?.error === 'totp-invalid' && !!twoFactorCode });
- setTwoFactorCode(code as any);
- return submit();
- } catch {
- // cancelled twoFactor modal
- }
- }
logEvent(events.PROFILE_SAVE_CHANGES_F);
- setValue('saving', false);
- setValue('currentPassword', null);
- setTwoFactorCode(null);
+ resetSavingState();
handleSaveUserProfileError(e, 'saving_profile');
}
};
diff --git a/app/views/ProfileView/methods/buildProfileParams.test.ts b/app/views/ProfileView/methods/buildProfileParams.test.ts
new file mode 100644
index 0000000000..02302cc704
--- /dev/null
+++ b/app/views/ProfileView/methods/buildProfileParams.test.ts
@@ -0,0 +1,80 @@
+import { sha256 } from 'js-sha256';
+
+import { type IUser } from '../../../definitions';
+import buildProfileParams from './buildProfileParams';
+
+const baseUser = {
+ name: 'John Doe',
+ username: 'john.doe',
+ emails: [{ address: 'john@rocket.chat', verified: true }],
+ bio: 'My bio',
+ nickname: 'johnny'
+} as unknown as IUser;
+
+const baseForm = {
+ name: 'John Doe',
+ username: 'john.doe',
+ email: 'john@rocket.chat',
+ currentPassword: null,
+ bio: 'My bio',
+ nickname: 'johnny'
+};
+
+describe('buildProfileParams', () => {
+ it('returns an empty object when nothing changed', () => {
+ expect(buildProfileParams(baseForm, baseUser)).toEqual({});
+ });
+
+ it('includes name only when it changed', () => {
+ expect(buildProfileParams({ ...baseForm, name: 'Jane Doe' }, baseUser)).toEqual({ name: 'Jane Doe' });
+ });
+
+ it('includes username only when it changed', () => {
+ expect(buildProfileParams({ ...baseForm, username: 'jane.doe' }, baseUser)).toEqual({ username: 'jane.doe' });
+ });
+
+ it('includes email only when it changed', () => {
+ expect(buildProfileParams({ ...baseForm, email: 'jane@rocket.chat' }, baseUser)).toEqual({ email: 'jane@rocket.chat' });
+ });
+
+ it('includes bio only when it changed', () => {
+ expect(buildProfileParams({ ...baseForm, bio: 'New bio' }, baseUser)).toEqual({ bio: 'New bio' });
+ });
+
+ it('includes nickname only when it changed', () => {
+ expect(buildProfileParams({ ...baseForm, nickname: 'jd' }, baseUser)).toEqual({ nickname: 'jd' });
+ });
+
+ it('hashes currentPassword with sha256 when provided', () => {
+ const params = buildProfileParams({ ...baseForm, currentPassword: 'my-secret' }, baseUser);
+ expect(params).toEqual({ currentPassword: sha256('my-secret') });
+ expect(params.currentPassword).not.toBe('my-secret');
+ });
+
+ it('does not include currentPassword when it is null', () => {
+ const params = buildProfileParams(baseForm, baseUser);
+ expect(params).not.toHaveProperty('currentPassword');
+ });
+
+ it('aggregates every changed field at once', () => {
+ const params = buildProfileParams(
+ {
+ name: 'Jane Doe',
+ username: 'jane.doe',
+ email: 'jane@rocket.chat',
+ currentPassword: 'pass123',
+ bio: 'New bio',
+ nickname: 'jd'
+ },
+ baseUser
+ );
+ expect(params).toEqual({
+ name: 'Jane Doe',
+ username: 'jane.doe',
+ email: 'jane@rocket.chat',
+ bio: 'New bio',
+ nickname: 'jd',
+ currentPassword: sha256('pass123')
+ });
+ });
+});
diff --git a/app/views/ProfileView/methods/buildProfileParams.ts b/app/views/ProfileView/methods/buildProfileParams.ts
new file mode 100644
index 0000000000..8adfa56156
--- /dev/null
+++ b/app/views/ProfileView/methods/buildProfileParams.ts
@@ -0,0 +1,28 @@
+import { sha256 } from 'js-sha256';
+
+import { type IProfileParams, type IUser } from '../../../definitions';
+
+interface IProfileFormValues {
+ name: string;
+ username: string;
+ email: string | null;
+ currentPassword: string | null;
+ bio?: string;
+ nickname?: string;
+}
+
+const buildProfileParams = (formValues: IProfileFormValues, user: IUser): IProfileParams => {
+ const { name, username, email, currentPassword, bio, nickname } = formValues;
+ const params = {} as IProfileParams;
+
+ if (user.name !== name) params.name = name;
+ if (user.username !== username) params.username = username;
+ if (user.emails?.[0].address !== email) params.email = email;
+ if (user.bio !== bio) params.bio = bio;
+ if (user.nickname !== nickname) params.nickname = nickname;
+ if (currentPassword) params.currentPassword = sha256(currentPassword);
+
+ return params;
+};
+
+export default buildProfileParams;