Skip to content

[$250] Full story : Duplicate member shown in Members list with different roles/permissions #72095

Closed

Description

@m-natarajan

If you haven’t already, check out our contributing guidelines for onboarding and email contributors@expensify.com to request to join our Slack channel!


Version Number:
Reproducible in staging?: Needs Reproduction
Reproducible in production?: Needs Reproduction
If this was caught during regression testing, add the test name, ID and link from TestRail:
Email or phone of affected tester (no customers):
Logs: https://stackoverflow.com/c/expensify/questions/4856
Expensify/Expensify Issue URL:
Issue reported by: @dylanexpensify
Slack conversation (hyperlinked to channel name): #Expensify Bugs

Action Performed:

  1. Go to Workspace settings → Members
  2. Observe the list of members
  3. Notice that one user (same email address) appears twice with different roles (e.g., one as Admin, one as Member)

Expected Result:

Each user should appear only once in the Members list.

Actual Result:

The same user appears twice in the list. Each entry shows different permissions (e.g., Admin + Member).

Workaround:

Unknown

Platforms:

Select the officially supported platforms where the issue was reproduced:

  • Android: App
  • Android: mWeb Chrome
  • iOS: App
  • iOS: mWeb Safari
  • iOS: mWeb Chrome
  • Windows: Firefox
  • MacOS: Chrome / Safari
  • MacOS: Desktop
Platforms Tested: On which of our officially supported platforms was this issue tested:
  • Android: App
  • Android: mWeb Chrome
  • iOS: App
  • iOS: mWeb Safari
  • iOS: mWeb Chrome
  • Windows: Chrome
  • MacOS: Chrome / Safari
  • MacOS: Desktop

Screenshots/Videos

Add any screenshot/video evidence

The link for the FS is in OP

View all open jobs on GitHub

Upwork Automation - Do Not Edit
  • Upwork Job URL: https://www.upwork.com/jobs/~021977671500797095654
  • Upwork Job ID: 1977671500797095654
  • Last Price Increase: 2025-10-13
  • Automatic offers:
    • truph01 | Contributor | 109109458
Issue OwnerCurrent Issue Owner: @kadiealexander

