Skip to content

[SAML Configuration] Verify Domain flow#73016

Merged
mountiny merged 46 commits intoExpensify:mainfrom
software-mansion-labs:feat/domain-validation
Nov 6, 2025
Merged

[SAML Configuration] Verify Domain flow#73016
mountiny merged 46 commits intoExpensify:mainfrom
software-mansion-labs:feat/domain-validation

Conversation

@mhawryluk
Copy link
Contributor

@mhawryluk mhawryluk commented Oct 20, 2025

Explanation of Change

Creates a domain verification flow, accessible by clicking on a non-verified domain's row in the Workspaces tab.

Fixed Issues

$ #72599
PROPOSAL: N/A

Tests

  1. Log in to an Expensify account that is a member (but not an admin) of an unverfied domain
  2. Navigate to the “Workspaces” tab
  3. You should see a "Domains" section below the list of workspaces. This section should not contain any "Not verified" badges. No three-dot menu should be visible in the row too. Clicking on the row should be disabled.

also:

  1. Log in to an Expensify account that is an admin of an unverified domain
  2. Navigate to the “Workspaces” tab
  3. Verify you see a “not verified” badge next to your domain
  4. Click on the three-dot menu for the domain
  5. Verify you see a menu option saying “Verify domain”
  6. Click on “Verify Domain”
  7. Verify it opens up the “Verify your domain” RHP, and a verification code is displayed
  8. Validate your domain following the To Verify a Domain help site instructions and click “Verify domain”. Trying to verify without the correct DNS record should fail and the error should be displayed above the button.
  9. Verify you’re routed to the confirmation RHP page
  10. Click on the confirm button.
  11. Verify you’re back on the workspaces list page
  12. No "not verified" badge should be visible on the domain row (consulted this decision with the design team)
  13. Check if accessing RHPs via URL is possible only when it should (so domain-verified page should only be for verified domains, verify-domain only for non verified)
  14. Check that refreshing the page when on RHP makes it reload with Workspaces list page still below (web only)
  15. Clicking on the row of a domain that has been verified should still open old-dot domains setting page in a new tab.

Offline tests

  1. Log in to an account that has claimed but not validated a domain
  2. Navigate to the “Workspaces” tab
  3. Click on the three-dot menu for an unverified domain
  4. Select the “Verify Domain” option
  5. Verify that the “Verify Domain” RHP opens up
  6. Verify that the “Verify Domain” button is inactionable, and an indicator is displayed saying you’re offline.
  7. Instead of a validation code there should be an error saying the fetch failed and the "Retry" button should be disabled.

QA Steps

Same as tests.

