Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
46 commits
Select commit Hold shift + click to select a range
7f2cc8a
Create domain validation pages
mhawryluk Oct 20, 2025
92832de
Merge branch 'feat/saml-display-domain-list' into feat/domain-validation
mhawryluk Oct 22, 2025
6f14e73
Merge branch 'main' into feat/domain-validation
mhawryluk Oct 23, 2025
4ce735b
Extend navigation, actions and loading states
mhawryluk Oct 27, 2025
47c3a3c
Add link and error message to Verify Domain RHP
mhawryluk Oct 27, 2025
7188e71
Display error from BE in Verify Domain page
mhawryluk Oct 27, 2025
3ece949
Fix RHP refresh behavior
mhawryluk Oct 27, 2025
3c1cc9c
Display verification badge only to admins
mhawryluk Oct 27, 2025
b9ed09a
Support offline status in Verify Domain RHP
mhawryluk Oct 27, 2025
3f8cf0e
Normalize workspaces path in getMatchingFullScreenRoute
mhawryluk Oct 28, 2025
d99e562
Fix spell check
mhawryluk Oct 28, 2025
45f4a4d
Use FormAlertWithSubmitButton in VerifyDomainPage
mhawryluk Oct 28, 2025
d98f0de
Fixes and refactors to the verify domain flow
mhawryluk Oct 28, 2025
a2aa3d0
Rename the new modal stack navigator
mhawryluk Oct 28, 2025
ae0b941
Change validation code loading UI + add error state
mhawryluk Oct 28, 2025
a932956
Adjust domain validate code loading and error state UI
mhawryluk Oct 29, 2025
ee45ed8
Remove unnecessary (for now) translation key
mhawryluk Oct 29, 2025
ae41ec6
Fix backend integration in the verify domain flow
mhawryluk Oct 29, 2025
e1fa943
Review fixes and translations
mhawryluk Oct 30, 2025
9bd80f6
Improve spanish translations
mhawryluk Oct 30, 2025
fe3b4b6
Update jsdoc
mhawryluk Oct 30, 2025
3623d08
Disable retry button when user is offline
mhawryluk Oct 30, 2025
e1648fd
Move status string literals to constant
mhawryluk Oct 30, 2025
14801f8
Change Domain error status handling
mhawryluk Oct 31, 2025
fc1b7f7
Show dot indicator conditionally
mhawryluk Oct 31, 2025
23e7e50
FIx word overflow bug in VerifyDomain page on mobile
mhawryluk Oct 31, 2025
fc7aa0a
Add bottom padding to DomainVerifiedPage
mhawryluk Oct 31, 2025
e5e76d1
Merge branch 'main' into feat/domain-validation
mhawryluk Oct 31, 2025
d4a2fd0
Merge branch 'main' into feat/domain-validation
mhawryluk Oct 31, 2025
8e3ad31
Improve DomainsListRow styling
mhawryluk Oct 31, 2025
eaa6c9b
Tiny fix
mhawryluk Oct 31, 2025
aed85ad
Remove unnecessary optional chaining
mhawryluk Oct 31, 2025
9b72d87
Review fixes
mhawryluk Nov 3, 2025
a6c396c
Show validateCode error directly from onyx
mhawryluk Nov 3, 2025
ec90ad0
Merge branch 'main' into feat/domain-validation
mhawryluk Nov 3, 2025
d3677b4
Align workspaces and domain row icons
mhawryluk Nov 3, 2025
92a7c6a
Implement AI review suggestions
mhawryluk Nov 3, 2025
88eaae0
Show NotFoundPage for not found domains + fix verified page padding
mhawryluk Nov 4, 2025
2de1e2d
Fix verified page description on mobile and safe area bottom padding
mhawryluk Nov 5, 2025
6465449
Reset domainValidationError when entering the verify page
mhawryluk Nov 5, 2025
679c07a
Fix resetting error for non-existing domains
mhawryluk Nov 5, 2025
3835b07
More review fixes
mhawryluk Nov 6, 2025
857953e
Show full screen spinner instead of not-found when onyx domain data i…
mhawryluk Nov 6, 2025
1165888
Merge branch 'main' into feat/domain-validation
mhawryluk Nov 6, 2025
b5b1440
Change onLinkPress for not found page
mhawryluk Nov 6, 2025
03f935e
Remove unnecessary whitespace
mhawryluk Nov 6, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion cspell.json
Original file line number Diff line number Diff line change
Expand Up @@ -779,7 +779,8 @@
"Selec",
"setuptools",
"DYNAMICEXTERNAL",
"RNCORE"
"RNCORE",
"Wooo"
],
"ignorePaths": [
"src/languages/de.ts",
Expand Down
1 change: 1 addition & 0 deletions src/CONST/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1026,6 +1026,7 @@ const CONST = {
PLAN_TYPES_AND_PRICING_HELP_URL: 'https://help.expensify.com/articles/new-expensify/billing-and-subscriptions/Plan-types-and-pricing',
MERGE_ACCOUNT_HELP_URL: 'https://help.expensify.com/articles/new-expensify/settings/Merge-Accounts',
CONNECT_A_BUSINESS_BANK_ACCOUNT_HELP_URL: 'https://help.expensify.com/articles/new-expensify/expenses-&-payments/Connect-a-Business-Bank-Account',
DOMAIN_VERIFICATION_HELP_URL: 'https://help.expensify.com/articles/expensify-classic/domains/Claim-And-Verify-A-Domain',
REGISTER_FOR_WEBINAR_URL: 'https://events.zoom.us/eo/Aif1I8qCi1GZ7KnLnd1vwGPmeukSRoPjFpyFAZ2udQWn0-B86e1Z~AggLXsr32QYFjq8BlYLZ5I06Dg',
TEST_RECEIPT_URL: `${CLOUDFRONT_URL}/images/fake-receipt__tacotodds.png`,
// Use Environment.getEnvironmentURL to get the complete URL with port number
Expand Down
8 changes: 8 additions & 0 deletions src/ROUTES.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3331,6 +3331,14 @@ const ROUTES = {
// eslint-disable-next-line no-restricted-syntax -- Legacy route generation
getRoute: (backTo?: string) => getUrlWithBackToParam('test-tools' as const, backTo),
},
WORKSPACES_VERIFY_DOMAIN: {
route: 'workspaces/verify-domain/:accountID',
getRoute: (accountID: number) => `workspaces/verify-domain/${accountID}` as const,
},
WORKSPACES_DOMAIN_VERIFIED: {
route: 'workspaces/domain-verified/:accountID',
getRoute: (accountID: number) => `workspaces/domain-verified/${accountID}` as const,
},
} as const;

/**
Expand Down
3 changes: 3 additions & 0 deletions src/SCREENS.ts
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,7 @@ const SCREENS = {
REPORT_CHANGE_APPROVER: 'Report_Change_Approver',
REPORT_VERIFY_ACCOUNT: 'Report_Verify_Account',
MERGE_TRANSACTION: 'MergeTransaction',
DOMAIN: 'Domain',
},
PUBLIC_CONSOLE_DEBUG: 'Console_Debug',
SIGN_IN_WITH_APPLE_DESKTOP: 'AppleSignInDesktop',
Expand Down Expand Up @@ -818,6 +819,8 @@ const SCREENS = {
TEST_TOOLS_MODAL: {
ROOT: 'TestToolsModal_Root',
},
WORKSPACES_VERIFY_DOMAIN: 'Workspaces_Verify_Domain',
WORKSPACES_DOMAIN_VERIFIED: 'Workspaces_Domain_Verified',
} as const;

type Screen = DeepValueOf<typeof SCREENS>;
Expand Down
37 changes: 37 additions & 0 deletions src/components/Domain/CopyableTextField.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import React from 'react';
import {View} from 'react-native';
import ActivityIndicator from '@components/ActivityIndicator';
import CopyTextToClipboard from '@components/CopyTextToClipboard';
import Text from '@components/Text';
import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';

type CopyableTextFieldProps = {
/** Text to display and to copy */
value?: string;

/** Should an activity indicator be shown instead of the text and button */
isLoading?: boolean;
};

function CopyableTextField({value, isLoading = false}: CopyableTextFieldProps) {
const styles = useThemeStyles();
const theme = useTheme();
return (
<View style={[styles.qbdSetupLinkBox, styles.border, styles.flexRow, styles.gap2, styles.justifyContentCenter, styles.alignItemsCenter]}>
{isLoading ? (
<ActivityIndicator color={theme.text} />
) : (
<>
<Text style={styles.copyableTextField}>{value ?? ''}</Text>
<View style={[styles.reportActionContextMenuMiniButton, styles.overflowHidden, styles.buttonHoveredBG]}>
<CopyTextToClipboard urlToCopy={value ?? ''} />
</View>
</>
)}
</View>
);
}

CopyableTextField.displayName = 'CopyableTextField';
export default CopyableTextField;
87 changes: 87 additions & 0 deletions src/components/Domain/DomainMenuItem.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import React from 'react';
import * as Expensicons from '@components/Icon/Expensicons';
import type {OfflineWithFeedbackProps} from '@components/OfflineWithFeedback';
import OfflineWithFeedback from '@components/OfflineWithFeedback';
import type {PopoverMenuItem} from '@components/PopoverMenu';
import {PressableWithoutFeedback} from '@components/Pressable';
import useLocalize from '@hooks/useLocalize';
import useThemeStyles from '@hooks/useThemeStyles';
import Navigation from '@libs/Navigation/Navigation';
import CONST from '@src/CONST';
import ROUTES from '@src/ROUTES';
import DomainsListRow from './DomainsListRow';

