From 13dd1231a4e8d86619699ba4a48bafc82c3a3ecd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Musia=C5=82?= Date: Tue, 10 Feb 2026 13:31:20 +0100 Subject: [PATCH 01/21] move members poc --- src/CONST/index.ts | 1 + src/ONYXKEYS.ts | 4 + src/ROUTES.ts | 4 + src/SCREENS.ts | 1 + src/languages/en.ts | 4 + .../ChangeDomainSecurityGroupParams.ts | 8 ++ src/libs/API/parameters/index.ts | 1 + src/libs/API/types.ts | 2 + .../ModalStackNavigators/index.tsx | 1 + .../linkingConfig/RELATIONS/DOMAIN_TO_RHP.ts | 2 +- src/libs/Navigation/linkingConfig/config.ts | 3 + src/libs/Navigation/types.ts | 3 + src/libs/actions/Domain.ts | 112 ++++++++++++++++++ .../domain/Members/DomainMembersPage.tsx | 25 +++- .../Members/MoveUsersBetweenGroupsPage.tsx | 111 +++++++++++++++++ src/selectors/Domain.ts | 26 ++++ 16 files changed, 304 insertions(+), 4 deletions(-) create mode 100644 src/libs/API/parameters/ChangeDomainSecurityGroupParams.ts create mode 100644 src/pages/domain/Members/MoveUsersBetweenGroupsPage.tsx diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 5ee4106335a79..073ab528fbc7c 100755 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -8319,6 +8319,7 @@ const CONST = { MEMBERS_BULK_ACTION_TYPES: { CLOSE_ACCOUNT: 'closeAccount', + MOVE_TO_GROUP: 'moveToGroup', }, }, diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index a0652741b56de..a78e000da5f6f 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -397,6 +397,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', @@ -1319,6 +1322,7 @@ type OnyxValuesMapping = { [ONYXKEYS.IS_BETA]: boolean; [ONYXKEYS.IS_CHECKING_PUBLIC_ROOM]: boolean; [ONYXKEYS.MY_DOMAIN_SECURITY_GROUPS]: Record; + [ONYXKEYS.DOMAIN_MEMBERS_SELECTED_FOR_MOVE]: string[]; [ONYXKEYS.VERIFY_3DS_SUBSCRIPTION]: string; [ONYXKEYS.PREFERRED_THEME]: ValueOf; [ONYXKEYS.MAPBOX_ACCESS_TOKEN]: OnyxTypes.MapboxAccessToken; diff --git a/src/ROUTES.ts b/src/ROUTES.ts index 7e25186123783..63d0b9911ae62 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -3893,6 +3893,10 @@ const ROUTES = { route: 'domain/:domainAccountID/members/invite', getRoute: (domainAccountID: number) => `domain/${domainAccountID}/members/invite` 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', diff --git a/src/SCREENS.ts b/src/SCREENS.ts index c8075b2ae1eec..caaed4dcc4af8 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -943,6 +943,7 @@ const SCREENS = { MEMBER_DETAILS: 'Member_Details', RESET_DOMAIN: 'Domain_Reset', ADD_MEMBER: 'Domain_Add_Member', + MOVE_USERS: 'Domain_Move_Users', }, MULTIFACTOR_AUTHENTICATION: { MAGIC_CODE: 'Multifactor_Authentication_Magic_Code', diff --git a/src/languages/en.ts b/src/languages/en.ts index b6738bd10229a..08e41fc06213e 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -8410,9 +8410,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.', + moveError: 'Unable to move this member. Please try again.', }, }, }, diff --git a/src/libs/API/parameters/ChangeDomainSecurityGroupParams.ts b/src/libs/API/parameters/ChangeDomainSecurityGroupParams.ts new file mode 100644 index 0000000000000..f0b7ce63df86e --- /dev/null +++ b/src/libs/API/parameters/ChangeDomainSecurityGroupParams.ts @@ -0,0 +1,8 @@ +type ChangeDomainSecurityGroupParams = { + domainName: string; + newID: string; + employeeEmail: string; + domainAccountID: number; +}; + +export default ChangeDomainSecurityGroupParams; diff --git a/src/libs/API/parameters/index.ts b/src/libs/API/parameters/index.ts index 9eb594c8ee367..e4b0b48075ce1 100644 --- a/src/libs/API/parameters/index.ts +++ b/src/libs/API/parameters/index.ts @@ -470,6 +470,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'; diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts index 49a4019896601..b37b1df00b8c0 100644 --- a/src/libs/API/types.ts +++ b/src/libs/API/types.ts @@ -550,6 +550,7 @@ const WRITE_COMMANDS = { REMOVE_DOMAIN_ADMIN: 'RemoveDomainAdmin', DELETE_DOMAIN: 'DeleteDomain', DELETE_DOMAIN_MEMBER: 'DeleteDomainMember', + CHANGE_DOMAIN_SECURITY_GROUP: 'ChangeDomainSecurityGroup', } as const; type WriteCommand = ValueOf; @@ -1122,6 +1123,7 @@ type WriteCommandParameters = { [WRITE_COMMANDS.DELETE_DOMAIN]: Parameters.DeleteDomainParams; [WRITE_COMMANDS.ADD_DOMAIN_ADMIN]: Parameters.AddAdminToDomainParams; [WRITE_COMMANDS.ADD_DOMAIN_MEMBER]: Parameters.AddMemberToDomainParams; + [WRITE_COMMANDS.CHANGE_DOMAIN_SECURITY_GROUP]: Parameters.ChangeDomainSecurityGroupParams; }; const READ_COMMANDS = { diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx index 875bfdd880f39..79ef8a69a56ee 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx @@ -892,6 +892,7 @@ const SettingsModalStackNavigator = createModalStackNavigator require('../../../../pages/domain/Members/DomainMemberDetailsPage').default, [SCREENS.DOMAIN.RESET_DOMAIN]: () => require('../../../../pages/domain/DomainResetPage').default, [SCREENS.DOMAIN.ADD_MEMBER]: () => require('../../../../pages/domain/Members/DomainAddMemberPage').default, + [SCREENS.DOMAIN.MOVE_USERS]: () => require('../../../../pages/domain/Members/MoveUsersBetweenGroupsPage').default, }); const TwoFactorAuthenticatorStackNavigator = createModalStackNavigator({ diff --git a/src/libs/Navigation/linkingConfig/RELATIONS/DOMAIN_TO_RHP.ts b/src/libs/Navigation/linkingConfig/RELATIONS/DOMAIN_TO_RHP.ts index 89338ab8c518a..fa34918c48555 100755 --- a/src/libs/Navigation/linkingConfig/RELATIONS/DOMAIN_TO_RHP.ts +++ b/src/libs/Navigation/linkingConfig/RELATIONS/DOMAIN_TO_RHP.ts @@ -6,7 +6,7 @@ const DOMAIN_TO_RHP: Partial['config'] = { [SCREENS.DOMAIN.ADD_MEMBER]: { path: ROUTES.DOMAIN_ADD_MEMBER.route, }, + [SCREENS.DOMAIN.MOVE_USERS]: { + path: ROUTES.DOMAIN_MOVE_USERS.route, + }, }, }, [SCREENS.RIGHT_MODAL.TWO_FACTOR_AUTH]: { diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index fdf8f2526cabf..961f748ce91c1 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -1535,6 +1535,9 @@ type SettingsNavigatorParamList = { [SCREENS.DOMAIN.ADD_MEMBER]: { domainAccountID: number; }; + [SCREENS.DOMAIN.MOVE_USERS]: { + domainAccountID: number; + }; } & ReimbursementAccountNavigatorParamList; type DomainCardNavigatorParamList = { diff --git a/src/libs/actions/Domain.ts b/src/libs/actions/Domain.ts index c9254533e8b28..c907c8d1655dd 100644 --- a/src/libs/actions/Domain.ts +++ b/src/libs/actions/Domain.ts @@ -4,6 +4,7 @@ import * as API from '@libs/API'; import type { AddAdminToDomainParams, AddMemberToDomainParams, + ChangeDomainSecurityGroupParams, DeleteDomainMemberParams, DeleteDomainParams, RemoveDomainAdminParams, @@ -16,6 +17,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 PrefixedRecord from '@src/types/utils/PrefixedRecord'; import type {ScimTokenWithState} from './ScimToken/ScimTokenUtils'; @@ -1040,6 +1042,113 @@ function closeUserAccount(domainAccountID: number, domain: string, targetEmail: API.write(WRITE_COMMANDS.DELETE_DOMAIN_MEMBER, parameters, {optimisticData, successData, failureData}); } +function changeDomainSecurityGroup( + domainAccountID: number, + domainName: string, + employeeEmail: string, + accountID: number, + currentSecurityGroupKey: SecurityGroupKey, + currentSecurityGroup: Partial, + 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>, + }, + { + 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>, + }, + { + 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.moveError')}, + }, + }, + }, + ]; + + 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, @@ -1066,4 +1175,7 @@ export { addMemberToDomain, clearDomainMemberError, closeUserAccount, + changeDomainSecurityGroup, + setDomainMembersSelectedForMove, + clearDomainMembersSelectedForMove, }; diff --git a/src/pages/domain/Members/DomainMembersPage.tsx b/src/pages/domain/Members/DomainMembersPage.tsx index 9029bcd5fabd8..abe30438f6b7d 100644 --- a/src/pages/domain/Members/DomainMembersPage.tsx +++ b/src/pages/domain/Members/DomainMembersPage.tsx @@ -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'; @@ -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 {getLatestError} from '@libs/ErrorUtils'; import Navigation from '@navigation/Navigation'; @@ -32,7 +32,7 @@ function DomainMembersPage({route}: DomainMembersPageProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); const illustrations = useMemoizedLazyIllustrations(['Profile']); - const icons = useMemoizedLazyExpensifyIcons(['Plus', 'RemoveMembers']); + const icons = useMemoizedLazyExpensifyIcons(['Plus', 'RemoveMembers', 'Transfer']); const {shouldUseNarrowLayout} = useResponsiveLayout(); const [selectedMembers, setSelectedMembers] = useState([]); const clearSelectedMembers = () => setSelectedMembers([]); @@ -58,6 +58,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, @@ -115,6 +125,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 = () => { diff --git a/src/pages/domain/Members/MoveUsersBetweenGroupsPage.tsx b/src/pages/domain/Members/MoveUsersBetweenGroupsPage.tsx new file mode 100644 index 0000000000000..12d581169ac7f --- /dev/null +++ b/src/pages/domain/Members/MoveUsersBetweenGroupsPage.tsx @@ -0,0 +1,111 @@ +import {domainNameSelector, securityGroupsSelector, selectSecurityGroupForAccount} from '@selectors/Domain'; +import React, {useState} from 'react'; +import Button from '@components/Button'; +import FixedFooter from '@components/FixedFooter'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import ScreenWrapper from '@components/ScreenWrapper'; +import SelectionList from '@components/SelectionList'; +import type {ListItem} from '@components/SelectionList/ListItem/types'; +import SingleSelectListItem from '@components/SelectionListWithSections/SingleSelectListItem'; +import Text from '@components/Text'; +import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; +import useThemeStyles from '@hooks/useThemeStyles'; +import {changeDomainSecurityGroup, clearDomainMembersSelectedForMove} from '@libs/actions/Domain'; +import Navigation from '@navigation/Navigation'; +import type {PlatformStackScreenProps} from '@navigation/PlatformStackNavigation/types'; +import type {SettingsNavigatorParamList} from '@navigation/types'; +import DomainNotFoundPageWrapper from '@pages/domain/DomainNotFoundPageWrapper'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import type SCREENS from '@src/SCREENS'; +import type {SecurityGroupKey} from '@src/types/onyx/Domain'; + +type SecurityGroupItem = ListItem & { + value: SecurityGroupKey; +}; + +type MoveUsersBetweenGroupsPageProps = PlatformStackScreenProps; + +function MoveUsersBetweenGroupsPage({route}: MoveUsersBetweenGroupsPageProps) { + const {domainAccountID} = route.params; + const {translate} = useLocalize(); + const styles = useThemeStyles(); + + const [selectedGroupKey, setSelectedGroupKey] = useState(); + + const [domain] = useOnyx(`${ONYXKEYS.COLLECTION.DOMAIN}${domainAccountID}`, {canBeMissing: true}); + const [domainName] = useOnyx(`${ONYXKEYS.COLLECTION.DOMAIN}${domainAccountID}`, {canBeMissing: true, selector: domainNameSelector}); + const [securityGroups] = useOnyx(`${ONYXKEYS.COLLECTION.DOMAIN}${domainAccountID}`, {canBeMissing: true, selector: securityGroupsSelector}); + const [selectedMemberAccountIDs] = useOnyx(ONYXKEYS.DOMAIN_MEMBERS_SELECTED_FOR_MOVE, {canBeMissing: true}); + const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, {canBeMissing: true}); + + const memberCount = selectedMemberAccountIDs?.length ?? 0; + + const data = (): SecurityGroupItem[] => { + return (securityGroups ?? []).map((group) => ({ + text: group.name, + keyForList: group.key, + value: group.key, + isSelected: group.key === selectedGroupKey, + })); + }; + + const handleSelectRow = (item: SecurityGroupItem) => { + setSelectedGroupKey(item.value); + }; + + const handleSave = () => { + if (!selectedGroupKey || !selectedMemberAccountIDs?.length || !domain || !domainName) { + return; + } + + for (const accountIDString of selectedMemberAccountIDs) { + const accountID = Number(accountIDString); + const memberLogin = personalDetails?.[accountID]?.login ?? ''; + const currentGroupData = selectSecurityGroupForAccount(accountID)(domain); + + if (!currentGroupData) { + continue; + } + + changeDomainSecurityGroup(domainAccountID, domainName, memberLogin, accountID, currentGroupData.key, currentGroupData.securityGroup, selectedGroupKey); + } + + clearDomainMembersSelectedForMove(); + Navigation.goBack(ROUTES.DOMAIN_MEMBERS.getRoute(domainAccountID)); + }; + + return ( + + + Navigation.goBack(ROUTES.DOMAIN_MEMBERS.getRoute(domainAccountID))} + /> + {translate('domain.members.chooseWhereToMove', {count: memberCount})} + + data={data()} + onSelectRow={handleSelectRow} + ListItem={SingleSelectListItem} + initiallyFocusedItemKey={selectedGroupKey} + /> + +