diff --git a/assets/images/simple-illustrations/simple-illustration__approval-members.svg b/assets/images/simple-illustrations/simple-illustration__approval-members.svg new file mode 100644 index 0000000000000..5e0837a3e14fc --- /dev/null +++ b/assets/images/simple-illustrations/simple-illustration__approval-members.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index 700a4e3affb4b..2ad248fa1c63d 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -731,6 +731,9 @@ const ONYXKEYS = { /** SAML login metadata for a domain */ SAML_METADATA: 'saml_metadata_', + + /** Stores domain admin account ID */ + EXPENSIFY_ADMIN_ACCESS_PREFIX: 'expensify_adminPermissions_', }, /** List of Form ids */ @@ -1117,6 +1120,7 @@ type OnyxCollectionValuesMapping = { [ONYXKEYS.COLLECTION.ISSUE_NEW_EXPENSIFY_CARD]: OnyxTypes.IssueNewCard; [ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_ADMIN_ACCESS]: boolean; [ONYXKEYS.COLLECTION.SAML_METADATA]: OnyxTypes.SamlMetadata; + [ONYXKEYS.COLLECTION.EXPENSIFY_ADMIN_ACCESS_PREFIX]: number; }; type OnyxValuesMapping = { diff --git a/src/ROUTES.ts b/src/ROUTES.ts index 8621adc029be5..f56f6a9d7815a 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -3421,38 +3421,42 @@ const ROUTES = { 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, + route: 'workspaces/verify-domain/:domainAccountID', + getRoute: (domainAccountID: number) => `workspaces/verify-domain/${domainAccountID}` as const, }, WORKSPACES_DOMAIN_VERIFIED: { - route: 'workspaces/domain-verified/:accountID', - getRoute: (accountID: number) => `workspaces/domain-verified/${accountID}` as const, + route: 'workspaces/domain-verified/:domainAccountID', + getRoute: (domainAccountID: number) => `workspaces/domain-verified/${domainAccountID}` as const, }, WORKSPACES_ADD_DOMAIN: 'workspaces/add-domain', WORKSPACES_ADD_DOMAIN_VERIFY_ACCOUNT: `workspaces/add-domain/${VERIFY_ACCOUNT}`, WORKSPACES_DOMAIN_ADDED: { - route: 'workspaces/domain-added/:accountID', - getRoute: (accountID: number) => `workspaces/domain-added/${accountID}` as const, + route: 'workspaces/domain-added/:domainAccountID', + getRoute: (domainAccountID: number) => `workspaces/domain-added/${domainAccountID}` as const, }, WORKSPACES_DOMAIN_ACCESS_RESTRICTED: { - route: 'workspaces/domain-access-restricted/:accountID', - getRoute: (accountID: number) => `workspaces/domain-access-restricted/${accountID}` as const, + route: 'workspaces/domain-access-restricted/:domainAccountID', + getRoute: (domainAccountID: number) => `workspaces/domain-access-restricted/${domainAccountID}` as const, }, DOMAIN_INITIAL: { - route: 'domain/:accountID', - getRoute: (accountID: number) => `domain/${accountID}` as const, + route: 'domain/:domainAccountID', + getRoute: (domainAccountID: number) => `domain/${domainAccountID}` as const, }, DOMAIN_SAML: { - route: 'domain/:accountID/saml', - getRoute: (accountID: number) => `domain/${accountID}/saml` as const, + route: 'domain/:domainAccountID/saml', + getRoute: (domainAccountID: number) => `domain/${domainAccountID}/saml` as const, }, DOMAIN_VERIFY: { - route: 'domain/:accountID/verify', - getRoute: (accountID: number) => `domain/${accountID}/verify` as const, + route: 'domain/:domainAccountID/verify', + getRoute: (domainAccountID: number) => `domain/${domainAccountID}/verify` as const, }, DOMAIN_VERIFIED: { - route: 'domain/:accountID/verified', - getRoute: (accountID: number) => `domain/${accountID}/verified` as const, + route: 'domain/:domainAccountID/verified', + getRoute: (domainAccountID: number) => `domain/${domainAccountID}/verified` as const, + }, + DOMAIN_ADMINS: { + route: 'domain/:domainAccountID/admins', + getRoute: (domainAccountID: number) => `domain/${domainAccountID}/admins` as const, }, } as const; diff --git a/src/SCREENS.ts b/src/SCREENS.ts index 24092ca91a3cd..0dbae61f5fec9 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -848,6 +848,7 @@ const SCREENS = { VERIFIED: 'Domain_Verified', INITIAL: 'Domain_Initial', SAML: 'Domain_SAML', + ADMINS: 'Domain_Admins', }, } as const; diff --git a/src/components/Icon/chunks/illustrations.chunk.ts b/src/components/Icon/chunks/illustrations.chunk.ts index fa28809fadb68..7c685464478a4 100644 --- a/src/components/Icon/chunks/illustrations.chunk.ts +++ b/src/components/Icon/chunks/illustrations.chunk.ts @@ -79,6 +79,7 @@ import Abacus from '@assets/images/simple-illustrations/simple-illustration__aba // Simple Illustrations - Original core ones import Accounting from '@assets/images/simple-illustrations/simple-illustration__accounting.svg'; import Alert from '@assets/images/simple-illustrations/simple-illustration__alert.svg'; +import Members from '@assets/images/simple-illustrations/simple-illustration__approval-members.svg'; import Approval from '@assets/images/simple-illustrations/simple-illustration__approval.svg'; import Binoculars from '@assets/images/simple-illustrations/simple-illustration__binoculars.svg'; import BlueShield from '@assets/images/simple-illustrations/simple-illustration__blueshield.svg'; @@ -315,6 +316,7 @@ const Illustrations = { Mailbox, ShieldYellow, Clock, + Members, }; /** diff --git a/src/components/Navigation/NavigationTabBar/index.tsx b/src/components/Navigation/NavigationTabBar/index.tsx index f33d4dab019dd..4ba13eb31ed0c 100644 --- a/src/components/Navigation/NavigationTabBar/index.tsx +++ b/src/components/Navigation/NavigationTabBar/index.tsx @@ -88,7 +88,7 @@ function NavigationTabBar({selectedTab, isTopLevelBar = false, shouldShowFloatin const expensifyIcons = useMemoizedLazyExpensifyIcons(['ExpensifyAppIcon', 'Inbox', 'MoneySearch', 'Buildings'] as const); const paramsPolicyID = params && 'policyID' in params ? params.policyID : undefined; - const paramsDomainAccountID = params && 'accountID' in params ? params.accountID : undefined; + const paramsDomainAccountID = params && 'domainAccountID' in params ? params.domainAccountID : undefined; const lastViewedPolicySelector = useCallback( (policies: OnyxCollection) => { diff --git a/src/languages/de.ts b/src/languages/de.ts index 438a7f79fc6f0..33fbbad21c08f 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -8001,6 +8001,7 @@ Hier ist ein *Testbeleg*, um dir zu zeigen, wie es funktioniert:`, subtitle: 'Erzwingen Sie für Mitglieder Ihrer Domain die Anmeldung per Single Sign-On, schränken Sie die Erstellung von Workspaces ein und vieles mehr.', enable: 'Aktivieren', }, + admins: {title: 'Admins', findAdmin: 'Admin finden'}, }, }; // IMPORTANT: This line is manually replaced in generate translation files by scripts/generateTranslations.ts, diff --git a/src/languages/en.ts b/src/languages/en.ts index 9eafd177a7046..73038db3eb245 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -7845,6 +7845,10 @@ const translations = { subtitle: 'Require members on your domain to log in via single sign-on, restrict workspace creation, and more.', enable: 'Enable', }, + admins: { + title: 'Admins', + findAdmin: 'Find admin', + }, }, }; diff --git a/src/languages/es.ts b/src/languages/es.ts index c6810a535868f..aecf6bc201c5f 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -7941,6 +7941,10 @@ ${amount} para ${merchant} - ${date}`, subtitle: 'Solicita que los miembros de tu dominio inicien sesión mediante inicio de sesión único, restringe la creación de espacios de trabajo y más.', enable: 'Habilitar', }, + admins: { + title: 'Administradores', + findAdmin: 'Encontrar administrador', + }, }, }; diff --git a/src/languages/fr.ts b/src/languages/fr.ts index f9b578835ca54..e21b600f9db91 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -8005,6 +8005,7 @@ Voici un *reçu test* pour vous montrer comment cela fonctionne :`, subtitle: "Exiger que les membres de votre domaine se connectent via l'authentification unique, restreindre la création d'espaces de travail, et plus encore.", enable: 'Activer', }, + admins: {title: 'Admins', findAdmin: 'Trouver un admin'}, }, }; // IMPORTANT: This line is manually replaced in generate translation files by scripts/generateTranslations.ts, diff --git a/src/languages/it.ts b/src/languages/it.ts index a169b1e947130..06c38bcdab102 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -7980,6 +7980,7 @@ Ecco una *ricevuta di prova* per mostrarti come funziona:`, subtitle: 'Richiedi ai membri del tuo dominio di accedere tramite Single Sign-On, limita la creazione di spazi di lavoro e altro ancora.', enable: 'Abilita', }, + admins: {title: 'Amministratori', findAdmin: 'Trova amministratore'}, }, }; // IMPORTANT: This line is manually replaced in generate translation files by scripts/generateTranslations.ts, diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 52d58496a89bc..439e4d9eb3193 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -7922,6 +7922,7 @@ Expensify の使い方をお見せするための*テストレシート*がこ subtitle: 'ドメインのメンバーにシングルサインオンでのログインを必須化し、ワークスペースの作成を制限するなど、さらに多くのことができます。', enable: '有効にする', }, + admins: {title: '管理者', findAdmin: '管理者を検索'}, }, }; // IMPORTANT: This line is manually replaced in generate translation files by scripts/generateTranslations.ts, diff --git a/src/languages/nl.ts b/src/languages/nl.ts index e205c3130aff1..8ee1f5b729d1f 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -7962,6 +7962,7 @@ Hier is een *testbon* om je te laten zien hoe het werkt:`, subtitle: 'Verplicht leden van je domein om in te loggen via single sign-on, beperk het aanmaken van werkruimten en meer.', enable: 'Inschakelen', }, + admins: {title: 'Beheerders', findAdmin: 'Beheerder zoeken'}, }, }; // IMPORTANT: This line is manually replaced in generate translation files by scripts/generateTranslations.ts, diff --git a/src/languages/pl.ts b/src/languages/pl.ts index da8ef238832d6..dfbd08e3f5a3d 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -7950,6 +7950,7 @@ Oto *paragon testowy*, który pokazuje, jak to działa:`, subtitle: 'Wymagaj, aby członkowie Twojej domeny logowali się przez Single Sign-On (SSO), ograniczaj tworzenie obszarów roboczych i nie tylko.', enable: 'Włącz', }, + admins: {title: 'Administratorzy', findAdmin: 'Znajdź administratora'}, }, }; // IMPORTANT: This line is manually replaced in generate translation files by scripts/generateTranslations.ts, diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index e827ad66059f0..3e7dfed5f27ec 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -7955,6 +7955,7 @@ Aqui está um *recibo de teste* para mostrar como funciona:`, subtitle: 'Exija que os membros do seu domínio façam login por meio de logon único (SSO), restrinja a criação de espaços de trabalho e muito mais.', enable: 'Ativar', }, + admins: {title: 'Administradores', findAdmin: 'Encontrar administrador'}, }, }; // IMPORTANT: This line is manually replaced in generate translation files by scripts/generateTranslations.ts, diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 0ac0fd5f87e7a..5af51c44e5739 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -7786,6 +7786,7 @@ ${reportName} addDomain: {title: '添加域', subtitle: '请输入您想访问的私有域名(例如:expensify.com)。', domainName: '域名', newDomain: '新域名'}, domainAdded: {title: '已添加域名', description: '接下来,您需要验证域名的所有权并调整您的安全设置。', configure: '配置'}, enhancedSecurity: {title: '增强的安全性', subtitle: '要求您域内的成员使用单点登录登录、限制工作区创建等。', enable: '启用'}, + admins: {title: '管理员', findAdmin: '查找管理员'}, }, }; // IMPORTANT: This line is manually replaced in generate translation files by scripts/generateTranslations.ts, diff --git a/src/libs/Navigation/AppNavigator/Navigators/DomainSplitNavigator.tsx b/src/libs/Navigation/AppNavigator/Navigators/DomainSplitNavigator.tsx index 81d1819ec2207..c7d883abc54cf 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/DomainSplitNavigator.tsx +++ b/src/libs/Navigation/AppNavigator/Navigators/DomainSplitNavigator.tsx @@ -13,6 +13,7 @@ import type ReactComponentModule from '@src/types/utils/ReactComponentModule'; const loadDomainInitialPage = () => require('../../../../pages/domain/DomainInitialPage').default; const loadDomainSamlPage = () => require('../../../../pages/domain/DomainSamlPage').default; +const loadDomainAdminsPage = () => require('../../../../pages/domain/Admins/DomainAdminsPage').default; const Split = createSplitNavigator(); @@ -43,6 +44,12 @@ function DomainSplitNavigator({route, navigation}: PlatformStackScreenProps + + diff --git a/src/libs/Navigation/AppNavigator/createRootStackNavigator/GetStateForActionHandlers.ts b/src/libs/Navigation/AppNavigator/createRootStackNavigator/GetStateForActionHandlers.ts index 358b8ab189e97..b9a8ad120b0c3 100644 --- a/src/libs/Navigation/AppNavigator/createRootStackNavigator/GetStateForActionHandlers.ts +++ b/src/libs/Navigation/AppNavigator/createRootStackNavigator/GetStateForActionHandlers.ts @@ -107,7 +107,7 @@ function handleOpenDomainSplitAction( const actionToPushDomainSplitNavigator = StackActions.push(NAVIGATORS.DOMAIN_SPLIT_NAVIGATOR, { screen: action.payload.screenName, params: { - accountID: action.payload.accountID, + domainAccountID: action.payload.domainAccountID, }, }); diff --git a/src/libs/Navigation/AppNavigator/createRootStackNavigator/types.ts b/src/libs/Navigation/AppNavigator/createRootStackNavigator/types.ts index 72d4c5c330d58..18bf27cc4a3bd 100644 --- a/src/libs/Navigation/AppNavigator/createRootStackNavigator/types.ts +++ b/src/libs/Navigation/AppNavigator/createRootStackNavigator/types.ts @@ -22,7 +22,7 @@ type RootStackNavigatorActionType = | { type: typeof CONST.NAVIGATION.ACTION_TYPE.OPEN_DOMAIN_SPLIT; payload: { - accountID: number; + domainAccountID: number; screenName: DomainScreenName; }; } diff --git a/src/libs/Navigation/helpers/navigateToWorkspacesPage.ts b/src/libs/Navigation/helpers/navigateToWorkspacesPage.ts index 77e28c8be21f2..df07d7afcc293 100644 --- a/src/libs/Navigation/helpers/navigateToWorkspacesPage.ts +++ b/src/libs/Navigation/helpers/navigateToWorkspacesPage.ts @@ -100,7 +100,7 @@ const navigateToWorkspacesPage = ({currentUserLogin, shouldUseNarrowLayout, poli return navigationRef.dispatch({ type: CONST.NAVIGATION.ACTION_TYPE.OPEN_DOMAIN_SPLIT, - payload: {accountID: domain.accountID, screenName: domainScreenName}, + payload: {domainAccountID: domain.accountID, screenName: domainScreenName}, }); } } diff --git a/src/libs/Navigation/linkingConfig/config.ts b/src/libs/Navigation/linkingConfig/config.ts index 568cef73983e8..96700d6b571a3 100644 --- a/src/libs/Navigation/linkingConfig/config.ts +++ b/src/libs/Navigation/linkingConfig/config.ts @@ -1929,6 +1929,9 @@ const config: LinkingOptions['config'] = { [SCREENS.DOMAIN.SAML]: { path: ROUTES.DOMAIN_SAML.route, }, + [SCREENS.DOMAIN.ADMINS]: { + path: ROUTES.DOMAIN_ADMINS.route, + }, }, }, diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index dab35a7466d06..edf94161deb50 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -1319,10 +1319,10 @@ type SettingsNavigatorParamList = { subRateID: string; }; [SCREENS.DOMAIN.VERIFY]: { - accountID: number; + domainAccountID: number; }; [SCREENS.DOMAIN.VERIFIED]: { - accountID: number; + domainAccountID: number; }; } & ReimbursementAccountNavigatorParamList; @@ -2108,10 +2108,10 @@ type MergeTransactionNavigatorParamList = { type WorkspacesDomainModalNavigatorParamList = { [SCREENS.WORKSPACES_VERIFY_DOMAIN]: { - accountID: number; + domainAccountID: number; }; [SCREENS.WORKSPACES_DOMAIN_VERIFIED]: { - accountID: number; + domainAccountID: number; }; [SCREENS.WORKSPACES_ADD_DOMAIN]: undefined; [SCREENS.WORKSPACES_ADD_DOMAIN_VERIFY_ACCOUNT]: undefined; @@ -2119,7 +2119,7 @@ type WorkspacesDomainModalNavigatorParamList = { accountID: number; }; [SCREENS.WORKSPACES_DOMAIN_ACCESS_RESTRICTED]: { - accountID: number; + domainAccountID: number; }; }; @@ -2407,10 +2407,13 @@ type WorkspaceSplitNavigatorParamList = { type DomainSplitNavigatorParamList = { [SCREENS.DOMAIN.INITIAL]: { - accountID: number; + domainAccountID: number; }; [SCREENS.DOMAIN.SAML]: { - accountID: number; + domainAccountID: number; + }; + [SCREENS.DOMAIN.ADMINS]: { + domainAccountID: number; }; }; diff --git a/src/pages/domain/Admins/DomainAdminsPage.tsx b/src/pages/domain/Admins/DomainAdminsPage.tsx new file mode 100644 index 0000000000000..f3687dda1c8f4 --- /dev/null +++ b/src/pages/domain/Admins/DomainAdminsPage.tsx @@ -0,0 +1,148 @@ +import {adminAccountIDsSelector} from '@selectors/Domain'; +import React from 'react'; +import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView'; +import FullScreenLoadingIndicator from '@components/FullscreenLoadingIndicator'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import ScreenWrapper from '@components/ScreenWrapper'; +import SearchBar from '@components/SearchBar'; +import CustomListHeader from '@components/SelectionListWithModal/CustomListHeader'; +import SelectionList from '@components/SelectionListWithSections'; +import TableListItem from '@components/SelectionListWithSections/TableListItem'; +import type {ListItem} from '@components/SelectionListWithSections/types'; +import {useMemoizedLazyExpensifyIcons, useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useSearchResults from '@hooks/useSearchResults'; +import useThemeStyles from '@hooks/useThemeStyles'; +import {sortAlphabetically} from '@libs/OptionsListUtils'; +import {getDisplayNameOrDefault} from '@libs/PersonalDetailsUtils'; +import tokenizedSearch from '@libs/tokenizedSearch'; +import Navigation from '@navigation/Navigation'; +import type {PlatformStackScreenProps} from '@navigation/PlatformStackNavigation/types'; +import type {DomainSplitNavigatorParamList} from '@navigation/types'; +import {getCurrentUserAccountID} from '@userActions/Report'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import type SCREENS from '@src/SCREENS'; +import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; + +type DomainAdminsPageProps = PlatformStackScreenProps; + +type AdminOption = Omit & { + accountID: number; + login: string; +}; + +function DomainAdminsPage({route}: DomainAdminsPageProps) { + const {domainAccountID} = route.params; + + const {translate, formatPhoneNumber, localeCompare} = useLocalize(); + const styles = useThemeStyles(); + const illustrations = useMemoizedLazyIllustrations(['Members'] as const); + const icons = useMemoizedLazyExpensifyIcons(['FallbackAvatar'] as const); + const {shouldUseNarrowLayout} = useResponsiveLayout(); + + const [adminAccountIDs, domainMetadata] = useOnyx(`${ONYXKEYS.COLLECTION.DOMAIN}${domainAccountID}`, { + canBeMissing: true, + selector: adminAccountIDsSelector, + }); + const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, {canBeMissing: true}); + + const data: AdminOption[] = []; + for (const accountID of adminAccountIDs ?? []) { + const details = personalDetails?.[accountID]; + data.push({ + keyForList: String(accountID), + accountID, + login: details?.login ?? '', + text: formatPhoneNumber(getDisplayNameOrDefault(details)), + alternateText: formatPhoneNumber(details?.login ?? ''), + icons: [ + { + source: details?.avatar ?? icons.FallbackAvatar, + name: formatPhoneNumber(details?.login ?? ''), + type: CONST.ICON_TYPE_AVATAR, + id: accountID, + }, + ], + }); + } + + const filterMember = (adminOption: AdminOption, searchQuery: string) => { + const results = tokenizedSearch([adminOption], searchQuery, (option) => [option.text ?? '', option.alternateText ?? '']); + return results.length > 0; + }; + const sortMembers = (adminOptions: AdminOption[]) => sortAlphabetically(adminOptions, 'text', localeCompare); + const [inputValue, setInputValue, filteredData] = useSearchResults(data, filterMember, sortMembers); + + const getCustomListHeader = () => { + if (filteredData.length === 0) { + return null; + } + + return ( + + ); + }; + + const listHeaderContent = + data.length > CONST.SEARCH_ITEM_LIMIT ? ( + + ) : null; + + if (isLoadingOnyxValue(domainMetadata)) { + return ; + } + + const currentUserAccountID = getCurrentUserAccountID(); + const isAdmin = adminAccountIDs?.includes(currentUserAccountID); + + return ( + + Navigation.goBack(ROUTES.WORKSPACES_LIST.route)} + shouldShow={!isAdmin} + shouldForceFullScreen + > + + {}} + shouldShowListEmptyContent={false} + listItemTitleContainerStyles={shouldUseNarrowLayout ? undefined : [styles.pr3]} + showScrollIndicator={false} + addBottomSafeAreaPadding + customListHeader={getCustomListHeader()} + /> + + + ); +} + +DomainAdminsPage.displayName = 'DomainAdminsPage'; + +export default DomainAdminsPage; diff --git a/src/pages/domain/BaseDomainVerifiedPage.tsx b/src/pages/domain/BaseDomainVerifiedPage.tsx index c88f4c9f7a6c6..90b5c08c3cfff 100644 --- a/src/pages/domain/BaseDomainVerifiedPage.tsx +++ b/src/pages/domain/BaseDomainVerifiedPage.tsx @@ -19,18 +19,18 @@ import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; type BaseDomainVerifiedPageProps = { /** The accountID of the domain */ - accountID: number; + domainAccountID: number; /** Route to redirect to when trying to access the page for an unverified domain */ redirectTo: Route; }; -function BaseDomainVerifiedPage({accountID, redirectTo}: BaseDomainVerifiedPageProps) { +function BaseDomainVerifiedPage({domainAccountID, redirectTo}: BaseDomainVerifiedPageProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); - const [domain, domainMetadata] = useOnyx(`${ONYXKEYS.COLLECTION.DOMAIN}${accountID}`, {canBeMissing: false}); - const [isAdmin, isAdminMetadata] = useOnyx(`${ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_ADMIN_ACCESS}${accountID}`, {canBeMissing: false}); + const [domain, domainMetadata] = useOnyx(`${ONYXKEYS.COLLECTION.DOMAIN}${domainAccountID}`, {canBeMissing: false}); + const [isAdmin, isAdminMetadata] = useOnyx(`${ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_ADMIN_ACCESS}${domainAccountID}`, {canBeMissing: false}); const doesDomainExist = !!domain; useEffect(() => { @@ -38,7 +38,7 @@ function BaseDomainVerifiedPage({accountID, redirectTo}: BaseDomainVerifiedPageP return; } Navigation.setNavigationActionToMicrotaskQueue(() => Navigation.navigate(redirectTo, {forceReplace: true})); - }, [accountID, domain?.validated, doesDomainExist, redirectTo]); + }, [domainAccountID, domain?.validated, doesDomainExist, redirectTo]); if (isLoadingOnyxValue(domainMetadata, isAdminMetadata)) { return ; @@ -65,7 +65,7 @@ function BaseDomainVerifiedPage({accountID, redirectTo}: BaseDomainVerifiedPageP innerContainerStyle={styles.p10} buttonText={translate('common.buttonConfirm')} shouldShowButton - onButtonPress={() => Navigation.navigate(ROUTES.DOMAIN_INITIAL.getRoute(accountID))} + onButtonPress={() => Navigation.navigate(ROUTES.DOMAIN_INITIAL.getRoute(domainAccountID))} /> ); diff --git a/src/pages/domain/BaseVerifyDomainPage.tsx b/src/pages/domain/BaseVerifyDomainPage.tsx index 4a18e68a1f5c9..6f904740af229 100644 --- a/src/pages/domain/BaseVerifyDomainPage.tsx +++ b/src/pages/domain/BaseVerifyDomainPage.tsx @@ -38,18 +38,18 @@ function OrderedListRow({index, children}: PropsWithChildren<{index: number}>) { type BaseVerifyDomainPageProps = { /** The accountID of the domain */ - accountID: number; + domainAccountID: number; /** Route to navigate to after successful verification */ forwardTo: Route; }; -function BaseVerifyDomainPage({accountID, forwardTo}: BaseVerifyDomainPageProps) { +function BaseVerifyDomainPage({domainAccountID, forwardTo}: BaseVerifyDomainPageProps) { const styles = useThemeStyles(); const theme = useTheme(); const {translate} = useLocalize(); - const [domain, domainMetadata] = useOnyx(`${ONYXKEYS.COLLECTION.DOMAIN}${accountID}`, {canBeMissing: true}); + const [domain, domainMetadata] = useOnyx(`${ONYXKEYS.COLLECTION.DOMAIN}${domainAccountID}`, {canBeMissing: true}); const domainName = domain ? Str.extractEmailDomain(domain.email) : ''; const doesDomainExist = !!domain; @@ -60,21 +60,21 @@ function BaseVerifyDomainPage({accountID, forwardTo}: BaseVerifyDomainPageProps) return; } Navigation.setNavigationActionToMicrotaskQueue(() => Navigation.navigate(forwardTo, {forceReplace: true})); - }, [accountID, domain?.hasValidationSucceeded, forwardTo]); + }, [domainAccountID, domain?.hasValidationSucceeded, forwardTo]); useEffect(() => { if (!doesDomainExist) { return; } - getDomainValidationCode(accountID, domainName); - }, [accountID, domainName, doesDomainExist]); + getDomainValidationCode(domainAccountID, domainName); + }, [domainAccountID, domainName, doesDomainExist]); useEffect(() => { if (!doesDomainExist) { return; } - resetDomainValidationError(accountID); - }, [accountID, doesDomainExist]); + resetDomainValidationError(domainAccountID); + }, [domainAccountID, doesDomainExist]); if (isLoadingOnyxValue(domainMetadata)) { return ; @@ -127,7 +127,7 @@ function BaseVerifyDomainPage({accountID, forwardTo}: BaseVerifyDomainPageProps) {!!domain.validateCodeError && ( getDomainValidationCode(accountID, domainName)} + onRetry={() => getDomainValidationCode(domainAccountID, domainName)} isButtonSmall /> )} @@ -155,7 +155,7 @@ function BaseVerifyDomainPage({accountID, forwardTo}: BaseVerifyDomainPageProps) validateDomain(accountID, domainName)} + onSubmit={() => validateDomain(domainAccountID, domainName)} message={getLatestErrorMessage({errors: domain.domainValidationError})} isAlertVisible={!!domain.domainValidationError} containerStyles={styles.mb5} diff --git a/src/pages/domain/DomainAccessRestrictedPage.tsx b/src/pages/domain/DomainAccessRestrictedPage.tsx index 928c602638e4d..14d2494f7b312 100644 --- a/src/pages/domain/DomainAccessRestrictedPage.tsx +++ b/src/pages/domain/DomainAccessRestrictedPage.tsx @@ -41,8 +41,8 @@ function DomainAccessRestrictedPage({route}: DomainAccessRestrictedPageProps) { const theme = useTheme(); const {translate} = useLocalize(); - const accountID = route.params.accountID; - const [domainName, domainNameResults] = useOnyx(`${ONYXKEYS.COLLECTION.DOMAIN}${accountID}`, {canBeMissing: false, selector: domainNameSelector}); + const {domainAccountID} = route.params; + const [domainName, domainNameResults] = useOnyx(`${ONYXKEYS.COLLECTION.DOMAIN}${domainAccountID}`, {canBeMissing: false, selector: domainNameSelector}); if (isLoadingOnyxValue(domainNameResults)) { return ; @@ -87,7 +87,7 @@ function DomainAccessRestrictedPage({route}: DomainAccessRestrictedPageProps) { large success text={translate('common.verify')} - onPress={() => Navigation.navigate(ROUTES.WORKSPACES_VERIFY_DOMAIN.getRoute(accountID))} + onPress={() => Navigation.navigate(ROUTES.WORKSPACES_VERIFY_DOMAIN.getRoute(domainAccountID))} /> diff --git a/src/pages/domain/DomainInitialPage.tsx b/src/pages/domain/DomainInitialPage.tsx index 9bb827fdf219e..00076d3f42948 100644 --- a/src/pages/domain/DomainInitialPage.tsx +++ b/src/pages/domain/DomainInitialPage.tsx @@ -54,23 +54,23 @@ function DomainInitialPage({route}: DomainInitialPageProps) { const {translate} = useLocalize(); const shouldDisplayLHB = !shouldUseNarrowLayout; - const accountID = route.params?.accountID; - const [domain] = useOnyx(`${ONYXKEYS.COLLECTION.DOMAIN}${accountID}`, {canBeMissing: true}); + const domainAccountID = route.params?.domainAccountID; + const [domain] = useOnyx(`${ONYXKEYS.COLLECTION.DOMAIN}${domainAccountID}`, {canBeMissing: true}); const domainName = domain ? Str.extractEmailDomain(domain.email) : undefined; - const [isAdmin] = useOnyx(`${ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_ADMIN_ACCESS}${accountID}`, {canBeMissing: false}); + const [isAdmin] = useOnyx(`${ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_ADMIN_ACCESS}${domainAccountID}`, {canBeMissing: false}); const domainMenuItems: DomainMenuItem[] = useMemo(() => { const menuItems: DomainMenuItem[] = [ { translationKey: 'domain.saml', icon: icons.UserLock, - action: singleExecution(waitForNavigate(() => Navigation.navigate(ROUTES.DOMAIN_SAML.getRoute(accountID)))), + action: singleExecution(waitForNavigate(() => Navigation.navigate(ROUTES.DOMAIN_SAML.getRoute(domainAccountID)))), screenName: SCREENS.DOMAIN.SAML, }, ]; return menuItems; - }, [accountID, singleExecution, waitForNavigate, icons.UserLock]); + }, [domainAccountID, singleExecution, waitForNavigate, icons.UserLock]); useEffect(() => { if (!domainName) { diff --git a/src/pages/domain/DomainSamlPage.tsx b/src/pages/domain/DomainSamlPage.tsx index 52b5c53d1205f..79733f1be4f4a 100644 --- a/src/pages/domain/DomainSamlPage.tsx +++ b/src/pages/domain/DomainSamlPage.tsx @@ -35,10 +35,10 @@ function DomainSamlPage({route}: DomainSamlPageProps) { const {translate} = useLocalize(); const illustrations = useMemoizedLazyIllustrations(['LaptopOnDeskWithCoffeeAndKey', 'LockClosed', 'OpenSafe', 'ShieldYellow'] as const); - const accountID = route.params.accountID; - const [domain, domainResults] = useOnyx(`${ONYXKEYS.COLLECTION.DOMAIN}${accountID}`, {canBeMissing: true}); - const [isAdmin, isAdminResults] = useOnyx(`${ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_ADMIN_ACCESS}${accountID}`, {canBeMissing: false}); - const [domainSettings, domainSettingsResults] = useOnyx(`${ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_DOMAIN_MEMBER}${accountID}`, { + const domainAccountID = route.params?.domainAccountID; + const [domain, domainResults] = useOnyx(`${ONYXKEYS.COLLECTION.DOMAIN}${domainAccountID}`, {canBeMissing: true}); + const [isAdmin, isAdminResults] = useOnyx(`${ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_ADMIN_ACCESS}${domainAccountID}`, {canBeMissing: false}); + const [domainSettings, domainSettingsResults] = useOnyx(`${ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_DOMAIN_MEMBER}${domainAccountID}`, { canBeMissing: false, selector: domainMemberSamlSettingsSelector, }); @@ -106,7 +106,7 @@ function DomainSamlPage({route}: DomainSamlPageProps) { childrenStyles={[styles.gap6, styles.pt6]} > @@ -143,7 +143,7 @@ function DomainSamlPage({route}: DomainSamlPageProps) { ctaText={translate('domain.verifyDomain.title')} ctaAccessibilityLabel={translate('domain.verifyDomain.title')} onCtaPress={() => { - Navigation.navigate(ROUTES.DOMAIN_VERIFY.getRoute(accountID)); + Navigation.navigate(ROUTES.DOMAIN_VERIFY.getRoute(domainAccountID)); }} illustrationBackgroundColor={colors.blue700} illustration={illustrations.LaptopOnDeskWithCoffeeAndKey} diff --git a/src/pages/domain/SamlDomainVerifiedPage.tsx b/src/pages/domain/SamlDomainVerifiedPage.tsx index 16842dbd284f5..504e2b127a219 100644 --- a/src/pages/domain/SamlDomainVerifiedPage.tsx +++ b/src/pages/domain/SamlDomainVerifiedPage.tsx @@ -8,12 +8,12 @@ import BaseDomainVerifiedPage from './BaseDomainVerifiedPage'; type SamlDomainVerifiedPageProps = PlatformStackScreenProps; function SamlDomainVerifiedPage({route}: SamlDomainVerifiedPageProps) { - const accountID = route.params.accountID; + const {domainAccountID} = route.params; return ( ); } diff --git a/src/pages/domain/SamlVerifyDomainPage.tsx b/src/pages/domain/SamlVerifyDomainPage.tsx index efa027338eb55..98498baba4fbb 100644 --- a/src/pages/domain/SamlVerifyDomainPage.tsx +++ b/src/pages/domain/SamlVerifyDomainPage.tsx @@ -8,12 +8,12 @@ import BaseVerifyDomainPage from './BaseVerifyDomainPage'; type SamlVerifyDomainPageProps = PlatformStackScreenProps; function SamlVerifyDomainPage({route}: SamlVerifyDomainPageProps) { - const accountID = route.params.accountID; + const {domainAccountID} = route.params; return ( ); } diff --git a/src/pages/domain/WorkspacesDomainVerifiedPage.tsx b/src/pages/domain/WorkspacesDomainVerifiedPage.tsx index 94228f7f220de..20ddd797cd69c 100644 --- a/src/pages/domain/WorkspacesDomainVerifiedPage.tsx +++ b/src/pages/domain/WorkspacesDomainVerifiedPage.tsx @@ -8,12 +8,12 @@ import BaseDomainVerifiedPage from './BaseDomainVerifiedPage'; type WorkspacesDomainVerifiedPageProps = PlatformStackScreenProps; function WorkspacesDomainVerifiedPage({route}: WorkspacesDomainVerifiedPageProps) { - const accountID = route.params.accountID; + const {domainAccountID} = route.params; return ( ); } diff --git a/src/pages/domain/WorkspacesVerifyDomainPage.tsx b/src/pages/domain/WorkspacesVerifyDomainPage.tsx index 4c595e92ea124..65e5319e0cc96 100644 --- a/src/pages/domain/WorkspacesVerifyDomainPage.tsx +++ b/src/pages/domain/WorkspacesVerifyDomainPage.tsx @@ -8,12 +8,12 @@ import BaseVerifyDomainPage from './BaseVerifyDomainPage'; type WorkspacesVerifyDomainPageProps = PlatformStackScreenProps; function WorkspacesVerifyDomainPage({route}: WorkspacesVerifyDomainPageProps) { - const accountID = route.params.accountID; + const {domainAccountID} = route.params; return ( ); } diff --git a/src/pages/workspace/WorkspacesListPage.tsx b/src/pages/workspace/WorkspacesListPage.tsx index 7677780dec644..6a879ac74b11a 100755 --- a/src/pages/workspace/WorkspacesListPage.tsx +++ b/src/pages/workspace/WorkspacesListPage.tsx @@ -502,11 +502,11 @@ function WorkspacesListPage() { [shouldUseNarrowLayout], ); - const navigateToDomain = useCallback(({accountID, isAdmin}: {accountID: number; isAdmin: boolean}) => { + const navigateToDomain = useCallback(({domainAccountID, isAdmin}: {domainAccountID: number; isAdmin: boolean}) => { if (!isAdmin) { - return Navigation.navigate(ROUTES.WORKSPACES_DOMAIN_ACCESS_RESTRICTED.getRoute(accountID)); + return Navigation.navigate(ROUTES.WORKSPACES_DOMAIN_ACCESS_RESTRICTED.getRoute(domainAccountID)); } - Navigation.navigate(ROUTES.DOMAIN_INITIAL.getRoute(accountID)); + Navigation.navigate(ROUTES.DOMAIN_INITIAL.getRoute(domainAccountID)); }, []); /** @@ -593,7 +593,7 @@ function WorkspacesListPage() { listItemType: 'domain', accountID: domain.accountID, title: Str.extractEmailDomain(domain.email), - action: () => navigateToDomain({accountID: domain.accountID, isAdmin}), + action: () => navigateToDomain({domainAccountID: domain.accountID, isAdmin}), isAdmin, isValidated: domain.validated, pendingAction: domain.pendingAction, diff --git a/src/selectors/Domain.ts b/src/selectors/Domain.ts index af098c732c896..c24e8cc4225c3 100644 --- a/src/selectors/Domain.ts +++ b/src/selectors/Domain.ts @@ -1,6 +1,8 @@ import {Str} from 'expensify-common'; import type {OnyxEntry} from 'react-native-onyx'; +import ONYXKEYS from '@src/ONYXKEYS'; import type {CardFeeds, Domain, SamlMetadata} from '@src/types/onyx'; +import getEmptyArray from '@src/types/utils/getEmptyArray'; const domainMemberSamlSettingsSelector = (domainSettings: OnyxEntry) => domainSettings?.settings; @@ -18,4 +20,30 @@ const domainNameSelector = (domain: OnyxEntry) => (domain?.email ? Str.e const metaIdentitySelector = (samlMetadata: OnyxEntry) => samlMetadata?.metaIdentity; -export {domainMemberSamlSettingsSelector, domainSamlSettingsStateSelector, domainNameSelector, metaIdentitySelector}; +/** + * Extracts a list of admin IDs (accountIDs) from the domain object. + * It filters the domain properties for keys starting with the admin permissions prefix + * and returns the values as an array of numbers. + * + * @param domain - The domain object from Onyx + * @returns An array of admin account IDs + */ +function adminAccountIDsSelector(domain: OnyxEntry): number[] { + if (!domain) { + return []; + } + + return ( + Object.entries(domain).reduce((acc, [key, value]) => { + if (!key.startsWith(ONYXKEYS.COLLECTION.EXPENSIFY_ADMIN_ACCESS_PREFIX) || value === undefined || value === null) { + return acc; + } + + acc.push(Number(value)); + + return acc; + }, []) ?? getEmptyArray() + ); +} + +export {domainMemberSamlSettingsSelector, domainSamlSettingsStateSelector, domainNameSelector, metaIdentitySelector, adminAccountIDsSelector}; diff --git a/src/types/onyx/Domain.ts b/src/types/onyx/Domain.ts index 400f703a2219d..22a3bd61f6036 100644 --- a/src/types/onyx/Domain.ts +++ b/src/types/onyx/Domain.ts @@ -1,5 +1,11 @@ +import type ONYXKEYS from '@src/ONYXKEYS'; import type * as OnyxCommon from './OnyxCommon'; +/** + * A utility type that creates a record where all keys are strings that start with a specified prefix. + */ +type PrefixedRecord = Record<`${Prefix}${string}`, ValueType>; + /** Model of domain data */ type Domain = OnyxCommon.OnyxValueWithOfflineFeedback<{ /** Whether the domain is validated */ @@ -40,7 +46,8 @@ type Domain = OnyxCommon.OnyxValueWithOfflineFeedback<{ /** Whether setting SAML required setting has failed and why */ samlRequiredError?: OnyxCommon.Errors; -}>; +}> & + PrefixedRecord; /** Model of SAML metadata */ type SamlMetadata = { diff --git a/tests/unit/DomainSelectorsTest.ts b/tests/unit/DomainSelectorsTest.ts new file mode 100644 index 0000000000000..3be6dcac32277 --- /dev/null +++ b/tests/unit/DomainSelectorsTest.ts @@ -0,0 +1,45 @@ +import {adminAccountIDsSelector} from '@selectors/Domain'; +import type {OnyxEntry} from 'react-native-onyx'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Domain} from '@src/types/onyx'; + +describe('domainSelectors', () => { + describe('adminAccountIDsSelector', () => { + it('Should return an empty array if the domain object is undefined', () => { + expect(adminAccountIDsSelector(undefined)).toEqual([]); + }); + + it('Should return an array of admin IDs when keys start with the admin access prefix', () => { + const domain = { + [`${ONYXKEYS.COLLECTION.EXPENSIFY_ADMIN_ACCESS_PREFIX}123`]: 321, + [`${ONYXKEYS.COLLECTION.EXPENSIFY_ADMIN_ACCESS_PREFIX}321`]: 123, + } as unknown as OnyxEntry; + + expect(adminAccountIDsSelector(domain)).toEqual([321, 123]); + }); + + it('Should ignore keys that do not start with the admin access prefix', () => { + const domain = { + [`${ONYXKEYS.COLLECTION.EXPENSIFY_ADMIN_ACCESS_PREFIX}123`]: 321, + somOtherProperty: 'value', + } as unknown as OnyxEntry; + + expect(adminAccountIDsSelector(domain)).toEqual([321]); + }); + + it('Should ignore keys with falsy values even if they have the correct prefix', () => { + const domain = { + [`${ONYXKEYS.COLLECTION.EXPENSIFY_ADMIN_ACCESS_PREFIX}123`]: 123, + [`${ONYXKEYS.COLLECTION.EXPENSIFY_ADMIN_ACCESS_PREFIX}0`]: undefined, + [`${ONYXKEYS.COLLECTION.EXPENSIFY_ADMIN_ACCESS_PREFIX}999`]: null, + } as unknown as OnyxEntry; + + expect(adminAccountIDsSelector(domain)).toEqual([123]); + }); + + it('Should return an empty array if the domain object is empty', () => { + const domain = {} as OnyxEntry; + expect(adminAccountIDsSelector(domain)).toEqual([]); + }); + }); +});