Skip to content

[$175] Member Security - UI does not reflect that the unlock request was submitted. #102734

Open

Description

@applause-bot

If you haven’t already, check out our contributing guidelines for onboarding. To join our Slack channel, fill out this form.


Version Number: v9.5.0-0
Reproducible in staging?: Yes
Reproducible in production?: Yes
If this was caught during regression testing, add the test name, ID and link from BrowserStack: https://test-management.browserstack.com/projects/2219752/folder/53678307/test-cases/94420076
Email or phone of affected tester (no customers): [email protected]
Issue reported by: Applause Internal Team
Bug source: Regression TC Execution
Device used: iOS , Windows 11, Android
App Component: Other

Action Performed:

Preconditions
1.Sign into NewDot as a domain admin. (use [email protected] account)
2.Add a new member to test this scenario and don't lock any existing members

Steps:

  1. Navigate to Workspace > Domains [Your Domain] > Domain Members and click on a member row
  2. Click Report suspicious activity
  3. Review the warning message and Verify it states: "Are you sure? This will lock [email]'s account..."
  4. Click the red Report suspicious activity button at the bottom
  5. Click Lock account to confirm
  6. Click Unlock account
  7. Click Got it

Expected Result:

The modal closes and the UI reflects that the unlock request has been submitted

Actual Result:

UI does not reflect that the unlock request was submitted.

Workaround:

Unknown

Platforms:

  • Android: App
  • Android: mWeb Chrome
  • iOS: App
  • iOS: mWeb Safari
  • iOS: mWeb Chrome
  • Windows: Chrome
  • MacOS: Chrome Safari

Screenshots/Videos

Bug7272332_1790838590433.Repro_on_Android.mp4

View all open jobs on GitHub

Applause Internal Information
Exported by: Julia Ponikarchuk
Bug ID: 7272332

Upwork Automation - Do Not Edit
Issue OwnerCurrent Issue Owner: @marufsharifi

