From 256a7d64b2e4f04d95c3c36a3d059b75fa63e08d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Musia=C5=82?= Date: Tue, 16 Dec 2025 16:35:13 +0100 Subject: [PATCH 01/18] Basic revoke admin access --- src/ONYXKEYS.ts | 12 +++ src/languages/en.ts | 1 + src/libs/DomainUtils.ts | 24 +++++ src/libs/actions/Domain.ts | 87 +++++++++++++++++++ .../domain/Admins/DomainAdminDetailsPage.tsx | 38 +++++++- src/selectors/Domain.ts | 6 +- src/types/onyx/CardFeeds.ts | 3 + 7 files changed, 168 insertions(+), 3 deletions(-) create mode 100644 src/libs/DomainUtils.ts diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index 2a7e41df0b04..86895cea8108 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -737,6 +737,15 @@ const ONYXKEYS = { /** Stores domain admin account ID */ EXPENSIFY_ADMIN_ACCESS_PREFIX: 'expensify_adminPermissions_', + + /** Pending actions for a domain */ + DOMAIN_PENDING_ACTIONS: 'domainPendingActions_', + + /** Domain admin permissions */ + DOMAIN_ADMIN_PERMISSIONS: 'expensify_adminPermissions_', + + /** Errors related to a domain */ + DOMAIN_ERRORS: 'domainErrors_', }, /** List of Form ids */ @@ -1124,6 +1133,9 @@ type OnyxCollectionValuesMapping = { [ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_ADMIN_ACCESS]: boolean; [ONYXKEYS.COLLECTION.SAML_METADATA]: OnyxTypes.SamlMetadata; [ONYXKEYS.COLLECTION.EXPENSIFY_ADMIN_ACCESS_PREFIX]: number; + [ONYXKEYS.COLLECTION.DOMAIN_ADMIN_PERMISSIONS]: number; + [ONYXKEYS.COLLECTION.DOMAIN_PENDING_ACTIONS]: OnyxTypes.DomainPendingActions; + [ONYXKEYS.COLLECTION.DOMAIN_ERRORS]: OnyxTypes.DomainErrors; }; type OnyxValuesMapping = { diff --git a/src/languages/en.ts b/src/languages/en.ts index 8f20c1f98ff5..18b9e12613a1 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -7839,6 +7839,7 @@ const translations = { admins: { title: 'Admins', findAdmin: 'Find admin', + revokeAdminAccess: 'Revoke domain access', }, }, }; diff --git a/src/libs/DomainUtils.ts b/src/libs/DomainUtils.ts new file mode 100644 index 000000000000..5bc5e257d007 --- /dev/null +++ b/src/libs/DomainUtils.ts @@ -0,0 +1,24 @@ +import Onyx from 'react-native-onyx'; +import type {OnyxUpdate} from 'react-native-onyx'; + +/** + * DEV ONLY HELPER - do not use in production code + * Applies two sets of Onyx state updates with a delay between them. + * Useful for simulating state transitions or testing optimistic updates. + * + * @param firstData - The first set of Onyx updates to apply (e.g., optimisticData) + * @param secondData - The second set of Onyx updates to apply after delay (e.g., successData) + * @param delayMs - The delay in milliseconds between applying the updates (defaults to 1000ms) + * @returns A promise that resolves when both updates are complete + */ +async function applyUpdatesWithDelay(firstData: OnyxUpdate[], secondData: OnyxUpdate[], delayMs = 1000): Promise { + await Onyx.update(firstData); + return new Promise((resolve) => { + setTimeout(() => { + Onyx.update(secondData).then(resolve); + }, delayMs); + }); +} + +// eslint-disable-next-line import/prefer-default-export +export {applyUpdatesWithDelay}; diff --git a/src/libs/actions/Domain.ts b/src/libs/actions/Domain.ts index 4c2d982dd090..92ada3f8beb8 100644 --- a/src/libs/actions/Domain.ts +++ b/src/libs/actions/Domain.ts @@ -2,6 +2,7 @@ import Onyx from 'react-native-onyx'; import type {OnyxUpdate} from 'react-native-onyx'; import * as API from '@libs/API'; import {READ_COMMANDS, SIDE_EFFECT_REQUEST_COMMANDS, WRITE_COMMANDS} from '@libs/API/types'; +import {applyUpdatesWithDelay} from '@libs/DomainUtils'; import {getMicroSecondOnyxErrorWithTranslationKey} from '@libs/ErrorUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -350,6 +351,89 @@ function createDomain(domainName: string) { function resetCreateDomainForm() { Onyx.merge(ONYXKEYS.FORMS.CREATE_DOMAIN_FORM, null); } +/** + * Removes admin access for a domain member + */ +function revokeDomainAdminAccess(domainAccountID: number, accountID: number) { + const PERMISSION_KEY = `${ONYXKEYS.COLLECTION.DOMAIN_ADMIN_PERMISSIONS}${accountID}`; + + const optimisticData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.DOMAIN}${domainAccountID}`, + value: { + [PERMISSION_KEY]: null, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.DOMAIN_PENDING_ACTIONS}${domainAccountID}`, + value: { + admin: { + [accountID]: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, + }, + }, + }, + ]; + const successData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.DOMAIN_PENDING_ACTIONS}${domainAccountID}`, + value: { + admin: { + [accountID]: null, + }, + }, + }, + ]; + const failureData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.DOMAIN}${domainAccountID}`, + value: { + [PERMISSION_KEY]: accountID, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.DOMAIN_PENDING_ACTIONS}${domainAccountID}`, + value: { + admin: { + [accountID]: null, + }, + }, + }, + // TODO update after BE is ready: DEV stuff only below this line + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.DOMAIN_ERRORS}${domainAccountID}`, + value: { + adminErrors: { + [accountID]: {errors: {[Date.now()]: 'Unable to add/remove this user as an Admin. Please try again.'}}, + }, + }, + }, + ]; + + applyUpdatesWithDelay(optimisticData, successData); + // applyUpdatesWithDelay(optimisticData, failureData); +} + +/** + * Removes an error after trying to remove admin + */ +function clearRemoveAdminError(domainAccountID: number, accountID: number) { + Onyx.merge(`${ONYXKEYS.COLLECTION.DOMAIN_ERRORS}${domainAccountID}`, { + adminErrors: { + [accountID]: null, + }, + }); + Onyx.merge(`${ONYXKEYS.COLLECTION.DOMAIN_PENDING_ACTIONS}${domainAccountID}`, { + admin: { + [accountID]: null, + }, + }); +} export { getDomainValidationCode, @@ -365,4 +449,7 @@ export { getScimToken, createDomain, resetCreateDomainForm, + addAdminToDomain, + revokeDomainAdminAccess, + clearRemoveAdminError, }; diff --git a/src/pages/domain/Admins/DomainAdminDetailsPage.tsx b/src/pages/domain/Admins/DomainAdminDetailsPage.tsx index 952fe5a9489e..2db6d3afb051 100644 --- a/src/pages/domain/Admins/DomainAdminDetailsPage.tsx +++ b/src/pages/domain/Admins/DomainAdminDetailsPage.tsx @@ -8,14 +8,17 @@ import FullScreenLoadingIndicator from '@components/FullscreenLoadingIndicator'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import MenuItem from '@components/MenuItem'; import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import {ModalActions} from '@components/Modal/Global/ModalContext'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import ScreenWrapper from '@components/ScreenWrapper'; import ScrollView from '@components/ScrollView'; import Text from '@components/Text'; +import useConfirmModal from '@hooks/useConfirmModal'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import useThemeStyles from '@hooks/useThemeStyles'; +import {revokeDomainAdminAccess} from '@libs/actions/Domain'; import {getDisplayNameOrDefault, getPhoneNumber} from '@libs/PersonalDetailsUtils'; import Navigation from '@navigation/Navigation'; import type {PlatformStackScreenProps} from '@navigation/PlatformStackNavigation/types'; @@ -25,6 +28,7 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; +import {domainSettingsPrimaryContactSelector} from '@src/selectors/Domain'; import type {PersonalDetails} from '@src/types/onyx'; import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; @@ -34,23 +38,46 @@ function DomainAdminDetailsPage({route}: DomainAdminDetailsPageProps) { const {domainAccountID, accountID} = route.params; const styles = useThemeStyles(); const {translate, formatPhoneNumber} = useLocalize(); - const icons = useMemoizedLazyExpensifyIcons(['Info'] as const); + const icons = useMemoizedLazyExpensifyIcons(['Info', 'ClosedSign'] as const); const [adminAccountIDs, domainMetadata] = useOnyx(`${ONYXKEYS.COLLECTION.DOMAIN}${domainAccountID}`, { canBeMissing: true, selector: adminAccountIDsSelector, }); + + const [primaryContact] = useOnyx(`${ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_DOMAIN_MEMBER}${domainAccountID}`, { + selector: domainSettingsPrimaryContactSelector, + canBeMissing: false, + }); const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, {canBeMissing: true}); const details = personalDetails?.[accountID] ?? ({} as PersonalDetails); const displayName = formatPhoneNumber(getDisplayNameOrDefault(details)); const memberLogin = personalDetails?.[accountID]?.login ?? ''; + const isCurrentUserPrimaryContact = primaryContact === memberLogin; const isSMSLogin = Str.isSMSLogin(memberLogin); const phoneNumber = getPhoneNumber(details); const fallbackIcon = details.fallbackIcon ?? ''; const currentUserAccountID = getCurrentUserAccountID(); const isAdmin = adminAccountIDs?.includes(currentUserAccountID); + const domainHasOnlyOneAdmin = adminAccountIDs?.length === 1; + + const {showConfirmModal} = useConfirmModal(); + + const handleRevokeAdminAccess = async () => { + const confirmResult = await showConfirmModal({ + title: translate('domain.admins.revokeAdminAccess'), + prompt: translate('workspace.people.removeMemberPrompt', {memberName: displayName}), + shouldShowCancelButton: true, + danger: true, + }); + if (confirmResult.action !== ModalActions.CONFIRM) { + return; + } + revokeDomainAdminAccess(route.params.domainAccountID, route.params.accountID); + Navigation.dismissModal(); + }; if (isLoadingOnyxValue(domainMetadata)) { return ; @@ -98,6 +125,15 @@ function DomainAdminDetailsPage({route}: DomainAdminDetailsPageProps) { interactive={false} copyable /> + {!domainHasOnlyOneAdmin && ( + + )} ) => domainSettings?.settings; @@ -18,6 +18,8 @@ const domainSamlSettingsStateSelector = (domain: OnyxEntry) => const domainNameSelector = (domain: OnyxEntry) => (domain?.email ? Str.extractEmailDomain(domain.email) : undefined); +const domainSettingsPrimaryContactSelector = (domainSettings: OnyxEntry) => domainSettings?.settings?.technicalContactEmail; + const metaIdentitySelector = (samlMetadata: OnyxEntry) => samlMetadata?.metaIdentity; /** @@ -46,4 +48,4 @@ function adminAccountIDsSelector(domain: OnyxEntry): number[] { ); } -export {domainMemberSamlSettingsSelector, domainSamlSettingsStateSelector, domainNameSelector, metaIdentitySelector, adminAccountIDsSelector}; +export {domainMemberSamlSettingsSelector, domainSettingsPrimaryContactSelector, domainSamlSettingsStateSelector, domainNameSelector, metaIdentitySelector, adminAccountIDsSelector}; diff --git a/src/types/onyx/CardFeeds.ts b/src/types/onyx/CardFeeds.ts index 3d10bc67717d..2f60d6a471de 100644 --- a/src/types/onyx/CardFeeds.ts +++ b/src/types/onyx/CardFeeds.ts @@ -148,6 +148,9 @@ type DomainSettings = { /** Encrypted SCIM token, exists only when Okta is enabled for the domain by support */ oktaSCIM?: string; + + /** Email to primary contact from the domain */ + technicalContactEmail?: string; }; }; From 764ceeeb0be6c863b4099fb6ce2e754ecfd83007 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Musia=C5=82?= Date: Tue, 16 Dec 2025 16:35:28 +0100 Subject: [PATCH 02/18] Error habdling --- src/libs/actions/Domain.ts | 1 - src/pages/domain/Admins/DomainAdminsPage.tsx | 9 +++++++++ src/types/onyx/DomainErrors.ts | 14 ++++++++++++++ src/types/onyx/DomainPendingActions.ts | 13 +++++++++++++ src/types/onyx/index.ts | 4 ++++ 5 files changed, 40 insertions(+), 1 deletion(-) create mode 100644 src/types/onyx/DomainErrors.ts create mode 100644 src/types/onyx/DomainPendingActions.ts diff --git a/src/libs/actions/Domain.ts b/src/libs/actions/Domain.ts index 92ada3f8beb8..7673c3c5d2c8 100644 --- a/src/libs/actions/Domain.ts +++ b/src/libs/actions/Domain.ts @@ -449,7 +449,6 @@ export { getScimToken, createDomain, resetCreateDomainForm, - addAdminToDomain, revokeDomainAdminAccess, clearRemoveAdminError, }; diff --git a/src/pages/domain/Admins/DomainAdminsPage.tsx b/src/pages/domain/Admins/DomainAdminsPage.tsx index 58c93ff40754..a3707594c18e 100644 --- a/src/pages/domain/Admins/DomainAdminsPage.tsx +++ b/src/pages/domain/Admins/DomainAdminsPage.tsx @@ -15,6 +15,8 @@ import useOnyx from '@hooks/useOnyx'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useSearchResults from '@hooks/useSearchResults'; import useThemeStyles from '@hooks/useThemeStyles'; +import {clearRemoveAdminError} from '@libs/actions/Domain'; +import {getLatestError} from '@libs/ErrorUtils'; import {sortAlphabetically} from '@libs/OptionsListUtils'; import {getDisplayNameOrDefault} from '@libs/PersonalDetailsUtils'; import tokenizedSearch from '@libs/tokenizedSearch'; @@ -48,6 +50,11 @@ function DomainAdminsPage({route}: DomainAdminsPageProps) { canBeMissing: true, selector: adminAccountIDsSelector, }); + + const [domainErrors] = useOnyx(`${ONYXKEYS.COLLECTION.DOMAIN_ERRORS}${domainAccountID}`, { + canBeMissing: true, + }); + const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, {canBeMissing: true}); const data: AdminOption[] = []; @@ -67,6 +74,7 @@ function DomainAdminsPage({route}: DomainAdminsPageProps) { id: accountID, }, ], + errors: getLatestError(domainErrors?.adminErrors?.[accountID]?.errors), }); } @@ -138,6 +146,7 @@ function DomainAdminsPage({route}: DomainAdminsPageProps) { showScrollIndicator={false} addBottomSafeAreaPadding customListHeader={getCustomListHeader()} + onDismissError={(item: AdminOption) => clearRemoveAdminError(domainAccountID, item.accountID)} /> diff --git a/src/types/onyx/DomainErrors.ts b/src/types/onyx/DomainErrors.ts new file mode 100644 index 000000000000..26c262927044 --- /dev/null +++ b/src/types/onyx/DomainErrors.ts @@ -0,0 +1,14 @@ +import type * as OnyxCommon from './OnyxCommon'; + +/** + * Collection of errors related to domain operations received from the backend + */ +type DomainErrors = { + /** + * Errors related to specific domain administrators, keyed by their adminID + */ + // eslint-disable-next-line jsdoc/require-jsdoc + adminErrors?: Record; +}; + +export default DomainErrors; diff --git a/src/types/onyx/DomainPendingActions.ts b/src/types/onyx/DomainPendingActions.ts new file mode 100644 index 000000000000..5318ab9f4d41 --- /dev/null +++ b/src/types/onyx/DomainPendingActions.ts @@ -0,0 +1,13 @@ +import type * as OnyxCommon from './OnyxCommon'; + +/** + * Pending actions triggered by user operations on the domain + */ +type DomainPendingAction = { + /** + * Pending actions for specific administrators, keyed by their accountID + */ + admin?: Record; +}; + +export default DomainPendingAction; diff --git a/src/types/onyx/index.ts b/src/types/onyx/index.ts index a1e57eebd55e..3b906472a08a 100644 --- a/src/types/onyx/index.ts +++ b/src/types/onyx/index.ts @@ -32,6 +32,8 @@ import type DismissedProductTraining from './DismissedProductTraining'; import type DismissedReferralBanners from './DismissedReferralBanners'; import type Domain from './Domain'; import type {SamlMetadata} from './Domain'; +import type DomainErrors from './DomainErrors'; +import type DomainPendingActions from './DomainPendingActions'; import type Download from './Download'; import type DuplicateWorkspace from './DuplicateWorkspace'; import type ExpensifyCardBankAccountMetadata from './ExpensifyCardBankAccountMetadata'; @@ -298,4 +300,6 @@ export type { HybridApp, AppReview, SamlMetadata, + DomainPendingActions, + DomainErrors, }; From a5ceb396773d8e662a3bb1505379aa2de45becd0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Musia=C5=82?= Date: Wed, 17 Dec 2025 10:26:15 +0100 Subject: [PATCH 03/18] remove redundant onyxkey --- src/ONYXKEYS.ts | 5 ----- src/libs/actions/Domain.ts | 2 +- 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index 86895cea8108..903af1017cd7 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -740,10 +740,6 @@ const ONYXKEYS = { /** Pending actions for a domain */ DOMAIN_PENDING_ACTIONS: 'domainPendingActions_', - - /** Domain admin permissions */ - DOMAIN_ADMIN_PERMISSIONS: 'expensify_adminPermissions_', - /** Errors related to a domain */ DOMAIN_ERRORS: 'domainErrors_', }, @@ -1133,7 +1129,6 @@ type OnyxCollectionValuesMapping = { [ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_ADMIN_ACCESS]: boolean; [ONYXKEYS.COLLECTION.SAML_METADATA]: OnyxTypes.SamlMetadata; [ONYXKEYS.COLLECTION.EXPENSIFY_ADMIN_ACCESS_PREFIX]: number; - [ONYXKEYS.COLLECTION.DOMAIN_ADMIN_PERMISSIONS]: number; [ONYXKEYS.COLLECTION.DOMAIN_PENDING_ACTIONS]: OnyxTypes.DomainPendingActions; [ONYXKEYS.COLLECTION.DOMAIN_ERRORS]: OnyxTypes.DomainErrors; }; diff --git a/src/libs/actions/Domain.ts b/src/libs/actions/Domain.ts index 7673c3c5d2c8..6207df61b08f 100644 --- a/src/libs/actions/Domain.ts +++ b/src/libs/actions/Domain.ts @@ -355,7 +355,7 @@ function resetCreateDomainForm() { * Removes admin access for a domain member */ function revokeDomainAdminAccess(domainAccountID: number, accountID: number) { - const PERMISSION_KEY = `${ONYXKEYS.COLLECTION.DOMAIN_ADMIN_PERMISSIONS}${accountID}`; + const PERMISSION_KEY = `${ONYXKEYS.COLLECTION.EXPENSIFY_ADMIN_ACCESS_PREFIX}${accountID}`; const optimisticData: OnyxUpdate[] = [ { From 603435457e9deb625cb3e673d6da36699bf283ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Musia=C5=82?= Date: Wed, 17 Dec 2025 10:39:29 +0100 Subject: [PATCH 04/18] update pending action structure --- src/libs/actions/Domain.ts | 16 ++++++++++++---- src/types/onyx/DomainErrors.ts | 13 +++++++++++-- src/types/onyx/DomainPendingActions.ts | 12 +++++++++++- 3 files changed, 34 insertions(+), 7 deletions(-) diff --git a/src/libs/actions/Domain.ts b/src/libs/actions/Domain.ts index 6207df61b08f..ba9563881fc2 100644 --- a/src/libs/actions/Domain.ts +++ b/src/libs/actions/Domain.ts @@ -370,7 +370,9 @@ function revokeDomainAdminAccess(domainAccountID: number, accountID: number) { key: `${ONYXKEYS.COLLECTION.DOMAIN_PENDING_ACTIONS}${domainAccountID}`, value: { admin: { - [accountID]: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, + [accountID]: { + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, + }, }, }, }, @@ -381,7 +383,9 @@ function revokeDomainAdminAccess(domainAccountID: number, accountID: number) { key: `${ONYXKEYS.COLLECTION.DOMAIN_PENDING_ACTIONS}${domainAccountID}`, value: { admin: { - [accountID]: null, + [accountID]: { + pendingAction: null, + }, }, }, }, @@ -399,7 +403,9 @@ function revokeDomainAdminAccess(domainAccountID: number, accountID: number) { key: `${ONYXKEYS.COLLECTION.DOMAIN_PENDING_ACTIONS}${domainAccountID}`, value: { admin: { - [accountID]: null, + [accountID]: { + pendingAction: null, + }, }, }, }, @@ -430,7 +436,9 @@ function clearRemoveAdminError(domainAccountID: number, accountID: number) { }); Onyx.merge(`${ONYXKEYS.COLLECTION.DOMAIN_PENDING_ACTIONS}${domainAccountID}`, { admin: { - [accountID]: null, + [accountID]: { + pendingAction: null, + }, }, }); } diff --git a/src/types/onyx/DomainErrors.ts b/src/types/onyx/DomainErrors.ts index 26c262927044..092e4b767ceb 100644 --- a/src/types/onyx/DomainErrors.ts +++ b/src/types/onyx/DomainErrors.ts @@ -1,5 +1,15 @@ import type * as OnyxCommon from './OnyxCommon'; +/** + * Basic errors for domain admins + */ +type GeneralDomainAdminErrors = { + /** + * Base pending actions + */ + errors: OnyxCommon.Errors; +}; + /** * Collection of errors related to domain operations received from the backend */ @@ -7,8 +17,7 @@ type DomainErrors = { /** * Errors related to specific domain administrators, keyed by their adminID */ - // eslint-disable-next-line jsdoc/require-jsdoc - adminErrors?: Record; + adminErrors?: Record; }; export default DomainErrors; diff --git a/src/types/onyx/DomainPendingActions.ts b/src/types/onyx/DomainPendingActions.ts index 5318ab9f4d41..2eeea7c24a71 100644 --- a/src/types/onyx/DomainPendingActions.ts +++ b/src/types/onyx/DomainPendingActions.ts @@ -1,5 +1,15 @@ import type * as OnyxCommon from './OnyxCommon'; +/** + * General pending action structure for domain admins + */ +type GeneralDomainAdminPendingAction = { + /** + * Base pending actions + */ + pendingAction: OnyxCommon.PendingAction; +}; + /** * Pending actions triggered by user operations on the domain */ @@ -7,7 +17,7 @@ type DomainPendingAction = { /** * Pending actions for specific administrators, keyed by their accountID */ - admin?: Record; + admin?: Record; }; export default DomainPendingAction; From ef412d804ce8ca088c53da54059d1de4ce7e8991 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Musia=C5=82?= Date: Wed, 17 Dec 2025 14:46:47 +0100 Subject: [PATCH 05/18] fix after merge --- src/languages/en.ts | 2 +- .../domain/Admins/DomainAdminDetailsPage.tsx | 29 +++++++++++++++---- src/pages/domain/Admins/DomainAdminsPage.tsx | 1 + src/selectors/Domain.ts | 1 - 4 files changed, 25 insertions(+), 8 deletions(-) diff --git a/src/languages/en.ts b/src/languages/en.ts index 0dd43b1bab4c..b8b378e8806d 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -7821,7 +7821,7 @@ const translations = { primaryContact: 'Primary contact', addPrimaryContact: 'Add primary contact', settings: 'Settings', - revokeAdminAccess: 'Revoke domain access', + revokeAdminAccess: 'Revoke admin access', }, }, }; diff --git a/src/pages/domain/Admins/DomainAdminDetailsPage.tsx b/src/pages/domain/Admins/DomainAdminDetailsPage.tsx index 0aa7fd9a471e..cc72c8e6cbd4 100644 --- a/src/pages/domain/Admins/DomainAdminDetailsPage.tsx +++ b/src/pages/domain/Admins/DomainAdminDetailsPage.tsx @@ -26,7 +26,7 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; -import {domainSettingsPrimaryContactSelector} from '@src/selectors/Domain'; +import {adminAccountIDsSelector, domainSettingsPrimaryContactSelector} from '@src/selectors/Domain'; import type {PersonalDetailsList} from '@src/types/onyx'; type DomainAdminDetailsPageProps = PlatformStackScreenProps; @@ -37,16 +37,16 @@ function DomainAdminDetailsPage({route}: DomainAdminDetailsPageProps) { const {translate, formatPhoneNumber} = useLocalize(); const icons = useMemoizedLazyExpensifyIcons(['Info', 'ClosedSign'] as const); - const [adminAccountIDs, domainMetadata] = useOnyx(`${ONYXKEYS.COLLECTION.DOMAIN}${domainAccountID}`, { - canBeMissing: true, - selector: adminAccountIDsSelector, - }); - const [primaryContact] = useOnyx(`${ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_DOMAIN_MEMBER}${domainAccountID}`, { selector: domainSettingsPrimaryContactSelector, canBeMissing: false, }); + const [adminAccountIDs] = useOnyx(`${ONYXKEYS.COLLECTION.DOMAIN}${domainAccountID}`, { + canBeMissing: true, + selector: adminAccountIDsSelector, + }); + // eslint-disable-next-line rulesdir/no-inline-useOnyx-selector const [adminPersonalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, { canBeMissing: true, @@ -60,6 +60,23 @@ function DomainAdminDetailsPage({route}: DomainAdminDetailsPageProps) { const phoneNumber = getPhoneNumber(adminPersonalDetails); const fallbackIcon = adminPersonalDetails?.fallbackIcon ?? ''; + const domainHasOnlyOneAdmin = adminAccountIDs?.length === 1; + const {showConfirmModal} = useConfirmModal(); + + const handleRevokeAdminAccess = async () => { + const confirmResult = await showConfirmModal({ + title: translate('domain.admins.revokeAdminAccess'), + prompt: translate('workspace.people.removeMemberPrompt', {memberName: displayName}), + shouldShowCancelButton: true, + danger: true, + }); + if (confirmResult.action !== ModalActions.CONFIRM) { + return; + } + revokeDomainAdminAccess(route.params.domainAccountID, route.params.accountID); + Navigation.dismissModal(); + }; + return ( , errors: getLatestError(domainErrors?.adminErrors?.[accountID]?.errors), + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, }); } diff --git a/src/selectors/Domain.ts b/src/selectors/Domain.ts index 42edb00058f0..d9ccd47ea84e 100644 --- a/src/selectors/Domain.ts +++ b/src/selectors/Domain.ts @@ -48,7 +48,6 @@ function adminAccountIDsSelector(domain: OnyxEntry): number[] { ); } -// TODO unify that with domainSettingsPrimaryContactSelector const technicalContactEmailSelector = (domainMemberSharedNVP: OnyxEntry) => domainMemberSharedNVP?.settings?.technicalContactEmail; From aedc0fb6d2fb875f86f014f419364f07bfb55ad2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Musia=C5=82?= Date: Wed, 17 Dec 2025 15:19:05 +0100 Subject: [PATCH 06/18] add pending action --- src/libs/actions/Domain.ts | 13 +++---------- src/pages/domain/Admins/DomainAdminsPage.tsx | 9 +++++++-- src/selectors/Domain.ts | 14 ++++++++++++-- 3 files changed, 22 insertions(+), 14 deletions(-) diff --git a/src/libs/actions/Domain.ts b/src/libs/actions/Domain.ts index 7f476a59be40..34587337bb41 100644 --- a/src/libs/actions/Domain.ts +++ b/src/libs/actions/Domain.ts @@ -456,13 +456,6 @@ function revokeDomainAdminAccess(domainAccountID: number, accountID: number) { }, ]; const failureData: OnyxUpdate[] = [ - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.DOMAIN}${domainAccountID}`, - value: { - [PERMISSION_KEY]: accountID, - }, - }, { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.DOMAIN_PENDING_ACTIONS}${domainAccountID}`, @@ -480,14 +473,14 @@ function revokeDomainAdminAccess(domainAccountID: number, accountID: number) { key: `${ONYXKEYS.COLLECTION.DOMAIN_ERRORS}${domainAccountID}`, value: { adminErrors: { - [accountID]: {errors: {[Date.now()]: 'Unable to add/remove this user as an Admin. Please try again.'}}, + [accountID]: {errors: {[Date.now()]: 'Unable to remove this user as an Admin. Please try again.'}}, }, }, }, ]; - applyUpdatesWithDelay(optimisticData, successData); - // applyUpdatesWithDelay(optimisticData, failureData); + // applyUpdatesWithDelay(optimisticData, successData); + applyUpdatesWithDelay(optimisticData, failureData); } /** diff --git a/src/pages/domain/Admins/DomainAdminsPage.tsx b/src/pages/domain/Admins/DomainAdminsPage.tsx index 1d9d1c572278..2f9c65dccb49 100644 --- a/src/pages/domain/Admins/DomainAdminsPage.tsx +++ b/src/pages/domain/Admins/DomainAdminsPage.tsx @@ -1,4 +1,4 @@ -import {adminAccountIDsSelector, technicalContactEmailSelector} from '@selectors/Domain'; +import {adminAccountIDsSelector, adminPendingActionSelector, technicalContactEmailSelector} from '@selectors/Domain'; import React from 'react'; import {View} from 'react-native'; import Badge from '@components/Badge'; @@ -58,6 +58,11 @@ function DomainAdminsPage({route}: DomainAdminsPageProps) { canBeMissing: true, }); + const [domainPendingAction] = useOnyx(`${ONYXKEYS.COLLECTION.DOMAIN_PENDING_ACTIONS}${domainAccountID}`, { + canBeMissing: true, + selector: adminPendingActionSelector, + }); + const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, {canBeMissing: true}); const [technicalContactEmail] = useOnyx(`${ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_DOMAIN_MEMBER}${domainAccountID}`, { canBeMissing: false, @@ -86,7 +91,7 @@ function DomainAdminsPage({route}: DomainAdminsPageProps) { ], rightElement: technicalContactEmail === details?.login && , errors: getLatestError(domainErrors?.adminErrors?.[accountID]?.errors), - pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + pendingAction: domainPendingAction?.[accountID]?.pendingAction, }); } diff --git a/src/selectors/Domain.ts b/src/selectors/Domain.ts index d9ccd47ea84e..2d55be67a50c 100644 --- a/src/selectors/Domain.ts +++ b/src/selectors/Domain.ts @@ -1,7 +1,7 @@ import {Str} from 'expensify-common'; import type {OnyxEntry} from 'react-native-onyx'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {CardFeeds, Domain, DomainSettings, SamlMetadata} from '@src/types/onyx'; +import type {CardFeeds, Domain, DomainPendingActions, DomainSettings, SamlMetadata} from '@src/types/onyx'; import getEmptyArray from '@src/types/utils/getEmptyArray'; const domainMemberSamlSettingsSelector = (domainSettings: OnyxEntry) => domainSettings?.settings; @@ -50,5 +50,15 @@ function adminAccountIDsSelector(domain: OnyxEntry): number[] { const technicalContactEmailSelector = (domainMemberSharedNVP: OnyxEntry) => domainMemberSharedNVP?.settings?.technicalContactEmail; +const adminPendingActionSelector = (pendingAction: OnyxEntry) => pendingAction?.admin ?? {}; -export {domainMemberSamlSettingsSelector, domainSettingsPrimaryContactSelector, domainSamlSettingsStateSelector, domainNameSelector, metaIdentitySelector, adminAccountIDsSelector, technicalContactEmailSelector}; +export { + domainMemberSamlSettingsSelector, + domainSettingsPrimaryContactSelector, + domainSamlSettingsStateSelector, + domainNameSelector, + metaIdentitySelector, + adminAccountIDsSelector, + technicalContactEmailSelector, + adminPendingActionSelector, +}; From 8f24ba7370e2ce5e3412419d4f561787e834eac2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Musia=C5=82?= Date: Tue, 23 Dec 2025 17:39:24 +0100 Subject: [PATCH 07/18] fix merge issue --- src/selectors/Domain.ts | 7 ------- 1 file changed, 7 deletions(-) diff --git a/src/selectors/Domain.ts b/src/selectors/Domain.ts index c2b665c893c0..8bf073bbede1 100644 --- a/src/selectors/Domain.ts +++ b/src/selectors/Domain.ts @@ -60,20 +60,13 @@ const domainEmailSelector = (domain: OnyxEntry) => domain?.email; const adminPendingActionSelector = (pendingAction: OnyxEntry) => pendingAction?.admin ?? {}; export { - domainMemberSamlSettingsSelector, - domainSettingsPrimaryContactSelector, domainSamlSettingsStateSelector, - domainNameSelector, - metaIdentitySelector, - adminAccountIDsSelector, - technicalContactSettingsSelector, domainEmailSelector, -, adminPendingActionSelector, }; From 183ceb8c3b31302a304b095ad387db9cb7816ed1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Musia=C5=82?= Date: Wed, 7 Jan 2026 11:54:37 +0100 Subject: [PATCH 08/18] update for backend --- src/languages/en.ts | 4 +++ .../API/parameters/RemoveDomainAdminParams.ts | 6 ++++ src/libs/API/parameters/index.ts | 1 + src/libs/API/types.ts | 2 ++ src/libs/actions/Domain.ts | 35 +++++-------------- .../domain/Admins/DomainAdminDetailsPage.tsx | 1 + src/pages/domain/Admins/DomainAdminsPage.tsx | 9 ++--- 7 files changed, 26 insertions(+), 32 deletions(-) create mode 100644 src/libs/API/parameters/RemoveDomainAdminParams.ts diff --git a/src/languages/en.ts b/src/languages/en.ts index e3ea42ef2ee4..b58e395b9f68 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -7888,6 +7888,10 @@ const translations = { invite: 'Invite', addAdminError: 'Unable to add this member as an admin. Please try again.', revokeAdminAccess: 'Revoke admin access', + cantRevokeAdminAccess: "Can't revoke admin access from the technical contact", + error: { + removeAdmin: 'Unable to remove this user as an Admin. Please try again.', + }, }, }, }; diff --git a/src/libs/API/parameters/RemoveDomainAdminParams.ts b/src/libs/API/parameters/RemoveDomainAdminParams.ts new file mode 100644 index 000000000000..bac1692f5cf4 --- /dev/null +++ b/src/libs/API/parameters/RemoveDomainAdminParams.ts @@ -0,0 +1,6 @@ +type RemoveDomainAdminParams = { + domainAccountID: number; + targetAccountID: number; +}; + +export default RemoveDomainAdminParams; diff --git a/src/libs/API/parameters/index.ts b/src/libs/API/parameters/index.ts index bece054a0237..b96f3f6d7c66 100644 --- a/src/libs/API/parameters/index.ts +++ b/src/libs/API/parameters/index.ts @@ -454,3 +454,4 @@ export type {default as UpdateSamlRequiredParams} from './UpdateSamlRequiredPara export type {default as SetPolicyRequireCompanyCardsEnabledParams} from './SetPolicyRequireCompanyCardsEnabled'; export type {default as SetTechnicalContactEmailParams} from './SetTechnicalContactEmailParams'; export type {default as ToggleConsolidatedDomainBillingParams} from './ToggleConsolidatedDomainBillingParams'; +export type {default as RemoveDomainAdminParams} from './RemoveDomainAdminParams'; diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts index 0e2fca6d06cd..00a08c966c88 100644 --- a/src/libs/API/types.ts +++ b/src/libs/API/types.ts @@ -531,6 +531,7 @@ const WRITE_COMMANDS = { SET_TECHNICAL_CONTACT_EMAIL: 'SetTechnicalContactEmail', TOGGLE_CONSOLIDATED_DOMAIN_BILLING: 'ToggleConsolidatedDomainBilling', ADD_DOMAIN_ADMIN: 'AddDomainAdmin', + REMOVE_DOMAIN_ADMIN: 'RemoveDomainAdmin', } as const; type WriteCommand = ValueOf; @@ -1082,6 +1083,7 @@ type WriteCommandParameters = { [WRITE_COMMANDS.CREATE_DOMAIN]: Parameters.DomainParams; [WRITE_COMMANDS.SET_TECHNICAL_CONTACT_EMAIL]: Parameters.SetTechnicalContactEmailParams; [WRITE_COMMANDS.TOGGLE_CONSOLIDATED_DOMAIN_BILLING]: Parameters.ToggleConsolidatedDomainBillingParams; + [WRITE_COMMANDS.REMOVE_DOMAIN_ADMIN]: Parameters.RemoveDomainAdminParams; [WRITE_COMMANDS.ADD_DOMAIN_ADMIN]: Parameters.AddAdminToDomainParams; }; diff --git a/src/libs/actions/Domain.ts b/src/libs/actions/Domain.ts index 93a03e144a70..28bf93901fef 100644 --- a/src/libs/actions/Domain.ts +++ b/src/libs/actions/Domain.ts @@ -1,9 +1,8 @@ import Onyx from 'react-native-onyx'; import type {OnyxUpdate} from 'react-native-onyx'; import * as API from '@libs/API'; -import type {AddAdminToDomainParams, SetTechnicalContactEmailParams, ToggleConsolidatedDomainBillingParams} from '@libs/API/parameters'; +import type {AddAdminToDomainParams, RemoveDomainAdminParams, SetTechnicalContactEmailParams, ToggleConsolidatedDomainBillingParams} from '@libs/API/parameters'; import {READ_COMMANDS, SIDE_EFFECT_REQUEST_COMMANDS, WRITE_COMMANDS} from '@libs/API/types'; -import {applyUpdatesWithDelay} from '@libs/DomainUtils'; import {getMicroSecondOnyxErrorWithTranslationKey} from '@libs/ErrorUtils'; import {getAuthToken} from '@libs/Network/NetworkStore'; import CONST from '@src/CONST'; @@ -628,7 +627,7 @@ function addAdminToDomain(domainAccountID: number, accountID: number, targetEmai /** * Removes an error after trying to add admin */ -function clearAddAdminError(domainAccountID: number, accountID: number) { +function clearAdminError(domainAccountID: number, accountID: number) { const PERMISSION_KEY = `${ONYXKEYS.COLLECTION.EXPENSIFY_ADMIN_ACCESS_PREFIX}${accountID}`; Onyx.merge(`${ONYXKEYS.COLLECTION.DOMAIN}${domainAccountID}`, { @@ -698,38 +697,23 @@ function revokeDomainAdminAccess(domainAccountID: number, accountID: number) { }, }, }, - // TODO update after BE is ready: DEV stuff only below this line { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.DOMAIN_ERRORS}${domainAccountID}`, value: { adminErrors: { - [accountID]: {errors: {[Date.now()]: 'Unable to remove this user as an Admin. Please try again.'}}, + [accountID]: {errors: getMicroSecondOnyxErrorWithTranslationKey('domain.admins.error.removeAdmin')}, }, }, }, ]; - // applyUpdatesWithDelay(optimisticData, successData); - applyUpdatesWithDelay(optimisticData, failureData); -} + const parameters: RemoveDomainAdminParams = { + domainAccountID, + targetAccountID: accountID, + }; -/** - * Removes an error after trying to remove admin - */ -function clearRemoveAdminError(domainAccountID: number, accountID: number) { - Onyx.merge(`${ONYXKEYS.COLLECTION.DOMAIN_ERRORS}${domainAccountID}`, { - adminErrors: { - [accountID]: null, - }, - }); - Onyx.merge(`${ONYXKEYS.COLLECTION.DOMAIN_PENDING_ACTIONS}${domainAccountID}`, { - admin: { - [accountID]: { - pendingAction: null, - }, - }, - }); + API.write(WRITE_COMMANDS.REMOVE_DOMAIN_ADMIN, parameters, {optimisticData, successData, failureData}); } export { @@ -751,7 +735,6 @@ export { toggleConsolidatedDomainBilling, clearToggleConsolidatedDomainBillingErrors, addAdminToDomain, - clearAddAdminError, + clearAdminError, revokeDomainAdminAccess, - clearRemoveAdminError, }; diff --git a/src/pages/domain/Admins/DomainAdminDetailsPage.tsx b/src/pages/domain/Admins/DomainAdminDetailsPage.tsx index cc72c8e6cbd4..26d2b968dd4c 100644 --- a/src/pages/domain/Admins/DomainAdminDetailsPage.tsx +++ b/src/pages/domain/Admins/DomainAdminDetailsPage.tsx @@ -118,6 +118,7 @@ function DomainAdminDetailsPage({route}: DomainAdminDetailsPageProps) { {!domainHasOnlyOneAdmin && ( , errors: getLatestError(domainErrors?.adminErrors?.[accountID]?.errors), - pendingAction: domainPendingActions?.admin?.[accountID]?.pendingAction, + pendingAction: domainPendingAction?.[accountID]?.pendingAction, }); } @@ -194,7 +191,7 @@ function DomainAdminsPage({route}: DomainAdminsPageProps) { showScrollIndicator={false} addBottomSafeAreaPadding customListHeader={getCustomListHeader()} - onDismissError={(item: AdminOption) => clearAddAdminError(domainAccountID, item.accountID)} + onDismissError={(item: AdminOption) => clearAdminError(domainAccountID, item.accountID)} /> From 33174150832f2c0166ef688f9187f58804a79cce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Musia=C5=82?= Date: Wed, 7 Jan 2026 11:56:36 +0100 Subject: [PATCH 09/18] remove unused util --- src/libs/DomainUtils.ts | 24 ------------------------ 1 file changed, 24 deletions(-) delete mode 100644 src/libs/DomainUtils.ts diff --git a/src/libs/DomainUtils.ts b/src/libs/DomainUtils.ts deleted file mode 100644 index 5bc5e257d007..000000000000 --- a/src/libs/DomainUtils.ts +++ /dev/null @@ -1,24 +0,0 @@ -import Onyx from 'react-native-onyx'; -import type {OnyxUpdate} from 'react-native-onyx'; - -/** - * DEV ONLY HELPER - do not use in production code - * Applies two sets of Onyx state updates with a delay between them. - * Useful for simulating state transitions or testing optimistic updates. - * - * @param firstData - The first set of Onyx updates to apply (e.g., optimisticData) - * @param secondData - The second set of Onyx updates to apply after delay (e.g., successData) - * @param delayMs - The delay in milliseconds between applying the updates (defaults to 1000ms) - * @returns A promise that resolves when both updates are complete - */ -async function applyUpdatesWithDelay(firstData: OnyxUpdate[], secondData: OnyxUpdate[], delayMs = 1000): Promise { - await Onyx.update(firstData); - return new Promise((resolve) => { - setTimeout(() => { - Onyx.update(secondData).then(resolve); - }, delayMs); - }); -} - -// eslint-disable-next-line import/prefer-default-export -export {applyUpdatesWithDelay}; From 78d104569ac3c900d13ed9cf1b0008e180b1ee59 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Musia=C5=82?= Date: Thu, 8 Jan 2026 15:08:49 +0100 Subject: [PATCH 10/18] translations --- src/languages/de.ts | 3 +++ src/languages/es.ts | 3 +++ src/languages/fr.ts | 5 ++++- src/languages/it.ts | 3 +++ src/languages/ja.ts | 3 +++ src/languages/nl.ts | 3 +++ src/languages/pl.ts | 3 +++ src/languages/pt-BR.ts | 3 +++ src/languages/zh-hans.ts | 3 +++ 9 files changed, 28 insertions(+), 1 deletion(-) diff --git a/src/languages/de.ts b/src/languages/de.ts index 736eab6999d3..1cec84db8ddd 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -7935,6 +7935,9 @@ Hier ist ein *Testbeleg*, um dir zu zeigen, wie es funktioniert:`, addAdmin: 'Admin hinzufügen', invite: 'Einladen', addAdminError: 'Dieser Benutzer kann nicht als Admin hinzugefügt werden. Bitte versuche es erneut.', + revokeAdminAccess: 'Administratorzugriff widerrufen', + cantRevokeAdminAccess: 'Adminzugriff kann dem technischen Ansprechpartner nicht entzogen werden', + error: {removeAdmin: 'Dieser Benutzer kann nicht als Admin entfernt werden. Bitte versuchen Sie es erneut.'}, }, }, gps: { diff --git a/src/languages/es.ts b/src/languages/es.ts index 2ed90a809ac4..dccbf5528ca3 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -7974,6 +7974,9 @@ ${amount} para ${merchant} - ${date}`, addAdmin: 'Añadir administrador', invite: 'Invitar', addAdminError: 'No se pudo añadir a este miembro como administrador. Por favor, inténtalo de nuevo.', + revokeAdminAccess: 'Revocar acceso de administrador', + cantRevokeAdminAccess: 'No se puede revocar el acceso de administrador del contacto técnico', + error: {removeAdmin: 'No se pudo eliminar a este usuario como administrador. Por favor, inténtalo de nuevo.'}, }, }, gps: { diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 6974bab3986c..89f0990cd235 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -7939,7 +7939,10 @@ Voici un *reçu test* pour vous montrer comment cela fonctionne :`, consolidatedDomainBillingError: 'La facturation de domaine consolidée n’a pas pu être modifiée. Veuillez réessayer plus tard.', addAdmin: 'Ajouter un administrateur', invite: 'Inviter', - addAdminError: 'Impossible d’ajouter ce membre en tant qu’administrateur. Veuillez réessayer.', + addAdminError: "Impossible d'ajouter ce membre en tant qu'administrateur. Veuillez réessayer.", + revokeAdminAccess: "Révoquer l'accès administrateur", + cantRevokeAdminAccess: "Impossible de révoquer l'accès administrateur au contact technique", + error: {removeAdmin: "Impossible de supprimer cet utilisateur en tant qu'administrateur. Veuillez réessayer."}, }, }, gps: { diff --git a/src/languages/it.ts b/src/languages/it.ts index 0c6a41bf918b..314d375882cc 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -7918,6 +7918,9 @@ Ecco una *ricevuta di prova* per mostrarti come funziona:`, addAdmin: 'Aggiungi amministratore', invite: 'Invita', addAdminError: 'Impossibile aggiungere questo membro come amministratore. Riprova.', + revokeAdminAccess: 'Revoca accesso amministratore', + cantRevokeAdminAccess: 'Impossibile revocare i privilegi di amministratore dal referente tecnico', + error: {removeAdmin: 'Impossibile rimuovere questo utente come amministratore. Riprova.'}, }, }, gps: { diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 385fc00e960b..2ed752b2245e 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -7856,6 +7856,9 @@ Expensify の使い方をお見せするための*テストレシート*がこ addAdmin: '管理者を追加', invite: '招待', addAdminError: 'このメンバーを管理者として追加できません。もう一度お試しください。', + revokeAdminAccess: '管理者アクセスを取り消す', + cantRevokeAdminAccess: '技術連絡先から管理者アクセス権を取り消すことはできません', + error: {removeAdmin: 'このユーザーを管理者として削除できません。もう一度お試しください。'}, }, }, gps: { diff --git a/src/languages/nl.ts b/src/languages/nl.ts index d405ca615944..c8608d4d4453 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -7904,6 +7904,9 @@ Hier is een *testbon* om je te laten zien hoe het werkt:`, addAdmin: 'Beheerder toevoegen', invite: 'Uitnodigen', addAdminError: 'Kan dit lid niet als beheerder toevoegen. Probeer het opnieuw.', + revokeAdminAccess: 'Beheerdersrechten intrekken', + cantRevokeAdminAccess: 'Kan de beheerdersrechten niet intrekken van de technische contactpersoon', + error: {removeAdmin: 'Kan deze gebruiker niet als beheerder verwijderen. Probeer het opnieuw.'}, }, }, gps: { diff --git a/src/languages/pl.ts b/src/languages/pl.ts index a60e02d7c502..ca3c7d455be3 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -7888,6 +7888,9 @@ Oto *paragon testowy*, który pokazuje, jak to działa:`, addAdmin: 'Dodaj administratora', invite: 'Zaproś', addAdminError: 'Nie można dodać tego członka jako administratora. Spróbuj ponownie.', + revokeAdminAccess: 'Cofnij uprawnienia administratora', + cantRevokeAdminAccess: 'Nie można odebrać uprawnień administratora kontaktowi technicznemu', + error: {removeAdmin: 'Nie można usunąć tego użytkownika jako administratora. Spróbuj ponownie.'}, }, }, gps: { diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 5c6d81a4e554..696ca54c8810 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -7894,6 +7894,9 @@ Aqui está um *recibo de teste* para mostrar como funciona:`, addAdmin: 'Adicionar administrador', invite: 'Convidar', addAdminError: 'Não foi possível adicionar este membro como administrador. Tente novamente.', + revokeAdminAccess: 'Revogar acesso de administrador', + cantRevokeAdminAccess: 'Não é possível revogar o acesso de administrador do contato técnico', + error: {removeAdmin: 'Não foi possível remover este usuário como Administrador. Tente novamente.'}, }, }, gps: { diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 1783d8af0106..c0bd7b3d2e6f 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -7712,6 +7712,9 @@ ${reportName} addAdmin: '添加管理员', invite: '邀请', addAdminError: '无法将此成员添加为管理员。请重试。', + revokeAdminAccess: '撤销管理员访问权限', + cantRevokeAdminAccess: '无法撤销技术联系人的管理员访问权限', + error: {removeAdmin: '无法将此用户移除管理员角色。请重试。'}, }, }, gps: { From 33bb57f243fbff5a8ebed44b1854a19df657101d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Musia=C5=82?= Date: Thu, 8 Jan 2026 20:27:08 +0100 Subject: [PATCH 11/18] fix pr comments --- src/languages/fr.ts | 8 ++--- tests/unit/DomainSelectorsTest.ts | 50 +++++++++++++++++++++++++++++-- 2 files changed, 52 insertions(+), 6 deletions(-) diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 89f0990cd235..9887f7c0068a 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -7939,10 +7939,10 @@ Voici un *reçu test* pour vous montrer comment cela fonctionne :`, consolidatedDomainBillingError: 'La facturation de domaine consolidée n’a pas pu être modifiée. Veuillez réessayer plus tard.', addAdmin: 'Ajouter un administrateur', invite: 'Inviter', - addAdminError: "Impossible d'ajouter ce membre en tant qu'administrateur. Veuillez réessayer.", - revokeAdminAccess: "Révoquer l'accès administrateur", - cantRevokeAdminAccess: "Impossible de révoquer l'accès administrateur au contact technique", - error: {removeAdmin: "Impossible de supprimer cet utilisateur en tant qu'administrateur. Veuillez réessayer."}, + addAdminError: 'Impossible d’ajouter ce membre en tant qu’administrateur. Veuillez réessayer.', + revokeAdminAccess: 'Révoquer l’accès administrateur', + cantRevokeAdminAccess: 'Impossible de révoquer l’accès administrateur au contact technique', + error: {removeAdmin: 'Impossible de supprimer cet utilisateur en tant qu’administrateur. Veuillez réessayer.'}, }, }, gps: { diff --git a/tests/unit/DomainSelectorsTest.ts b/tests/unit/DomainSelectorsTest.ts index 15fe44fbd13d..ac68ed70a10e 100644 --- a/tests/unit/DomainSelectorsTest.ts +++ b/tests/unit/DomainSelectorsTest.ts @@ -1,7 +1,7 @@ -import {adminAccountIDsSelector, domainEmailSelector, technicalContactSettingsSelector} from '@selectors/Domain'; +import {adminAccountIDsSelector, adminPendingActionSelector, domainEmailSelector, domainSettingsPrimaryContactSelector, technicalContactSettingsSelector} from '@selectors/Domain'; import type {OnyxEntry} from 'react-native-onyx'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {CardFeeds, Domain} from '@src/types/onyx'; +import type {CardFeeds, Domain, DomainPendingActions, DomainSettings} from '@src/types/onyx'; describe('domainSelectors', () => { describe('adminAccountIDsSelector', () => { @@ -118,4 +118,50 @@ describe('domainSelectors', () => { expect(domainEmailSelector(domain)).toBeUndefined(); }); }); + + describe('domainSettingsPrimaryContactSelector', () => { + it.each([ + ['undefined', undefined, undefined], + ['empty object', {} as OnyxEntry, undefined], + ['settings without technicalContactEmail', {settings: {}} as OnyxEntry, undefined], + ])('Should return undefined when domainSettings is %s', (_description, domainSettings, expected) => { + expect(domainSettingsPrimaryContactSelector(domainSettings)).toBe(expected); + }); + + it('Should return the technical contact email when it exists', () => { + const domainSettings = { + settings: { + technicalContactEmail: 'admin@example.com', + }, + } as OnyxEntry; + + expect(domainSettingsPrimaryContactSelector(domainSettings)).toBe('admin@example.com'); + }); + }); + + describe('adminPendingActionSelector', () => { + + it.each([ + ['undefined', undefined, {}], + ['empty object', {} as OnyxEntry, {}], + ])('Should return empty object when pendingAction is %s', (_description, pendingAction, expected) => { + expect(adminPendingActionSelector(pendingAction)).toEqual(expected); + }); + + it('Should return the admin pending actions when they exist', () => { + const userID1 = 123; + const userID2 = 456; + const pendingAction: OnyxEntry = { + admin: { + [userID1]: {pendingAction: 'update'}, + [userID2]: {pendingAction: 'delete'}, + }, + }; + + expect(adminPendingActionSelector(pendingAction)).toEqual({ + [userID1]: {pendingAction: 'update'}, + [userID2]: {pendingAction: 'delete'}, + }); + }); + }); }); From 0b2e5c3b575b6ad6ab87991924d846973f544e0f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Musia=C5=82?= Date: Thu, 8 Jan 2026 20:37:09 +0100 Subject: [PATCH 12/18] change canBeMissing to false --- src/pages/domain/Admins/DomainAdminDetailsPage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/domain/Admins/DomainAdminDetailsPage.tsx b/src/pages/domain/Admins/DomainAdminDetailsPage.tsx index 26d2b968dd4c..91c4a979f3b6 100644 --- a/src/pages/domain/Admins/DomainAdminDetailsPage.tsx +++ b/src/pages/domain/Admins/DomainAdminDetailsPage.tsx @@ -39,7 +39,7 @@ function DomainAdminDetailsPage({route}: DomainAdminDetailsPageProps) { const [primaryContact] = useOnyx(`${ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_DOMAIN_MEMBER}${domainAccountID}`, { selector: domainSettingsPrimaryContactSelector, - canBeMissing: false, + canBeMissing: true, }); const [adminAccountIDs] = useOnyx(`${ONYXKEYS.COLLECTION.DOMAIN}${domainAccountID}`, { From 0e0c8edecaafefeca71753db351ead340983705e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Musia=C5=82?= Date: Thu, 8 Jan 2026 22:24:02 +0100 Subject: [PATCH 13/18] remove empty line --- tests/unit/DomainSelectorsTest.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/unit/DomainSelectorsTest.ts b/tests/unit/DomainSelectorsTest.ts index ac68ed70a10e..faf327fd5c78 100644 --- a/tests/unit/DomainSelectorsTest.ts +++ b/tests/unit/DomainSelectorsTest.ts @@ -140,7 +140,6 @@ describe('domainSelectors', () => { }); describe('adminPendingActionSelector', () => { - it.each([ ['undefined', undefined, {}], ['empty object', {} as OnyxEntry, {}], From 4412cc931aa925e5169ae1d2812222a0b692844a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Musia=C5=82?= Date: Thu, 8 Jan 2026 23:05:15 +0100 Subject: [PATCH 14/18] fix the permissionKey logic --- src/libs/actions/Domain.ts | 21 +++-- .../domain/Admins/DomainAdminDetailsPage.tsx | 18 ++++- src/selectors/Domain.ts | 25 ++++++ tests/unit/DomainSelectorsTest.ts | 77 ++++++++++++++++++- 4 files changed, 124 insertions(+), 17 deletions(-) diff --git a/src/libs/actions/Domain.ts b/src/libs/actions/Domain.ts index 28bf93901fef..127b654879ff 100644 --- a/src/libs/actions/Domain.ts +++ b/src/libs/actions/Domain.ts @@ -625,15 +625,9 @@ function addAdminToDomain(domainAccountID: number, accountID: number, targetEmai } /** - * Removes an error after trying to add admin + * Removes an error and pending actions after trying to add admin */ function clearAdminError(domainAccountID: number, accountID: number) { - const PERMISSION_KEY = `${ONYXKEYS.COLLECTION.EXPENSIFY_ADMIN_ACCESS_PREFIX}${accountID}`; - - Onyx.merge(`${ONYXKEYS.COLLECTION.DOMAIN}${domainAccountID}`, { - [PERMISSION_KEY]: null, - }); - Onyx.merge(`${ONYXKEYS.COLLECTION.DOMAIN_ERRORS}${domainAccountID}`, { adminErrors: { [accountID]: null, @@ -649,15 +643,13 @@ function clearAdminError(domainAccountID: number, accountID: number) { /** * Removes admin access for a domain member */ -function revokeDomainAdminAccess(domainAccountID: number, accountID: number) { - const PERMISSION_KEY = `${ONYXKEYS.COLLECTION.EXPENSIFY_ADMIN_ACCESS_PREFIX}${accountID}`; - +function revokeDomainAdminAccess(domainAccountID: number, permissionKey: string, accountID: number) { const optimisticData: OnyxUpdate[] = [ { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.DOMAIN}${domainAccountID}`, value: { - [PERMISSION_KEY]: null, + [permissionKey]: null, }, }, { @@ -686,6 +678,13 @@ function revokeDomainAdminAccess(domainAccountID: number, accountID: number) { }, ]; const failureData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.DOMAIN}${domainAccountID}`, + value: { + [permissionKey]: accountID, + }, + }, { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.DOMAIN_PENDING_ACTIONS}${domainAccountID}`, diff --git a/src/pages/domain/Admins/DomainAdminDetailsPage.tsx b/src/pages/domain/Admins/DomainAdminDetailsPage.tsx index 91c4a979f3b6..c255ff146bac 100644 --- a/src/pages/domain/Admins/DomainAdminDetailsPage.tsx +++ b/src/pages/domain/Admins/DomainAdminDetailsPage.tsx @@ -26,7 +26,7 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; -import {adminAccountIDsSelector, domainSettingsPrimaryContactSelector} from '@src/selectors/Domain'; +import {adminAccessReverseMapSelector, adminAccountIDsSelector, domainSettingsPrimaryContactSelector} from '@src/selectors/Domain'; import type {PersonalDetailsList} from '@src/types/onyx'; type DomainAdminDetailsPageProps = PlatformStackScreenProps; @@ -53,6 +53,13 @@ function DomainAdminDetailsPage({route}: DomainAdminDetailsPageProps) { selector: (personalDetailsList: OnyxEntry) => personalDetailsList?.[accountID], }); + const [adminAccountIDKeysMap] = useOnyx(`${ONYXKEYS.COLLECTION.DOMAIN}${domainAccountID}`, { + canBeMissing: true, + selector: adminAccessReverseMapSelector, + }); + + const currentAdminPermissionKey = adminAccountIDKeysMap?.[route.params.accountID]; + const displayName = formatPhoneNumber(getDisplayNameOrDefault(adminPersonalDetails)); const memberLogin = adminPersonalDetails?.login ?? ''; const isCurrentUserPrimaryContact = primaryContact === memberLogin; @@ -64,6 +71,9 @@ function DomainAdminDetailsPage({route}: DomainAdminDetailsPageProps) { const {showConfirmModal} = useConfirmModal(); const handleRevokeAdminAccess = async () => { + if (!currentAdminPermissionKey) { + return; + } const confirmResult = await showConfirmModal({ title: translate('domain.admins.revokeAdminAccess'), prompt: translate('workspace.people.removeMemberPrompt', {memberName: displayName}), @@ -73,7 +83,9 @@ function DomainAdminDetailsPage({route}: DomainAdminDetailsPageProps) { if (confirmResult.action !== ModalActions.CONFIRM) { return; } - revokeDomainAdminAccess(route.params.domainAccountID, route.params.accountID); + const permissionKey = `${ONYXKEYS.COLLECTION.EXPENSIFY_ADMIN_ACCESS_PREFIX}${adminAccountIDKeysMap[route.params.accountID]}`; + + revokeDomainAdminAccess(route.params.domainAccountID, permissionKey, route.params.accountID); Navigation.dismissModal(); }; @@ -117,7 +129,7 @@ function DomainAdminDetailsPage({route}: DomainAdminDetailsPageProps) { /> {!domainHasOnlyOneAdmin && ( ) => domain?.email; const adminPendingActionSelector = (pendingAction: OnyxEntry) => pendingAction?.admin ?? {}; +/** + * Creates a reverse map of admin access data from the domain object. + * Maps account IDs (values) to their corresponding keys (without the prefix). + * + * @param domain - The domain object from Onyx + * @returns A record mapping account IDs to their keys + */ +function adminAccessReverseMapSelector(domain: OnyxEntry): Record { + if (!domain) { + return {}; + } + + return Object.entries(domain).reduce>((acc, [key, value]) => { + if (!key.startsWith(ONYXKEYS.COLLECTION.EXPENSIFY_ADMIN_ACCESS_PREFIX) || value === undefined || value === null) { + return acc; + } + + const keyWithoutPrefix = key.replace(ONYXKEYS.COLLECTION.EXPENSIFY_ADMIN_ACCESS_PREFIX, ''); + acc[Number(value)] = keyWithoutPrefix; + + return acc; + }, {}); +} + export { domainMemberSamlSettingsSelector, domainSettingsPrimaryContactSelector, @@ -69,4 +93,5 @@ export { technicalContactSettingsSelector, domainEmailSelector, adminPendingActionSelector, + adminAccessReverseMapSelector, }; diff --git a/tests/unit/DomainSelectorsTest.ts b/tests/unit/DomainSelectorsTest.ts index faf327fd5c78..d70350e44d24 100644 --- a/tests/unit/DomainSelectorsTest.ts +++ b/tests/unit/DomainSelectorsTest.ts @@ -1,9 +1,18 @@ -import {adminAccountIDsSelector, adminPendingActionSelector, domainEmailSelector, domainSettingsPrimaryContactSelector, technicalContactSettingsSelector} from '@selectors/Domain'; +import { + adminAccessReverseMapSelector, + adminAccountIDsSelector, + adminPendingActionSelector, + domainEmailSelector, + domainSettingsPrimaryContactSelector, + technicalContactSettingsSelector, +} from '@selectors/Domain'; import type {OnyxEntry} from 'react-native-onyx'; import ONYXKEYS from '@src/ONYXKEYS'; import type {CardFeeds, Domain, DomainPendingActions, DomainSettings} from '@src/types/onyx'; describe('domainSelectors', () => { + const userID1 = 123; + const userID2 = 456; describe('adminAccountIDsSelector', () => { it('Should return an empty array if the domain object is undefined', () => { expect(adminAccountIDsSelector(undefined)).toEqual([]); @@ -148,8 +157,6 @@ describe('domainSelectors', () => { }); it('Should return the admin pending actions when they exist', () => { - const userID1 = 123; - const userID2 = 456; const pendingAction: OnyxEntry = { admin: { [userID1]: {pendingAction: 'update'}, @@ -163,4 +170,68 @@ describe('domainSelectors', () => { }); }); }); + + describe('adminAccessReverseMapSelector', () => { + it.each([ + ['undefined', undefined, {}], + ['empty object', {} as OnyxEntry, {}], + ])('Should return empty object when domain is %s', (_description, domain, expected) => { + expect(adminAccessReverseMapSelector(domain)).toEqual(expected); + }); + + it('Should return a reverse map with accountID as key and suffix as value', () => { + const domain = { + [`${ONYXKEYS.COLLECTION.EXPENSIFY_ADMIN_ACCESS_PREFIX}user123`]: userID1, + [`${ONYXKEYS.COLLECTION.EXPENSIFY_ADMIN_ACCESS_PREFIX}admin456`]: userID2, + } as unknown as OnyxEntry; + + expect(adminAccessReverseMapSelector(domain)).toEqual({ + [userID1]: 'user123', + [userID2]: 'admin456', + }); + }); + + it('Should ignore keys that do not start with the admin access prefix', () => { + const domain = { + [`${ONYXKEYS.COLLECTION.EXPENSIFY_ADMIN_ACCESS_PREFIX}user123`]: userID1, + someOtherProperty: 'value', + anotherKey: 999, + } as unknown as OnyxEntry; + + expect(adminAccessReverseMapSelector(domain)).toEqual({ + [userID1]: 'user123', + }); + }); + + it.each([ + [ + 'undefined values', + { + [`${ONYXKEYS.COLLECTION.EXPENSIFY_ADMIN_ACCESS_PREFIX}user123`]: userID1, + [`${ONYXKEYS.COLLECTION.EXPENSIFY_ADMIN_ACCESS_PREFIX}user0`]: undefined, + } as unknown as OnyxEntry, + {[userID1]: 'user123'}, + ], + [ + 'null values', + { + [`${ONYXKEYS.COLLECTION.EXPENSIFY_ADMIN_ACCESS_PREFIX}user123`]: userID1, + [`${ONYXKEYS.COLLECTION.EXPENSIFY_ADMIN_ACCESS_PREFIX}user999`]: null, + } as unknown as OnyxEntry, + {[userID1]: 'user123'}, + ], + [ + 'mixed undefined and null values', + { + [`${ONYXKEYS.COLLECTION.EXPENSIFY_ADMIN_ACCESS_PREFIX}user123`]: userID1, + [`${ONYXKEYS.COLLECTION.EXPENSIFY_ADMIN_ACCESS_PREFIX}user0`]: undefined, + [`${ONYXKEYS.COLLECTION.EXPENSIFY_ADMIN_ACCESS_PREFIX}user999`]: null, + [`${ONYXKEYS.COLLECTION.EXPENSIFY_ADMIN_ACCESS_PREFIX}user456`]: userID2, + } as unknown as OnyxEntry, + {[userID1]: 'user123', [userID2]: 'user456'}, + ], + ])('Should ignore keys with falsy values - %s', (_description, domain, expected) => { + expect(adminAccessReverseMapSelector(domain)).toEqual(expected); + }); + }); }); From 14f0afd65c4773719bcc9e28d7426d31c727425e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Musia=C5=82?= Date: Fri, 9 Jan 2026 10:04:49 +0100 Subject: [PATCH 15/18] disabled row on delete offline --- src/libs/actions/Domain.ts | 16 +--- .../domain/Admins/DomainAdminDetailsPage.tsx | 17 +---- src/pages/domain/Admins/DomainAdminsPage.tsx | 3 + src/selectors/Domain.ts | 25 ------- tests/unit/DomainSelectorsTest.ts | 73 +------------------ 5 files changed, 8 insertions(+), 126 deletions(-) diff --git a/src/libs/actions/Domain.ts b/src/libs/actions/Domain.ts index 127b654879ff..25e041c7798b 100644 --- a/src/libs/actions/Domain.ts +++ b/src/libs/actions/Domain.ts @@ -643,15 +643,8 @@ function clearAdminError(domainAccountID: number, accountID: number) { /** * Removes admin access for a domain member */ -function revokeDomainAdminAccess(domainAccountID: number, permissionKey: string, accountID: number) { +function revokeDomainAdminAccess(domainAccountID: number, accountID: number) { const optimisticData: OnyxUpdate[] = [ - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.DOMAIN}${domainAccountID}`, - value: { - [permissionKey]: null, - }, - }, { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.DOMAIN_PENDING_ACTIONS}${domainAccountID}`, @@ -678,13 +671,6 @@ function revokeDomainAdminAccess(domainAccountID: number, permissionKey: string, }, ]; const failureData: OnyxUpdate[] = [ - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.DOMAIN}${domainAccountID}`, - value: { - [permissionKey]: accountID, - }, - }, { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.DOMAIN_PENDING_ACTIONS}${domainAccountID}`, diff --git a/src/pages/domain/Admins/DomainAdminDetailsPage.tsx b/src/pages/domain/Admins/DomainAdminDetailsPage.tsx index c255ff146bac..eabc98040c1f 100644 --- a/src/pages/domain/Admins/DomainAdminDetailsPage.tsx +++ b/src/pages/domain/Admins/DomainAdminDetailsPage.tsx @@ -26,7 +26,7 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; -import {adminAccessReverseMapSelector, adminAccountIDsSelector, domainSettingsPrimaryContactSelector} from '@src/selectors/Domain'; +import {adminAccountIDsSelector, domainSettingsPrimaryContactSelector} from '@src/selectors/Domain'; import type {PersonalDetailsList} from '@src/types/onyx'; type DomainAdminDetailsPageProps = PlatformStackScreenProps; @@ -53,13 +53,6 @@ function DomainAdminDetailsPage({route}: DomainAdminDetailsPageProps) { selector: (personalDetailsList: OnyxEntry) => personalDetailsList?.[accountID], }); - const [adminAccountIDKeysMap] = useOnyx(`${ONYXKEYS.COLLECTION.DOMAIN}${domainAccountID}`, { - canBeMissing: true, - selector: adminAccessReverseMapSelector, - }); - - const currentAdminPermissionKey = adminAccountIDKeysMap?.[route.params.accountID]; - const displayName = formatPhoneNumber(getDisplayNameOrDefault(adminPersonalDetails)); const memberLogin = adminPersonalDetails?.login ?? ''; const isCurrentUserPrimaryContact = primaryContact === memberLogin; @@ -71,9 +64,6 @@ function DomainAdminDetailsPage({route}: DomainAdminDetailsPageProps) { const {showConfirmModal} = useConfirmModal(); const handleRevokeAdminAccess = async () => { - if (!currentAdminPermissionKey) { - return; - } const confirmResult = await showConfirmModal({ title: translate('domain.admins.revokeAdminAccess'), prompt: translate('workspace.people.removeMemberPrompt', {memberName: displayName}), @@ -83,9 +73,8 @@ function DomainAdminDetailsPage({route}: DomainAdminDetailsPageProps) { if (confirmResult.action !== ModalActions.CONFIRM) { return; } - const permissionKey = `${ONYXKEYS.COLLECTION.EXPENSIFY_ADMIN_ACCESS_PREFIX}${adminAccountIDKeysMap[route.params.accountID]}`; - revokeDomainAdminAccess(route.params.domainAccountID, permissionKey, route.params.accountID); + revokeDomainAdminAccess(route.params.domainAccountID, route.params.accountID); Navigation.dismissModal(); }; @@ -129,7 +118,7 @@ function DomainAdminDetailsPage({route}: DomainAdminDetailsPageProps) { /> {!domainHasOnlyOneAdmin && ( , errors: getLatestError(domainErrors?.adminErrors?.[accountID]?.errors), pendingAction: domainPendingAction?.[accountID]?.pendingAction, + isInteractive: !isPendingActionDelete, + isDisabled: isPendingActionDelete, }); } diff --git a/src/selectors/Domain.ts b/src/selectors/Domain.ts index 6ef74fb23900..8bf073bbede1 100644 --- a/src/selectors/Domain.ts +++ b/src/selectors/Domain.ts @@ -59,30 +59,6 @@ const domainEmailSelector = (domain: OnyxEntry) => domain?.email; const adminPendingActionSelector = (pendingAction: OnyxEntry) => pendingAction?.admin ?? {}; -/** - * Creates a reverse map of admin access data from the domain object. - * Maps account IDs (values) to their corresponding keys (without the prefix). - * - * @param domain - The domain object from Onyx - * @returns A record mapping account IDs to their keys - */ -function adminAccessReverseMapSelector(domain: OnyxEntry): Record { - if (!domain) { - return {}; - } - - return Object.entries(domain).reduce>((acc, [key, value]) => { - if (!key.startsWith(ONYXKEYS.COLLECTION.EXPENSIFY_ADMIN_ACCESS_PREFIX) || value === undefined || value === null) { - return acc; - } - - const keyWithoutPrefix = key.replace(ONYXKEYS.COLLECTION.EXPENSIFY_ADMIN_ACCESS_PREFIX, ''); - acc[Number(value)] = keyWithoutPrefix; - - return acc; - }, {}); -} - export { domainMemberSamlSettingsSelector, domainSettingsPrimaryContactSelector, @@ -93,5 +69,4 @@ export { technicalContactSettingsSelector, domainEmailSelector, adminPendingActionSelector, - adminAccessReverseMapSelector, }; diff --git a/tests/unit/DomainSelectorsTest.ts b/tests/unit/DomainSelectorsTest.ts index d70350e44d24..51c332323428 100644 --- a/tests/unit/DomainSelectorsTest.ts +++ b/tests/unit/DomainSelectorsTest.ts @@ -1,11 +1,4 @@ -import { - adminAccessReverseMapSelector, - adminAccountIDsSelector, - adminPendingActionSelector, - domainEmailSelector, - domainSettingsPrimaryContactSelector, - technicalContactSettingsSelector, -} from '@selectors/Domain'; +import {adminAccountIDsSelector, adminPendingActionSelector, domainEmailSelector, domainSettingsPrimaryContactSelector, technicalContactSettingsSelector} from '@selectors/Domain'; import type {OnyxEntry} from 'react-native-onyx'; import ONYXKEYS from '@src/ONYXKEYS'; import type {CardFeeds, Domain, DomainPendingActions, DomainSettings} from '@src/types/onyx'; @@ -170,68 +163,4 @@ describe('domainSelectors', () => { }); }); }); - - describe('adminAccessReverseMapSelector', () => { - it.each([ - ['undefined', undefined, {}], - ['empty object', {} as OnyxEntry, {}], - ])('Should return empty object when domain is %s', (_description, domain, expected) => { - expect(adminAccessReverseMapSelector(domain)).toEqual(expected); - }); - - it('Should return a reverse map with accountID as key and suffix as value', () => { - const domain = { - [`${ONYXKEYS.COLLECTION.EXPENSIFY_ADMIN_ACCESS_PREFIX}user123`]: userID1, - [`${ONYXKEYS.COLLECTION.EXPENSIFY_ADMIN_ACCESS_PREFIX}admin456`]: userID2, - } as unknown as OnyxEntry; - - expect(adminAccessReverseMapSelector(domain)).toEqual({ - [userID1]: 'user123', - [userID2]: 'admin456', - }); - }); - - it('Should ignore keys that do not start with the admin access prefix', () => { - const domain = { - [`${ONYXKEYS.COLLECTION.EXPENSIFY_ADMIN_ACCESS_PREFIX}user123`]: userID1, - someOtherProperty: 'value', - anotherKey: 999, - } as unknown as OnyxEntry; - - expect(adminAccessReverseMapSelector(domain)).toEqual({ - [userID1]: 'user123', - }); - }); - - it.each([ - [ - 'undefined values', - { - [`${ONYXKEYS.COLLECTION.EXPENSIFY_ADMIN_ACCESS_PREFIX}user123`]: userID1, - [`${ONYXKEYS.COLLECTION.EXPENSIFY_ADMIN_ACCESS_PREFIX}user0`]: undefined, - } as unknown as OnyxEntry, - {[userID1]: 'user123'}, - ], - [ - 'null values', - { - [`${ONYXKEYS.COLLECTION.EXPENSIFY_ADMIN_ACCESS_PREFIX}user123`]: userID1, - [`${ONYXKEYS.COLLECTION.EXPENSIFY_ADMIN_ACCESS_PREFIX}user999`]: null, - } as unknown as OnyxEntry, - {[userID1]: 'user123'}, - ], - [ - 'mixed undefined and null values', - { - [`${ONYXKEYS.COLLECTION.EXPENSIFY_ADMIN_ACCESS_PREFIX}user123`]: userID1, - [`${ONYXKEYS.COLLECTION.EXPENSIFY_ADMIN_ACCESS_PREFIX}user0`]: undefined, - [`${ONYXKEYS.COLLECTION.EXPENSIFY_ADMIN_ACCESS_PREFIX}user999`]: null, - [`${ONYXKEYS.COLLECTION.EXPENSIFY_ADMIN_ACCESS_PREFIX}user456`]: userID2, - } as unknown as OnyxEntry, - {[userID1]: 'user123', [userID2]: 'user456'}, - ], - ])('Should ignore keys with falsy values - %s', (_description, domain, expected) => { - expect(adminAccessReverseMapSelector(domain)).toEqual(expected); - }); - }); }); From 24685638883d52bcb9019dc6947a160db916b2f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Musia=C5=82?= Date: Fri, 9 Jan 2026 13:46:26 +0100 Subject: [PATCH 16/18] change confirm & cancel text on confirm modal --- src/pages/domain/Admins/DomainAdminDetailsPage.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/pages/domain/Admins/DomainAdminDetailsPage.tsx b/src/pages/domain/Admins/DomainAdminDetailsPage.tsx index eabc98040c1f..0d9eb0b582a4 100644 --- a/src/pages/domain/Admins/DomainAdminDetailsPage.tsx +++ b/src/pages/domain/Admins/DomainAdminDetailsPage.tsx @@ -67,6 +67,9 @@ function DomainAdminDetailsPage({route}: DomainAdminDetailsPageProps) { const confirmResult = await showConfirmModal({ title: translate('domain.admins.revokeAdminAccess'), prompt: translate('workspace.people.removeMemberPrompt', {memberName: displayName}), + confirmText: translate('common.remove'), + cancelText: translate('common.cancel'), + shouldShowCancelButton: true, danger: true, }); From e28d3bc5d7db466fc2cb0189cdbb423f6de8a9f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Musia=C5=82?= Date: Fri, 9 Jan 2026 14:43:27 +0100 Subject: [PATCH 17/18] update jsdoc --- src/types/onyx/DomainPendingActions.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/types/onyx/DomainPendingActions.ts b/src/types/onyx/DomainPendingActions.ts index 9bf6a78d497f..b7cd36dbf633 100644 --- a/src/types/onyx/DomainPendingActions.ts +++ b/src/types/onyx/DomainPendingActions.ts @@ -1,7 +1,8 @@ import type * as OnyxCommon from './OnyxCommon'; /** - * General pending action structure for domain admins + * General pending action structure for domain admins. + * Pending actions structure is dictated by how `domain_` updates are handled in the app to prevent them from resetting unintentionally. */ type GeneralDomainAdminPendingAction = { /** From 462d80e64707f3c0e9ab9995315f732f588bac0c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Musia=C5=82?= Date: Fri, 9 Jan 2026 15:07:06 +0100 Subject: [PATCH 18/18] prettier fix --- src/types/onyx/DomainPendingActions.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/types/onyx/DomainPendingActions.ts b/src/types/onyx/DomainPendingActions.ts index b7cd36dbf633..fda27e5a1cf4 100644 --- a/src/types/onyx/DomainPendingActions.ts +++ b/src/types/onyx/DomainPendingActions.ts @@ -1,7 +1,7 @@ import type * as OnyxCommon from './OnyxCommon'; /** - * General pending action structure for domain admins. + * General pending action structure for domain admins. * Pending actions structure is dictated by how `domain_` updates are handled in the app to prevent them from resetting unintentionally. */ type GeneralDomainAdminPendingAction = {