diff --git a/assets/images/simple-illustrations/simple-illustration__user-shield.svg b/assets/images/simple-illustrations/simple-illustration__user-shield.svg
new file mode 100644
index 000000000000..315a0efd4134
--- /dev/null
+++ b/assets/images/simple-illustrations/simple-illustration__user-shield.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/CONST/index.ts b/src/CONST/index.ts
index 6dc1f5c03d4d..d7bce6e03019 100755
--- a/src/CONST/index.ts
+++ b/src/CONST/index.ts
@@ -7907,6 +7907,13 @@ const CONST = {
HEADER_ACTION_BUTTON: 'Task-HeaderActionButton',
},
},
+
+ DOMAIN: {
+ /** Onyx prefix for domain admin account IDs */
+ EXPENSIFY_ADMIN_ACCESS_PREFIX: 'expensify_adminPermissions_',
+ /** Onyx prefix for domain security groups */
+ DOMAIN_SECURITY_GROUP_PREFIX: 'domain_securityGroup_',
+ },
} as const;
const CONTINUATION_DETECTION_SEARCH_FILTER_KEYS = [
diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts
index 5bf1bcf1f2c0..603a3507b0b0 100755
--- a/src/ONYXKEYS.ts
+++ b/src/ONYXKEYS.ts
@@ -761,9 +761,6 @@ const ONYXKEYS = {
/** SAML login metadata for a domain */
SAML_METADATA: 'saml_metadata_',
- /** Stores domain admin account ID */
- EXPENSIFY_ADMIN_ACCESS_PREFIX: 'expensify_adminPermissions_',
-
/** Pending actions for a domain */
DOMAIN_PENDING_ACTIONS: 'domainPendingActions_',
@@ -1160,7 +1157,6 @@ 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;
[ONYXKEYS.COLLECTION.DOMAIN_PENDING_ACTIONS]: OnyxTypes.DomainPendingActions;
[ONYXKEYS.COLLECTION.DOMAIN_ERRORS]: OnyxTypes.DomainErrors;
};
diff --git a/src/ROUTES.ts b/src/ROUTES.ts
index 0e3313852625..5119da278a08 100644
--- a/src/ROUTES.ts
+++ b/src/ROUTES.ts
@@ -3600,6 +3600,10 @@ const ROUTES = {
route: 'domain/:domainAccountID/admins/invite',
getRoute: (domainAccountID: number) => `domain/${domainAccountID}/admins/invite` as const,
},
+ DOMAIN_MEMBERS: {
+ route: 'domain/:domainAccountID/members',
+ getRoute: (domainAccountID: number) => `domain/${domainAccountID}/members` as const,
+ },
} as const;
/**
diff --git a/src/SCREENS.ts b/src/SCREENS.ts
index 85c9b8346785..590fb002eb3d 100644
--- a/src/SCREENS.ts
+++ b/src/SCREENS.ts
@@ -874,6 +874,7 @@ const SCREENS = {
ADMINS_SETTINGS: 'Admins_Settings',
ADD_PRIMARY_CONTACT: 'Add_Primary_Contact',
ADD_ADMIN: 'Domain_Add_Admin',
+ MEMBERS: 'Domain_Members',
},
} as const;
diff --git a/src/components/Icon/chunks/illustrations.chunk.ts b/src/components/Icon/chunks/illustrations.chunk.ts
index f1c505d7e1d7..ce7cf4c06406 100644
--- a/src/components/Icon/chunks/illustrations.chunk.ts
+++ b/src/components/Icon/chunks/illustrations.chunk.ts
@@ -156,6 +156,7 @@ import TrashCan from '@assets/images/simple-illustrations/simple-illustration__t
import TravelAlerts from '@assets/images/simple-illustrations/simple-illustration__travelalerts.svg';
import TreasureChest from '@assets/images/simple-illustrations/simple-illustration__treasurechest.svg';
import CompanyCard from '@assets/images/simple-illustrations/simple-illustration__twocards-horizontal.svg';
+import UserShield from '@assets/images/simple-illustrations/simple-illustration__user-shield.svg';
import VirtualCard from '@assets/images/simple-illustrations/simple-illustration__virtualcard.svg';
import Workflows from '@assets/images/simple-illustrations/simple-illustration__workflows.svg';
import ExpensifyApprovedLogo from '@assets/images/subscription-details__approvedlogo.svg';
@@ -329,6 +330,7 @@ const Illustrations = {
ShieldYellow,
Clock,
Members,
+ UserShield,
};
/**
diff --git a/src/languages/de.ts b/src/languages/de.ts
index 020ccf7147e6..d970d75bc093 100644
--- a/src/languages/de.ts
+++ b/src/languages/de.ts
@@ -7933,6 +7933,7 @@ Hier ist ein *Testbeleg*, um dir zu zeigen, wie es funktioniert:`,
cantRevokeAdminAccess: 'Adminzugriff kann dem technischen Ansprechpartner nicht entzogen werden',
error: {removeAdmin: 'Dieser Benutzer kann nicht als Admin entfernt werden. Bitte versuchen Sie es erneut.'},
},
+ members: {title: 'Mitglieder', findMember: 'Mitglied suchen'},
},
gps: {
tooltip: 'GPS-Verfolgung läuft! Wenn du fertig bist, stoppe die Verfolgung unten.',
diff --git a/src/languages/en.ts b/src/languages/en.ts
index 9cc61cc8166f..2cc3aec1d4f3 100755
--- a/src/languages/en.ts
+++ b/src/languages/en.ts
@@ -7841,6 +7841,10 @@ const translations = {
removeAdmin: 'Unable to remove this user as an Admin. Please try again.',
},
},
+ members: {
+ title: 'Members',
+ findMember: 'Find member',
+ },
},
};
diff --git a/src/languages/es.ts b/src/languages/es.ts
index 222e2131599a..08e7aeab9646 100644
--- a/src/languages/es.ts
+++ b/src/languages/es.ts
@@ -8009,6 +8009,10 @@ ${amount} para ${merchant} - ${date}`,
cantRevokeAdminAccess: 'No se puede revocar el acceso de administrador del contacto técnico',
error: {removeAdmin: 'No se pudo eliminar a este usuario como administrador. Por favor, inténtalo de nuevo.'},
},
+ members: {
+ title: 'Miembros',
+ findMember: 'Buscar miembro',
+ },
},
gps: {
tooltip: '¡Seguimiento por GPS en curso! Cuando termines, detén el seguimiento a continuación.',
diff --git a/src/languages/fr.ts b/src/languages/fr.ts
index 7bc96c367822..c2d89cc6e4f0 100644
--- a/src/languages/fr.ts
+++ b/src/languages/fr.ts
@@ -7937,6 +7937,7 @@ Voici un *reçu test* pour vous montrer comment cela fonctionne :`,
cantRevokeAdminAccess: 'Impossible de révoquer l’accès administrateur au contact technique',
error: {removeAdmin: 'Impossible de supprimer cet utilisateur en tant qu’administrateur. Veuillez réessayer.'},
},
+ members: {title: 'Membres', findMember: 'Rechercher un membre'},
},
gps: {
tooltip: 'Suivi GPS en cours ! Quand vous avez terminé, arrêtez le suivi ci-dessous.',
diff --git a/src/languages/it.ts b/src/languages/it.ts
index f8ae6fef71c9..7c395735ca5c 100644
--- a/src/languages/it.ts
+++ b/src/languages/it.ts
@@ -7915,6 +7915,7 @@ Ecco una *ricevuta di prova* per mostrarti come funziona:`,
cantRevokeAdminAccess: 'Impossibile revocare i privilegi di amministratore dal referente tecnico',
error: {removeAdmin: 'Impossibile rimuovere questo utente come amministratore. Riprova.'},
},
+ members: {title: 'Membri', findMember: 'Trova membro'},
},
gps: {
tooltip: 'Monitoraggio GPS in corso! Quando hai finito, interrompi il monitoraggio qui sotto.',
diff --git a/src/languages/ja.ts b/src/languages/ja.ts
index c9ea2e2d83b6..992bf294e18a 100644
--- a/src/languages/ja.ts
+++ b/src/languages/ja.ts
@@ -7853,6 +7853,7 @@ Expensify の使い方をお見せするための*テストレシート*がこ
cantRevokeAdminAccess: '技術連絡先から管理者アクセス権を取り消すことはできません',
error: {removeAdmin: 'このユーザーを管理者として削除できません。もう一度お試しください。'},
},
+ members: {title: 'メンバー', findMember: 'メンバーを検索'},
},
gps: {
tooltip: 'GPS 追跡を進行中です!完了したら、下で追跡を停止してください。',
diff --git a/src/languages/nl.ts b/src/languages/nl.ts
index efb0e5be92bd..1b050cbbcb9b 100644
--- a/src/languages/nl.ts
+++ b/src/languages/nl.ts
@@ -7904,6 +7904,7 @@ Hier is een *testbon* om je te laten zien hoe het werkt:`,
cantRevokeAdminAccess: 'Kan de beheerdersrechten niet intrekken van de technische contactpersoon',
error: {removeAdmin: 'Kan deze gebruiker niet als beheerder verwijderen. Probeer het opnieuw.'},
},
+ members: {title: 'Leden', findMember: 'Lid zoeken'},
},
gps: {
tooltip: 'GPS-tracking bezig! Als je klaar bent, stop dan hieronder met tracken.',
diff --git a/src/languages/pl.ts b/src/languages/pl.ts
index 4b158b362d7a..9cfef9445cf9 100644
--- a/src/languages/pl.ts
+++ b/src/languages/pl.ts
@@ -7884,6 +7884,7 @@ Oto *paragon testowy*, który pokazuje, jak to działa:`,
cantRevokeAdminAccess: 'Nie można odebrać uprawnień administratora kontaktowi technicznemu',
error: {removeAdmin: 'Nie można usunąć tego użytkownika jako administratora. Spróbuj ponownie.'},
},
+ members: {title: 'Członkowie', findMember: 'Znajdź członka'},
},
gps: {
tooltip: 'Śledzenie GPS w toku! Gdy skończysz, zatrzymaj śledzenie poniżej.',
diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts
index 94f3cf4ca44c..d5bcd20d7fcf 100644
--- a/src/languages/pt-BR.ts
+++ b/src/languages/pt-BR.ts
@@ -7891,6 +7891,7 @@ Aqui está um *recibo de teste* para mostrar como funciona:`,
cantRevokeAdminAccess: 'Não é possível revogar o acesso de administrador do contato técnico',
error: {removeAdmin: 'Não foi possível remover este usuário como Administrador. Tente novamente.'},
},
+ members: {title: 'Membros', findMember: 'Encontrar membro'},
},
gps: {
tooltip: 'Rastreamento por GPS em andamento! Quando terminar, pare o rastreamento abaixo.',
diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts
index 29373376dc3a..b917d1abf583 100644
--- a/src/languages/zh-hans.ts
+++ b/src/languages/zh-hans.ts
@@ -7708,6 +7708,7 @@ ${reportName}
cantRevokeAdminAccess: '无法撤销技术联系人的管理员访问权限',
error: {removeAdmin: '无法将此用户移除管理员角色。请重试。'},
},
+ members: {title: '成员', findMember: '查找成员'},
},
gps: {
tooltip: 'GPS 跟踪进行中!完成后,请在下方停止跟踪。',
diff --git a/src/libs/Navigation/AppNavigator/Navigators/DomainSplitNavigator.tsx b/src/libs/Navigation/AppNavigator/Navigators/DomainSplitNavigator.tsx
index 1c94244e75d7..649f911492e0 100644
--- a/src/libs/Navigation/AppNavigator/Navigators/DomainSplitNavigator.tsx
+++ b/src/libs/Navigation/AppNavigator/Navigators/DomainSplitNavigator.tsx
@@ -14,6 +14,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 loadDomainMembersPage = () => require('../../../../pages/domain/Members/DomainMembersPage').default;
const Split = createSplitNavigator();
@@ -50,6 +51,12 @@ function DomainSplitNavigator({route, navigation}: PlatformStackScreenProps
+
+
diff --git a/src/libs/Navigation/linkingConfig/config.ts b/src/libs/Navigation/linkingConfig/config.ts
index 1712346ca6a9..0881a7cda9d0 100644
--- a/src/libs/Navigation/linkingConfig/config.ts
+++ b/src/libs/Navigation/linkingConfig/config.ts
@@ -2014,6 +2014,9 @@ const config: LinkingOptions['config'] = {
[SCREENS.DOMAIN.ADMINS]: {
path: ROUTES.DOMAIN_ADMINS.route,
},
+ [SCREENS.DOMAIN.MEMBERS]: {
+ path: ROUTES.DOMAIN_MEMBERS.route,
+ },
},
},
diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts
index 58e320b05a4f..0322c1e09c33 100644
--- a/src/libs/Navigation/types.ts
+++ b/src/libs/Navigation/types.ts
@@ -2511,6 +2511,9 @@ type DomainSplitNavigatorParamList = {
[SCREENS.DOMAIN.ADMINS]: {
domainAccountID: number;
};
+ [SCREENS.DOMAIN.MEMBERS]: {
+ domainAccountID: number;
+ };
};
type OnboardingModalNavigatorParamList = {
diff --git a/src/libs/actions/Domain.ts b/src/libs/actions/Domain.ts
index 25e041c7798b..34c4a7f3c966 100644
--- a/src/libs/actions/Domain.ts
+++ b/src/libs/actions/Domain.ts
@@ -7,6 +7,7 @@ import {getMicroSecondOnyxErrorWithTranslationKey} from '@libs/ErrorUtils';
import {getAuthToken} from '@libs/Network/NetworkStore';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
+import type PrefixedRecord from '@src/types/utils/PrefixedRecord';
import type {ScimTokenWithState} from './ScimToken/ScimTokenUtils';
import {ScimTokenState} from './ScimToken/ScimTokenUtils';
@@ -527,7 +528,7 @@ function clearToggleConsolidatedDomainBillingErrors(domainAccountID: number) {
}
function addAdminToDomain(domainAccountID: number, accountID: number, targetEmail: string, domainName: string) {
- const PERMISSION_KEY = `${ONYXKEYS.COLLECTION.EXPENSIFY_ADMIN_ACCESS_PREFIX}${accountID}`;
+ const PERMISSION_KEY = `${CONST.DOMAIN.EXPENSIFY_ADMIN_ACCESS_PREFIX}${accountID}`;
const optimisticData: OnyxUpdate[] = [
{
@@ -535,7 +536,7 @@ function addAdminToDomain(domainAccountID: number, accountID: number, targetEmai
key: `${ONYXKEYS.COLLECTION.DOMAIN}${domainAccountID}`,
value: {
[PERMISSION_KEY]: accountID,
- },
+ } as PrefixedRecord,
},
{
onyxMethod: Onyx.METHOD.MERGE,
diff --git a/src/pages/domain/Admins/DomainAddPrimaryContactPage.tsx b/src/pages/domain/Admins/DomainAddPrimaryContactPage.tsx
index e274070b3ade..c7dcdec997ec 100644
--- a/src/pages/domain/Admins/DomainAddPrimaryContactPage.tsx
+++ b/src/pages/domain/Admins/DomainAddPrimaryContactPage.tsx
@@ -70,11 +70,13 @@ function DomainAddPrimaryContactPage({route}: DomainAddPrimaryContactPageProps)
}
const details = personalDetails?.[accountID];
- if (details?.login === technicalContactSettings?.technicalContactEmail) {
+ let isSelected = false;
+ if (!!details?.login && !!technicalContactSettings?.technicalContactEmail && details.login === technicalContactSettings.technicalContactEmail) {
technicalContactEmailKey = String(accountID);
+ isSelected = true;
}
data.push({
- isSelected: details?.login === technicalContactSettings?.technicalContactEmail,
+ isSelected,
keyForList: String(accountID),
accountID,
login: details?.login ?? '',
diff --git a/src/pages/domain/Admins/DomainAdminsPage.tsx b/src/pages/domain/Admins/DomainAdminsPage.tsx
index 7b05f9a0a046..c241395abf69 100644
--- a/src/pages/domain/Admins/DomainAdminsPage.tsx
+++ b/src/pages/domain/Admins/DomainAdminsPage.tsx
@@ -1,56 +1,33 @@
import {adminAccountIDsSelector, adminPendingActionSelector, technicalContactSettingsSelector} from '@selectors/Domain';
import React from 'react';
-import {View} from 'react-native';
import Badge from '@components/Badge';
import Button from '@components/Button';
-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';
-// eslint-disable-next-line no-restricted-imports
-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 {getLatestError} from '@libs/ErrorUtils';
-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 DomainNotFoundPageWrapper from '@pages/domain/DomainNotFoundPageWrapper';
+import BaseDomainMembersPage from '@pages/domain/BaseDomainMembersPage';
import {clearAdminError} from '@userActions/Domain';
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 {translate} = useLocalize();
const styles = useThemeStyles();
- const illustrations = useMemoizedLazyIllustrations(['Members']);
- const icons = useMemoizedLazyExpensifyIcons(['FallbackAvatar', 'Gear', 'Plus']);
const {shouldUseNarrowLayout} = useResponsiveLayout();
+ const illustrations = useMemoizedLazyIllustrations(['UserShield']);
+ const icons = useMemoizedLazyExpensifyIcons(['Gear', 'Plus']);
- const [adminAccountIDs, domainMetadata] = useOnyx(`${ONYXKEYS.COLLECTION.DOMAIN}${domainAccountID}`, {
+ const [adminAccountIDs] = useOnyx(`${ONYXKEYS.COLLECTION.DOMAIN}${domainAccountID}`, {
canBeMissing: true,
selector: adminAccountIDsSelector,
});
@@ -73,131 +50,53 @@ function DomainAdminsPage({route}: DomainAdminsPageProps) {
const currentUserAccountID = getCurrentUserAccountID();
const isAdmin = adminAccountIDs?.includes(currentUserAccountID);
- const data: AdminOption[] = [];
- for (const accountID of adminAccountIDs ?? []) {
- const details = personalDetails?.[accountID];
- const isPrimaryContact = technicalContactSettings?.technicalContactEmail === details?.login;
- const isPendingActionDelete = domainPendingAction?.[accountID]?.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE;
- 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,
- },
- ],
- rightElement: isPrimaryContact && ,
- errors: getLatestError(domainErrors?.adminErrors?.[accountID]?.errors),
- pendingAction: domainPendingAction?.[accountID]?.pendingAction,
- isInteractive: !isPendingActionDelete,
- isDisabled: isPendingActionDelete,
- });
- }
-
- 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) {
+ const getCustomRightElement = (accountID: number) => {
+ const technicalContactEmail = technicalContactSettings?.technicalContactEmail;
+ const login = personalDetails?.[accountID]?.login;
+ if (!technicalContactEmail || !login || technicalContactEmail !== login) {
return null;
}
-
- return (
-
- );
+ return ;
};
- const getHeaderButtons = () => {
- if (!isAdmin) {
- return null;
- }
- return (
-
-
- );
- };
+ const getCustomRowProps = (accountID: number) => ({
+ errors: domainErrors?.adminErrors?.[accountID]?.errors,
+ pendingAction: domainPendingAction?.[accountID]?.pendingAction,
+ });
- const listHeaderContent =
- data.length > CONST.SEARCH_ITEM_LIMIT ? (
-
+ Navigation.navigate(ROUTES.DOMAIN_ADD_ADMIN.getRoute(domainAccountID))}
+ text={translate('domain.admins.addAdmin')}
+ icon={icons.Plus}
+ innerStyles={[shouldUseNarrowLayout && styles.alignItemsCenter]}
+ style={shouldUseNarrowLayout && [styles.flexGrow1, styles.mb3]}
/>
- ) : null;
-
- if (isLoadingOnyxValue(domainMetadata)) {
- return ;
- }
+ Navigation.navigate(ROUTES.DOMAIN_ADMINS_SETTINGS.getRoute(domainAccountID))}
+ text={translate('domain.admins.settings')}
+ icon={icons.Gear}
+ innerStyles={[shouldUseNarrowLayout && styles.alignItemsCenter]}
+ style={shouldUseNarrowLayout ? [styles.flexGrow1, styles.mb3] : undefined}
+ />
+ >
+ ) : null;
return (
-
-
-
- {!shouldUseNarrowLayout && getHeaderButtons()}
-
-
- {shouldUseNarrowLayout && {getHeaderButtons()}}
- Navigation.navigate(ROUTES.DOMAIN_ADMIN_DETAILS.getRoute(domainAccountID, item.accountID))}
- shouldShowListEmptyContent={false}
- listItemTitleContainerStyles={shouldUseNarrowLayout ? undefined : [styles.pr3]}
- showScrollIndicator={false}
- addBottomSafeAreaPadding
- customListHeader={getCustomListHeader()}
- onDismissError={(item: AdminOption) => clearAdminError(domainAccountID, item.accountID)}
- />
-
-
+ clearAdminError(domainAccountID, item.accountID)}
+ onSelectRow={(item) => Navigation.navigate(ROUTES.DOMAIN_ADMIN_DETAILS.getRoute(domainAccountID, item.accountID))}
+ />
);
}
diff --git a/src/pages/domain/BaseDomainMembersPage.tsx b/src/pages/domain/BaseDomainMembersPage.tsx
new file mode 100644
index 000000000000..8feb4bf27b88
--- /dev/null
+++ b/src/pages/domain/BaseDomainMembersPage.tsx
@@ -0,0 +1,186 @@
+import React from 'react';
+import {View} from 'react-native';
+import HeaderWithBackButton from '@components/HeaderWithBackButton';
+import ScreenWrapper from '@components/ScreenWrapper';
+import SearchBar from '@components/SearchBar';
+import CustomListHeader from '@components/SelectionListWithModal/CustomListHeader';
+// eslint-disable-next-line no-restricted-imports
+import SelectionList from '@components/SelectionListWithSections';
+import TableListItem from '@components/SelectionListWithSections/TableListItem';
+import type {ListItem} from '@components/SelectionListWithSections/types';
+import {useMemoizedLazyExpensifyIcons} 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 {getLatestError} from '@libs/ErrorUtils';
+import {sortAlphabetically} from '@libs/OptionsListUtils';
+import {getDisplayNameOrDefault} from '@libs/PersonalDetailsUtils';
+import tokenizedSearch from '@libs/tokenizedSearch';
+import Navigation from '@navigation/Navigation';
+import CONST from '@src/CONST';
+import ONYXKEYS from '@src/ONYXKEYS';
+import type {Errors, PendingAction} from '@src/types/onyx/OnyxCommon';
+import type IconAsset from '@src/types/utils/IconAsset';
+import DomainNotFoundPageWrapper from './DomainNotFoundPageWrapper';
+
+type MemberOption = Omit & {
+ /** Member accountID */
+ accountID: number;
+ /** Member login */
+ login: string;
+};
+
+type BaseDomainMembersPageProps = {
+ /** The ID of the domain used for the not found wrapper */
+ domainAccountID: number;
+
+ /** The list of accountIDs to display */
+ accountIDs: number[];
+
+ /** The title of the header */
+ headerTitle: string;
+
+ /** Placeholder text for the search bar */
+ searchPlaceholder: string;
+
+ /** Content to display in the header (e.g., Add/Settings buttons) */
+ headerContent?: React.ReactNode;
+
+ /** Callback fired when a row is selected */
+ onSelectRow: (item: MemberOption) => void;
+
+ /** Icon displayed in the header of the tab */
+ headerIcon?: IconAsset;
+
+ /** Function to render a custom right element for a row */
+ getCustomRightElement?: (accountID: number) => React.ReactNode;
+
+ /** Function to return additional row-specific properties like errors or pending actions */
+ getCustomRowProps?: (accountID: number) => {errors?: Errors; pendingAction?: PendingAction};
+
+ /** Callback fired when the user dismisses an error message for a specific row */
+ onDismissError?: (item: MemberOption) => void;
+};
+
+function BaseDomainMembersPage({
+ domainAccountID,
+ accountIDs,
+ headerTitle,
+ searchPlaceholder,
+ headerContent,
+ onSelectRow,
+ headerIcon,
+ getCustomRightElement,
+ getCustomRowProps,
+ onDismissError,
+}: BaseDomainMembersPageProps) {
+ const {formatPhoneNumber, localeCompare} = useLocalize();
+ const styles = useThemeStyles();
+ const {shouldUseNarrowLayout} = useResponsiveLayout();
+ const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, {canBeMissing: true});
+ const icons = useMemoizedLazyExpensifyIcons(['FallbackAvatar']);
+
+ const data: MemberOption[] = accountIDs.map((accountID) => {
+ const details = personalDetails?.[accountID];
+ const login = details?.login ?? '';
+ const customProps = getCustomRowProps?.(accountID);
+ const isPendingActionDelete = customProps?.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE;
+
+ return {
+ keyForList: String(accountID),
+ accountID,
+ login,
+ text: formatPhoneNumber(getDisplayNameOrDefault(details)),
+ alternateText: formatPhoneNumber(login),
+ icons: [
+ {
+ source: details?.avatar ?? icons.FallbackAvatar,
+ name: formatPhoneNumber(login),
+ type: CONST.ICON_TYPE_AVATAR,
+ id: accountID,
+ },
+ ],
+ rightElement: getCustomRightElement?.(accountID),
+ errors: getLatestError(customProps?.errors),
+ pendingAction: customProps?.pendingAction,
+ isInteractive: !isPendingActionDelete,
+ isDisabled: isPendingActionDelete,
+ };
+ });
+
+ const filterMember = (memberOption: MemberOption, searchQuery: string) => {
+ const results = tokenizedSearch([memberOption], searchQuery, (option) => [option.text ?? '', option.alternateText ?? '']);
+ return results.length > 0;
+ };
+
+ const sortMembers = (options: MemberOption[]) => sortAlphabetically(options, '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;
+
+ return (
+
+
+
+ {!shouldUseNarrowLayout && !!headerContent && {headerContent}}
+
+
+ {shouldUseNarrowLayout && !!headerContent && {headerContent}}
+
+
+
+
+ );
+}
+
+BaseDomainMembersPage.displayName = 'BaseDomainMembersPage';
+export type {MemberOption};
+export default BaseDomainMembersPage;
diff --git a/src/pages/domain/Members/DomainMembersPage.tsx b/src/pages/domain/Members/DomainMembersPage.tsx
new file mode 100644
index 000000000000..d791b2c0a93a
--- /dev/null
+++ b/src/pages/domain/Members/DomainMembersPage.tsx
@@ -0,0 +1,38 @@
+import {memberAccountIDsSelector} from '@selectors/Domain';
+import React from 'react';
+import {useMemoizedLazyIllustrations} from '@hooks/useLazyAsset';
+import useLocalize from '@hooks/useLocalize';
+import useOnyx from '@hooks/useOnyx';
+import type {PlatformStackScreenProps} from '@navigation/PlatformStackNavigation/types';
+import type {DomainSplitNavigatorParamList} from '@navigation/types';
+import BaseDomainMembersPage from '@pages/domain/BaseDomainMembersPage';
+import ONYXKEYS from '@src/ONYXKEYS';
+import type SCREENS from '@src/SCREENS';
+
+type DomainMembersPageProps = PlatformStackScreenProps;
+
+function DomainMembersPage({route}: DomainMembersPageProps) {
+ const {domainAccountID} = route.params;
+ const {translate} = useLocalize();
+ const illustrations = useMemoizedLazyIllustrations(['Profile']);
+
+ const [memberIDs] = useOnyx(`${ONYXKEYS.COLLECTION.DOMAIN}${domainAccountID}`, {
+ canBeMissing: true,
+ selector: memberAccountIDsSelector,
+ });
+
+ return (
+ {}}
+ headerIcon={illustrations.Profile}
+ />
+ );
+}
+
+DomainMembersPage.displayName = 'DomainMembersPage';
+
+export default DomainMembersPage;
diff --git a/src/selectors/Domain.ts b/src/selectors/Domain.ts
index 8bf073bbede1..9109c336b13b 100644
--- a/src/selectors/Domain.ts
+++ b/src/selectors/Domain.ts
@@ -1,7 +1,7 @@
import {Str} from 'expensify-common';
import type {OnyxEntry} from 'react-native-onyx';
-import ONYXKEYS from '@src/ONYXKEYS';
-import type {CardFeeds, Domain, DomainPendingActions, DomainSettings, SamlMetadata} from '@src/types/onyx';
+import CONST from '@src/CONST';
+import type {CardFeeds, Domain, DomainPendingActions, DomainSecurityGroup, DomainSettings, SamlMetadata} from '@src/types/onyx';
import getEmptyArray from '@src/types/utils/getEmptyArray';
const domainMemberSamlSettingsSelector = (domainSettings: OnyxEntry) => domainSettings?.settings;
@@ -37,7 +37,7 @@ function adminAccountIDsSelector(domain: OnyxEntry): number[] {
return (
Object.entries(domain).reduce((acc, [key, value]) => {
- if (!key.startsWith(ONYXKEYS.COLLECTION.EXPENSIFY_ADMIN_ACCESS_PREFIX) || value === undefined || value === null) {
+ if (!key.startsWith(CONST.DOMAIN.EXPENSIFY_ADMIN_ACCESS_PREFIX) || value === undefined || value === null) {
return acc;
}
@@ -55,6 +55,40 @@ const technicalContactSettingsSelector = (domainMemberSharedNVP: OnyxEntry): number[] {
+ if (!domain) {
+ return getEmptyArray();
+ }
+
+ const memberIDs = Object.entries(domain).reduce((acc, [key, value]) => {
+ if (key.startsWith(CONST.DOMAIN.DOMAIN_SECURITY_GROUP_PREFIX)) {
+ const securityGroup = value as DomainSecurityGroup;
+
+ const sharedMembers = securityGroup?.shared ?? {};
+
+ for (const id of Object.keys(sharedMembers)) {
+ const accountID = Number(id);
+ if (!Number.isNaN(accountID)) {
+ acc.push(accountID);
+ }
+ }
+ }
+ return acc;
+ }, []);
+
+ const uniqueIDs = [...new Set(memberIDs)];
+
+ return uniqueIDs.length > 0 ? uniqueIDs : getEmptyArray();
+}
+
const domainEmailSelector = (domain: OnyxEntry) => domain?.email;
const adminPendingActionSelector = (pendingAction: OnyxEntry) => pendingAction?.admin ?? {};
@@ -66,7 +100,8 @@ export {
domainNameSelector,
metaIdentitySelector,
adminAccountIDsSelector,
- technicalContactSettingsSelector,
+ memberAccountIDsSelector,
domainEmailSelector,
adminPendingActionSelector,
+ technicalContactSettingsSelector,
};
diff --git a/src/types/onyx/Domain.ts b/src/types/onyx/Domain.ts
index 7eb6cac4eae3..163b070e814c 100644
--- a/src/types/onyx/Domain.ts
+++ b/src/types/onyx/Domain.ts
@@ -1,10 +1,7 @@
-import type ONYXKEYS from '@src/ONYXKEYS';
+import type CONST from '@src/CONST';
+import type PrefixedRecord from '@src/types/utils/PrefixedRecord';
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>;
+import type SecurityGroup from './SecurityGroup';
/** Model of domain data */
type Domain = OnyxCommon.OnyxValueWithOfflineFeedback<{
@@ -47,7 +44,8 @@ type Domain = OnyxCommon.OnyxValueWithOfflineFeedback<{
/** Whether setting SAML required setting has failed and why */
samlRequiredError?: OnyxCommon.Errors;
}> &
- PrefixedRecord;
+ PrefixedRecord &
+ PrefixedRecord;
/** Model of SAML metadata */
type SamlMetadata = {
@@ -94,6 +92,15 @@ type SamlMetadata = {
samlMetadataError: OnyxCommon.Errors;
};
-export {type SamlMetadata};
+/** Model of Security Group data */
+type DomainSecurityGroup = SecurityGroup & {
+ /**
+ * A map of member account IDs
+ * Key: The accountID of the member
+ */
+ shared: Record;
+};
+
+export {type SamlMetadata, type DomainSecurityGroup};
export default Domain;
diff --git a/src/types/onyx/SearchResults.ts b/src/types/onyx/SearchResults.ts
index ca6dfe59fdda..c711db8944e3 100644
--- a/src/types/onyx/SearchResults.ts
+++ b/src/types/onyx/SearchResults.ts
@@ -6,6 +6,7 @@ import type TransactionListItem from '@components/SelectionListWithSections/Sear
import type {ReportActionListItemType, TaskListItemType, TransactionGroupListItemType, TransactionListItemType} from '@components/SelectionListWithSections/types';
import type CONST from '@src/CONST';
import type ONYXKEYS from '@src/ONYXKEYS';
+import type PrefixedRecord from '@src/types/utils/PrefixedRecord';
import type {BankName} from './Bank';
import type * as OnyxCommon from './OnyxCommon';
import type PersonalDetails from './PersonalDetails';
@@ -171,11 +172,6 @@ type SearchWithdrawalIDGroup = {
state: number;
};
-/**
- * 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 search results */
type SearchResults = {
/** Current search results state */
diff --git a/src/types/onyx/index.ts b/src/types/onyx/index.ts
index b1c4c6e0cebf..a6fd3fbfcdae 100644
--- a/src/types/onyx/index.ts
+++ b/src/types/onyx/index.ts
@@ -32,7 +32,7 @@ import type {OutstandingReportsByPolicyIDDerivedValue, ReportAttributesDerivedVa
import type DismissedProductTraining from './DismissedProductTraining';
import type DismissedReferralBanners from './DismissedReferralBanners';
import type Domain from './Domain';
-import type {SamlMetadata} from './Domain';
+import type {DomainSecurityGroup, SamlMetadata} from './Domain';
import type DomainErrors from './DomainErrors';
import type DomainPendingActions from './DomainPendingActions';
import type Download from './Download';
@@ -315,4 +315,5 @@ export type {
SamlMetadata,
DomainErrors,
DomainPendingActions,
+ DomainSecurityGroup,
};
diff --git a/src/types/utils/PrefixedRecord.ts b/src/types/utils/PrefixedRecord.ts
new file mode 100644
index 000000000000..8b1a80789572
--- /dev/null
+++ b/src/types/utils/PrefixedRecord.ts
@@ -0,0 +1,6 @@
+/**
+ * A utility type that creates a record where all keys are strings that start with a specified prefix.
+ */
+type PrefixedRecord = Record<`${Prefix}${string}`, ValueType>;
+
+export default PrefixedRecord;
diff --git a/tests/unit/DomainSelectorsTest.ts b/tests/unit/DomainSelectorsTest.ts
index 51c332323428..2d9d6e0eecb6 100644
--- a/tests/unit/DomainSelectorsTest.ts
+++ b/tests/unit/DomainSelectorsTest.ts
@@ -1,6 +1,13 @@
-import {adminAccountIDsSelector, adminPendingActionSelector, domainEmailSelector, domainSettingsPrimaryContactSelector, technicalContactSettingsSelector} from '@selectors/Domain';
+import {
+ adminAccountIDsSelector,
+ adminPendingActionSelector,
+ domainEmailSelector,
+ domainSettingsPrimaryContactSelector,
+ memberAccountIDsSelector,
+ technicalContactSettingsSelector,
+} from '@selectors/Domain';
import type {OnyxEntry} from 'react-native-onyx';
-import ONYXKEYS from '@src/ONYXKEYS';
+import CONST from '@src/CONST';
import type {CardFeeds, Domain, DomainPendingActions, DomainSettings} from '@src/types/onyx';
describe('domainSelectors', () => {
@@ -13,8 +20,8 @@ describe('domainSelectors', () => {
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,
+ [`${CONST.DOMAIN.EXPENSIFY_ADMIN_ACCESS_PREFIX}123`]: 321,
+ [`${CONST.DOMAIN.EXPENSIFY_ADMIN_ACCESS_PREFIX}321`]: 123,
} as unknown as OnyxEntry;
expect(adminAccountIDsSelector(domain)).toEqual([321, 123]);
@@ -22,7 +29,7 @@ describe('domainSelectors', () => {
it('Should ignore keys that do not start with the admin access prefix', () => {
const domain = {
- [`${ONYXKEYS.COLLECTION.EXPENSIFY_ADMIN_ACCESS_PREFIX}123`]: 321,
+ [`${CONST.DOMAIN.EXPENSIFY_ADMIN_ACCESS_PREFIX}123`]: 321,
somOtherProperty: 'value',
} as unknown as OnyxEntry;
@@ -31,9 +38,9 @@ describe('domainSelectors', () => {
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,
+ [`${CONST.DOMAIN.EXPENSIFY_ADMIN_ACCESS_PREFIX}123`]: 123,
+ [`${CONST.DOMAIN.EXPENSIFY_ADMIN_ACCESS_PREFIX}0`]: undefined,
+ [`${CONST.DOMAIN.EXPENSIFY_ADMIN_ACCESS_PREFIX}999`]: null,
} as unknown as OnyxEntry;
expect(adminAccountIDsSelector(domain)).toEqual([123]);
@@ -163,4 +170,105 @@ describe('domainSelectors', () => {
});
});
});
+ describe('selectMemberIDs', () => {
+ it('Should return an empty array if the domain object is undefined', () => {
+ expect(memberAccountIDsSelector(undefined)).toEqual([]);
+ });
+
+ it('Should return an empty array if the domain object is empty', () => {
+ const domain = {} as OnyxEntry;
+ expect(memberAccountIDsSelector(domain)).toEqual([]);
+ });
+
+ it('Should return member IDs when keys start with the security group prefix', () => {
+ const domain = {
+ [`${CONST.DOMAIN.DOMAIN_SECURITY_GROUP_PREFIX}1`]: {
+ shared: {
+ // eslint-disable-next-line @typescript-eslint/naming-convention
+ '100': 'value',
+ // eslint-disable-next-line @typescript-eslint/naming-convention
+ '200': 'value',
+ },
+ },
+ [`${CONST.DOMAIN.DOMAIN_SECURITY_GROUP_PREFIX}2`]: {
+ shared: {
+ // eslint-disable-next-line @typescript-eslint/naming-convention
+ '300': 'value',
+ },
+ },
+ } as unknown as OnyxEntry;
+
+ expect(memberAccountIDsSelector(domain).sort()).toEqual([100, 200, 300]);
+ });
+
+ it('Should return unique member IDs if they appear in multiple security groups', () => {
+ const domain = {
+ [`${CONST.DOMAIN.DOMAIN_SECURITY_GROUP_PREFIX}1`]: {
+ shared: {
+ // eslint-disable-next-line @typescript-eslint/naming-convention
+ '123': 'value',
+ },
+ },
+ [`${CONST.DOMAIN.DOMAIN_SECURITY_GROUP_PREFIX}2`]: {
+ shared: {
+ // eslint-disable-next-line @typescript-eslint/naming-convention
+ '123': 'value',
+ },
+ },
+ } as unknown as OnyxEntry;
+
+ expect(memberAccountIDsSelector(domain)).toEqual([123]);
+ });
+
+ it('Should ignore keys that do not start with the security group prefix', () => {
+ const domain = {
+ [`${CONST.DOMAIN.DOMAIN_SECURITY_GROUP_PREFIX}1`]: {
+ shared: {
+ // eslint-disable-next-line @typescript-eslint/naming-convention
+ '456': 'value',
+ },
+ },
+ someOtherKey: {
+ shared: {
+ // eslint-disable-next-line @typescript-eslint/naming-convention
+ '789': 'value',
+ },
+ },
+ } as unknown as OnyxEntry;
+
+ expect(memberAccountIDsSelector(domain)).toEqual([456]);
+ });
+
+ it('Should ignore groups that do not have a shared property', () => {
+ const domain = {
+ [`${CONST.DOMAIN.DOMAIN_SECURITY_GROUP_PREFIX}1`]: {},
+ [`${CONST.DOMAIN.DOMAIN_SECURITY_GROUP_PREFIX}2`]: {shared: null},
+ [`${CONST.DOMAIN.DOMAIN_SECURITY_GROUP_PREFIX}3`]: {
+ shared: {
+ // eslint-disable-next-line @typescript-eslint/naming-convention
+ '111': 'value',
+ },
+ },
+ } as unknown as OnyxEntry;
+
+ expect(memberAccountIDsSelector(domain)).toEqual([111]);
+ });
+
+ it('Should filter out non-numeric shared keys', () => {
+ const domain = {
+ [`${CONST.DOMAIN.DOMAIN_SECURITY_GROUP_PREFIX}1`]: {
+ shared: {
+ // eslint-disable-next-line @typescript-eslint/naming-convention
+ '123': 'value',
+ // eslint-disable-next-line @typescript-eslint/naming-convention
+ 'not-a-number': 'value',
+ // eslint-disable-next-line @typescript-eslint/naming-convention
+ '456': 'value',
+ },
+ },
+ } as unknown as OnyxEntry;
+
+ expect(memberAccountIDsSelector(domain).sort()).toEqual([123, 456]);
+ });
+ });
});