Skip to content
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
13dd123
move members poc
jmusial Feb 10, 2026
34c8d44
minor updates
jmusial Feb 10, 2026
d47b6d8
Merge branch 'main' into feat/groups-move-members
jmusial Feb 10, 2026
69e1936
Merge branch 'main' into feat/groups-move-members
jmusial Feb 20, 2026
ef0158f
update const
jmusial Feb 20, 2026
3538f07
Merge branch 'main' into feat/groups-move-members
jmusial Mar 13, 2026
192877c
Merge branch 'main' into feat/groups-move-members
jmusial Mar 17, 2026
797fb1c
rename consts
jmusial Mar 17, 2026
b95c9e2
remove redundant selector (impelmented as a part of other PR)
jmusial Mar 17, 2026
9872df5
move errors & pending actions keys
jmusial Mar 17, 2026
8d425a4
move getMemberCustomRowProps to utils
jmusial Mar 17, 2026
940dceb
refactor getMemberCustomRowProps
jmusial Mar 18, 2026
f898ad8
fix ts
jmusial Mar 18, 2026
1c04c6e
add tests
jmusial Mar 18, 2026
0114ae1
add translations
jmusial Mar 18, 2026
6fd4757
add jsdoc and guard against moving to the same group
jmusial Mar 18, 2026
0370968
Merge branch 'main' into feat/groups-move-members
jmusial Mar 18, 2026
f78b787
fix cache clearing
jmusial Mar 18, 2026
1cb95e7
optimize selectors
jmusial Mar 18, 2026
7ddbd3e
move clearing selection to a hook
jmusial Mar 18, 2026
3a6e9c7
fix lint
jmusial Mar 18, 2026
b4ec86a
remove duplicated variable
jmusial Mar 19, 2026
de19658
remove unneeded lint ignore
jmusial Mar 19, 2026
5ab351b
fix revert on failure
jmusial Mar 19, 2026
b0a3f1b
Merge branch 'main' into feat/groups-move-members
jmusial Mar 24, 2026
2fc451f
clear state on back button press
jmusial Mar 24, 2026
dbe9d32
fix list item import
jmusial Mar 24, 2026
4bcad73
Merge branch 'main' into feat/groups-move-members
jmusial Apr 3, 2026
3b5ceb6
increase button size
jmusial Apr 3, 2026
659bd60
fix not showing errors
jmusial Apr 3, 2026
d505683
Merge branch 'main' into feat/groups-move-members
jmusial Apr 3, 2026
6db97af
Merge branch 'main' into feat/groups-move-members
jmusial Apr 8, 2026
7ff0b76
workaround test
jmusial Apr 9, 2026
6733712
working workaround for domain member page errors
jmusial Apr 13, 2026
ebf9bf9
Merge branch 'main' into feat/groups-move-members
jmusial Apr 13, 2026
62da0bc
Merge branch 'main' into feat/groups-move-members
jmusial Apr 14, 2026
7461c72
Merge branch 'main' into feat/groups-move-members
jmusial Apr 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/CONST/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8853,6 +8853,7 @@ const CONST = {
},
BULK_ACTION_TYPES: {
CLOSE_ACCOUNT: 'closeAccount',
MOVE_TO_GROUP: 'moveToGroup',
},
},
},
Expand Down
4 changes: 4 additions & 0 deletions src/ONYXKEYS.ts
Original file line number Diff line number Diff line change
Expand Up @@ -394,6 +394,9 @@ const ONYXKEYS = {
/** A map of the user's security group IDs they belong to in specific domains */
MY_DOMAIN_SECURITY_GROUPS: 'myDomainSecurityGroups',

/** Selected domain member account IDs for the move-to-group operation */
DOMAIN_MEMBERS_SELECTED_FOR_MOVE: 'domainMembersSelectedForMove',

// The theme setting set by the user in preferences.
// This can be either "light", "dark" or "system"
PREFERRED_THEME: 'nvp_preferredTheme',
Expand Down Expand Up @@ -1318,6 +1321,7 @@ type OnyxValuesMapping = {
[ONYXKEYS.IS_BETA]: boolean;
[ONYXKEYS.IS_CHECKING_PUBLIC_ROOM]: boolean;
[ONYXKEYS.MY_DOMAIN_SECURITY_GROUPS]: Record<string, string>;
[ONYXKEYS.DOMAIN_MEMBERS_SELECTED_FOR_MOVE]: string[];
[ONYXKEYS.VERIFY_3DS_SUBSCRIPTION]: string;
[ONYXKEYS.PREFERRED_THEME]: ValueOf<typeof CONST.THEME>;
[ONYXKEYS.MAPBOX_ACCESS_TOKEN]: OnyxTypes.MapboxAccessToken;
Expand Down
4 changes: 4 additions & 0 deletions src/ROUTES.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3913,6 +3913,10 @@ const ROUTES = {
route: 'domain/:domainAccountID/members/settings/two-factor-auth',
getRoute: (domainAccountID: number) => `domain/${domainAccountID}/members/settings/two-factor-auth` as const,
},
DOMAIN_MOVE_USERS: {
route: 'domain/:domainAccountID/members/move',
getRoute: (domainAccountID: number) => `domain/${domainAccountID}/members/move` as const,
},

MULTIFACTOR_AUTHENTICATION_MAGIC_CODE: `multifactor-authentication/magic-code`,
MULTIFACTOR_AUTHENTICATION_BIOMETRICS_TEST: 'multifactor-authentication/scenario/biometrics-test',
Expand Down
1 change: 1 addition & 0 deletions src/SCREENS.ts
Original file line number Diff line number Diff line change
Expand Up @@ -947,6 +947,7 @@ const SCREENS = {
MEMBERS_SETTINGS: 'Members_Settings',
MEMBERS_SETTINGS_TWO_FACTOR_AUTH: 'Members_Settings_Two_Factor_Auth',
GROUPS: 'Domain_Groups',
MOVE_USERS: 'Domain_Move_Users',
},
MULTIFACTOR_AUTHENTICATION: {
MAGIC_CODE: 'Multifactor_Authentication_Magic_Code',
Expand Down
4 changes: 4 additions & 0 deletions src/languages/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8414,9 +8414,13 @@ const translations = {
one: 'Close account',
other: 'Close accounts',
}),
moveToGroup: 'Move to group',
chooseWhereToMove: ({count}: {count: number}) => `Choose where to move ${count} ${count === 1 ? 'member' : 'members'}.`,
move: 'Move',
error: {
addMember: 'Unable to add this member. Please try again.',
removeMember: 'Unable to remove this user. Please try again.',
moveMember: 'Unable to move this member. Please try again.',
vacationDelegate: 'Unable to set this user as a vacation delegate. Please try again.',
},
forceTwoFactorAuth: 'Force two-factor authentication',
Expand Down
8 changes: 8 additions & 0 deletions src/libs/API/parameters/ChangeDomainSecurityGroupParams.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
type ChangeDomainSecurityGroupParams = {
domainName: string;
newID: string;
employeeEmail: string;
domainAccountID: number;
};

export default ChangeDomainSecurityGroupParams;
1 change: 1 addition & 0 deletions src/libs/API/parameters/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -474,6 +474,7 @@ export type {default as SetTechnicalContactEmailParams} from './SetTechnicalCont
export type {default as ToggleConsolidatedDomainBillingParams} from './ToggleConsolidatedDomainBillingParams';
export type {default as RemoveDomainAdminParams} from './RemoveDomainAdminParams';
export type {default as DeleteDomainMemberParams} from './DeleteDomainMemberParams';
export type {default as ChangeDomainSecurityGroupParams} from './ChangeDomainSecurityGroupParams';
export type {default as DeleteDomainParams} from './DeleteDomainParams';
export type {default as GetDuplicateTransactionDetailsParams} from './GetDuplicateTransactionDetailsParams';
export type {default as UpdateTravelInvoicingSettlementFrequencyParams} from './UpdateTravelInvoicingSettlementFrequencyParams';
Expand Down
2 changes: 2 additions & 0 deletions src/libs/API/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -557,6 +557,7 @@ const WRITE_COMMANDS = {
DELETE_DOMAIN: 'DeleteDomain',
DELETE_DOMAIN_MEMBER: 'DeleteDomainMember',
TOGGLE_TWO_FACTOR_AUTH_REQUIRED_FOR_DOMAIN: 'ToggleTwoFactorAuthRequiredForDomain',
CHANGE_DOMAIN_SECURITY_GROUP: 'ChangeDomainSecurityGroup',
} as const;

type WriteCommand = ValueOf<typeof WRITE_COMMANDS>;
Expand Down Expand Up @@ -1136,6 +1137,7 @@ type WriteCommandParameters = {
[WRITE_COMMANDS.ADD_DOMAIN_ADMIN]: Parameters.AddAdminToDomainParams;
[WRITE_COMMANDS.ADD_DOMAIN_MEMBER]: Parameters.AddMemberToDomainParams;
[WRITE_COMMANDS.TOGGLE_TWO_FACTOR_AUTH_REQUIRED_FOR_DOMAIN]: Parameters.ToggleTwoFactorAuthRequiredForDomainParams;
[WRITE_COMMANDS.CHANGE_DOMAIN_SECURITY_GROUP]: Parameters.ChangeDomainSecurityGroupParams;
};

const READ_COMMANDS = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -895,6 +895,7 @@ const SettingsModalStackNavigator = createModalStackNavigator<SettingsNavigatorP
[SCREENS.DOMAIN.ADD_MEMBER]: () => require<ReactComponentModule>('../../../../pages/domain/Members/DomainAddMemberPage').default,
[SCREENS.DOMAIN.MEMBERS_SETTINGS]: () => require<ReactComponentModule>('../../../../pages/domain/Members/DomainMembersSettingsPage').default,
[SCREENS.DOMAIN.MEMBERS_SETTINGS_TWO_FACTOR_AUTH]: () => require<ReactComponentModule>('../../../../pages/domain/Members/DomainRequireTwoFactorAuthPage').default,
[SCREENS.DOMAIN.MOVE_USERS]: () => require<ReactComponentModule>('../../../../pages/domain/Members/MoveUsersBetweenGroupsPage').default,
});