PR Author Checklist

  • I linked the correct issue in the ### Fixed Issues section above
  • I wrote clear testing steps that cover the changes made in this PR
    • I added steps for local testing in the Tests section
    • I added steps for the expected offline behavior in the Offline steps section
    • I added steps for Staging and/or Production testing in the QA steps section
    • I added steps to cover failure scenarios (i.e. verify an input displays the correct error message if the entered data is not correct)
    • I turned off my network connection and tested it while offline to ensure it matches the expected behavior (i.e. verify the default avatar icon is displayed if app is offline)
    • I tested this PR with a High Traffic account against the staging or production API to ensure there are no regressions (e.g. long loading states that impact usability).
  • I included screenshots or videos for tests on all platforms
  • I ran the tests on all platforms & verified they passed on:
    • Android: Native
    • Android: mWeb Chrome
    • iOS: Native
    • iOS: mWeb Safari
    • MacOS: Chrome / Safari
    • MacOS: Desktop
  • I verified there are no console errors (if there's a console error not related to the PR, report it or open an issue for it to be fixed)
  • I verified there are no new alerts related to the canBeMissing param for useOnyx
  • I followed proper code patterns (see Reviewing the code)
    • I verified that any callback methods that were added or modified are named for what the method does and never what callback they handle (i.e. toggleReport and not onIconClick)
    • I verified that comments were added to code that is not self explanatory
    • I verified that any new or modified comments were clear, correct English, and explained "why" the code was doing something instead of only explaining "what" the code was doing.
    • I verified any copy / text shown in the product is localized by adding it to src/languages/* files and using the translation method
      • If any non-english text was added/modified, I used JaimeGPT to get English > Spanish translation. I then posted it in #expensify-open-source and it was approved by an internal Expensify engineer. Link to Slack message:
    • I verified all numbers, amounts, dates and phone numbers shown in the product are using the localization methods
    • I verified any copy / text that was added to the app is grammatically correct in English. It adheres to proper capitalization guidelines (note: only the first word of header/labels should be capitalized), and is either coming verbatim from figma or has been approved by marketing (in order to get marketing approval, ask the Bug Zero team member to add the Waiting for copy label to the issue)
    • I verified proper file naming conventions were followed for any new files or renamed files. All non-platform specific files are named after what they export and are not named "index.js". All platform-specific files are named for the platform the code supports as outlined in the README.
    • I verified the JSDocs style guidelines (in STYLE.md) were followed
  • If a new code pattern is added I verified it was agreed to be used by multiple Expensify engineers
  • I followed the guidelines as stated in the Review Guidelines
  • I tested other components that can be impacted by my changes (i.e. if the PR modifies a shared library or component like Avatar, I verified the components using Avatar are working as expected)
  • I verified all code is DRY (the PR doesn't include any logic written more than once, with the exception of tests)
  • I verified any variables that can be defined as constants (ie. in CONST.ts or at the top of the file that uses the constant) are defined as such
  • I verified that if a function's arguments changed that all usages have also been updated correctly
  • If any new file was added I verified that:
    • The file has a description of what it does and/or why is needed at the top of the file if the code is not self explanatory
  • If a new CSS style is added I verified that:
    • A similar style doesn't already exist
    • The style can't be created with an existing StyleUtils function (i.e. StyleUtils.getBackgroundAndBorderStyle(theme.componentBG))
  • If new assets were added or existing ones were modified, I verified that:
    • The assets are optimized and compressed (for SVG files, run npm run compress-svg)
    • The assets load correctly across all supported platforms.
  • If the PR modifies code that runs when editing or sending messages, I tested and verified there is no unexpected behavior for all supported markdown - URLs, single line code, code blocks, quotes, headings, bold, strikethrough, and italic.
  • If the PR modifies a generic component, I tested and verified that those changes do not break usages of that component in the rest of the App (i.e. if a shared library or component like Avatar is modified, I verified that Avatar is working as expected in all cases)
  • If the PR modifies a component related to any of the existing Storybook stories, I tested and verified all stories for that component are still working as expected.
  • If the PR modifies a component or page that can be accessed by a direct deeplink, I verified that the code functions as expected when the deeplink is used - from a logged in and logged out account.
  • If the PR modifies the UI (e.g. new buttons, new UI components, changing the padding/spacing/sizing, moving components, etc) or modifies the form input styles:
    • I verified that all the inputs inside a form are aligned with each other.
    • I added Design label and/or tagged @Expensify/design so the design team can review the changes.
  • If a new page is added, I verified it's using the ScrollView component to make it scrollable when more elements are added to the page.
  • I added unit tests for any new feature or bug fix in this PR to help automatically prevent regressions in this user flow.
  • If the main branch was merged into this PR after a review, I tested again and verified the outcome was still expected according to the Test steps.

Screenshots/Videos

Android: Native
Nagranie.z.ekranu.2025-10-31.o.18.26.40.mov
Android: mWeb Chrome
Nagranie.z.ekranu.2025-10-31.o.19.09.35.mov
Nagranie.z.ekranu.2025-10-31.o.19.18.06.mov
iOS: Native
Simulator.Screen.Recording.-.iPhone.16.Pro.Max.-.2025-10-31.at.18.19.07.mp4
iOS: mWeb Safari
Simulator.Screen.Recording.-.iPhone.16.Pro.Max.-.2025-10-31.at.19.11.22.mp4
MacOS: Chrome / Safari
Nagranie.z.ekranu.2025-10-31.o.18.54.44.mov
Nagranie.z.ekranu.2025-10-31.o.19.18.06.mov
MacOS: Desktop
Nagranie.z.ekranu.2025-10-31.o.19.15.53.mov
Nagranie.z.ekranu.2025-10-31.o.19.17.08.mov

@melvin-bot
Copy link

melvin-bot bot commented Oct 27, 2025

Hey, I noticed you changed src/languages/en.ts in a PR from a fork. If you want to automatically generate translations for other locales, you can run the Generate static translations GitHub workflow.

Please look at the code and make sure there are no malicious changes before running the workflow.

If you have the K2 extension, you can simply click: [this button]

@mhawryluk mhawryluk changed the title [SAML Configuration] Create Verify Domain flow [SAML Configuration] Verify Domain flow Oct 28, 2025
return {
name: SCREENS.WORKSPACES_LIST,
path: ROUTES.WORKSPACES_LIST.route,
path: normalizePath(ROUTES.WORKSPACES_LIST.route),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you share a bit more about the reasoning for this change? 🤔

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

after refreshing an RHP that is shown above the Workspaces list page and then closing it, the url is wrong. in this case it would go back to /workspaces/verify-domain/workspaces instead of /workspaces. it's because react navigation invokes history.replaceState with workspaces instead of /workspaces. so we prepend a slash here. right now this is happening for all RHPs above the workspaces tab, this change fixes it for ones that don't use backTo. consulted this with the navigation team

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you add a comment to the code explaining why we need to use the normalize here so its easy to understand for any developer reading it, please?


const accountID = route.params?.accountID;
const [domain] = useOnyx(`${ONYXKEYS.COLLECTION.DOMAIN}${accountID}`, {canBeMissing: true});
const domainName = domain ? Str.extractEmailDomain(domain.email) : '';
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is reused couple times, maybe worth to wrap it in an util ? Like getDomainName(domain) ?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not sure. it would essentially just wrap Str.extractEmailDomain and encourage fetching full domain from onyx, even when email is enough

: undefined;

return (
<OfflineWithFeedback
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This Page file is already huge, maybe extract this to a separate file / hook ?

Copy link
Contributor Author

@mhawryluk mhawryluk Oct 30, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

agreed. I made DomainMenuItem and WorkspacesEmptyStateComponent separate components and moved them to dedicated files. domains and workspaces implementations differ now, but I guess it's okay (?). refactoring the workspaces part is a bigger task

@OSBotify
Copy link
Contributor

🦜 Polyglot Parrot! 🦜

Squawk! Looks like you added some shiny new English strings. Allow me to parrot them back to you in other tongues:

The diff is too large to include in this comment (355KB), so I've created a gist for you:

📋 View the translation diff here 📋

Note

You can apply these changes to your branch by copying the patch to your clipboard, then running pbpaste | git apply, or directly by running gh gist view --raw 1a2f5d14911392673771d92135258fc0 | git apply 😉

@melvin-bot
Copy link

melvin-bot bot commented Oct 30, 2025

Hey, I noticed you changed src/languages/en.ts in a PR from a fork. For security reasons, translations are not generated automatically for PRs from forks.

If you want to automatically generate translations for other locales, an Expensify employee will have to:

  1. Look at the code and make sure there are no malicious changes.
  2. Run the Generate static translations GitHub workflow. If you have write access and the K2 extension, you can simply click: [this button]

Alternatively, if you are an external contributor, you can run the translation script locally with your own OpenAI API key. To learn more, try running:

npx ts-node ./scripts/generateTranslations.ts --help

Typically, you'd want to translate only what you changed by running npx ts-node ./scripts/generateTranslations.ts --compare-ref main

@ZhenjaHorbach
Copy link
Contributor

Small issue
But after reloading the screen
We have not here page for a split second

2025-11-06.11.35.52.mov

@mhawryluk
Copy link
Contributor Author

Small issue But after reloading the screen We have not here page for a split second

I added showing full-screen spinner when domain onyx data is loading; this should fix this

Zrzut ekranu 2025-11-6 o 12 08 37

@ZhenjaHorbach
Copy link
Contributor

Small issue But after reloading the screen We have not here page for a split second

I added showing full-screen spinner when domain onyx data is loading; this should fix this

Zrzut ekranu 2025-11-6 o 12 08 37

Good idea!

@ZhenjaHorbach
Copy link
Contributor

LGTM!

@melvin-bot melvin-bot bot requested a review from mountiny November 6, 2025 13:05
mountiny
mountiny previously approved these changes Nov 6, 2025
Copy link
Contributor

@mountiny mountiny left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @ZhenjaHorbach @mhawryluk

@NikkiWines wanna do the final blessing?

const doesDomainExist = !!domain;

useEffect(() => {
if (!domain?.validated) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes sense, thanks!

@ZhenjaHorbach
Copy link
Contributor

ZhenjaHorbach commented Nov 6, 2025

But wait
One more issue
On not here page we use the wrong link
I suppose we just need to close modal

2025-11-06.14.28.20.mov

@mhawryluk
Copy link
Contributor Author

But wait One more issue On not here page we use the wrong link I suppose we just need to close modal

changed onLinkPress to close the modal. let me know if I should change the link text too, but I think it's a minor issue and we can adjust the text in the following PRs to not block this one

Copy link
Contributor

@NikkiWines NikkiWines left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Couple NABs otherwise looks good


/** Whether validation code is currently loading */
/** Errors that occurred when validating the domain */
domainValidationError?: OnyxCommon.Errors;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NAB: Should this be domainVerificationError instead? (Though i guess this would involve changing the backend too if so)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just for linguistic consistency, maybe we can do a follow up if we decide to change it

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we use the term "validation" internally, and "verification" for user facing parts. the ideal here imo would be "validationError", to not repeat "domain" unnecessarily, but I think it's fine. we can change it in the future though

return;
}
Navigation.setNavigationActionToMicrotaskQueue(() => Navigation.navigate(ROUTES.WORKSPACES_VERIFY_DOMAIN.getRoute(accountID), {forceReplace: true}));
}, [accountID, domain?.validated, doesDomainExist]);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NAB: you don't really need doesDomainExist

