diff --git a/src/CONST/index.ts b/src/CONST/index.ts index b89caed8b868..6c1531dada68 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -10881,6 +10881,8 @@ const CONST = { PRIVATE_VACATION_DELEGATE_PREFIX: 'private_vacationDelegate_', /** Onyx prefix for lock account IDs */ PRIVATE_LOCKED_ACCOUNT_PREFIX: 'private_lockAccountDetails_', + /** Onyx prefix for account IDs with a pending unlock request */ + PRIVATE_UNLOCK_REQUESTED_PREFIX: 'private_unlockRequested_', MEMBERS: { SECONDARY_ACTIONS: { diff --git a/src/languages/de.ts b/src/languages/de.ts index b266179670b7..39bf5834a4fa 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -2560,6 +2560,7 @@ const translations: TranslationDeepObject = { lockAccount: 'Konto sperren', lockMyAccount: 'Sperren Sie mein Konto', unlockAccount: 'Konto entsperren', + pendingUnlockRequest: 'Ausstehende Entsperranfrage', findYourSituation: 'Die meisten Probleme erfordern keine Sperrung Ihres Kontos! Finden Sie Ihre Situation unten:', lostCardOrCharges: 'Karte verloren oder unbekannte Abbuchungen: Sperren Sie Ihre Karte und kontaktieren Sie Concierge, um unbekannte Transaktionen anzufechten.', diff --git a/src/languages/el.ts b/src/languages/el.ts index 91e95965f360..ece93c91faf2 100644 --- a/src/languages/el.ts +++ b/src/languages/el.ts @@ -2614,6 +2614,7 @@ const translations: TranslationDeepObject = { lockAccount: 'Κλείδωμα λογαριασμού', lockMyAccount: 'Κλείδωμα του λογαριασμού μου', unlockAccount: 'Ξεκλειδώστε τον λογαριασμό', + pendingUnlockRequest: 'Εκκρεμές αίτημα ξεκλειδώματος', unlockTitle: 'Λάβαμε το αίτημά σας', unlockDescription: 'Θα εξετάσουμε τον λογαριασμό για να επιβεβαιώσουμε ότι είναι ασφαλές να ξεκλειδωθεί και θα επικοινωνήσουμε μέσω Concierge για τυχόν ερωτήσεις.', findYourSituation: 'Τα περισσότερα προβλήματα δεν απαιτούν κλείδωμα του λογαριασμού σας. Βρείτε την περίπτωσή σας παρακάτω:', diff --git a/src/languages/en.ts b/src/languages/en.ts index 62f0a9fde7b5..a686a78f8068 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -2718,6 +2718,7 @@ const translations = { lockAccount: 'Lock account', lockMyAccount: 'Lock my account', unlockAccount: 'Unlock account', + pendingUnlockRequest: 'Pending unlock request', unlockTitle: 'We’ve received your request', unlockDescription: 'We’ll review the account to verify it’s safe to unlock and reach out via Concierge with any questions.', findYourSituation: 'Most issues don’t require locking your account! Find your situation below:', diff --git a/src/languages/es.ts b/src/languages/es.ts index 3a2419adfe53..71090baa4227 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -2465,6 +2465,7 @@ const translations: TranslationDeepObject = { lockAccount: 'Bloquear cuenta', lockMyAccount: 'Bloquear mi cuenta', unlockAccount: 'Desbloquear cuenta', + pendingUnlockRequest: 'Solicitud de desbloqueo pendiente', unlockTitle: 'Hemos recibido tu solicitud', unlockDescription: 'Revisaremos la cuenta para verificar que sea seguro desbloquearla y nos comunicaremos a través de Concierge si tenemos alguna pregunta.', findYourSituation: '¡La mayoría de los problemas no requieren bloquear tu cuenta! Busca tu situación a continuación:', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 82e02b34e51a..e80d464afbb8 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -2565,6 +2565,7 @@ const translations: TranslationDeepObject = { lockAccount: 'Verrouiller le compte', lockMyAccount: 'Verrouiller mon compte', unlockAccount: 'Déverrouiller le compte', + pendingUnlockRequest: 'Demande de déverrouillage en attente', findYourSituation: 'La plupart des problèmes ne nécessitent pas de verrouiller votre compte ! Trouvez votre situation ci-dessous :', lostCardOrCharges: 'Carte perdue ou frais inconnus : Annulez votre carte et contactez Concierge pour contester les transactions inconnues.', diff --git a/src/languages/it.ts b/src/languages/it.ts index 8cc08900cd31..07f9f112b536 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -2550,6 +2550,7 @@ const translations: TranslationDeepObject = { lockAccount: 'Blocca account', lockMyAccount: 'Blocca il mio account', unlockAccount: 'Sblocca conto', + pendingUnlockRequest: 'Richiesta di sblocco in sospeso', findYourSituation: 'La maggior parte dei problemi non richiede il blocco del tuo account! Trova la tua situazione qui sotto:', lostCardOrCharges: 'Carta smarrita o addebiti non riconosciuti: Annulla la tua carta e contatta Concierge per contestare le transazioni sconosciute.', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index f40ef600826f..6631ccc0bb2f 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -2534,6 +2534,7 @@ const translations: TranslationDeepObject = { lockAccount: 'アカウントをロック', lockMyAccount: 'アカウントをロックする', unlockAccount: 'アカウントのロック解除', + pendingUnlockRequest: 'ロック解除リクエスト保留中', findYourSituation: 'ほとんどの問題ではアカウントをロックする必要はありません。以下からご自身の状況をお探しください。', lostCardOrCharges: 'カードの紛失または身に覚えのない請求:カードを解約し、不明な取引について異議申し立てをするために Concierge へご連絡ください。', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 56739889131c..30ba8f28f482 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -2552,6 +2552,7 @@ const translations: TranslationDeepObject = { lockAccount: 'Account vergrendelen', lockMyAccount: 'Account vergrendelen', unlockAccount: 'Account ontgrendelen', + pendingUnlockRequest: 'Ontgrendelingsverzoek in behandeling', findYourSituation: 'Voor de meeste problemen hoef je je account niet te blokkeren. Zoek hieronder jouw situatie:', lostCardOrCharges: 'Kaart kwijt of onbekende kosten: Annuleer je kaart en neem contact op met Concierge om onbekende transacties te betwisten.', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 7fbe47811243..d81f707027af 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -2591,6 +2591,7 @@ const translations: TranslationDeepObject = { lockAccount: 'Zablokuj konto', lockMyAccount: 'Zablokuj moje konto', unlockAccount: 'Odblokuj konto', + pendingUnlockRequest: 'Oczekująca prośba o odblokowanie', findYourSituation: 'Większość problemów nie wymaga blokowania konta! Znajdź swoją sytuację poniżej:', lostCardOrCharges: 'Zgubiona karta lub nieznane obciążenia: Anuluj swoją kartę i skontaktuj się z Concierge, żeby zakwestionować nieznane transakcje.', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 374f235d7f7b..59ad46c5ecd0 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -2544,6 +2544,7 @@ const translations: TranslationDeepObject = { lockAccount: 'Bloquear conta', lockMyAccount: 'Bloquear minha conta', unlockAccount: 'Desbloquear conta', + pendingUnlockRequest: 'Solicitação de desbloqueio pendente', findYourSituation: 'A maioria dos problemas não exige bloquear sua conta! Encontre sua situação abaixo:', lostCardOrCharges: 'Cartão perdido ou cobranças desconhecidas: Cancele seu cartão e contate o Concierge para contestar transações desconhecidas.', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 49d479d6153d..078ad036b07b 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -2469,6 +2469,7 @@ const translations: TranslationDeepObject = { lockAccount: '锁定账户', lockMyAccount: '锁定我的账户', unlockAccount: '解锁账户', + pendingUnlockRequest: '解锁请求待处理', findYourSituation: '大多数问题都不需要锁定你的账户!请在以下列表中找到与你相符的情况:', lostCardOrCharges: '卡片遗失或出现不明扣款:请挂失您的卡片,并联系 Concierge 申诉未知交易。', diff --git a/src/libs/DomainUtils.ts b/src/libs/DomainUtils.ts index 31883529eba3..4811ab1ec11c 100644 --- a/src/libs/DomainUtils.ts +++ b/src/libs/DomainUtils.ts @@ -137,7 +137,9 @@ function hasDomainMemberDetailsErrors(memberDetailsErrors: DomainMemberErrors): return ( !isEmptyObject(memberDetailsErrors?.vacationDelegateErrors) || !isEmptyObject(memberDetailsErrors?.twoFactorAuthExemptEmailsError) || - !isEmptyObject(memberDetailsErrors?.changeDomainSecurityGroupErrors) + !isEmptyObject(memberDetailsErrors?.changeDomainSecurityGroupErrors) || + !isEmptyObject(memberDetailsErrors?.lockAccountErrors) || + !isEmptyObject(memberDetailsErrors?.unlockAccountErrors) ); } @@ -163,6 +165,7 @@ function getMemberCustomRowProps(accountID: number, domainPendingActions: Domain const mergedErrors = { ...getLatestError(accountIDErrors?.errors), ...getLatestError(accountIDErrors?.lockAccountErrors), + ...getLatestError(accountIDErrors?.unlockAccountErrors), ...getLatestError({...accountIDErrors?.changeDomainSecurityGroupErrors, ...emailErrors?.changeDomainSecurityGroupErrors}), ...getLatestError(emailErrors?.errors), ...getLatestError(accountIDErrors?.vacationDelegateErrors), diff --git a/src/libs/actions/Domain.ts b/src/libs/actions/Domain.ts index 6e8052df45b5..2736a5c9fa64 100644 --- a/src/libs/actions/Domain.ts +++ b/src/libs/actions/Domain.ts @@ -1422,10 +1422,16 @@ function clearDomainMemberError(domainAccountID: number, accountID: number, emai }, }); + // Keep the lock and unlock pending actions: those requests may still be queued, and dropping them would let the user send a duplicate Onyx.merge(`${ONYXKEYS.COLLECTION.DOMAIN_PENDING_ACTIONS}${domainAccountID}`, { member: { [email]: null, - [accountID]: null, + [accountID]: { + pendingAction: null, + vacationDelegate: null, + twoFactorAuthExemptEmails: null, + changeDomainSecurityGroup: null, + }, }, }); } diff --git a/src/libs/actions/User.ts b/src/libs/actions/User.ts index 7673258ab18e..8acada57bae8 100644 --- a/src/libs/actions/User.ts +++ b/src/libs/actions/User.ts @@ -1462,6 +1462,7 @@ function lockAccount(currentUserAccountID: number, accountID: number | undefined let domainSuccessData: DomainOnyxUpdate[] = []; if (accountID && domainAccountID) { const userLockKey = `${CONST.DOMAIN.PRIVATE_LOCKED_ACCOUNT_PREFIX}${accountID}`; + const userUnlockRequestedKey = `${CONST.DOMAIN.PRIVATE_UNLOCK_REQUESTED_PREFIX}${accountID}`; domainOptimisticData = [ { @@ -1469,7 +1470,9 @@ function lockAccount(currentUserAccountID: number, accountID: number | undefined key: `${ONYXKEYS.COLLECTION.DOMAIN}${domainAccountID}`, value: { [userLockKey]: true, - } as PrefixedRecord, + // Clear any unlock request left over from a previous lock so the new lock doesn't show as pending unlock + [userUnlockRequestedKey]: false, + } as PrefixedRecord, }, { onyxMethod: Onyx.METHOD.MERGE, @@ -1599,12 +1602,103 @@ function lockAccount(currentUserAccountID: number, accountID: number | undefined return API.makeRequestWithSideEffects(SIDE_EFFECT_REQUEST_COMMANDS.LOCK_ACCOUNT, params, {optimisticData, successData, failureData}); } -function requestUnlockAccount(accountID: number) { +function requestUnlockAccount(accountID: number, domainAccountID?: number, domainName?: string) { + let optimisticData: DomainOnyxUpdate[] = []; + let successData: DomainOnyxUpdate[] = []; + let failureData: DomainOnyxUpdate[] = []; + if (domainAccountID) { + const userUnlockRequestedKey = `${CONST.DOMAIN.PRIVATE_UNLOCK_REQUESTED_PREFIX}${accountID}`; + + // Only mark the request as pending while it's queued; the unlock-requested flag is set once the server receives it + optimisticData = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.DOMAIN_PENDING_ACTIONS}${domainAccountID}`, + value: { + member: { + [accountID]: { + unlockAccount: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + }, + }, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.DOMAIN_ERRORS}${domainAccountID}`, + value: { + memberErrors: { + [accountID]: { + unlockAccountErrors: null, + }, + }, + }, + }, + ]; + + successData = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.DOMAIN}${domainAccountID}`, + value: { + [userUnlockRequestedKey]: true, + } as PrefixedRecord, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.DOMAIN_PENDING_ACTIONS}${domainAccountID}`, + value: { + member: { + [accountID]: { + unlockAccount: null, + }, + }, + }, + }, + ]; + + failureData = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.DOMAIN_PENDING_ACTIONS}${domainAccountID}`, + value: { + member: { + [accountID]: { + unlockAccount: null, + }, + }, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.DOMAIN_ERRORS}${domainAccountID}`, + value: { + memberErrors: { + [accountID]: { + unlockAccountErrors: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage'), + }, + }, + }, + }, + ]; + } + const params: LockAccountParams = { accountID, + domainAccountID, + domainName, }; - API.write(WRITE_COMMANDS.REQUEST_UNLOCK_ACCOUNT, params); + API.write(WRITE_COMMANDS.REQUEST_UNLOCK_ACCOUNT, params, {optimisticData, successData, failureData}); +} + +function clearUnlockAccountError(domainAccountID: number, accountID: number) { + Onyx.merge(`${ONYXKEYS.COLLECTION.DOMAIN_ERRORS}${domainAccountID}`, { + memberErrors: { + [accountID]: { + unlockAccountErrors: null, + }, + }, + }); } type RespondToProactiveAppReviewParams = { @@ -2112,6 +2206,7 @@ export { clearBetaOverrides, lockAccount, requestUnlockAccount, + clearUnlockAccountError, respondToProactiveAppReview, verifyAddSecondaryLoginCode, updateIsVerifiedValidateActionCode, diff --git a/src/pages/domain/Members/DomainMemberDetailsPage.tsx b/src/pages/domain/Members/DomainMemberDetailsPage.tsx index b012ff301f7b..9d6bf52312fd 100644 --- a/src/pages/domain/Members/DomainMemberDetailsPage.tsx +++ b/src/pages/domain/Members/DomainMemberDetailsPage.tsx @@ -23,7 +23,7 @@ import { setTwoFactorAuthExemptEmailForDomain, } from '@libs/actions/Domain'; import {clearDraftValues} from '@libs/actions/FormActions'; -import {requestUnlockAccount} from '@libs/actions/User'; +import {clearUnlockAccountError, requestUnlockAccount} from '@libs/actions/User'; import {getLatestError} from '@libs/ErrorUtils'; import Navigation from '@navigation/Navigation'; @@ -45,7 +45,7 @@ import type {Domain} from '@src/types/onyx'; import type {OnyxEntry} from 'react-native-onyx'; import {requiresTwoFactorAuthSelector} from '@selectors/Account'; -import {accountLockSelector, domainMemberSettingsSelector, domainNameSelector, selectSecurityGroupForAccount, vacationDelegateSelector} from '@selectors/Domain'; +import {accountLockSelector, domainMemberSettingsSelector, domainNameSelector, selectSecurityGroupForAccount, unlockRequestedSelector, vacationDelegateSelector} from '@selectors/Domain'; import React, {useCallback, useState} from 'react'; import {View} from 'react-native'; @@ -55,7 +55,7 @@ function DomainMemberDetailsPage({route}: DomainMemberDetailsPageProps) { const {domainAccountID, accountID} = route.params; const styles = useThemeStyles(); const {translate} = useLocalize(); - const icons = useMemoizedLazyExpensifyIcons(['RemoveMembers', 'Flag', 'Unlock', 'CircularArrowBackwards']); + const icons = useMemoizedLazyExpensifyIcons(['RemoveMembers', 'Flag', 'Unlock', 'Hourglass', 'CircularArrowBackwards']); const [isModalVisible, setIsModalVisible] = useState(false); const [shouldForceCloseAccount, setShouldForceCloseAccount] = useState(); // We need to use isSmallScreenWidth here because the DecisionModal is opening from RHP and ShouldUseNarrowLayout layout will not work in this place @@ -88,6 +88,8 @@ function DomainMemberDetailsPage({route}: DomainMemberDetailsPageProps) { const memberLogin = personalDetails?.login ?? ''; const [isAccountLocked] = useOnyx(`${ONYXKEYS.COLLECTION.DOMAIN}${domainAccountID}`, {selector: accountLockSelector(accountID)}); + const [isUnlockRequested] = useOnyx(`${ONYXKEYS.COLLECTION.DOMAIN}${domainAccountID}`, {selector: unlockRequestedSelector(accountID)}); + const unlockAccountPendingAction = domainPendingActions?.member?.[accountID]?.unlockAccount; const lockDomainError = getLatestError(domainErrors?.memberErrors?.[accountID]?.lockAccountErrors); const lockDomainErrorMessage = Object.values(lockDomainError).at(0); @@ -136,7 +138,10 @@ function DomainMemberDetailsPage({route}: DomainMemberDetailsPageProps) { ); const showUnlockAccountModal = () => { - requestUnlockAccount(accountID); + // Don't send another request while one is already queued or has been received + if (!isUnlockRequested && !unlockAccountPendingAction) { + requestUnlockAccount(accountID, domainAccountID, domainName); + } showConfirmModal({ title: translate('lockAccountPage.unlockTitle'), prompt: translate('lockAccountPage.unlockDescription'), @@ -211,12 +216,19 @@ function DomainMemberDetailsPage({route}: DomainMemberDetailsPageProps) { )} {isAccountLocked ? ( - + errorRowStyles={styles.mh5} + pendingAction={unlockAccountPendingAction} + errors={getLatestError(domainErrors?.memberErrors?.[accountID]?.unlockAccountErrors)} + onClose={() => clearUnlockAccountError(domainAccountID, accountID)} + > + + ) : ( ): DomainSecurityGroupWithID[] const accountLockSelector = (accountID: number) => (domain: OnyxEntry) => domain?.[`${CONST.DOMAIN.PRIVATE_LOCKED_ACCOUNT_PREFIX}${accountID}`]; +const unlockRequestedSelector = (accountID: number) => (domain: OnyxEntry) => domain?.[`${CONST.DOMAIN.PRIVATE_UNLOCK_REQUESTED_PREFIX}${accountID}`]; + function selectRestrictedPrimaryPolicyID(groupID?: string) { return (domain: OnyxEntry): string | undefined => { const targetGroup = groupsSelector(domain)?.find((g) => g.id === groupID); @@ -341,6 +343,7 @@ export { groupsSelector, vacationDelegateSelector, accountLockSelector, + unlockRequestedSelector, isAdminSelector, domainAccessSelector, hasPendingAdminshipRequestSelector, diff --git a/src/types/onyx/Domain.ts b/src/types/onyx/Domain.ts index fd587885cf1d..a3854380c06d 100644 --- a/src/types/onyx/Domain.ts +++ b/src/types/onyx/Domain.ts @@ -53,7 +53,8 @@ type Domain = OnyxCommon.OnyxValueWithOfflineFeedback<{ PrefixedRecord & PrefixedRecord & PrefixedRecord & - PrefixedRecord; + PrefixedRecord & + PrefixedRecord; /** Model of SAML metadata */ type SamlMetadata = { diff --git a/src/types/onyx/DomainErrors.ts b/src/types/onyx/DomainErrors.ts index 990e553fd19e..98b8633e8781 100644 --- a/src/types/onyx/DomainErrors.ts +++ b/src/types/onyx/DomainErrors.ts @@ -28,6 +28,12 @@ type DomainMemberErrors = { * Errors related to specific domain member lock account status. */ lockAccountErrors?: OnyxCommon.Errors; + + /** + * Errors related to requesting an unlock for a specific domain member account. + */ + unlockAccountErrors?: OnyxCommon.Errors; + /** * Errors related to changing domain member group. */ diff --git a/src/types/onyx/DomainPendingActions.ts b/src/types/onyx/DomainPendingActions.ts index 6b2b2a0cfdd9..a8ad66f65881 100644 --- a/src/types/onyx/DomainPendingActions.ts +++ b/src/types/onyx/DomainPendingActions.ts @@ -31,6 +31,11 @@ type DomainMemberPendingActions = { */ lockAccount?: OnyxCommon.PendingAction; + /** + * Pending action for a domain member unlock account request. + */ + unlockAccount?: OnyxCommon.PendingAction; + changeDomainSecurityGroup?: OnyxCommon.PendingAction; } & GeneralDomainMemberPendingAction; diff --git a/tests/actions/DomainTest.ts b/tests/actions/DomainTest.ts index 0c290a7a4419..f0f412db6aba 100644 --- a/tests/actions/DomainTest.ts +++ b/tests/actions/DomainTest.ts @@ -1087,6 +1087,36 @@ describe('actions/Domain', () => { }); }); + it('clearDomainMemberError - keeps queued lock and unlock pending actions', async () => { + const domainAccountID = 123; + const accountID = 456; + const email = 'user@test.com'; + + // Given a member with a failed vacation delegate update while an unlock request is still queued + await Onyx.set(`${ONYXKEYS.COLLECTION.DOMAIN_PENDING_ACTIONS}${domainAccountID}` as const, { + member: { + [accountID]: { + vacationDelegate: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + lockAccount: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + unlockAccount: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + }, + }, + }); + + // When the user dismisses the error from the members list + clearDomainMemberError(domainAccountID, accountID, email, ''); + + // Then the vacation delegate pending action is cleared, but the lock and unlock ones stay so a duplicate request can't be sent + await TestHelper.getOnyxData({ + key: `${ONYXKEYS.COLLECTION.DOMAIN_PENDING_ACTIONS}${domainAccountID}`, + callback: (pendingActions) => { + expect(pendingActions?.member?.[accountID]?.vacationDelegate).toBeFalsy(); + expect(pendingActions?.member?.[accountID]?.lockAccount).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE); + expect(pendingActions?.member?.[accountID]?.unlockAccount).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE); + }, + }); + }); + it('clearDomainMemberError - clears the placeholder personal details for an optimistic account', async () => { const domainAccountID = 123; const email = 'user@test.com'; diff --git a/tests/actions/UserTest.ts b/tests/actions/UserTest.ts index fa52414a556a..11bc444d98eb 100644 --- a/tests/actions/UserTest.ts +++ b/tests/actions/UserTest.ts @@ -971,6 +971,7 @@ describe('actions/User', () => { const domainAccountID = 200; const domainName = 'expensify.com'; const userLockKey = `${CONST.DOMAIN.PRIVATE_LOCKED_ACCOUNT_PREFIX}${accountID}`; + const userUnlockRequestedKey = `${CONST.DOMAIN.PRIVATE_UNLOCK_REQUESTED_PREFIX}${accountID}`; it('should include correct domain optimistic, success, and failure data', async () => { UserActions.lockAccount(currentUserAccountID, accountID, domainAccountID, domainName); @@ -981,11 +982,11 @@ describe('actions/User', () => { const failureData = onyxData?.failureData ?? []; const successData = onyxData?.successData ?? []; - // Optimistic: sets lock flag to true + // Optimistic: sets lock flag to true and clears any unlock request from a previous lock expect(optimisticData.find((update) => update.key === `${ONYXKEYS.COLLECTION.DOMAIN}${domainAccountID}`)).toEqual({ onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.DOMAIN}${domainAccountID}`, - value: {[userLockKey]: true}, + value: {[userLockKey]: true, [userUnlockRequestedKey]: false}, }); // Optimistic: sets pending action to UPDATE @@ -1055,7 +1056,65 @@ describe('actions/User', () => { UserActions.requestUnlockAccount(accountID); await waitForBatchedUpdates(); - expect(mockAPI.write).toHaveBeenCalledWith(WRITE_COMMANDS.REQUEST_UNLOCK_ACCOUNT, {accountID}); + expect(mockAPI.write).toHaveBeenCalledWith(WRITE_COMMANDS.REQUEST_UNLOCK_ACCOUNT, {accountID}, {optimisticData: [], successData: [], failureData: []}); + }); + + it('should only mark the unlock as requested once the server receives it when called from a domain', async () => { + // Given a domain admin requesting an unlock for a locked domain member + const accountID = 123456; + const domainAccountID = 200; + const domainName = 'expensify.com'; + const userUnlockRequestedKey = `${CONST.DOMAIN.PRIVATE_UNLOCK_REQUESTED_PREFIX}${accountID}`; + + // When the unlock is requested + UserActions.requestUnlockAccount(accountID, domainAccountID, domainName); + await waitForBatchedUpdates(); + + // Then the domain identifiers are sent so the backend knows which domain the request came from + expect(mockAPI.write).toHaveBeenCalledWith(WRITE_COMMANDS.REQUEST_UNLOCK_ACCOUNT, {accountID, domainAccountID, domainName}, expect.anything()); + + const onyxData = mockAPI.write.mock.calls.at(0)?.[2]; + const optimisticData = onyxData?.optimisticData ?? []; + const successData = onyxData?.successData ?? []; + const failureData = onyxData?.failureData ?? []; + const pendingActionsKey = `${ONYXKEYS.COLLECTION.DOMAIN_PENDING_ACTIONS}${domainAccountID}`; + + // Then the queued request is only marked with a pending action, so offline it doesn't look like the server received it + expect(optimisticData.find((update) => update.key === `${ONYXKEYS.COLLECTION.DOMAIN}${domainAccountID}`)).toBeUndefined(); + expect(optimisticData.find((update) => update.key === pendingActionsKey)).toEqual({ + onyxMethod: Onyx.METHOD.MERGE, + key: pendingActionsKey, + value: {member: {[accountID]: {unlockAccount: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}}}, + }); + + // Then a successful request marks the member as having a pending unlock request and clears the pending action + expect(successData.find((update) => update.key === `${ONYXKEYS.COLLECTION.DOMAIN}${domainAccountID}`)).toEqual({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.DOMAIN}${domainAccountID}`, + value: {[userUnlockRequestedKey]: true}, + }); + expect(successData.find((update) => update.key === pendingActionsKey)).toEqual({ + onyxMethod: Onyx.METHOD.MERGE, + key: pendingActionsKey, + value: {member: {[accountID]: {unlockAccount: null}}}, + }); + + // Then a failed request clears the pending action so the admin can try again + expect(failureData.find((update) => update.key === `${ONYXKEYS.COLLECTION.DOMAIN}${domainAccountID}`)).toBeUndefined(); + expect(failureData.find((update) => update.key === pendingActionsKey)).toEqual({ + onyxMethod: Onyx.METHOD.MERGE, + key: pendingActionsKey, + value: {member: {[accountID]: {unlockAccount: null}}}, + }); + + // Then a failed request surfaces an error on the member + expect(failureData.find((update) => update.key === `${ONYXKEYS.COLLECTION.DOMAIN_ERRORS}${domainAccountID}`)).toEqual( + expect.objectContaining({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.DOMAIN_ERRORS}${domainAccountID}`, + value: {memberErrors: {[accountID]: {unlockAccountErrors: anyObject}}}, + }), + ); }); }); diff --git a/tests/unit/DomainSelectorsTest.ts b/tests/unit/DomainSelectorsTest.ts index 3e9b8c5a2ffa..b4a55a419d69 100644 --- a/tests/unit/DomainSelectorsTest.ts +++ b/tests/unit/DomainSelectorsTest.ts @@ -26,6 +26,7 @@ import { selectRestrictedPrimaryPolicyID, selectSecurityGroupForAccount, technicalContactSettingsSelector, + unlockRequestedSelector, vacationDelegateSelector, } from '@selectors/Domain'; @@ -1059,6 +1060,26 @@ describe('domainSelectors', () => { }); }); + describe('unlockRequestedSelector', () => { + it('Should return the unlock requested state for the given account ID', () => { + // Given a domain where an unlock was requested for one member + const accountID = 123; + const domain = createDomainFixture({boundaryEntries: {[`${CONST.DOMAIN.PRIVATE_UNLOCK_REQUESTED_PREFIX}${accountID}`]: true}}); + + // When reading the unlock requested state for that member and another member + // Then only the member with the request is reported as pending + expect(unlockRequestedSelector(accountID)(domain)).toBe(true); + expect(unlockRequestedSelector(456)(domain)).toBeUndefined(); + }); + + it('Should return undefined when the domain object is undefined', () => { + // Given the domain has not loaded yet + // When reading the unlock requested state + // Then nothing is reported as pending + expect(unlockRequestedSelector(123)(undefined)).toBeUndefined(); + }); + }); + describe('selectRestrictedPrimaryPolicyID', () => { const makeGroup = (enableRestrictedPrimaryPolicy: boolean, restrictedPrimaryPolicyID?: string): DomainSecurityGroup => createFixture(domainSecurityGroupFixture, {enableRestrictedPrimaryPolicy, restrictedPrimaryPolicyID}); diff --git a/tests/unit/DomainUtilsTest.ts b/tests/unit/DomainUtilsTest.ts index 9607003e5683..45082b4f3484 100644 --- a/tests/unit/DomainUtilsTest.ts +++ b/tests/unit/DomainUtilsTest.ts @@ -357,6 +357,28 @@ describe('DomainUtils', () => { }; expect(hasDomainMembersErrors(domainErrors)).toBe(true); }); + + it('should return true when a member has only a lock account error', () => { + // Given a member whose lock request failed, which is stored outside the generic `errors` field + const domainErrors: DomainErrors = { + errors: {}, + memberErrors: {user1: {errors: {}, lockAccountErrors: {timestamp1: 'Lock error'}}}, + }; + + // When / Then the Domain members row and the Domains list should still show the red dot + expect(hasDomainMembersErrors(domainErrors)).toBe(true); + }); + + it('should return true when a member has only an unlock account error', () => { + // Given a member whose unlock request failed, which is stored outside the generic `errors` field + const domainErrors: DomainErrors = { + errors: {}, + memberErrors: {user1: {errors: {}, unlockAccountErrors: {timestamp1: 'Unlock error'}}}, + }; + + // When / Then the Domain members row and the Domains list should still show the red dot + expect(hasDomainMembersErrors(domainErrors)).toBe(true); + }); }); describe('hasDomainMembersSettingsErrors', () => {