Activity

  1. added
    BugSomething is broken. Auto assigns a BugZero manager.
    on Oct 1, 2026
  2. MelvinBot commented on Oct 1, 2026

    @MelvinBot
    Contributor

    Proposal

    Please re-state the problem that we are trying to solve in this issue.

    A domain admin taps Unlock account for a locked member and then taps Got it. After that, the member details page looks the same as before. Nothing shows that the unlock request was sent.

    What is the root cause of that problem?

    The App sends the request, but it never records it. The UI has no "unlock requested" state to show.

    • showUnlockAccountModal calls requestUnlockAccount(accountID), then opens a modal that only gives information. Got it just closes the modal.
    • requestUnlockAccount makes a plain API.write call with no optimistic, success, or failure Onyx data, so Onyx doesn't change.
    • The page decides what to show from one true/false lock flag. That flag stays true until the security review finishes, so the Unlock account row stays the same. The admin can also tap it again and send duplicate requests.
    Code references

    This matches the original design. [Release 3.3] [Domain Control] Report suspicious activity only tested "Click Unlock account → Dismiss modal". [Test cases] Domain Control - Release 3 expects only "The modal closes. The unlock request is submitted." The wording "UI reflects that the unlock request has been submitted" appears only in the BrowserStack test case.

    What changes do you think we should make in order to solve the problem?

    First, product should decide whether a visible "Unlock requested" state is wanted. If it is not, this is expected behavior. Then we should update BrowserStack test case 94420076, step 13, to "modal closes" and close this issue.

    If product wants the state, follow the pattern already used to unlock bank accounts (BankAccounts.ts:1767-1786):

    1. Backend (outside App): store an "unlock requested" marker for the member and return it with the domain data. The App can't make this state survive a reload or show on other devices without it. An internal engineer would need to handle this part.
    2. App, requestUnlockAccount: set that marker in optimisticData. Add failureData that rolls it back and shows an error. Send domainAccountID and domainName too, the same way lockAccount does.
    3. App, DomainMemberDetailsPage: when the marker is set, show "Unlock requested" help text on the row. If the admin taps the row again, reopen the info modal but don't call the API again.
    4. Add the new copy to all src/languages/* files.

    What alternative solutions did you explore? (Optional)

    App-only local flag: set a flag that exists only on the client, with no backend change. The flag would disappear after a reload and wouldn't show on other devices, so I don't recommend relying on it alone.


    Next Steps for Contributor+ team:
    To accept: @MelvinBot implement [this](https://github.com/Expensify/App/issues/102734) to create a draft PR.
    To refine: @MelvinBot <your feedback>
    To reject: Explain why you are rejecting Melvin's proposal.


    view run

  3. added
    ExternalAdded to denote the issue can be worked on by a contributor
    on Oct 1, 2026
  4. melvin-bot commented on Oct 1, 2026

    @melvin-bot

    Triggered auto assignment to Contributor-plus team member for initial proposal review - @thesahindia (External)

  5. changed the title [-]Member Security - UI does not reflect that the unlock request was submitted.[/-] [+][$175] Member Security - UI does not reflect that the unlock request was submitted.[/+] on Oct 1, 2026
  6. melvin-bot commented on Oct 1, 2026

    @melvin-bot
  7. camesenin commented on Oct 1, 2026

    @camesenin

    Proposal

    Please re-state the problem that we are trying to solve in this issue.

    When a domain admin clicks Unlock account for a locked member in Workspace > Domains > Domain Members > [Member] and dismisses the info modal by clicking Got it, the UI does not indicate that the unlock request was submitted. The Unlock account action row remains unchanged and actionable, allowing the admin to repeatedly trigger unlock requests.


    What is the root cause of that problem?

    In src/pages/domain/Members/DomainMemberDetailsPage.tsx:

    1. showUnlockAccountModal calls requestUnlockAccount(accountID) and then presents an informational showConfirmModal.
    2. In src/libs/actions/User.ts, requestUnlockAccount only accepts accountID and issues a plain API.write(WRITE_COMMANDS.REQUEST_UNLOCK_ACCOUNT, params) without any optimistic, success, or failure Onyx updates:
    function requestUnlockAccount(accountID: number) {
        const params: LockAccountParams = {
            accountID,
        };
    
        API.write(WRITE_COMMANDS.REQUEST_UNLOCK_ACCOUNT, params);
    }
    1. In DomainMemberDetailsPage.tsx, the UnlockAccount menu item is not wrapped in OfflineWithFeedback, does not track any pendingAction (unlike changeDomainSecurityGroup and vacationDelegate), and only evaluates isAccountLocked (which remains true until internal security review clears the lock). Consequently, there is no UI indicator (such as pending status or feedback) indicating that the unlock request is pending review.

    What changes do you think we should make in order to solve the problem?

    Solution 1: If Product / Contributor+ team desires a persistent pending UI state

    1. In src/libs/actions/User.ts:
      Update requestUnlockAccount to accept domainAccountID?: number and pass optimistic, success, and failure data:

      function requestUnlockAccount(accountID: number, domainAccountID?: number) {
          const params: LockAccountParams = {
              accountID,
          };
      
          const optimisticData: DomainOnyxUpdate[] = domainAccountID ? [
              {
                  onyxMethod: Onyx.METHOD.MERGE,
                  key: `${ONYXKEYS.COLLECTION.DOMAIN_PENDING_ACTIONS}${domainAccountID}`,
                  value: {
                      member: {
                          [accountID]: {
                              unlockAccount: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE,
                          },
                      },
                  },
              },
          ] : [];
      
          const successData: DomainOnyxUpdate[] = domainAccountID ? [
              {
                  onyxMethod: Onyx.METHOD.MERGE,
                  key: `${ONYXKEYS.COLLECTION.DOMAIN_PENDING_ACTIONS}${domainAccountID}`,
                  value: {
                      member: {
                          [accountID]: {
                              unlockAccount: null,
                          },
                      },
                  },
              },
          ] : [];
      
          const failureData: DomainOnyxUpdate[] = domainAccountID ? [
              {
                  onyxMethod: Onyx.METHOD.MERGE,
                  key: `${ONYXKEYS.COLLECTION.DOMAIN_PENDING_ACTIONS}${domainAccountID}`,
                  value: {
                      member: {
                          [accountID]: {
                              unlockAccount: null,
                          },
                      },
                  },
              },
          ] : [];
      
          API.write(WRITE_COMMANDS.REQUEST_UNLOCK_ACCOUNT, params, {optimisticData, successData, failureData});
      }
    2. In src/pages/domain/Members/DomainMemberDetailsPage.tsx:

      • Pass domainAccountID to requestUnlockAccount(accountID, domainAccountID) in showUnlockAccountModal.
      • Wrap the UnlockAccount MenuItemAction inside OfflineWithFeedback:
      {isAccountLocked ? (
          <OfflineWithFeedback
              pendingAction={domainPendingActions?.member?.[accountID]?.unlockAccount}
              errors={getLatestError(domainErrors?.memberErrors?.[accountID]?.unlockAccountErrors)}
              onClose={() => clearUnlockAccountError(domainAccountID, accountID)}
          >
              <MenuItemAction
                  key="UnlockAccount"
                  title={translate('lockAccountPage.unlockAccount')}
                  icon={icons.Unlock}
                  onPress={showUnlockAccountModal}
              />
          </OfflineWithFeedback>
      ) : (

    Solution 2: If the Confirmation Modal was already the intended Product confirmation

    In the original implementation PR #78372 ([Domain Control] Report suspicious activity) and test cases issue #84809, the intentional flow was:

    • Tapping Unlock account immediately shows the confirmation modal stating:
      • Title: "We’ve received your request"
      • Body: "We’ll review the account to verify it’s safe to unlock and reach out via Concierge with any questions."
    • Clicking Got it dismisses the modal.
      If Product confirms that this modal was the intended confirmation feedback for the domain admin, then BrowserStack TC 94420076 (step 13: "Verify the modal closes and the UI reflects that the unlock request has been submitted") should be updated to "Verify the modal closes", and this issue closed as NAB.

    What alternative solutions did you explore? (Optional)

    • Local component state flag: Keeping an in-memory isUnlockRequested state in DomainMemberDetailsPage. This does not persist on page reload or across devices, so utilizing Onyx DOMAIN_PENDING_ACTIONS (Solution 1) is the standard NewDot pattern.
  8. bonnhatnguyen commented on Oct 1, 2026

    @bonnhatnguyen

    Proposal for Issue #102734: [$175] Member Security - UI does not reflect that the unlock request was submitted.

    Contributor Details

    • Contributor: @bonnhatnguyen
    • Email: [email protected]
    • Upwork: https://www.upwork.com/freelancers/~0139ed4098bdc50182

    Proposal

    Please re-state the problem that we are trying to solve in this issue.

    When a domain admin clicks Unlock account for a locked domain member in Workspace > Domains > [Domain] > Domain Members > [Member] and closes the confirmation modal by clicking Got it, the UI does not indicate that the unlock request was submitted. The Unlock account button remains unchanged and interactive, allowing the admin to repeatedly trigger duplicate unlock API requests.


    What is the root cause of that problem?

    1. In src/libs/actions/User.ts, requestUnlockAccount(accountID) only sends accountID via API.write(WRITE_COMMANDS.REQUEST_UNLOCK_ACCOUNT, params) with no optimisticData, successData, or failureData. It also omits domainAccountID and domainName, which are defined in LockAccountParams:
      function requestUnlockAccount(accountID: number) {
          const params: LockAccountParams = {
              accountID,
          };
      
          API.write(WRITE_COMMANDS.REQUEST_UNLOCK_ACCOUNT, params);
      }
    2. In src/pages/domain/Members/DomainMemberDetailsPage.tsx, showUnlockAccountModal unconditionally executes requestUnlockAccount(accountID) immediately on tap before showing the informational modal:
      const showUnlockAccountModal = () => {
          requestUnlockAccount(accountID);
          showConfirmModal({
              title: translate('lockAccountPage.unlockTitle'),
              prompt: translate('lockAccountPage.unlockDescription'),
              confirmText: translate('common.buttonConfirm'),
              shouldShowCancelButton: false,
          });
      };
    3. The UnlockAccount menu item is not wrapped in OfflineWithFeedback, does not track any pendingAction (unlike other member actions like changeDomainSecurityGroup), and does not provide any visual indication (e.g. description/helpText or pending status) indicating that an unlock request has been submitted. Because unlocking an account involves external security/risk review, isAccountLocked remains true, leaving the button visually active and re-triggerable.

    (Note on context: In the original PR #78372 [Domain Control] Report suspicious activity, the flow was designed to acknowledge the action via the modal We’ve received your request. However, BrowserStack TC 94420076 explicitly expects the page UI itself to reflect the submitted state).


    What changes do you think we should make in order to solve the problem?

    1. Update requestUnlockAccount in src/libs/actions/User.ts:

    Update the signature to accept (accountID: number, domainAccountID?: number, domainName?: string) and provide optimistic, failure, and error-clearing data:

    function requestUnlockAccount(accountID: number, domainAccountID?: number, domainName?: string) {
        const params: LockAccountParams = {
            accountID,
            domainAccountID,
            domainName,
        };
    
        let domainOptimisticData: DomainOnyxUpdate[] = [];
        let domainFailureData: DomainOnyxUpdate[] = [];
        if (domainAccountID) {
            domainOptimisticData = [
                {
                    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,
                            },
                        },
                    },
                },
            ];
    
            domainFailureData = [
                {
                    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('lockAccountPage.failedToUnlockAccount'),
                            },
                        },
                    },
                },
            ];
        }
    
        API.write(WRITE_COMMANDS.REQUEST_UNLOCK_ACCOUNT, params, {
            optimisticData: domainOptimisticData,
            failureData: domainFailureData,
        });
    }

    2. Add clearUnlockAccountError in src/libs/actions/User.ts (and export it):

    function clearUnlockAccountError(domainAccountID: number, accountID: number) {
        Onyx.merge(`${ONYXKEYS.COLLECTION.DOMAIN_ERRORS}${domainAccountID}`, {
            memberErrors: {
                [accountID]: {
                    unlockAccountErrors: null,
                },
            },
        });
    }

    3. Update DomainMemberDetailsPage.tsx:

    • Pass domainAccountID and domainName to requestUnlockAccount.
    • Prevent duplicate API writes if unlockAccount pending action is already active. If already submitted, simply show the informational showConfirmModal:
    const isUnlockPending = domainPendingActions?.member?.[accountID]?.unlockAccount === CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE;
    const unlockAccountErrors = getLatestError(domainErrors?.memberErrors?.[accountID]?.unlockAccountErrors);
    
    const showUnlockAccountModal = () => {
        if (!isUnlockPending) {
            requestUnlockAccount(accountID, domainAccountID, domainName);
        }
        showConfirmModal({
            title: translate('lockAccountPage.unlockTitle'),
            prompt: translate('lockAccountPage.unlockDescription'),
            confirmText: translate('common.buttonConfirm'),
            shouldShowCancelButton: false,
        });
    };
    • Wrap UnlockAccount in OfflineWithFeedback and render appropriate help/subtitle text indicating pending submission:
    {isAccountLocked ? (
        <OfflineWithFeedback
            pendingAction={domainPendingActions?.member?.[accountID]?.unlockAccount}
            errors={unlockAccountErrors}
            onClose={() => clearUnlockAccountError(domainAccountID, accountID)}
        >
            <MenuItemAction
                key="UnlockAccount"
                title={translate('lockAccountPage.unlockAccount')}
                description={isUnlockPending ? translate('lockAccountPage.unlockRequested') : undefined}
                icon={icons.Unlock}
                onPress={showUnlockAccountModal}
            />
        </OfflineWithFeedback>
    ) : (

    4. Add translation keys:

    In src/languages/en.ts (and other locale files):

    lockAccountPage: {
        ...
        unlockRequested: 'Unlock requested',
        failedToUnlockAccount: 'Failed to submit unlock request. Please try again or contact Concierge.',
    }

    What alternative solutions did you explore? (Optional)

    Product / QA specification review (NAB alternative):
    If Product confirms that the confirmation modal (We’ve received your request) was intentionally designed as the sole confirmation feedback (matching the original PR #78372 and test cases issue #84809), then BrowserStack TC 94420076 (Step 13: "The modal closes and the UI reflects that the unlock request has been submitted") should be updated to "The modal closes", and this issue closed as Expected Behavior.

  9. camesenin commented on Oct 1, 2026

    @camesenin

    Contributor details

  10. camesenin commented on Oct 1, 2026

    @camesenin

    Contributor details

  11. camesenin commented on Oct 1, 2026

    @camesenin

    Contributor details
    Your Expensify account email: [email protected]
    Upwork Profile Link: https://www.upwork.com/freelancers/~0170d6b02e9440e604

  12. 11 remaining items

  13. dubielzyk-expensify commented on Oct 15, 2026

    @dubielzyk-expensify
    Contributor

    I could see us changing the label from Unlock account to Pending unlock request 👍 cc @Expensify/design

  14. shawnborton commented on Oct 15, 2026

    @shawnborton
    Contributor

    Yeah same, maybe change the icon to an hourglass or something? But no strong feels, I think it technically works fine as it is.

  15. dubielzyk-expensify commented on Oct 15, 2026

    @dubielzyk-expensify
    Contributor

    I like that detail 👍 Let's do the hourglass as well

  16. melvin-bot commented on Oct 15, 2026

    @melvin-bot

    📣 @marufsharifi You have been assigned to this job!
    Please apply to the Upwork job and leave a comment on the Github issue letting us know when we can expect a PR to be ready for review 🧑‍💻
    Once you apply to this job, your Upwork ID will be stored and you will be automatically hired for future jobs!
    Keep in mind: Code of Conduct | Contributing 📖

  17. marufsharifi commented on Oct 15, 2026

    @marufsharifi
    Contributor

    @MelvinBot update your proposal based on latest agreement.

  18. MelvinBot commented on Oct 15, 2026

    @MelvinBot
    Contributor

    Updated Proposal

    Updated based on the design agreement from dubielzyk-expensify and shawnborton.

    Please re-state the problem that we are trying to solve in this issue.

    After a domain admin taps Unlock account and then Got it, the member page looks the same as before. Nothing shows that the unlock request was sent.

    What is the root cause of that problem?

    The App sends the request but never records it. requestUnlockAccount calls API.write with no Onyx data. The row only checks the lock flag, which stays true until the security review finishes. So the row never changes, and the admin can send the same request again.

    Code references

    What changes do you think we should make in order to solve the problem?

    Per design: after the request is sent, change the row label from Unlock account to Pending unlock request and change the icon to an hourglass.

    1. Add a marker for the request. Add CONST.DOMAIN.PRIVATE_UNLOCK_REQUESTED_PREFIX (for example, private_unlockRequested_). Add it to the Domain type next to the lock prefix. Add an unlockRequestedSelector(accountID) in selectors/Domain.ts.
    2. Update requestUnlockAccount(accountID, domainAccountID, domainName) in User.ts. Send domainAccountID and domainName, the same way lockAccount does. Then:
      • optimisticData: set private_unlockRequested_<accountID>: true on the domain.
      • failureData: set it back to false and add an unlockAccountErrors entry under DOMAIN_ERRORS.
    3. Clear stale markers. In lockAccount's optimistic data, set private_unlockRequested_<accountID>: false. Otherwise, a member who is locked again later would show "Pending" right away.
    4. Update DomainMemberDetailsPage. When the member is locked and the marker is set:
      • Show the title lockAccountPage.pendingUnlockRequest and the Hourglass icon. Hourglass already exists in the Expensify icons set.
      • On tap, reopen the "We’ve received your request" modal without calling the API again.
      • Wrap the row in OfflineWithFeedback to show unlockAccountErrors, and add a clearUnlockAccountError action.
    5. Add copy. Add pendingUnlockRequest: 'Pending unlock request' under lockAccountPage in all src/languages/* files.

    Backend note: The App part works on its own and survives a reload on the same device. To show the state on other devices or after clearing the cache, the backend must also set and return the same domain key when it handles RequestUnlockAccount, and clear it on unlock. An internal engineer would need to confirm or add that. I couldn't verify whether the backend already stores anything.

    Precedent

    Unlocking a locked bank account already uses this pattern. It sets an "unlock requested" key optimistically: BankAccounts.ts:2010.

    What alternative solutions did you explore? (Optional)

    Store the state in DOMAIN_PENDING_ACTIONS: I rejected this because pending actions are cleared when the request succeeds. The label would then switch back to Unlock account right away.


    Next Steps for Contributor+ team:
    To accept: @MelvinBot implement [this](https://github.com/Expensify/App/issues/102734) to create a draft PR.
    To refine: @MelvinBot <your feedback>
    To reject: Explain why you are rejecting Melvin's proposal.

  19. melvin-bot commented on Oct 15, 2026

    @melvin-bot

    @dubielzyk-expensify @marufsharifi this issue was created 2 weeks ago. Are we close to approving a proposal? If not, what's blocking us from getting this issue assigned? Don't hesitate to create a thread in #expensify-open-source to align faster in real time. Thanks!

  20. marufsharifi commented on Oct 16, 2026

    @marufsharifi
    Contributor

    i am reviewing the updated proposal

  21. marufsharifi commented on Oct 16, 2026

    @marufsharifi
    Contributor

    @MelvinBot implement this.

  22. MelvinBot commented on Oct 16, 2026

    @MelvinBot
    Contributor

    🤖 Draft PR created: Show a pending unlock request state for locked domain members
    Please mark it as "Ready for review" or ask me to do that for you when it is ready for review.

    After an admin requests an unlock, the row now reads Pending unlock request with an hourglass icon. Tapping it again reopens the "We’ve received your request" modal without sending another request. If the request fails, the row goes back to Unlock account and shows an error.

    Details
    • Checks: Typecheck, lint, formatting, spelling, and React Compiler checks pass. The UserTest, DomainSelectorsTest, and DomainUtilsTest suites pass (239 tests), including new tests for the optimistic and failure data.
    • Not tested in the UI: The automated web session showed a blank page, and the flow needs a domain admin account with a locked member. Please test it manually before marking the PR ready.
    • Backend note: The pending state is stored on the domain under private_unlockRequested_<accountID>. It survives a reload on the same device. To show it on other devices, the backend must also set and return this key for RequestUnlockAccount. An internal engineer should confirm this.
    • Fork: I couldn't push to your fork, so the branch is on upstream. The PR has a comment explaining how to move it to your fork.

    view run · no recording available

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

BugSomething is broken. Auto assigns a BugZero manager.DesignExternalAdded to denote the issue can be worked on by a contributorReviewingHas a PR in reviewWeeklyKSv2

Type

No type

Projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions