diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts
index 91b60f5acff8..486f5b6a5d0f 100755
--- a/src/ONYXKEYS.ts
+++ b/src/ONYXKEYS.ts
@@ -761,6 +761,9 @@ const ONYXKEYS = {
/** Stores domain admin account ID */
EXPENSIFY_ADMIN_ACCESS_PREFIX: 'expensify_adminPermissions_',
+ /** Stores domain security group */
+ DOMAIN_SECURITY_GROUP_PREFIX: 'expensify_securityGroup_',
+
/** Pending actions for a domain */
DOMAIN_PENDING_ACTIONS: 'domainPendingActions_',
@@ -1160,6 +1163,7 @@ type OnyxCollectionValuesMapping = {
[ONYXKEYS.COLLECTION.EXPENSIFY_ADMIN_ACCESS_PREFIX]: number;
[ONYXKEYS.COLLECTION.DOMAIN_PENDING_ACTIONS]: OnyxTypes.DomainPendingActions;
[ONYXKEYS.COLLECTION.DOMAIN_ERRORS]: OnyxTypes.DomainErrors;
+ [ONYXKEYS.COLLECTION.DOMAIN_SECURITY_GROUP_PREFIX]: OnyxTypes.DomainSecurityGroup;
};
type OnyxValuesMapping = {
diff --git a/src/ROUTES.ts b/src/ROUTES.ts
index d3624fc2eea5..5b6616ddf753 100644
--- a/src/ROUTES.ts
+++ b/src/ROUTES.ts
@@ -3575,6 +3575,18 @@ 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,
+ },
+ DOMAIN_MEMBER_DETAILS: {
+ route: 'domain/:domainAccountID/members/:accountID',
+ getRoute: (domainAccountID: number, accountID: number) => `domain/${domainAccountID}/members/${accountID}` as const,
+ },
+ DOMAIN_LOCK_ACCOUNT: {
+ route: 'domain/:domainAccountID/members/:accountID/lock-account',
+ getRoute: (domainAccountID: number, accountID: number) => `domain/${domainAccountID}/members/${accountID}/lock-account` as const,
+ },
} as const;
/**
diff --git a/src/SCREENS.ts b/src/SCREENS.ts
index a7eab1b46f70..53c82f57b498 100644
--- a/src/SCREENS.ts
+++ b/src/SCREENS.ts
@@ -872,6 +872,9 @@ const SCREENS = {
ADMINS_SETTINGS: 'Admins_Settings',
ADD_PRIMARY_CONTACT: 'Add_Primary_Contact',
ADD_ADMIN: 'Domain_Add_Admin',
+ MEMBERS: 'Domain_Members',
+ MEMBER_DETAILS: 'Member_Details',
+ MEMBER_LOCK_ACCOUNT: 'Member_Lock_Account',
},
} as const;
diff --git a/src/languages/en.ts b/src/languages/en.ts
index fc3b9e4cbd34..bace43c0093f 100755
--- a/src/languages/en.ts
+++ b/src/languages/en.ts
@@ -7888,6 +7888,15 @@ const translations = {
invite: 'Invite',
addAdminError: 'Unable to add this member as an admin. Please try again.',
},
+ members: {
+ title: 'Members',
+ findMember: 'Find member',
+ closeAccount: 'Close account',
+ reportSuspiciousActivityPrompt: (email: string) =>
+ `Are you sure? This will lock ${email}'s account.
Our team will then review the account and remove any unauthorized access. To regain access, they'll need to work with Concierge.`,
+ reportSuspiciousActivityConfirmationTitle: 'We’ve received your request',
+ reportSuspiciousActivityConfirmationPrompt: 'We’ll review the account to verify it’s safe to unlock and reach out via Concierge with any questions.',
+ },
},
};
diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx
index 77ba229ed0c6..a00e8b0a83e0 100644
--- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx
+++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx
@@ -800,6 +800,8 @@ const SettingsModalStackNavigator = createModalStackNavigator require('../../../../pages/domain/Admins/DomainAdminsSettingsPage').default,
[SCREENS.DOMAIN.ADD_PRIMARY_CONTACT]: () => require('../../../../pages/domain/Admins/DomainAddPrimaryContactPage').default,
[SCREENS.DOMAIN.ADD_ADMIN]: () => require('../../../../pages/domain/Admins/DomainAddAdminPage').default,
+ [SCREENS.DOMAIN.MEMBER_DETAILS]: () => require('../../../../pages/domain/Members/MembersDetailsPage').default,
+ [SCREENS.DOMAIN.MEMBER_LOCK_ACCOUNT]: () => require('../../../../pages/domain/Members/DomainReportSuspiciousActivityPage').default,
});
const TwoFactorAuthenticatorStackNavigator = createModalStackNavigator({
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/RELATIONS/DOMAIN_TO_RHP.ts b/src/libs/Navigation/linkingConfig/RELATIONS/DOMAIN_TO_RHP.ts
index 3a1e3bad6016..24970b74f655 100755
--- a/src/libs/Navigation/linkingConfig/RELATIONS/DOMAIN_TO_RHP.ts
+++ b/src/libs/Navigation/linkingConfig/RELATIONS/DOMAIN_TO_RHP.ts
@@ -6,6 +6,7 @@ const DOMAIN_TO_RHP: Partial['config'] = {
[SCREENS.DOMAIN.ADD_PRIMARY_CONTACT]: {
path: ROUTES.DOMAIN_ADD_PRIMARY_CONTACT.route,
},
+ [SCREENS.DOMAIN.MEMBER_DETAILS]: {
+ path: ROUTES.DOMAIN_MEMBER_DETAILS.route,
+ },
+ [SCREENS.DOMAIN.MEMBER_LOCK_ACCOUNT]: {
+ path: ROUTES.DOMAIN_LOCK_ACCOUNT.route,
+ },
},
},
[SCREENS.RIGHT_MODAL.TWO_FACTOR_AUTH]: {
@@ -1999,6 +2005,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 f157f6f8ada3..0e03324cb313 100644
--- a/src/libs/Navigation/types.ts
+++ b/src/libs/Navigation/types.ts
@@ -1384,6 +1384,14 @@ type SettingsNavigatorParamList = {
[SCREENS.DOMAIN.ADD_ADMIN]: {
domainAccountID: number;
};
+ [SCREENS.DOMAIN.MEMBER_DETAILS]: {
+ domainAccountID: number;
+ accountID: number;
+ };
+ [SCREENS.DOMAIN.MEMBER_LOCK_ACCOUNT]: {
+ domainAccountID: number;
+ accountID: number;
+ };
} & ReimbursementAccountNavigatorParamList;
type DomainCardNavigatorParamList = {
@@ -2487,6 +2495,13 @@ type DomainSplitNavigatorParamList = {
[SCREENS.DOMAIN.ADMINS]: {
domainAccountID: number;
};
+ [SCREENS.DOMAIN.MEMBERS]: {
+ domainAccountID: number;
+ };
+ [SCREENS.DOMAIN.MEMBER_DETAILS]: {
+ domainAccountID: number;
+ accountID: number;
+ };
};
type OnboardingModalNavigatorParamList = {
diff --git a/src/libs/actions/User.ts b/src/libs/actions/User.ts
index 140fc942f576..e85a19b652a5 100644
--- a/src/libs/actions/User.ts
+++ b/src/libs/actions/User.ts
@@ -1406,7 +1406,7 @@ function setIsDebugModeEnabled(isDebugModeEnabled: boolean) {
Onyx.set(ONYXKEYS.IS_DEBUG_MODE_ENABLED, isDebugModeEnabled);
}
-function lockAccount() {
+function lockAccount(accountID?: number) {
const optimisticData: Array> = [
{
onyxMethod: Onyx.METHOD.MERGE,
@@ -1445,7 +1445,7 @@ function lockAccount() {
];
const params: LockAccountParams = {
- accountID: currentUserAccountID,
+ accountID: accountID ?? currentUserAccountID,
};
// We need to know if this command fails so that we can navigate the user to a failure page.
diff --git a/src/pages/domain/Admins/DomainAdminDetailsPage.tsx b/src/pages/domain/Admins/DomainAdminDetailsPage.tsx
index e7007125178e..80ec705c2dc2 100644
--- a/src/pages/domain/Admins/DomainAdminDetailsPage.tsx
+++ b/src/pages/domain/Admins/DomainAdminDetailsPage.tsx
@@ -1,100 +1,40 @@
-import {Str} from 'expensify-common';
-import React from 'react';
-import {View} from 'react-native';
-import type {OnyxEntry} from 'react-native-onyx';
-import Avatar from '@components/Avatar';
-import HeaderWithBackButton from '@components/HeaderWithBackButton';
-import MenuItem from '@components/MenuItem';
-import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription';
-import OfflineWithFeedback from '@components/OfflineWithFeedback';
-import ScreenWrapper from '@components/ScreenWrapper';
-import ScrollView from '@components/ScrollView';
-import Text from '@components/Text';
+import React, {useMemo} from 'react';
import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset';
import useLocalize from '@hooks/useLocalize';
-import useOnyx from '@hooks/useOnyx';
-import useThemeStyles from '@hooks/useThemeStyles';
-import {getDisplayNameOrDefault, getPhoneNumber} from '@libs/PersonalDetailsUtils';
import Navigation from '@navigation/Navigation';
import type {PlatformStackScreenProps} from '@navigation/PlatformStackNavigation/types';
import type {SettingsNavigatorParamList} from '@navigation/types';
-import DomainNotFoundPageWrapper from '@pages/domain/DomainNotFoundPageWrapper';
-import CONST from '@src/CONST';
-import ONYXKEYS from '@src/ONYXKEYS';
+import BaseDomainMemberDetailsComponent from '@pages/domain/BaseDomainMemberDetailsComponent';
+import type {MemberDetailsMenuItem} from '@pages/domain/BaseDomainMemberDetailsComponent';
import ROUTES from '@src/ROUTES';
import type SCREENS from '@src/SCREENS';
-import type {PersonalDetailsList} from '@src/types/onyx';
type DomainAdminDetailsPageProps = PlatformStackScreenProps;
function DomainAdminDetailsPage({route}: DomainAdminDetailsPageProps) {
const {domainAccountID, accountID} = route.params;
- const styles = useThemeStyles();
- const {translate, formatPhoneNumber} = useLocalize();
+ const {translate} = useLocalize();
const icons = useMemoizedLazyExpensifyIcons(['Info'] as const);
- // eslint-disable-next-line rulesdir/no-inline-useOnyx-selector
- const [adminPersonalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, {
- canBeMissing: true,
- selector: (personalDetailsList: OnyxEntry) => personalDetailsList?.[accountID],
- });
-
- const displayName = formatPhoneNumber(getDisplayNameOrDefault(adminPersonalDetails));
- const memberLogin = adminPersonalDetails?.login ?? '';
- const isSMSLogin = Str.isSMSLogin(memberLogin);
- const phoneNumber = getPhoneNumber(adminPersonalDetails);
- const fallbackIcon = adminPersonalDetails?.fallbackIcon ?? '';
+ const menuItems = useMemo(
+ (): MemberDetailsMenuItem[] => [
+ {
+ key: 'profile',
+ title: translate('common.profile'),
+ icon: icons.Info,
+ onPress: () => Navigation.navigate(ROUTES.PROFILE.getRoute(accountID, Navigation.getActiveRoute())),
+ shouldShowRightIcon: true,
+ },
+ ],
+ [accountID, icons.Info, translate],
+ );
return (
-
-
-
-
-
-
-
-
-
- {!!displayName && (
-
- {displayName}
-
- )}
-
-
-
-
-
-
-
-
+
);
}
diff --git a/src/pages/domain/Admins/DomainAdminsPage.tsx b/src/pages/domain/Admins/DomainAdminsPage.tsx
index 3f9340e64608..355b9228db0c 100644
--- a/src/pages/domain/Admins/DomainAdminsPage.tsx
+++ b/src/pages/domain/Admins/DomainAdminsPage.tsx
@@ -1,34 +1,17 @@
import {adminAccountIDsSelector, technicalContactSettingsSelector} from '@selectors/Domain';
-import React from 'react';
-import {View} from 'react-native';
+import React, {useCallback} from 'react';
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 {clearAddAdminError} from '@userActions/Domain';
+import BaseDomainMembersPage from '@pages/domain/BaseDomainMembersPage';
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';
@@ -36,15 +19,9 @@ 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']);
@@ -54,139 +31,50 @@ function DomainAdminsPage({route}: DomainAdminsPageProps) {
canBeMissing: true,
selector: adminAccountIDsSelector,
});
+
const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, {canBeMissing: true});
+
const [technicalContactSettings] = useOnyx(`${ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_DOMAIN_MEMBER}${domainAccountID}`, {
canBeMissing: false,
selector: technicalContactSettingsSelector,
});
- const [domainErrors] = useOnyx(`${ONYXKEYS.COLLECTION.DOMAIN_ERRORS}${domainAccountID}`, {canBeMissing: true});
- const [domainPendingActions] = useOnyx(`${ONYXKEYS.COLLECTION.DOMAIN_PENDING_ACTIONS}${domainAccountID}`, {canBeMissing: true});
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;
- 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: domainPendingActions?.admin?.[accountID]?.pendingAction,
- });
- }
-
- 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;
+ const getCustomRightElement = useCallback(
+ (accountID: number) => {
+ const login = personalDetails?.[accountID]?.login;
+ if (technicalContactSettings?.technicalContactEmail !== login) {
+ return null;
}
+ return ;
+ },
+ [personalDetails, technicalContactSettings?.technicalContactEmail, translate],
+ );
- return (
-
- );
- };
-
- const getHeaderButtons = () => {
- if (!isAdmin) {
- return null;
- }
- return (
-
-
- );
- };
-
- const listHeaderContent =
- data.length > CONST.SEARCH_ITEM_LIMIT ? (
-
- ) : null;
-
- if (isLoadingOnyxValue(domainMetadata)) {
- return ;
- }
+ const headerContent = isAdmin ? (
+ 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) => clearAddAdminError(domainAccountID, item.accountID)}
- />
-
-
+ Navigation.navigate(ROUTES.DOMAIN_ADMIN_DETAILS.getRoute(domainAccountID, item.accountID))}
+ isLoading={isLoadingOnyxValue(domainMetadata)}
+ />
);
}
diff --git a/src/pages/domain/BaseDomainMemberDetailsComponent.tsx b/src/pages/domain/BaseDomainMemberDetailsComponent.tsx
new file mode 100644
index 000000000000..1a866ca888eb
--- /dev/null
+++ b/src/pages/domain/BaseDomainMemberDetailsComponent.tsx
@@ -0,0 +1,125 @@
+import {Str} from 'expensify-common';
+import React from 'react';
+import {View} from 'react-native';
+import Avatar from '@components/Avatar';
+import HeaderWithBackButton from '@components/HeaderWithBackButton';
+import MenuItem from '@components/MenuItem';
+import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription';
+import OfflineWithFeedback from '@components/OfflineWithFeedback';
+import ScreenWrapper from '@components/ScreenWrapper';
+import ScrollView from '@components/ScrollView';
+import Text from '@components/Text';
+import useLocalize from '@hooks/useLocalize';
+import useOnyx from '@hooks/useOnyx';
+import useThemeStyles from '@hooks/useThemeStyles';
+import {getDisplayNameOrDefault, getPhoneNumber} from '@libs/PersonalDetailsUtils';
+import CONST from '@src/CONST';
+import ONYXKEYS from '@src/ONYXKEYS';
+import type {PersonalDetails} from '@src/types/onyx';
+import type IconAsset from '@src/types/utils/IconAsset';
+import DomainNotFoundPageWrapper from './DomainNotFoundPageWrapper';
+
+type MemberDetailsMenuItem = {
+ key: string;
+ title: string;
+ icon: IconAsset;
+ onPress: () => void | Promise;
+ shouldShowRightIcon?: boolean;
+};
+
+type BaseDomainMemberDetailsComponentProps = {
+ /** ID domeny dla wrappera NotFound */
+ domainAccountID: number;
+
+ /** ID konta użytkownika */
+ accountID: number;
+
+ /** Lista pozycji menu (np. profil, uprawnienia) */
+ menuItems: MemberDetailsMenuItem[];
+
+ /** Opcjonalny przycisk pod awatarem (np. Close Account) */
+ actionButton?: React.ReactNode;
+
+ /** Dodatkowy kontent na dole strony */
+ children?: React.ReactNode;
+};
+
+function BaseDomainMemberDetailsComponent({domainAccountID, accountID, menuItems, actionButton, children}: BaseDomainMemberDetailsComponentProps) {
+ const styles = useThemeStyles();
+ const {translate, formatPhoneNumber} = useLocalize();
+
+ const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, {canBeMissing: true});
+
+ const details = personalDetails?.[accountID] ?? ({} as PersonalDetails);
+ const displayName = formatPhoneNumber(getDisplayNameOrDefault(details));
+ const phoneNumber = getPhoneNumber(details);
+
+ const memberLogin = details.login ?? '';
+ const isSMSLogin = Str.isSMSLogin(memberLogin);
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ {/* Renderujemy przycisk akcji tylko jeśli został przekazany */}
+ {actionButton}
+
+ {!!displayName && (
+
+ {displayName}
+
+ )}
+
+
+
+
+
+ {menuItems.map((item) => (
+
+ ))}
+
+ {children}
+
+
+
+ );
+}
+
+BaseDomainMemberDetailsComponent.displayName = 'BaseDomainMemberDetailsComponent';
+
+export type {MemberDetailsMenuItem};
+export default BaseDomainMemberDetailsComponent;
diff --git a/src/pages/domain/BaseDomainMembersPage.tsx b/src/pages/domain/BaseDomainMembersPage.tsx
new file mode 100644
index 000000000000..8524b049531c
--- /dev/null
+++ b/src/pages/domain/BaseDomainMembersPage.tsx
@@ -0,0 +1,181 @@
+import React, {useCallback, useMemo} from 'react';
+import {View} from 'react-native';
+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} 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 DomainNotFoundPageWrapper from '@pages/domain/DomainNotFoundPageWrapper';
+import CONST from '@src/CONST';
+import ONYXKEYS from '@src/ONYXKEYS';
+import type IconAsset from '@src/types/utils/IconAsset';
+
+type MemberOption = Omit & {
+ accountID: number;
+ 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;
+
+ /** Whether the data is still loading */
+ isLoading?: boolean;
+};
+
+function BaseDomainMembersPage({
+ domainAccountID,
+ accountIDs,
+ headerTitle,
+ searchPlaceholder,
+ headerContent,
+ onSelectRow,
+ headerIcon,
+ getCustomRightElement,
+ isLoading = false,
+}: 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[] = useMemo(() => {
+ const options: MemberOption[] = [];
+
+ for (const accountID of accountIDs) {
+ const details = personalDetails?.[accountID];
+ const login = details?.login ?? '';
+
+ options.push({
+ 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),
+ });
+ }
+ return options;
+ }, [accountIDs, personalDetails, formatPhoneNumber, icons.FallbackAvatar, getCustomRightElement]);
+
+ const filterMember = (option: MemberOption, searchQuery: string) => {
+ const results = tokenizedSearch([option], searchQuery, (option) => [option.text ?? '', option.alternateText ?? '']);
+ return results.length > 0;
+ };
+
+ const sortMembers = useCallback((options: MemberOption[]) => sortAlphabetically(options, 'text', localeCompare), [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 (isLoading) {
+ return ;
+ }
+
+ 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..981c9a0c5af8
--- /dev/null
+++ b/src/pages/domain/Members/DomainMembersPage.tsx
@@ -0,0 +1,49 @@
+import {selectMemberIDs} from '@selectors/Domain';
+import React from 'react';
+import {useMemoizedLazyIllustrations} from '@hooks/useLazyAsset';
+import useLocalize from '@hooks/useLocalize';
+import useOnyx from '@hooks/useOnyx';
+import Navigation from '@navigation/Navigation';
+import type {PlatformStackScreenProps} from '@navigation/PlatformStackNavigation/types';
+import type {DomainSplitNavigatorParamList} from '@navigation/types';
+import type {MemberOption} from '@pages/domain/BaseDomainMembersPage';
+import BaseDomainMembersPage from '@pages/domain/BaseDomainMembersPage';
+import ONYXKEYS from '@src/ONYXKEYS';
+import ROUTES from '@src/ROUTES';
+import type SCREENS from '@src/SCREENS';
+import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue';
+
+type DomainMembersPageProps = PlatformStackScreenProps;
+
+function DomainMembersPage({route}: DomainMembersPageProps) {
+ const {domainAccountID} = route.params;
+ const {translate} = useLocalize();
+ const illustrations = useMemoizedLazyIllustrations(['Members']);
+
+ const [memberIDs, memberMetadata] = useOnyx(`${ONYXKEYS.COLLECTION.DOMAIN}${domainAccountID}`, {
+ canBeMissing: true,
+ selector: selectMemberIDs,
+ });
+
+ const openMemberDetails = (item: MemberOption) => {
+ Navigation.setNavigationActionToMicrotaskQueue(() => {
+ Navigation.navigate(ROUTES.DOMAIN_MEMBER_DETAILS.getRoute(domainAccountID, item.accountID));
+ });
+ };
+
+ return (
+
+ );
+}
+
+DomainMembersPage.displayName = 'DomainMembersPage';
+
+export default DomainMembersPage;
diff --git a/src/pages/domain/Members/DomainReportSuspiciousActivityPage.tsx b/src/pages/domain/Members/DomainReportSuspiciousActivityPage.tsx
new file mode 100644
index 000000000000..e86f06723e39
--- /dev/null
+++ b/src/pages/domain/Members/DomainReportSuspiciousActivityPage.tsx
@@ -0,0 +1,51 @@
+import React from 'react';
+import RenderHTML from '@components/RenderHTML';
+import Text from '@components/Text';
+import useLocalize from '@hooks/useLocalize';
+import useOnyx from '@hooks/useOnyx';
+import Navigation from '@libs/Navigation/Navigation';
+import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types';
+import type {SettingsNavigatorParamList} from '@navigation/types';
+import DomainNotFoundPageWrapper from '@pages/domain/DomainNotFoundPageWrapper';
+import BaseLockAccountComponent from '@pages/settings/Security/LockAccount/LockAccountPageBase';
+import ONYXKEYS from '@src/ONYXKEYS';
+import ROUTES from '@src/ROUTES';
+import type SCREENS from '@src/SCREENS';
+
+type DomainReportSuspiciousActivityPageProps = PlatformStackScreenProps;
+
+function DomainReportSuspiciousActivityPage({route}: DomainReportSuspiciousActivityPageProps) {
+ const {domainAccountID, accountID} = route.params;
+
+ const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, {canBeMissing: true});
+ const memberLogin = personalDetails?.[accountID]?.login ?? '';
+
+ const {translate} = useLocalize();
+
+ const confirmModalPrompt = translate('domain.members.reportSuspiciousActivityConfirmationPrompt');
+
+ const lockAccountPagePrompt = (
+
+
+
+ );
+
+ const handleLockRequestFinish = () => {
+ Navigation.goBack(ROUTES.DOMAIN_MEMBER_DETAILS.getRoute(domainAccountID, accountID));
+ };
+
+ return (
+
+ Navigation.goBack(ROUTES.SETTINGS_LOCK_ACCOUNT)}
+ confirmModalPrompt={confirmModalPrompt}
+ lockAccountPagePrompt={lockAccountPagePrompt}
+ handleLockRequestFinish={handleLockRequestFinish}
+ accountID={accountID}
+ />
+
+ );
+}
+
+export default DomainReportSuspiciousActivityPage;
diff --git a/src/pages/domain/Members/MembersDetailsPage.tsx b/src/pages/domain/Members/MembersDetailsPage.tsx
new file mode 100644
index 000000000000..a0f33c934546
--- /dev/null
+++ b/src/pages/domain/Members/MembersDetailsPage.tsx
@@ -0,0 +1,55 @@
+import React, {useMemo} from 'react';
+import Button from '@components/Button';
+import * as Expensicons from '@components/Icon/Expensicons';
+import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset';
+import useLocalize from '@hooks/useLocalize';
+import useThemeStyles from '@hooks/useThemeStyles';
+import Navigation from '@libs/Navigation/Navigation';
+import type {PlatformStackScreenProps} from '@navigation/PlatformStackNavigation/types';
+import type {DomainSplitNavigatorParamList} from '@navigation/types';
+import BaseDomainMemberDetailsComponent from '@pages/domain/BaseDomainMemberDetailsComponent';
+import ROUTES from '@src/ROUTES';
+import type SCREENS from '@src/SCREENS';
+
+type DomainMemberDetailsPageProps = PlatformStackScreenProps;
+
+function DomainMemberDetailsPage({route}: DomainMemberDetailsPageProps) {
+ const {domainAccountID, accountID} = route.params;
+ const {translate} = useLocalize();
+ const styles = useThemeStyles();
+ const icons = useMemoizedLazyExpensifyIcons(['Flag'] as const);
+
+ const menuItems = useMemo(
+ () => [
+ {
+ key: 'report',
+ title: translate('lockAccountPage.reportSuspiciousActivity'),
+ icon: icons.Flag,
+ onPress: () => Navigation.navigate(ROUTES.DOMAIN_LOCK_ACCOUNT.getRoute(domainAccountID, accountID)),
+ shouldShowRightIcon: true,
+ },
+ ],
+ [accountID, domainAccountID, icons.Flag, translate],
+ );
+
+ return (
+ {}}
+ isDisabled
+ icon={Expensicons.ClosedSign}
+ style={styles.mb5}
+ />
+ }
+ />
+ );
+}
+
+DomainMemberDetailsPage.displayName = 'DomainMemberDetailsPage';
+
+export default DomainMemberDetailsPage;
diff --git a/src/pages/settings/Security/LockAccount/LockAccountPage.tsx b/src/pages/settings/Security/LockAccount/LockAccountPage.tsx
index 13810cc287cf..e971ff61c8a6 100644
--- a/src/pages/settings/Security/LockAccount/LockAccountPage.tsx
+++ b/src/pages/settings/Security/LockAccount/LockAccountPage.tsx
@@ -1,97 +1,47 @@
-import React, {useState} from 'react';
-import {View} from 'react-native';
-import Button from '@components/Button';
-import ConfirmModal from '@components/ConfirmModal';
-import HeaderPageLayout from '@components/HeaderPageLayout';
+import React from 'react';
import Text from '@components/Text';
import useLocalize from '@hooks/useLocalize';
-import useNetwork from '@hooks/useNetwork';
-import useOnyx from '@hooks/useOnyx';
import useThemeStyles from '@hooks/useThemeStyles';
import Navigation from '@libs/Navigation/Navigation';
-import {lockAccount} from '@userActions/User';
import CONST from '@src/CONST';
-import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';
+import type Response from '@src/types/onyx/Response';
+import BaseLockAccountComponent from './LockAccountPageBase';
function LockAccountPage() {
const {translate} = useLocalize();
- const [isConfirmModalVisible, setIsConfirmModalVisible] = useState(false);
const styles = useThemeStyles();
- const {isOffline} = useNetwork();
- const [isLoading, setIsLoading] = useState(false);
- const [session] = useOnyx(ONYXKEYS.SESSION, {canBeMissing: false});
- const lockAccountButton = (
- {
- setIsConfirmModalVisible(true);
- }}
- />
+ const handleLockRequestFinish = (response: void | Response) => {
+ if (response?.jsonCode === CONST.JSON_CODE.SUCCESS) {
+ Navigation.navigate(ROUTES.SETTINGS_UNLOCK_ACCOUNT);
+ } else {
+ Navigation.navigate(ROUTES.SETTINGS_FAILED_TO_LOCK_ACCOUNT);
+ }
+ };
+ const confirmModalPrompt = (
+ <>
+ {translate('lockAccountPage.areYouSure')}
+ {translate('lockAccountPage.onceLocked')}
+ >
);
- return (
+ const lockAccountPagePrompt = (
<>
- Navigation.goBack()}
- title={translate('lockAccountPage.reportSuspiciousActivity')}
- testID="LockAccountPage"
- footer={lockAccountButton}
- childrenContainerStyles={[styles.pt3, styles.gap6]}
- shouldShowOfflineIndicatorInWideScreen
- >
-
- {translate('lockAccountPage.compromisedDescription')}
- {translate('lockAccountPage.domainAdminsDescription')}
-
-
- {
- // If there is no user accountID yet (because the app isn't fully setup yet), so return early
- if (session?.accountID === -1) {
- return;
- }
-
- setIsConfirmModalVisible(false);
-
- setIsLoading(true);
- lockAccount().then((response) => {
- setIsLoading(false);
- if (!response?.jsonCode) {
- return;
- }
-
- if (response.jsonCode === CONST.JSON_CODE.SUCCESS) {
- Navigation.navigate(ROUTES.SETTINGS_UNLOCK_ACCOUNT);
- } else {
- Navigation.navigate(ROUTES.SETTINGS_FAILED_TO_LOCK_ACCOUNT);
- }
- });
- }}
- onCancel={() => setIsConfirmModalVisible(false)}
- isVisible={isConfirmModalVisible}
- prompt={
- <>
- {translate('lockAccountPage.areYouSure')}
- {translate('lockAccountPage.onceLocked')}
- >
- }
- confirmText={translate('lockAccountPage.lockAccount')}
- cancelText={translate('common.cancel')}
- shouldDisableConfirmButtonWhenOffline
- shouldShowCancelButton
- />
+ {translate('lockAccountPage.compromisedDescription')}
+ {translate('lockAccountPage.domainAdminsDescription')}
>
);
+
+ return (
+ Navigation.goBack(ROUTES.SETTINGS_LOCK_ACCOUNT)}
+ confirmModalPrompt={confirmModalPrompt}
+ lockAccountPagePrompt={lockAccountPagePrompt}
+ handleLockRequestFinish={handleLockRequestFinish}
+ />
+ );
}
export default LockAccountPage;
diff --git a/src/pages/settings/Security/LockAccount/LockAccountPageBase.tsx b/src/pages/settings/Security/LockAccount/LockAccountPageBase.tsx
new file mode 100644
index 000000000000..1c68b2abbd2a
--- /dev/null
+++ b/src/pages/settings/Security/LockAccount/LockAccountPageBase.tsx
@@ -0,0 +1,86 @@
+import React, {useState} from 'react';
+import {View} from 'react-native';
+import Button from '@components/Button';
+import HeaderPageLayout from '@components/HeaderPageLayout';
+import {ModalActions} from '@components/Modal/Global/ModalContext';
+import useConfirmModal from '@hooks/useConfirmModal';
+import useLocalize from '@hooks/useLocalize';
+import useNetwork from '@hooks/useNetwork';
+import useOnyx from '@hooks/useOnyx';
+import useThemeStyles from '@hooks/useThemeStyles';
+import {lockAccount} from '@userActions/User';
+import ONYXKEYS from '@src/ONYXKEYS';
+import type Response from '@src/types/onyx/Response';
+
+type BaseLockAccountComponentProps = {
+ confirmModalPrompt: React.JSX.Element | string;
+ lockAccountPagePrompt: React.JSX.Element | string;
+ testID: string;
+ onBackButtonPress: () => void;
+ handleLockRequestFinish: (response: void | Response) => void;
+ accountID?: number;
+};
+function BaseLockAccountComponent({confirmModalPrompt, lockAccountPagePrompt, testID, onBackButtonPress, handleLockRequestFinish, accountID}: BaseLockAccountComponentProps) {
+ const {translate} = useLocalize();
+ const styles = useThemeStyles();
+ const {isOffline} = useNetwork();
+ const [isLoading, setIsLoading] = useState(false);
+ const [session] = useOnyx(ONYXKEYS.SESSION, {canBeMissing: false});
+
+ const {showConfirmModal} = useConfirmModal();
+
+ const handleReportSuspiciousActivity = async () => {
+ if (!accountID && session?.accountID === -1) {
+ return;
+ }
+ const modalResult = await showConfirmModal({
+ danger: true,
+ title: translate('lockAccountPage.reportSuspiciousActivity'),
+
+ prompt: confirmModalPrompt,
+ confirmText: translate('lockAccountPage.lockAccount'),
+ cancelText: translate('common.cancel'),
+ shouldDisableConfirmButtonWhenOffline: true,
+ shouldShowCancelButton: true,
+ isConfirmLoading: isLoading,
+ });
+
+ if (modalResult.action !== ModalActions.CONFIRM) {
+ return;
+ }
+
+ setIsLoading(true);
+ const response = await lockAccount(accountID);
+ setIsLoading(false);
+
+ handleLockRequestFinish(response);
+ };
+
+ const lockAccountButton = (
+
+ );
+
+ return (
+
+ {lockAccountPagePrompt}
+
+ );
+}
+
+export default BaseLockAccountComponent;
diff --git a/src/selectors/Domain.ts b/src/selectors/Domain.ts
index 3ad5a02d53e5..815e64ff51e4 100644
--- a/src/selectors/Domain.ts
+++ b/src/selectors/Domain.ts
@@ -46,6 +46,29 @@ function adminAccountIDsSelector(domain: OnyxEntry): number[] {
);
}
+/**
+ * Extracts a list of member IDs (accountIDs) from the domain object.
+ * * It iterates through the security groups in the domain, extracts account IDs from the 'shared' property,
+ * and returns a unique list of numbers.
+ *
+ * @param domain - The domain object from Onyx
+ * @returns An array of unique member account IDs
+ */
+function selectMemberIDs(domain: OnyxEntry): number[] {
+ if (!domain) {
+ return [];
+ }
+
+ const memberIDs = Object.entries(domain).reduce((acc, [key, value]) => {
+ if (key.startsWith(ONYXKEYS.COLLECTION.DOMAIN_SECURITY_GROUP_PREFIX)) {
+ Object.keys((value as any)?.shared ?? {}).forEach((id) => acc.push(Number(id)));
+ }
+ return acc;
+ }, []);
+
+ return [...new Set(memberIDs)].filter((id) => !Number.isNaN(id)) ?? getEmptyArray();
+}
+
const technicalContactSettingsSelector = (domainMemberSharedNVP: OnyxEntry) => {
return {
technicalContactEmail: domainMemberSharedNVP?.settings?.technicalContactEmail,
@@ -63,4 +86,5 @@ export {
adminAccountIDsSelector,
technicalContactSettingsSelector,
domainEmailSelector,
+ selectMemberIDs,
};
diff --git a/src/types/onyx/Domain.ts b/src/types/onyx/Domain.ts
index 7eb6cac4eae3..f88fa1ac3009 100644
--- a/src/types/onyx/Domain.ts
+++ b/src/types/onyx/Domain.ts
@@ -1,4 +1,6 @@
import type ONYXKEYS from '@src/ONYXKEYS';
+import type * as OnyxTypes from '@src/types/onyx/index';
+import SecurityGroup from '@src/types/onyx/SecurityGroup';
import type * as OnyxCommon from './OnyxCommon';
/**
@@ -47,8 +49,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 +96,16 @@ type SamlMetadata = {
samlMetadataError: OnyxCommon.Errors;
};
-export {type SamlMetadata};
+/** Model of Security Group data */
+type DomainSecurityGroup = SecurityGroup & {
+ /**
+ * A map of member account IDs to their permission level within the group.
+ * Key: The accountID of the member.
+ * Value: The permission level (e.g. "read").
+ */
+ shared: Record;
+};
+
+export {type SamlMetadata, type DomainSecurityGroup};
export default Domain;
diff --git a/src/types/onyx/index.ts b/src/types/onyx/index.ts
index 973c10a369f1..ad0fcd770818 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';
@@ -313,4 +313,5 @@ export type {
SamlMetadata,
DomainErrors,
DomainPendingActions,
+ DomainSecurityGroup,
};
diff --git a/tests/unit/DomainSelectorsTest.ts b/tests/unit/DomainSelectorsTest.ts
index 15fe44fbd13d..666bbe120482 100644
--- a/tests/unit/DomainSelectorsTest.ts
+++ b/tests/unit/DomainSelectorsTest.ts
@@ -1,4 +1,4 @@
-import {adminAccountIDsSelector, domainEmailSelector, technicalContactSettingsSelector} from '@selectors/Domain';
+import {adminAccountIDsSelector, domainEmailSelector, selectMemberIDs, technicalContactSettingsSelector} from '@selectors/Domain';
import type {OnyxEntry} from 'react-native-onyx';
import ONYXKEYS from '@src/ONYXKEYS';
import type {CardFeeds, Domain} from '@src/types/onyx';
@@ -118,4 +118,106 @@ describe('domainSelectors', () => {
expect(domainEmailSelector(domain)).toBeUndefined();
});
});
+ describe('selectMemberIDs', () => {
+ it('Should return an empty array if the domain object is undefined', () => {
+ expect(selectMemberIDs(undefined)).toEqual([]);
+ });
+
+ it('Should return an empty array if the domain object is empty', () => {
+ const domain = {} as OnyxEntry;
+ expect(selectMemberIDs(domain)).toEqual([]);
+ });
+
+ it('Should return member IDs when keys start with the security group prefix', () => {
+ const domain = {
+ [`${ONYXKEYS.COLLECTION.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',
+ },
+ },
+ [`${ONYXKEYS.COLLECTION.DOMAIN_SECURITY_GROUP_PREFIX}2`]: {
+ shared: {
+ // eslint-disable-next-line @typescript-eslint/naming-convention
+ '300': 'value',
+ },
+ },
+ } as unknown as OnyxEntry;
+
+ // Sortujemy wynik, aby test był stabilny (kolejność w Set/Object.keys nie zawsze jest gwarantowana)
+ expect(selectMemberIDs(domain).sort()).toEqual([100, 200, 300]);
+ });
+
+ it('Should return unique member IDs if they appear in multiple security groups', () => {
+ const domain = {
+ [`${ONYXKEYS.COLLECTION.DOMAIN_SECURITY_GROUP_PREFIX}1`]: {
+ shared: {
+ // eslint-disable-next-line @typescript-eslint/naming-convention
+ '123': 'value',
+ },
+ },
+ [`${ONYXKEYS.COLLECTION.DOMAIN_SECURITY_GROUP_PREFIX}2`]: {
+ shared: {
+ // eslint-disable-next-line @typescript-eslint/naming-convention
+ '123': 'value',
+ },
+ },
+ } as unknown as OnyxEntry;
+
+ expect(selectMemberIDs(domain)).toEqual([123]);
+ });
+
+ it('Should ignore keys that do not start with the security group prefix', () => {
+ const domain = {
+ [`${ONYXKEYS.COLLECTION.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(selectMemberIDs(domain)).toEqual([456]);
+ });
+
+ it('Should ignore groups that do not have a shared property', () => {
+ const domain = {
+ [`${ONYXKEYS.COLLECTION.DOMAIN_SECURITY_GROUP_PREFIX}1`]: {},
+ [`${ONYXKEYS.COLLECTION.DOMAIN_SECURITY_GROUP_PREFIX}2`]: {shared: null},
+ [`${ONYXKEYS.COLLECTION.DOMAIN_SECURITY_GROUP_PREFIX}3`]: {
+ shared: {
+ // eslint-disable-next-line @typescript-eslint/naming-convention
+ '111': 'value',
+ },
+ },
+ } as unknown as OnyxEntry;
+
+ expect(selectMemberIDs(domain)).toEqual([111]);
+ });
+
+ it('Should filter out non-numeric shared keys', () => {
+ const domain = {
+ [`${ONYXKEYS.COLLECTION.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(selectMemberIDs(domain).sort()).toEqual([123, 456]);
+ });
+ });
});