Suggested change
}, [accountID, domain?.validated, doesDomainExist]);
useEffect(() => {
if (!domain || domain?.validated) {
return;
}
Navigation.setNavigationActionToMicrotaskQueue(() => Navigation.navigate(ROUTES.WORKSPACES_VERIFY_DOMAIN.getRoute(accountID), {forceReplace: true}));
}, [accountID, domain]);

Copy link
Contributor Author

@mhawryluk mhawryluk Nov 6, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this would make the effect run on every change within domain. not a huge deal, wouldn't really break anything, but I think I prefer !doesDomainExist or domain?.accountID === undefined instead of !domain

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cool, i don't feel too strongly about it, so this is fine

const domainName = domain ? Str.extractEmailDomain(domain.email) : '';
const {isOffline} = useNetwork();

const doesDomainExist = !!domain;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here

Copy link
Contributor Author

@mhawryluk mhawryluk Nov 6, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I first implemented it with domain?.accountID === undefined to not put the whole domain in effect dependencies, in some effects putting it there would actually break stuff, but then I thought doesDomainExist better communicates the intent

Copy link
Contributor

@mountiny mountiny left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks!

Copy link
Contributor

@NikkiWines NikkiWines left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thnks

@mountiny mountiny merged commit 203b9a2 into Expensify:main Nov 6, 2025
29 checks passed
@OSBotify
Copy link
Contributor