type DomainMenuItemProps = {
/** Domain menu item data */
item: DomainItem;

/** Row index in the menu */
index: number;
};

type DomainItem = {
/** Type of menu item row in the list of workspaces and domains */
listItemType: 'domain';

/** Main text to show in the row */
title: string;

/** Function to run after clicking on the row */
action: () => void;

/** ID of the row's domain */
accountID: number;

/** Whether the user is an admin of the row's domain */
isAdmin: boolean;

/** Whether the row's domain is validated (aka verified) */
isValidated: boolean;
} & Pick<OfflineWithFeedbackProps, 'pendingAction'>;

function DomainMenuItem({item, index}: DomainMenuItemProps) {
const styles = useThemeStyles();
const {translate} = useLocalize();
const {isAdmin, isValidated} = item;

const threeDotsMenuItems: PopoverMenuItem[] | undefined =
!isValidated && isAdmin
? [
{
icon: Expensicons.Globe,
text: translate('domain.verifyDomain.title'),
onSelected: () => Navigation.navigate(ROUTES.WORKSPACES_VERIFY_DOMAIN.getRoute(item.accountID)),
},
]
: undefined;

return (
<OfflineWithFeedback
key={`domain_${item.title}_${index}`}
pendingAction={item.pendingAction}
style={styles.mb2}
>
<PressableWithoutFeedback
role={CONST.ROLE.BUTTON}
accessibilityLabel="row"
style={styles.mh5}
onPress={item.action}
disabled={!isAdmin}
>
{({hovered}) => (
<DomainsListRow
title={item.title}
badgeText={isAdmin && !isValidated ? translate('domain.notVerified') : undefined}
isHovered={hovered}
menuItems={threeDotsMenuItems}
/>
)}
</PressableWithoutFeedback>
</OfflineWithFeedback>
);
}

DomainMenuItem.displayName = 'DomainMenuItem';

export type {DomainItem};
export default DomainMenuItem;
58 changes: 50 additions & 8 deletions src/components/Domain/DomainsListRow.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
import React from 'react';
import {View} from 'react-native';
import type {ValueOf} from 'type-fest';
import Badge from '@components/Badge';
import Icon from '@components/Icon';
import * as Expensicons from '@components/Icon/Expensicons';
import type {PopoverMenuItem} from '@components/PopoverMenu';
import TextWithTooltip from '@components/TextWithTooltip';
import ThreeDotsMenu from '@components/ThreeDotsMenu';
import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
import CONST from '@src/CONST';

type DomainsListRowProps = {
/** Name of the domain */
Expand All @@ -13,17 +18,23 @@ type DomainsListRowProps = {
/** Whether the row is hovered, so we can modify its style */
isHovered: boolean;

/** Whether the icon at the end of the row should be displayed */
shouldShowRightIcon: boolean;
/** The text to display inside a badge next to the title */
badgeText?: string;

/** Items for the three dots menu */
menuItems?: PopoverMenuItem[];

/** The type of brick road indicator to show. */
brickRoadIndicator?: ValueOf<typeof CONST.BRICK_ROAD_INDICATOR_STATUS>;
};

function DomainsListRow({title, isHovered, shouldShowRightIcon}: DomainsListRowProps) {
function DomainsListRow({title, isHovered, badgeText, brickRoadIndicator, menuItems}: DomainsListRowProps) {
const styles = useThemeStyles();
const theme = useTheme();

return (
<View style={[styles.flexRow, styles.highlightBG, styles.br3, styles.p5, styles.alignItemsCenter, styles.gap3, isHovered && styles.hoveredComponentBG]}>
<View style={[styles.flex1, styles.flexRow, styles.bgTransparent, styles.gap3, styles.alignItemsCenter]}>
<View style={[styles.flexRow, styles.highlightBG, styles.br3, styles.p5, styles.pr3, styles.alignItemsCenter, styles.gap3, isHovered && styles.hoveredComponentBG]}>
<View style={[styles.flex1, styles.flexRow, styles.bgTransparent, styles.gap3, styles.alignItemsCenter, styles.justifyContentStart]}>
<Icon
src={Expensicons.Globe}
fill={theme.icon}
Expand All @@ -32,19 +43,50 @@ function DomainsListRow({title, isHovered, shouldShowRightIcon}: DomainsListRowP
<TextWithTooltip
text={title}
shouldShowTooltip
style={[styles.textStrong]}
style={styles.textStrong}
/>

{!!badgeText && (
<View style={[styles.flexRow, styles.gap2, styles.alignItemsCenter, styles.justifyContentEnd]}>
<Badge
text={badgeText}
textStyles={styles.textStrong}
badgeStyles={[styles.alignSelfCenter, styles.badgeBordered]}
/>
</View>
)}
</View>

{shouldShowRightIcon && (
<View style={[styles.flexRow, styles.alignItemsCenter]}>
<View style={[styles.flexRow, styles.justifyContentEnd]}>
<View style={[styles.flexRow, styles.ml2, styles.alignItemsCenter]}>
<View style={[styles.flexRow, styles.alignItemsCenter, styles.workspaceListRBR, styles.pr3, styles.mt0]}>
{!!brickRoadIndicator && (
<Icon
src={Expensicons.DotIndicator}
fill={brickRoadIndicator === CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR ? theme.danger : theme.iconSuccessFill}
/>
)}
</View>
{!!menuItems?.length && (
<ThreeDotsMenu
shouldSelfPosition
menuItems={menuItems}
anchorAlignment={{horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.RIGHT, vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.TOP}}
shouldOverlay
isNested
/>
)}
</View>
</View>
<View style={styles.touchableButtonImage}>
<Icon
src={Expensicons.NewWindow}
fill={isHovered ? theme.iconHovered : theme.icon}
isButtonIcon
/>
</View>
)}
</View>
</View>
);
}
Expand Down
49 changes: 49 additions & 0 deletions src/components/WorkspacesEmptyStateComponent.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import React from 'react';
import useLocalize from '@hooks/useLocalize';
import usePreferredPolicy from '@hooks/usePreferredPolicy';
import useStyleUtils from '@hooks/useStyleUtils';
import useThemeStyles from '@hooks/useThemeStyles';
import interceptAnonymousUser from '@libs/interceptAnonymousUser';
import Navigation from '@libs/Navigation/Navigation';
import colors from '@styles/theme/colors';
import variables from '@styles/variables';
import CONST from '@src/CONST';
import ROUTES from '@src/ROUTES';
import EmptyStateComponent from './EmptyStateComponent';
import LottieAnimations from './LottieAnimations';
import WorkspaceRowSkeleton from './Skeletons/WorkspaceRowSkeleton';

function WorkspacesEmptyStateComponent() {
const styles = useThemeStyles();
const {translate} = useLocalize();
const StyleUtils = useStyleUtils();
const {isRestrictedPolicyCreation} = usePreferredPolicy();

return (
<EmptyStateComponent
SkeletonComponent={WorkspaceRowSkeleton}
headerMediaType={CONST.EMPTY_STATE_MEDIA.ANIMATION}
headerMedia={LottieAnimations.WorkspacePlanet}
title={translate('workspace.emptyWorkspace.title')}
subtitle={translate('workspace.emptyWorkspace.subtitle')}
titleStyles={styles.pt2}
headerStyles={[styles.overflowHidden, StyleUtils.getBackgroundColorStyle(colors.pink800), StyleUtils.getHeight(variables.sectionIllustrationHeight)]}
lottieWebViewStyles={styles.emptyWorkspaceListIllustrationStyle}
headerContentStyles={styles.emptyWorkspaceListIllustrationStyle}
buttons={
isRestrictedPolicyCreation
? []
: [
{
success: true,
buttonAction: () => interceptAnonymousUser(() => Navigation.navigate(ROUTES.WORKSPACE_CONFIRMATION.getRoute(ROUTES.WORKSPACES_LIST.route))),
buttonText: translate('workspace.new.newWorkspace'),
},
]
}
/>
);
}

WorkspacesEmptyStateComponent.displayName = 'WorkspacesEmptyStateComponent';
export default WorkspacesEmptyStateComponent;
22 changes: 22 additions & 0 deletions src/languages/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7409,6 +7409,28 @@ ${amount} für ${merchant} - ${date}`,
subtitle: `Wir konnten nicht alle Ihre Daten laden. Wir wurden benachrichtigt und untersuchen das Problem. Wenn das weiterhin besteht, wenden Sie sich bitte an`,
refreshAndTryAgain: 'Aktualisieren und erneut versuchen',
},
domain: {
notVerified: 'Nicht verifiziert',
retry: 'Erneut versuchen',
verifyDomain: {
title: 'Domain verifizieren',
beforeProceeding: ({domainName}: {domainName: string}) =>
`Bevor Sie fortfahren, bestätigen Sie, dass Sie <strong>${domainName}</strong> besitzen, indem Sie die DNS-Einstellungen der Domain aktualisieren.`,
accessYourDNS: ({domainName}: {domainName: string}) => `Greifen Sie auf Ihren DNS-Anbieter zu und öffnen Sie die DNS-Einstellungen für <strong>${domainName}</strong>.`,
addTXTRecord: 'Fügen Sie den folgenden TXT-Eintrag hinzu:',
saveChanges: 'Speichern Sie die Änderungen und kehren Sie hierher zurück, um Ihre Domain zu verifizieren.',
youMayNeedToConsult: `Möglicherweise müssen Sie sich an die IT-Abteilung Ihrer Organisation wenden, um die Verifizierung abzuschließen. <a href="${CONST.DOMAIN_VERIFICATION_HELP_URL}">Weitere Informationen</a>.`,
warning: 'Nach der Verifizierung erhalten alle Expensify-Mitglieder in Ihrer Domain eine E-Mail, dass ihr Konto unter Ihrer Domain verwaltet wird.',
codeFetchError: 'Verifizierungscode konnte nicht abgerufen werden',
genericError: 'Wir konnten Ihre Domain nicht verifizieren. Bitte versuchen Sie es erneut und wenden Sie sich an Concierge, wenn das Problem weiterhin besteht.',
},
domainVerified: {
title: 'Domain verifiziert',
header: 'Wooo! Ihre Domain wurde verifiziert',
description: ({domainName}: {domainName: string}) =>
`<muted-text><centered-text>Die Domain <strong>${domainName}</strong> wurde erfolgreich verifiziert und Sie können jetzt SAML und andere Sicherheitsfunktionen einrichten.</centered-text></muted-text>`,
},
},
};
// IMPORTANT: This line is manually replaced in generate translation files by scripts/generateTranslations.ts,
// so if you change it here, please update it there as well.
Expand Down
21 changes: 21 additions & 0 deletions src/languages/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7340,6 +7340,27 @@ const translations = {
exportInProgress: 'Export in progress',
conciergeWillSend: 'Concierge will send you the file shortly.',
},
domain: {
notVerified: 'Not verified',
retry: 'Retry',
verifyDomain: {
title: 'Verify domain',
beforeProceeding: ({domainName}: {domainName: string}) => `Before proceeding, verify that you own <strong>${domainName}</strong> by updating its DNS settings.`,
accessYourDNS: ({domainName}: {domainName: string}) => `Access your DNS provider and open DNS settings for <strong>${domainName}</strong>.`,
addTXTRecord: 'Add the following TXT record:',
saveChanges: 'Save changes and return here to verify your domain.',
youMayNeedToConsult: `You may need to consult your organization's IT department to complete verification. <a href="${CONST.DOMAIN_VERIFICATION_HELP_URL}">Learn more</a>.`,
warning: 'After verification, all Expensify members on your domain will receive an email that their account will be managed under your domain.',
codeFetchError: 'Couldn’t fetch verification code',
genericError: "We couldn't verify your domain. Please try again and reach out to Concierge if the problem persists.",
},
domainVerified: {
title: 'Domain verified',
header: 'Wooo! Your domain has been verified',
description: ({domainName}: {domainName: string}) =>
`<muted-text><centered-text>The domain <strong>${domainName}</strong> has been successfully verified and you can now set up SAML and other security features.</centered-text></muted-text>`,
},
},
};

// IMPORTANT: This line is manually replaced in generate translation files by scripts/generateTranslations.ts,
Expand Down
Loading
Loading