Only let policy admins and managers edit expenses on expense reports - #101029
Conversation
Co-authored-by: Cong Pham <suneox@users.noreply.github.com>
|
I created this PR from an upstream branch because I don't have push access to your fork. If you want to take ownership of this branch and push updates yourself, run: Then close this PR and open a new one from your fork. |
|
The What's failing: three tests in
All three assert on Already tracked and already being fixed: #100987 ( Nothing to do here. Once either fix PR merges, merge EvidenceThe same three assertions fail identically on unrelated open PRs:
The other red check, view run · no recording available |
|
🤖 Android test steps verification results: Only the two single-account steps could run. The steps that prove this PR's actual fix (2, 3, 4) all require a second signed-in user, and step 6 needs a domain admin plus a separate employee with a managed-card transaction — this Android session provides exactly one authenticated account, and minting or signing into another mid-run is a documented hard blocker. The read-only-for-non-owner behavior this PR changes was not verified on Android. No console errors were observed during the steps that ran. Steps 2–4 and 6 need a manual QA pass with two provisioned accounts, or a Melvin run in an environment that can drive two concurrent authenticated sessions. view run · no recording available |
|
🤖 Web test steps verification results: ❌ fail Setup for step 1 was interrupted when a "Referral" promo panel appeared over the in-progress Create Expense form and its control triggered a full-page navigation off the app to the public expensify.com marketing site. That is an unrecoverable session-killer, so driving stopped before any PR-specific assertion could be checked. Separately, steps 3, 4 and 6 could not have run regardless — they need a second signed-in user (and for step 6, a domain admin plus an employee's managed-card transaction), and this web session provides exactly one account. No console errors were captured before the session ended. Nothing about this PR was verified on web. The referral panel navigating off-app looks like a pre-existing hazard unrelated to this PR and is worth a separate look, since it can block verification of any PR. To cover these steps, this needs a manual QA pass with two provisioned accounts. view run · no recording available |
|
@codex review |
|
Codex Review: Didn't find any major issues. You're on a roll. Reviewed commit: ℹ️ About Codex in GitHubCodex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
If Codex has suggestions, it will comment; otherwise it will react with 👍. When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback". |
| // Only an expense report confers admin/manager editing rights, matching canCurrentUserEditExpense. Without this | ||
| // guard an unreported expense (self-DM track expense) is weighed against the caller's policy, which is the viewer's | ||
| // own default workspace rather than the expense's, so anyone who admins any workspace could edit someone else's | ||
| // expense. A self-DM also has no managerID, so an unresolved account ID would otherwise match it. |
|
@MelvinBot merge main, update the Test, QA Steps section following OP, finally mark checked all checklist then mark ready for review |
…erUnreportedExpenseEdit Co-authored-by: Cong Pham <suneox@users.noreply.github.com>
Co-authored-by: Cong Pham <suneox@users.noreply.github.com>
|
Done — merged Merge: Tests / QA Steps now follow the OP's numbered repro (self-DM → manual track expense → mention B with Invite to chat only → B opens the expense), with the expected result inverted to the fix: every coding field is read-only for B and the "Unexpected error creating this chat." message is unreachable. I added two steps the OP doesn't cover but the review does — user A keeps their own edit rights, and the domain-admin managed-card Two things to know before you approve:
The |
Restricting the admin/manager shortcut to expense reports also stripped it from invoice reports, which pushed a policy admin past the Dynamic External Workflow guard and made canEditFieldOfMoneyRequestTest fail whenever createRandomPolicy happened to pick that approval mode. Gate on isFinancialReportsForBusinesses instead, which covers both workspace report types while still excluding self-DMs, and add a deterministic regression test. Co-authored-by: Cong Pham <suneox@users.noreply.github.com>
…dExpenseEdit' into claude-restrictNonOwnerUnreportedExpenseEdit Co-authored-by: Cong Pham <suneox@users.noreply.github.com>
|
@chuckdries Please copy/paste the Reviewer Checklist from here into a new comment on this PR and complete it. If you have the K2 extension, you can simply click: [this button] |
|
MelvinBot has made 5 consecutive commits trying to fix CI failures. Automatic CI fix dispatches are now paused for this PR. Next steps: A human should review the recent commits and resolve the conflicting CI errors manually. |
|
Fixed and pushed. All 8 There were two separate problems, and the second one was mine. 1. 2. A real regression in my own change, found while re-testing after the merge. Gating The guard now uses I added a deterministic regression test that pins What ran locally
Not run: the full Two things still open, neither of them a code problem:
|
|
@suneox since melvin says he can't execute the tests because it requires two separate sessions, could you please execute them and post videos? Thanks |
Yes, I'll complete checklist today |
Reviewer Checklist
Screenshots/VideosMacOS: Chrome / SafariCleanShot.2026-09-17.at.16.12.55.1.mp4 |
garrettmknight
left a comment
There was a problem hiding this comment.
Looks good from a product perspective.
suneox
left a comment
There was a problem hiding this comment.
The implementation look good and works as expected
|
✋ This PR was not deployed to staging yet because QA is ongoing. It will be automatically deployed to staging after the next production release. |
|
🚧 chuckdries has triggered a test Expensify/App build. You can view the workflow run here. |
|
🧪🧪 Use the links below to test this adhoc build on Android, iOS, and Web. Happy testing! 🧪🧪
|
|
🚀 Deployed to staging by https://github.com/chuckdries in version: 9.4.82-0 🚀
|
|
🤖 Help site changes: yes, one gap worth filling. Draft PR: Docs updates for E/A#101029 clarify who can edit unreported expenses Nothing on the help site documented the buggy behavior, so there is no incorrect statement to fix. The gap is that Two articles, 7 lines added, nothing removed or reworded:
Conventions applied and UI verificationAgainst
I verified the relevant labels against a live NewDot session on web. The navigation tab is Spend with an Expenses sub-tab, which matches what both articles already say. One thing worth your eye during review: on a self-DM tracked expense with no workspace tags configured, the rows rendered were Amount, Description, Merchant, Date, Category, Reimbursable, and Report — no Tag and no Billable row. Your test steps name Tag and Billable, which show up once a default workspace supplies them. The new copy therefore says "every field" rather than naming individual rows, so it stays accurate in both configurations. Also note the live UI labels that row Reimbursable, not Billable. I did not touch any existing copy that names fields, so nothing in this PR depends on that distinction — flagging it only in case you want a separate pass on field naming across these articles. Considered and deliberately left alone:
@suneox, please review the linked help site PR and confirm it reflects the current behavior. Then mark the linked help site PR |
|
🚀 Deployed to staging by https://github.com/chuckdries in version: 9.4.86-0 🚀
|
|
🤖 Help site changes: yes — one gap, and the draft PR already exists. Draft PR: [No QA] Docs updates for E/A#101029 clarify who can edit unreported expenses ( I opened that PR on an earlier run of this same request, so I updated it instead of filing a duplicate. Why a change was needed. Nothing on the help site described the buggy behavior, so there is no incorrect statement to fix. The gap is that What the docs PR does. Two articles, no existing copy removed:
Added on this run ( UI labels checkedVerified against I could not run One thing to confirm. The title carries a @suneox, please review the linked help site PR and confirm it reflects the current behavior. Then mark the linked help site PR |
|
🚀 Deployed to staging by https://github.com/chuckdries in version: 9.4.89-0 🚀
|
|
🤖 Nothing new to do — the help site PR is already open and waiting on you. Draft PR: [No QA] Docs updates for E/A#101029 clarify who can edit unreported expenses ( The No further action from me. Recap of what the PR contains, for review:
@suneox, please review the linked help site PR and confirm it reflects the current behavior. Then mark the linked help site PR |
|
🚀 Deployed to production by https://github.com/luacmartins in version: 9.4.89-0 🚀
Bundle Size Analysis (Sentry): |









Explanation of Change
When user B opens a track expense that user A created in A's self-DM, every coding field (Description, Category, Tag, Billable, Report) renders as editable for B, and tapping Report → Create report fails with "Unexpected error creating this chat. Please try again later."
MoneyRequestViewswaps inpolicyForMovingExpensesfor unreported expenses — that is the workspace the viewer would move the expense to, not the workspace the expense belongs to. It then passes that policy straight intocanEditMoneyRequest, whereisAdminwas computed asreportPolicy?.role === ADMINwith no report-type guard. B admins their own workspace, soisAdminwastrueand the function returned early, skipping the requestor check entirely.canEditFieldOfMoneyRequestgates oncanEditMoneyRequestfirst, so every field inherited the same wrong answer. The error message is downstream: because the Report row was interactive, B could reachCreateAppReport, which sendsownerEmail= A withpolicyID= B's workspace, and A is not a member of it.This qualifies
isAdminandisManagerwithisExpenseReport(moneyRequestReport), matching whatcanCurrentUserEditExpensealready does. A self-DM is not an expense report, so B now falls through to the owner check and every field is read-only. The guard also closes theisManagervariant of the same hole: a self-DM has nomanagerID, so an unresolveddeprecatedCurrentUserAccountIDwould otherwise match it.Two behaviors are deliberately left alone:
isRequestorat the end of the function. The new unit test asserts both directions.|| isSelectedReportUnreportedinIOURequestEditReportCommonstays. The original proposal suggested dropping it; per review that would turn the Report step into/not-foundfor a domain admin reporting an employee's unreported managed-card transaction. Those transactions early-returntruebefore the policy is read, so that flow is unchanged by this PR.The alternative of not passing
policyForMovingExpensesinto the permission helpers at all was considered and rejected in review: it swaps the policy behind every permission read inMoneyRequestViewand carries regression risk on the wrong-workspace resolution.Fixed Issues
$ #98099
PROPOSAL: #98099 (comment)
Tests
Preconditions: two accounts (user A and user B) on two devices. Each of them needs their own workspace that they are an admin of — B's own workspace is what
policyForMovingExpensesresolves to and is what caused the bug.Regression checks:
/not-found.Automated checks Melvin ran
npm run typechecknpm run lint-changednpm run spell-changednpm test -- tests/unit/ReportUtilsTest.tsnpm test -- tests/unit/canEditFieldOfMoneyRequestTest.ts tests/unit/inlineEditing/TransactionInlineEdit.test.ts tests/unit/ReportSecondaryActionUtilsTest.ts tests/unit/hooks/useSelectedTransactionsActions.test.ts tests/actions/IOUTest/BulkEditTest.tsnpm test -- tests/ui/MoneyRequestViewTest.tsx tests/ui/MoneyRequestViewReceiptTest.tsx tests/ui/MoneyReportContentCreatedTest.tsx tests/ui/ReportActionComposeTest.tsxThe new unit test was confirmed to fail against
main(source change stashed) and pass with the fix, so it genuinely covers the regression.Scope of these results: the table above was produced before
mainwas merged in.mainmerged cleanly with no conflicts and did not touchcanEditMoneyRequest, but the post-merge re-run is CI's, not a local one.Not run, and why: the full
npm testsuite and the Storybook smoke test were skipped for runtime; the suites above are every test file that referencescanEditMoneyRequest,canEditFieldOfMoneyRequest,canCurrentUserEditExpense, orMoneyRequestView.npm run prettierno longer exists as a script in this repo — formatting is enforced through ESLint, which passed.Offline tests
This change is a client-side permission check with no API call of its own, so there is no optimistic data or offline queue behavior beyond the fields staying read-only in both network states.
QA Steps
Preconditions: two accounts (user A and user B) on two devices. Each of them has a self-DM and their own workspace that they are an admin of.
Regression checks:
/not-found.PR Author Checklist
### Fixed Issuessection aboveTestssectionOffline stepssectionQA stepssectionAvatar, I verified the components usingAvatarare working as expected)StyleUtils.getBackgroundAndBorderStyle(theme.componentBG))npm run compress-svg)Avataris modified, I verified thatAvataris working as expected in all cases)Designlabel and/or tagged@Expensify/designso the design team can review the changes.mainbranch was merged into this PR after a review, I tested again and verified the outcome was still expected according to theTeststeps.Screenshots/Videos
Android: Native
Android: mWeb Chrome
iOS: Native
iOS: mWeb Safari
MacOS: Chrome / Safari