Activity

  1. added
    BugSomething is broken. Auto assigns a BugZero manager.
    on Oct 8, 2025
  2. melvin-bot commented on Oct 8, 2025

    @melvin-bot

    Triggered auto assignment to @kadiealexander (Bug), see https://stackoverflow.com/c/expensify/questions/14418 for more details. Please add this bug to a GH project, as outlined in the SO.

  3. MelvinBot commented on Oct 8, 2025

    @MelvinBot
    Contributor

    This has been labelled "Needs Reproduction". Follow the steps here: https://stackoverflowteams.com/c/expensify/questions/16989

  4. truph01 commented on Oct 9, 2025

    @truph01
    Contributor

    I can reproduce this issue:

    Screen.Recording.2025-10-10.at.02.35.39.mov
  5. paulnjs commented on Oct 10, 2025

    @paulnjs
    Contributor

    Re-post proposal

    Proposal

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

    • Workspace - Duplicate members list shown after submitter create expenses in workspace chat

    What is the root cause of that problem?

    • The logic used to retrieve and display workspace members originates from this line:

      const accountID = Number(policyMemberEmailsToAccountIDs[email] ?? '');

    • The policyMemberEmailsToAccountIDs used there is derived from:

      function getMemberAccountIDsForWorkspace(employeeList: PolicyEmployeeList | undefined, includeMemberWithErrors = false, includeMemberWithPendingDelete = true): MemberEmailsToAccountIDs {

    • That function calls getPersonalDetailByEmail, found here:

      function getPersonalDetailByEmail(email: string): PersonalDetails | undefined {
      return emailToPersonalDetailsCache[email.toLowerCase()];
      }

    • getPersonalDetailByEmail relies on the emailToPersonalDetailsCache:

      let personalDetails: Array<PersonalDetails | null> = [];
      let allPersonalDetails: OnyxEntry<PersonalDetailsList> = {};
      let emailToPersonalDetailsCache: Record<string, PersonalDetails> = {};
      Onyx.connect({
      key: ONYXKEYS.PERSONAL_DETAILS_LIST,
      callback: (val) => {
      personalDetails = Object.values(val ?? {});
      allPersonalDetails = val;
      emailToPersonalDetailsCache = personalDetails.reduce((acc: Record<string, PersonalDetails>, detail) => {
      if (detail?.login) {
      acc[detail.login.toLowerCase()] = detail;
      }
      return acc;
      }, {});
      },
      });

    • Within that implementation, the cache stores the original reference to the detail object directly:

      acc[detail.login.toLowerCase()] = detail;

      As a result, both the cache and the source array share the same object reference. If the original detail is mutated later, those changes also affect the cached version, leading to unintended duplication.

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

    This ensures that subsequent mutations to the original object do not affect the cached data.

    What specific scenarios should we cover in automated tests to prevent reintroducing this issue in the future?

    None.

    What alternative solutions did you explore? (Optional)

    N/A

  6. dmkt9 commented on Oct 10, 2025

    @dmkt9
    Contributor

    Proposal

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

    Duplicate members list shown after submitter create expenses in workspace chat

    What is the root cause of that problem?

    1. The accountID is retrieved here via policyMemberEmailsToAccountIDs to generate the display data:

      const data: MemberOption[] = useMemo(() => {
      const result: MemberOption[] = [];
      Object.entries(policy?.employeeList ?? {}).forEach(([email, policyEmployee]) => {
      const accountID = Number(policyMemberEmailsToAccountIDs[email] ?? '');

    2. policyMemberEmailsToAccountIDs is obtained here from the getMemberAccountIDsForWorkspace function:

      const {policyMemberEmailsToAccountIDs, employeeListDetails} = useMemo(() => {
      const emailsToAccountIDs = getMemberAccountIDsForWorkspace(policy?.employeeList, true);
      const details = Object.keys(policy?.employeeList ?? {}).reduce<Record<number, PolicyEmployee>>((acc, email) => {
      const employee = policy?.employeeList?.[email];
      const accountID = emailsToAccountIDs[email];
      if (!employee) {
      return acc;
      }
      acc[accountID] = employee;
      return acc;
      }, {});
      return {policyMemberEmailsToAccountIDs: emailsToAccountIDs, employeeListDetails: details};
      }, [policy?.employeeList]);

    3. Inside the getMemberAccountIDsForWorkspace function, the result is returned based on the getPersonalDetailByEmail function:

      App/src/libs/PolicyUtils.ts

      Lines 387 to 391 in ac3686a

      const personalDetail = getPersonalDetailByEmail(email);
      if (!personalDetail?.login) {
      return;
      }
      memberEmailsToAccountIDs[email] = Number(personalDetail.accountID);

      function getPersonalDetailByEmail(email: string): PersonalDetails | undefined {
      return emailToPersonalDetailsCache[email.toLowerCase()];
      }

    4. It can be observed that emailToPersonalDetailsCache, allPersonalDetails, and personalDetails all share the same PersonalDetails item reference. Therefore, any modification to one collection item will affect the others.

      personalDetails = Object.values(val ?? {});
      allPersonalDetails = val;
      emailToPersonalDetailsCache = personalDetails.reduce((acc: Record<string, PersonalDetails>, detail) => {
      if (detail?.login) {
      acc[detail.login.toLowerCase()] = detail;
      }
      return acc;
      }, {});

    5. At this point, a new accountID was assigned to a PersonalDetails item, causing it to duplicate the accountID of another item:

      let lastActorDetails: Partial<PersonalDetails> | null = lastActorAccountID ? (personalDetails?.[lastActorAccountID] ?? null) : null;
      if (!lastActorDetails && lastAction) {
      const lastActorDisplayName = lastAction?.person?.[0]?.text;
      lastActorDetails = lastActorDisplayName
      ? {
      displayName: lastActorDisplayName,
      accountID: report.lastActorAccountID,
      }
      : null;
      }
      // Assign the actor account ID from the last action when it’s a REPORT_PREVIEW action.
      // to ensures that lastActorDetails.accountID is correctly set in case it's empty string
      if (lastAction?.actionName === CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW && lastActorDetails) {
      lastActorDetails.accountID = lastAction.actorAccountID;
      }

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

    Instead of returning a reference to the item, we should return a cloned object or only extract the necessary properties:

    let lastActorDetails: Partial<PersonalDetails> | null = lastActorAccountID ? (personalDetails?.[lastActorAccountID] ?? null) : null;

    let lastActorDetails: Partial<PersonalDetails> | null = lastActorAccountID && personalDetails?.[lastActorAccountID] ? {...personalDetails?.[lastActorAccountID]} : null;

    NOTE:
    - I’ve checked everything, and this is the only place where a new assignment occurs.
    - We can freeze the result returned from useOnyx and Onyx.connect to prevent accidental mutations.

    What specific scenarios should we cover in automated tests to prevent reintroducing this issue in the future?

    NA

    What alternative solutions did you explore? (Optional)

    NA

    Reminder: Please use plain English, be brief and avoid jargon. Feel free to use images, charts or pseudo-code if necessary. Do not post large multi-line diffs or write walls of text. Do not create PRs unless you have been hired for this job.

  7. mukhrr commented on Oct 10, 2025

    @mukhrr
    Contributor

    Proposal

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

    After an employee submits expenses in a workspace chat, duplicate member entries appear on the workspace members page.

    What is the root cause of that problem?

    The root cause is case-sensitive email keys in the policy.employeeList object combined with email normalization inconsistencies.

    if (policy?.employeeList) {
    const availableApprovers = Object.values(policy.employeeList)
    .map((employee): SelectionListApprover | null => {
    const email = employee.email;
    if (!email) {
    return null;
    }
    const policyMemberEmailsToAccountIDs = getMemberAccountIDsForWorkspace(policy?.employeeList);
    const accountID = Number(policyMemberEmailsToAccountIDs[email] ?? '');
    const {avatar, displayName = email, login} = personalDetails?.[accountID] ?? {};

    When members are added to a workspace through different flows (manual invitation, expense submission, workflow updates), the email keys may have different casings:

    Initial invite: {"user@company.com": {role: "user", ...}}
    Expense flow: {"User@Company.com": {role: "user", ...}}
    

    JavaScript treats these as two distinct object keys. When getMemberAccountIDsForWorkspace() iterates over these keys, it looks up personal details using getPersonalDetailByEmail(), which normalizes emails to lowercase.

    App/src/libs/PolicyUtils.ts

    Lines 374 to 391 in ac3686a

    Object.keys(members).forEach((email) => {
    if (!includeMemberWithErrors) {
    const member = members?.[email];
    if (Object.keys(member?.errors ?? {})?.length > 0) {
    return;
    }
    }
    if (!includeMemberWithPendingDelete) {
    const member = members?.[email];
    if (member.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) {
    return;
    }
    }
    const personalDetail = getPersonalDetailByEmail(email);
    if (!personalDetail?.login) {
    return;
    }
    memberEmailsToAccountIDs[email] = Number(personalDetail.accountID);

    function getPersonalDetailByEmail(email: string): PersonalDetails | undefined {
    return emailToPersonalDetailsCache[email.toLowerCase()];
    }

    Both variations map to the same accountID, resulting in duplicate entries for the same user in the members list.

    const {policyMemberEmailsToAccountIDs, employeeListDetails} = useMemo(() => {
    const emailsToAccountIDs = getMemberAccountIDsForWorkspace(policy?.employeeList, true);
    const details = Object.keys(policy?.employeeList ?? {}).reduce<Record<number, PolicyEmployee>>((acc, email) => {
    const employee = policy?.employeeList?.[email];
    const accountID = emailsToAccountIDs[email];
    if (!employee) {
    return acc;
    }
    acc[accountID] = employee;
    return acc;
    }, {});

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

    We can add deduplication logic to prevent the same accountID from being processed multiple times:

    function getMemberAccountIDsForWorkspace(...) {
        const members = employeeList ?? {};
        const memberEmailsToAccountIDs: MemberEmailsToAccountIDs = {};
        const processedAccountIDs = new Set<number>();
        
        Object.keys(members).forEach((email) => {
            // ... existing validation logic ...
            
            const accountID = Number(personalDetail.accountID);
            // Prevent duplicate entries for the same account with different email casings
            if (processedAccountIDs.has(accountID)) {
                return;
            }
            
            processedAccountIDs.add(accountID);
            memberEmailsToAccountIDs[email] = accountID;
        });
        return memberEmailsToAccountIDs;
    }

    And in WorkspaceMembersPage.tsx, only set employee details for unprocessed accountIDs

    const details = Object.keys(policy?.employeeList ?? {}).reduce<Record<number, PolicyEmployee>>((acc, email) => {
                const employee = policy?.employeeList?.[email];
                const accountID = emailsToAccountIDs[email];
                if (!employee || !accountID) {
                    return acc;
                }
    
                if (!acc[accountID]) {
                    acc[accountID] = employee;
                }
                return acc;
            }, {});

    What specific scenarios should we cover in automated tests to prevent reintroducing this issue in the future?

    We can add test

    it('should deduplicate members with different email casings', () => {
        const employeeList = {
            'user@company.com': {role: 'user', email: 'user@company.com'},
            'User@Company.com': {role: 'user', email: 'User@Company.com'},
            'admin@company.com': {role: 'admin', email: 'admin@company.com'},
        };
        
        const result = getMemberAccountIDsForWorkspace(employeeList, true);
        const accountIDs = Object.values(result);
        
        // Should return only 2 unique accountIDs (not 3)
        expect(accountIDs.length).toBe(2);
        expect(new Set(accountIDs).size).toBe(2);
    });

    What alternative solutions did you explore? (Optional)

  8. bernhardoj commented on Oct 10, 2025

    @bernhardoj
    Contributor

    Proposal

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

    Duplicate members shown in WS members page after creating a money request.

    What is the root cause of that problem?

    If we check the onyx data, it contains the correct member list data. We get each member accountID by email from policyMemberEmailsToAccountIDs mapping. However, there is a member email that maps to the same accountID. This means there is somewhere in the code that mutates the data.

    Object.entries(policy?.employeeList ?? {}).forEach(([email, policyEmployee]) => {
    const accountID = Number(policyMemberEmailsToAccountIDs[email] ?? '');
    if (isDeletedPolicyEmployee(policyEmployee, isOffline)) {
    return;
    }
    const details = personalDetails?.[accountID];

    policyMemberEmailsToAccountIDs is built from getMemberAccountIDsForWorkspace and the personal detail is from getPersonalDetailByEmail.

    const {policyMemberEmailsToAccountIDs, employeeListDetails} = useMemo(() => {
    const emailsToAccountIDs = getMemberAccountIDsForWorkspace(policy?.employeeList, true);

    App/src/libs/PolicyUtils.ts

    Lines 387 to 391 in ac3686a

    const personalDetail = getPersonalDetailByEmail(email);
    if (!personalDetail?.login) {
    return;
    }
    memberEmailsToAccountIDs[email] = Number(personalDetail.accountID);

    function getPersonalDetailByEmail(email: string): PersonalDetails | undefined {
    return emailToPersonalDetailsCache[email.toLowerCase()];
    }

    We build emailToPersonalDetailsCache from the personal details onyx data.

    let emailToPersonalDetailsCache: Record<string, PersonalDetails> = {};
    Onyx.connect({
    key: ONYXKEYS.PERSONAL_DETAILS_LIST,
    callback: (val) => {
    personalDetails = Object.values(val ?? {});
    allPersonalDetails = val;
    emailToPersonalDetailsCache = personalDetails.reduce((acc: Record<string, PersonalDetails>, detail) => {
    if (detail?.login) {
    acc[detail.login.toLowerCase()] = detail;
    }
    return acc;

    And the mutation happens here.

    if (lastAction?.actionName === CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW && lastActorDetails) {
    lastActorDetails.accountID = lastAction.actorAccountID;
    }

    It was added to fix this bug. The bug is that, when user A request money from user B, as user B, the last message should read "You: User B owes $xx", but the actual result is that the "You:" doesn't show. But if we observe the current behavior in main, it's the opposite. User A now sees the "You:" instead of User B.

    We previously get the last actor account ID from the report (old code)

    let lastActorDetails: Partial<PersonalDetails> | null = report.lastActorAccountID && personalDetails?.[report.lastActorAccountID] ? personalDetails[report.lastActorAccountID] : null;
    if (!lastActorDetails && visibleReportActionItems[report.reportID]) {
    const lastActorDisplayName = visibleReportActionItems[report.reportID]?.person?.[0]?.text;
    lastActorDetails = lastActorDisplayName
    ? {
    displayName: lastActorDisplayName,
    accountID: report.lastActorAccountID,
    }
    : null;
    }

    but now we get it from getReportActionActorAccountID.

    const lastActorAccountID = getReportActionActorAccountID(lastAction, undefined, undefined) || report.lastActorAccountID;

    App/src/libs/ReportUtils.ts

    Lines 10267 to 10277 in ac3686a

    switch (reportAction?.actionName) {
    case CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW: {
    const ownerAccountID = iouReport?.ownerAccountID ?? reportAction?.childOwnerAccountID;
    const actorAccountID = iouReport?.managerID ?? reportAction?.childManagerAccountID;
    if (isPolicyExpenseChat(report) || delegatePersonalDetails) {
    return ownerAccountID;
    }
    return actorAccountID;
    }

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

    First of all, we need to remove this workaround.

    // to ensures that lastActorDetails.accountID is correctly set in case it's empty string
    if (lastAction?.actionName === CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW && lastActorDetails) {
    lastActorDetails.accountID = lastAction.actorAccountID;
    }

    (the comment mention in case it's empty string, but the code doesn't check that at all and I don't see it's happening, if that happens, then it's definitely a BE issue)

    With this change, when User A requests from User B,
    User A will see: "User B owes $xx"
    User B will see: "You: User B owes $xx"

    If that's not expected, we should change the logic inside getReportActionActorAccountID instead of mutating the personal detail object.

  9. added
    ExternalAdded to denote the issue can be worked on by a contributor
    and removed on Oct 13, 2025
  10. 46 remaining items

  11. moved this from Bugs and Follow Up Issues to Done in #expensify-bugson Oct 25, 2025
  12. truph01 commented on Oct 27, 2025

    @truph01
    Contributor

    @cead22 Could you handle the payment for this issue? Both C and C+ were assigned, which aligns with our guidelines.

  13. cead22 commented on Oct 27, 2025

    @cead22
    Contributor

    Can you remind me how we handle payments for these cases?

  14. truph01 commented on Oct 27, 2025

    @truph01
    Contributor

    @cead22 Here is the reference.

  15. cead22 commented on Oct 27, 2025

    @cead22
    Contributor

    Thank you! @kadiealexander can you handle the payments for this please?

  16. reopened this on Oct 27, 2025
  17. mallenexpensify commented on Oct 27, 2025

    @mallenexpensify
    Contributor

    Payment Summary

    Contributor: @bernhardoj due $125 via NewDot
    Contributor+: @truph01 due $125 via Upwork (offer)

    Thx

  18. bernhardoj commented on Oct 28, 2025

    @bernhardoj
    Contributor

    Requested in ND.

  19. truph01 commented on Oct 29, 2025

    @truph01
    Contributor

    Contributor+: @truph01 due $125 via NewDot

    @mallenexpensify It'd be via Upwork for me

  20. quinthar commented on Nov 2, 2025

    @quinthar
    Contributor

    Approved $125 to @bernhardoj

  21. kadiealexander commented on Nov 7, 2025

    @kadiealexander
    Contributor

    @truph01 paid via Upwork.

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.DailyKSv2ExternalAdded to denote the issue can be worked on by a contributor

Type

No type

Projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions