Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions src/libs/ReportUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1885,6 +1885,25 @@ function isGroupChat(report: OnyxEntry<Report> | Partial<Report>): boolean {
return getChatType(report) === CONST.REPORT.CHAT_TYPE.GROUP;
}

/**
* Whether the current user can invite new members to the report from its members page.
*
* Any member of a group chat can invite. On an expense report an invited member gains visibility of every expense on it,
* so inviting is limited to the submitter and policy admins, and only while the report is still open.
*/
function canInviteMembersToReport(report: OnyxEntry<Report>, policy: OnyxEntry<Policy>, isReportArchived: boolean, currentUserAccountID?: number): boolean {
if (isReportArchived) {
return false;
}
if (isGroupChat(report)) {
return true;
}
if (!isOpenExpenseReport(report)) {
return false;
}
return isCurrentUserSubmitter(report, currentUserAccountID) || isPolicyAdminPolicyUtils(policy);
}

/**
* Only returns true if this is the Expensify DM report.
*
Expand Down Expand Up @@ -14077,6 +14096,7 @@ export {
canDeleteMoneyRequestReport,
canDeleteReportAction,
canHoldUnholdReportAction,
canInviteMembersToReport,
canEditReportPolicy,
canEditFieldOfMoneyRequest,
canEditMultipleTransactions,
Expand Down
53 changes: 33 additions & 20 deletions src/pages/DynamicReportParticipantsInvitePage.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView';
import FormAlertWithSubmitButton from '@components/FormAlertWithSubmitButton';
import HeaderWithBackButton from '@components/HeaderWithBackButton';
import ScreenWrapper from '@components/ScreenWrapper';
Expand All @@ -11,6 +12,8 @@ import useDynamicBackPath from '@hooks/useDynamicBackPath';
import useLocalize from '@hooks/useLocalize';
import useOnyx from '@hooks/useOnyx';
import usePersonalDetailSearchSelector from '@hooks/usePersonalDetailSearchSelector';
import usePolicy from '@hooks/usePolicy';
import useReportIsArchived from '@hooks/useReportIsArchived';
import useThemeStyles from '@hooks/useThemeStyles';

import {inviteToGroupChat, searchUserInServer} from '@libs/actions/Report';
Expand All @@ -22,12 +25,13 @@ import {getHeaderMessage} from '@libs/PersonalDetailOptionsListUtils';
import type {OptionData} from '@libs/PersonalDetailOptionsListUtils';
import {addSMSDomainIfPhoneNumber, parsePhoneNumber} from '@libs/PhoneNumber';
import {getGroupChatName} from '@libs/ReportNameUtils';
import {getParticipantsAccountIDsForDisplay} from '@libs/ReportUtils';
import {canInviteMembersToReport, getParticipantsAccountIDsForDisplay} from '@libs/ReportUtils';

import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import {DYNAMIC_ROUTES} from '@src/ROUTES';
import {newAccountIDsAndLoginsSelector, personalDetailsLoginsSelector} from '@src/selectors/PersonalDetails';
import {accountIDSelector} from '@src/selectors/Session';
import type {InvitedEmailsToAccountIDs} from '@src/types/onyx';
import getEmptyArray from '@src/types/utils/getEmptyArray';

Expand All @@ -52,6 +56,10 @@ function DynamicReportParticipantsInvitePage({report}: DynamicReportParticipants
const [pendingDeleteMemberAccountIDs] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_METADATA}${report?.reportID}`, {selector: pendingDeleteMemberAccountIDsSelector});
const [didScreenTransitionEnd, setDidScreenTransitionEnd] = useState(false);
const backPath = useDynamicBackPath(DYNAMIC_ROUTES.REPORT_PARTICIPANTS_INVITE.path);
const [currentUserAccountID] = useOnyx(ONYXKEYS.SESSION, {selector: accountIDSelector});
const policy = usePolicy(report?.policyID);
const isReportArchived = useReportIsArchived(report?.reportID);
const canInviteMembers = canInviteMembersToReport(report, policy, isReportArchived, currentUserAccountID);

// Any existing participants and Expensify emails should not be eligible for invitation
const excludedUsers: Record<string, boolean> = {
Expand Down Expand Up @@ -183,26 +191,31 @@ function DynamicReportParticipantsInvitePage({report}: DynamicReportParticipants
testID="DynamicReportParticipantsInvitePage"
onEntryTransitionEnd={() => setDidScreenTransitionEnd(true)}
>
<HeaderWithBackButton
title={translate('workspace.invite.members')}
subtitle={reportName}
<FullPageNotFoundView
shouldShow={!canInviteMembers}
onBackButtonPress={goBack}
/>

<SelectionListWithSections
canSelectMultiple
sections={sections}
onSelectRow={handleToggleSelection}
ListItem={InviteMemberListItem}
confirmButtonOptions={{
onConfirm: inviteUsers,
}}
shouldShowTextInput
textInputOptions={textInputOptions}
shouldPreventDefaultFocusOnSelectRow={!canUseTouchScreen()}
shouldShowLoadingPlaceholder={!areOptionsInitialized || !didScreenTransitionEnd}
footerContent={footerContent}
/>
>
<HeaderWithBackButton
title={translate('workspace.invite.members')}
subtitle={reportName}
onBackButtonPress={goBack}
/>

<SelectionListWithSections
canSelectMultiple
sections={sections}
onSelectRow={handleToggleSelection}
ListItem={InviteMemberListItem}
confirmButtonOptions={{
onConfirm: inviteUsers,
}}
shouldShowTextInput
textInputOptions={textInputOptions}
shouldPreventDefaultFocusOnSelectRow={!canUseTouchScreen()}
shouldShowLoadingPlaceholder={!areOptionsInitialized || !didScreenTransitionEnd}
footerContent={footerContent}
/>
</FullPageNotFoundView>
</ScreenWrapper>
);
}
Expand Down
16 changes: 11 additions & 5 deletions src/pages/DynamicReportParticipantsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import useLocalize from '@hooks/useLocalize';
import useMobileSelectionMode from '@hooks/useMobileSelectionMode';
import useNetwork from '@hooks/useNetwork';
import useOnyx from '@hooks/useOnyx';
import usePolicy from '@hooks/usePolicy';
import useReportAttributes from '@hooks/useReportAttributes';
import useReportIsArchived from '@hooks/useReportIsArchived';
import useResponsiveLayout from '@hooks/useResponsiveLayout';
Expand All @@ -32,6 +33,7 @@ import type {ParticipantsNavigatorParamList} from '@libs/Navigation/types';
import {temporaryGetDisplayNameOrDefault} from '@libs/PersonalDetailsUtils';
import {deprecatedGetReportName} from '@libs/ReportNameUtils';
import {
canInviteMembersToReport,
getReportPersonalDetailsParticipants,
isAnnounceRoom,
isArchivedNonExpenseReport,
Expand All @@ -51,6 +53,7 @@ import ONYXKEYS from '@src/ONYXKEYS';
import {DYNAMIC_ROUTES} from '@src/ROUTES';
import type SCREENS from '@src/SCREENS';
import {personalDetailsSelector} from '@src/selectors/PersonalDetails';
import {accountIDSelector} from '@src/selectors/Session';
import type {PersonalDetails} from '@src/types/onyx';

import type {TupleToUnion, ValueOf} from 'type-fest';
Expand Down Expand Up @@ -81,12 +84,13 @@ function DynamicReportParticipantsPage({report}: DynamicReportParticipantsPagePr
const [reportMetadata] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_METADATA}${report?.reportID}`);
const reportAttributes = useReportAttributes();
const isMobileSelectionModeEnabled = useMobileSelectionMode();
const [session] = useOnyx(ONYXKEYS.SESSION);
const [currentUserAccountID] = useOnyx(ONYXKEYS.SESSION, {selector: accountIDSelector});
const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST);
const currentUserAccountID = Number(session?.accountID);
const isCurrentUserAdmin = isGroupChatAdmin(report, currentUserAccountID);
const isCurrentUserAdmin = currentUserAccountID !== undefined && isGroupChatAdmin(report, currentUserAccountID);
const isGroupChat = isGroupChatUtils(report);
const isCurrentUserGroupChatAdmin = isGroupChat && isCurrentUserAdmin;
const policy = usePolicy(report?.policyID);
const shouldShowInviteButton = canInviteMembersToReport(report, policy, isReportArchived, currentUserAccountID);
const {isOffline} = useNetwork();
const canSelectMultiple = isGroupChat && isCurrentUserAdmin && (isSmallScreenWidth ? isMobileSelectionModeEnabled : true);

Expand All @@ -104,6 +108,8 @@ function DynamicReportParticipantsPage({report}: DynamicReportParticipantsPagePr
};

const [selectedMembers, setSelectedMembers] = useFilteredSelection(personalDetailsParticipants, filterParticipants);
// Bulk member actions (remove, change role) only exist for group chats, so expense reports always render the invite button instead.
const shouldShowBulkActionsButton = isGroupChat && (isSmallScreenWidth ? canSelectMultiple : selectedMembers.length > 0);
const firstSelectedMember = selectedMembers?.at(0);
const [firstSelectedMemberDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, {selector: personalDetailsSelector(firstSelectedMember)});

Expand Down Expand Up @@ -250,9 +256,9 @@ function DynamicReportParticipantsPage({report}: DynamicReportParticipantsPagePr
subtitle={StringUtils.lineBreaksToSpaces(deprecatedGetReportName(report, reportAttributes))}
/>
<View style={[styles.pl5, styles.pr5]}>
{isGroupChat && (
{shouldShowInviteButton && (
<View style={styles.w100}>
{(isSmallScreenWidth ? canSelectMultiple : selectedMembers.length > 0) ? (
{shouldShowBulkActionsButton ? (
<ButtonWithDropdownMenu<WorkspaceMemberBulkActionType>
variant={CONST.BUTTON_VARIANT.SUCCESS}
shouldAlwaysShowDropdownMenu
Expand Down
92 changes: 92 additions & 0 deletions tests/unit/ReportUtilsTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ import {
canEditWriteCapability,
canFlagReportAction,
canHoldUnholdReportAction,
canInviteMembersToReport,
canJoinChat,
canLeaveChat,
canMergeReports,
Expand Down Expand Up @@ -265,6 +266,7 @@ import {
createGroupChat,
createInvoiceReport,
createInvoiceRoom,
createIOUReport,
createPolicyExpenseChat,
createPolicyExpenseChatTask,
createPolicyExpenseChatThread,
Expand Down Expand Up @@ -11355,6 +11357,96 @@ describe('ReportUtils', () => {
});
});

describe('canInviteMembersToReport', () => {
const submitterAccountID = 80001;
const otherAccountID = 80002;
const adminPolicy: Policy = {...createRandomPolicy(80100), role: CONST.POLICY.ROLE.ADMIN};
const memberPolicy: Policy = {...createRandomPolicy(80100), role: CONST.POLICY.ROLE.USER};
const openExpenseReport: Report = {
...createExpenseReport(80200),
ownerAccountID: submitterAccountID,
stateNum: CONST.REPORT.STATE_NUM.OPEN,
statusNum: CONST.REPORT.STATUS_NUM.OPEN,
};

it('lets any participant of a group chat invite', () => {
// Given a group chat, which has no submitter/admin distinction
const groupChat = createGroupChat(80201, [submitterAccountID, otherAccountID]);

// Then a plain participant can invite, and no policy is needed to decide that
expect(canInviteMembersToReport(groupChat, undefined, false, otherAccountID)).toBe(true);
});

it('lets the submitter invite on an open expense report', () => {
// Given an open expense report owned by the current user on a workspace where they are only a member
// Then the submitter can invite
expect(canInviteMembersToReport(openExpenseReport, memberPolicy, false, submitterAccountID)).toBe(true);
});

it('lets a policy admin invite on an open expense report they did not submit', () => {
// Given an open expense report submitted by somebody else
// Then a policy admin can still invite
expect(canInviteMembersToReport(openExpenseReport, adminPolicy, false, otherAccountID)).toBe(true);
});

it('blocks a workspace member who is neither the submitter nor a policy admin', () => {
// Given an open expense report submitted by somebody else
// Then a plain workspace member cannot invite, because invitees gain visibility of every expense on the report
expect(canInviteMembersToReport(openExpenseReport, memberPolicy, false, otherAccountID)).toBe(false);
});

it('blocks the submitter once the expense report is no longer open', () => {
// Given an expense report the current user submitted that has already been submitted for approval
const submittedExpenseReport: Report = {
...openExpenseReport,
stateNum: CONST.REPORT.STATE_NUM.SUBMITTED,
statusNum: CONST.REPORT.STATUS_NUM.SUBMITTED,
};

// Then neither the submitter nor a policy admin can invite
expect(canInviteMembersToReport(submittedExpenseReport, memberPolicy, false, submitterAccountID)).toBe(false);
expect(canInviteMembersToReport(submittedExpenseReport, adminPolicy, false, otherAccountID)).toBe(false);
});

it('blocks inviting on an archived report', () => {
// Given an archived group chat and an archived open expense report
const groupChat = createGroupChat(80202, [submitterAccountID, otherAccountID]);

// Then nobody can invite, regardless of report type or role
expect(canInviteMembersToReport(groupChat, undefined, true, otherAccountID)).toBe(false);
expect(canInviteMembersToReport(openExpenseReport, adminPolicy, true, submitterAccountID)).toBe(false);
});

it('blocks inviting on an IOU report', () => {
// Given an open IOU report, i.e. a 1:1 expense that is not owned by a workspace
const iouReport: Report = {
...createIOUReport(80203, submitterAccountID, otherAccountID),
stateNum: CONST.REPORT.STATE_NUM.OPEN,
statusNum: CONST.REPORT.STATUS_NUM.OPEN,
};

// Then the submitter cannot invite, because there is no workspace to widen visibility to
expect(canInviteMembersToReport(iouReport, undefined, false, submitterAccountID)).toBe(false);
});

it('blocks inviting on report types that have no members to manage', () => {
// Given a regular chat and a self DM
const regularChat = createRegularChat(80204, [submitterAccountID, otherAccountID]);
const selfDM = createSelfDM(80205, submitterAccountID);

// Then inviting is not offered on either
expect(canInviteMembersToReport(regularChat, undefined, false, submitterAccountID)).toBe(false);
expect(canInviteMembersToReport(selfDM, undefined, false, submitterAccountID)).toBe(false);
});

it('blocks the submitter while the session account ID has not loaded yet', () => {
// Given an open expense report but no resolved current user account ID
// Then the submitter check cannot pass, though an admin is still recognised from the policy alone
expect(canInviteMembersToReport(openExpenseReport, memberPolicy, false, undefined)).toBe(false);
expect(canInviteMembersToReport(openExpenseReport, adminPolicy, false, undefined)).toBe(true);
});
});

describe('shouldDisableRename', () => {
it('should return true for archived reports', async () => {
// Given an archived policy room
Expand Down
Loading