OSBotify commented Nov 6, 2025

✋ This PR was not deployed to staging yet because QA is ongoing. It will be automatically deployed to staging after the next production release.

@OSBotify
Copy link
Contributor

OSBotify commented Nov 6, 2025

🚀 Deployed to staging by https://github.com/mountiny in version: 9.2.46-0 🚀

platform result
🖥 desktop 🖥 success ✅
🕸 web 🕸 success ✅
🤖 android 🤖 success ✅
🍎 iOS 🍎 success ✅

@IuliiaHerets
Copy link

IuliiaHerets commented Nov 7, 2025

@mhawryluk @NikkiWines I think the QA team can't execute this PR. Can you check it internally?
The QA team was able to go only up to step 7 of the PR. After that, domain verification is required — and it needs a real domain, not a temporary one.

2025-11-07.11-54-30.mp4

@m-natarajan
Copy link

@mhawryluk @NikkiWines @mountiny Can this PR be tested internally? Applause testers don’t have access to the private domain required for this test.

@mhawryluk
Copy link
Contributor Author

mhawryluk commented Nov 7, 2025

I tested it with a real private domain of mine, although it would be good for someone else to test too

@OSBotify
Copy link
Contributor

OSBotify commented Nov 7, 2025

🚀 Deployed to production by https://github.com/luacmartins in version: 9.2.46-3 🚀

platform result
🖥 desktop 🖥 success ✅
🕸 web 🕸 success ✅
🤖 android 🤖 success ✅
🍎 iOS 🍎 failure ❌

Comment on lines +101 to +109
<Text style={styles.webViewStyles.baseFontStyle}>
<RenderHTML html={translate('domain.verifyDomain.beforeProceeding', {domainName})} />
</Text>

<Text style={styles.webViewStyles.baseFontStyle}>
<OrderedListRow index={1}>
<RenderHTML html={translate('domain.verifyDomain.accessYourDNS', {domainName})} />
</OrderedListRow>
</Text>
Copy link
Member

@parasharrajat parasharrajat Jan 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need a block layout here for renderHTML to correctly calculate parent dimensions for rendering. It caused #77419

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.