const TwoFactorAuthenticatorStackNavigator = createModalStackNavigator<EnablePaymentsNavigatorParamList>({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ const DOMAIN_TO_RHP: Partial<Record<keyof DomainSplitNavigatorParamList, string[
[SCREENS.DOMAIN.MEMBERS]: [
SCREENS.DOMAIN.MEMBER_DETAILS,
SCREENS.DOMAIN.ADD_MEMBER,
SCREENS.DOMAIN.MOVE_USERS,
SCREENS.DOMAIN.MEMBERS_SETTINGS,
SCREENS.DOMAIN.MEMBERS_SETTINGS_TWO_FACTOR_AUTH,
SCREENS.DOMAIN.VACATION_DELEGATE,
Expand Down
3 changes: 3 additions & 0 deletions src/libs/Navigation/linkingConfig/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1307,6 +1307,9 @@ const config: LinkingOptions<RootNavigatorParamList>['config'] = {
[SCREENS.DOMAIN.MEMBERS_SETTINGS_TWO_FACTOR_AUTH]: {
path: ROUTES.DOMAIN_MEMBERS_SETTINGS_TWO_FACTOR_AUTH.route,
},
[SCREENS.DOMAIN.MOVE_USERS]: {
path: ROUTES.DOMAIN_MOVE_USERS.route,
},
},
},
[SCREENS.RIGHT_MODAL.TWO_FACTOR_AUTH]: {
Expand Down
3 changes: 3 additions & 0 deletions src/libs/Navigation/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1540,6 +1540,9 @@ type SettingsNavigatorParamList = {
domainAccountID: number;
accountID: number;
};
[SCREENS.DOMAIN.MOVE_USERS]: {
domainAccountID: number;
};
} & ReimbursementAccountNavigatorParamList;

type DomainCardNavigatorParamList = {
Expand Down
112 changes: 112 additions & 0 deletions src/libs/actions/Domain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import * as API from '@libs/API';
import type {
AddAdminToDomainParams,
AddMemberToDomainParams,
ChangeDomainSecurityGroupParams,
DeleteDomainMemberParams,
DeleteDomainParams,
RemoveDomainAdminParams,
Expand All @@ -18,6 +19,7 @@ import {generateAccountID} from '@libs/UserUtils';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import type {Domain, DomainSecurityGroup, UserSecurityGroupData} from '@src/types/onyx';
import type {SecurityGroupKey} from '@src/types/onyx/Domain';
import type {PendingAction} from '@src/types/onyx/OnyxCommon';
import type {BaseVacationDelegate} from '@src/types/onyx/VacationDelegate';
import type PrefixedRecord from '@src/types/utils/PrefixedRecord';
Expand Down Expand Up @@ -1365,6 +1367,113 @@ function clearVacationDelegateError(domainAccountID: number, domainMemberAccount
});
}

function changeDomainSecurityGroup(
domainAccountID: number,
domainName: string,
employeeEmail: string,
accountID: number,
currentSecurityGroupKey: SecurityGroupKey,
currentSecurityGroup: Partial<DomainSecurityGroup>,
targetSecurityGroupKey: SecurityGroupKey,
) {
const accountIDStr = String(accountID);

const optimisticData: OnyxUpdate[] = [
{
onyxMethod: Onyx.METHOD.MERGE,
key: `${ONYXKEYS.COLLECTION.DOMAIN}${domainAccountID}`,
value: {
[currentSecurityGroupKey]: {
shared: {
[accountIDStr]: null,
},
},
[targetSecurityGroupKey]: {
shared: {
[accountIDStr]: 'read',
},
},
} as PrefixedRecord<typeof CONST.DOMAIN.DOMAIN_SECURITY_GROUP_PREFIX, Partial<DomainSecurityGroup>>,
},
{
onyxMethod: Onyx.METHOD.MERGE,
key: `${ONYXKEYS.COLLECTION.DOMAIN_PENDING_ACTIONS}${domainAccountID}`,
value: {
member: {[employeeEmail]: {pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE as PendingAction}},
},
},
{
onyxMethod: Onyx.METHOD.MERGE,
key: `${ONYXKEYS.COLLECTION.DOMAIN_ERRORS}${domainAccountID}`,
value: {
memberErrors: {[employeeEmail]: null},
},
},
];

const successData: OnyxUpdate[] = [
{
onyxMethod: Onyx.METHOD.MERGE,
key: `${ONYXKEYS.COLLECTION.DOMAIN_PENDING_ACTIONS}${domainAccountID}`,
value: {
member: {[employeeEmail]: null},
},
},
{
onyxMethod: Onyx.METHOD.MERGE,
key: `${ONYXKEYS.COLLECTION.DOMAIN_ERRORS}${domainAccountID}`,
value: {
memberErrors: {[employeeEmail]: null},
},
},
];

const failureData: OnyxUpdate[] = [
{
onyxMethod: Onyx.METHOD.MERGE,
key: `${ONYXKEYS.COLLECTION.DOMAIN}${domainAccountID}`,
value: {
[currentSecurityGroupKey]: currentSecurityGroup,
} as PrefixedRecord<typeof CONST.DOMAIN.DOMAIN_SECURITY_GROUP_PREFIX, Partial<DomainSecurityGroup>>,
},
{
onyxMethod: Onyx.METHOD.MERGE,
key: `${ONYXKEYS.COLLECTION.DOMAIN_PENDING_ACTIONS}${domainAccountID}`,
value: {
member: {[employeeEmail]: null},
},
},
{
onyxMethod: Onyx.METHOD.MERGE,
key: `${ONYXKEYS.COLLECTION.DOMAIN_ERRORS}${domainAccountID}`,
value: {
memberErrors: {
[employeeEmail]: {errors: getMicroSecondOnyxErrorWithTranslationKey('domain.members.error.moveMember')},
},
},
},
];

const newID = targetSecurityGroupKey.replace(CONST.DOMAIN.DOMAIN_SECURITY_GROUP_PREFIX, '');

const parameters: ChangeDomainSecurityGroupParams = {
domainName,
newID,
employeeEmail,
domainAccountID,
};

API.write(WRITE_COMMANDS.CHANGE_DOMAIN_SECURITY_GROUP, parameters, {optimisticData, successData, failureData});
}

function setDomainMembersSelectedForMove(memberAccountIDs: string[]) {
Onyx.set(ONYXKEYS.DOMAIN_MEMBERS_SELECTED_FOR_MOVE, memberAccountIDs);
}

function clearDomainMembersSelectedForMove() {
Onyx.set(ONYXKEYS.DOMAIN_MEMBERS_SELECTED_FOR_MOVE, []);
}

export {
getDomainValidationCode,
validateDomain,
Expand Down Expand Up @@ -1396,4 +1505,7 @@ export {
setDomainVacationDelegate,
deleteDomainVacationDelegate,
clearVacationDelegateError,
changeDomainSecurityGroup,
setDomainMembersSelectedForMove,
clearDomainMembersSelectedForMove,
};
25 changes: 22 additions & 3 deletions src/pages/domain/Members/DomainMembersPage.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import {defaultSecurityGroupIDSelector, domainNameSelector, memberAccountIDsSelector, memberPendingActionSelector, selectSecurityGroupForAccount} from '@selectors/Domain';
import React, {useState} from 'react';
import React, {useEffect, useState} from 'react';
import Button from '@components/Button';
import ButtonWithDropdownMenu from '@components/ButtonWithDropdownMenu';
import type {DomainMemberBulkActionType, DropdownOption} from '@components/ButtonWithDropdownMenu/types';
Expand All @@ -13,7 +13,7 @@ import useOnyx from '@hooks/useOnyx';
import useResponsiveLayout from '@hooks/useResponsiveLayout';
import useSearchBackPress from '@hooks/useSearchBackPress';
import useThemeStyles from '@hooks/useThemeStyles';
import {clearDomainMemberError, closeUserAccount} from '@libs/actions/Domain';
import {clearDomainMemberError, closeUserAccount, setDomainMembersSelectedForMove} from '@libs/actions/Domain';
import {turnOffMobileSelectionMode} from '@libs/actions/MobileSelectionMode';
import {hasDomainMemberDetailsErrors} from '@libs/DomainUtils';
import {getLatestError} from '@libs/ErrorUtils';
Expand All @@ -34,7 +34,7 @@ function DomainMembersPage({route}: DomainMembersPageProps) {
const {translate} = useLocalize();
const styles = useThemeStyles();
const illustrations = useMemoizedLazyIllustrations(['Profile']);
const icons = useMemoizedLazyExpensifyIcons(['Plus', 'Gear', 'DotIndicator', 'RemoveMembers']);
const icons = useMemoizedLazyExpensifyIcons(['Plus', 'Gear', 'DotIndicator', 'RemoveMembers', 'Transfer']);
const {shouldUseNarrowLayout} = useResponsiveLayout();
const [selectedMembers, setSelectedMembers] = useState<string[]>([]);
const clearSelectedMembers = () => setSelectedMembers([]);
Expand All @@ -60,6 +60,16 @@ function DomainMembersPage({route}: DomainMembersPageProps) {
canBeMissing: true,
selector: memberAccountIDsSelector,
});
const [membersSelectedForMove] = useOnyx(ONYXKEYS.DOMAIN_MEMBERS_SELECTED_FOR_MOVE, {canBeMissing: true});

useEffect(() => {
if (!membersSelectedForMove || membersSelectedForMove.length > 0) {
return;
}
// State change syncs onyx to local state after the move members request has been submitted in MoveUsersBetweenGroupsPage
// eslint-disable-next-line react-hooks/set-state-in-effect
clearSelectedMembers();
}, [membersSelectedForMove]);

useSearchBackPress({
onClearSelection: clearSelectedMembers,
Expand Down Expand Up @@ -120,6 +130,15 @@ function DomainMembersPage({route}: DomainMembersPageProps) {
setIsModalVisible(true);
},
},
{
text: translate('domain.members.moveToGroup'),
value: CONST.DOMAIN.MEMBERS.BULK_ACTION_TYPES.MOVE_TO_GROUP,
icon: icons.Transfer,
onSelected: () => {
setDomainMembersSelectedForMove(selectedMembers);
Navigation.navigate(ROUTES.DOMAIN_MOVE_USERS.getRoute(domainAccountID));
},
},
];

const getHeaderButtons = () => {
Expand Down
Loading
Loading