Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Access control on IOU request #41730

Merged
merged 9 commits into from
May 30, 2024
3 changes: 3 additions & 0 deletions src/CONST.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1567,6 +1567,9 @@ const CONST = {
ACCOUNTANT: 'accountant',
},
},
ACCESS_VARIANTS: {
CREATE: 'create',
},
},

GROWL: {
Expand Down
4 changes: 2 additions & 2 deletions src/components/ConnectionLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import {View} from 'react-native';
import useLocalize from '@hooks/useLocalize';
import useThemeStyles from '@hooks/useThemeStyles';
import Navigation from '@libs/Navigation/Navigation';
import type {PolicyAccessVariant} from '@pages/workspace/AccessOrNotFoundWrapper';
import type {AccessVariant} from '@pages/workspace/AccessOrNotFoundWrapper';
import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper';
import type {TranslationPaths} from '@src/languages/types';
import type {PolicyFeatureName} from '@src/types/onyx/Policy';
Expand Down Expand Up @@ -33,7 +33,7 @@ type ConnectionLayoutProps = {
policyID: string;

/** Defines which types of access should be verified */
accessVariants?: PolicyAccessVariant[];
accessVariants?: AccessVariant[];

/** The current feature name that the user tries to get access to */
featureName?: PolicyFeatureName;
Expand Down
4 changes: 2 additions & 2 deletions src/components/SelectionScreen.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import React from 'react';
import useLocalize from '@hooks/useLocalize';
import type {PolicyAccessVariant} from '@pages/workspace/AccessOrNotFoundWrapper';
import type {AccessVariant} from '@pages/workspace/AccessOrNotFoundWrapper';
import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper';
import type {TranslationPaths} from '@src/languages/types';
import type {PolicyFeatureName} from '@src/types/onyx/Policy';
Expand Down Expand Up @@ -45,7 +45,7 @@ type SelectionScreenProps = {
policyID: string;

/** Defines which types of access should be verified */
accessVariants?: PolicyAccessVariant[];
accessVariants?: AccessVariant[];

/** The current feature name that the user tries to get access to */
featureName?: PolicyFeatureName;
Expand Down
43 changes: 18 additions & 25 deletions src/pages/iou/request/IOURequestStartPage.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
import {useFocusEffect} from '@react-navigation/native';
import React, {useCallback, useEffect, useRef, useState} from 'react';
import {View} from 'react-native';
import type {OnyxCollection, OnyxEntry} from 'react-native-onyx';
import type {OnyxEntry} from 'react-native-onyx';
import {withOnyx} from 'react-native-onyx';
import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView';
import DragAndDropProvider from '@components/DragAndDrop/Provider';
import FullScreenLoadingIndicator from '@components/FullscreenLoadingIndicator';
import HeaderWithBackButton from '@components/HeaderWithBackButton';
Expand All @@ -13,13 +12,12 @@ import useLocalize from '@hooks/useLocalize';
import usePermissions from '@hooks/usePermissions';
import useThemeStyles from '@hooks/useThemeStyles';
import * as DeviceCapabilities from '@libs/DeviceCapabilities';
import * as IOUUtils from '@libs/IOUUtils';
import * as KeyDownPressListener from '@libs/KeyboardShortcut/KeyDownPressListener';
import Navigation from '@libs/Navigation/Navigation';
import OnyxTabNavigator, {TopTab} from '@libs/Navigation/OnyxTabNavigator';
import * as PolicyUtils from '@libs/PolicyUtils';
import * as ReportUtils from '@libs/ReportUtils';
import * as TransactionUtils from '@libs/TransactionUtils';
import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper';
import * as IOU from '@userActions/IOU';
import type {IOURequestType} from '@userActions/IOU';
import CONST from '@src/CONST';
Expand All @@ -44,9 +42,6 @@ type IOURequestStartPageOnyxProps = {

/** The transaction being modified */
transaction: OnyxEntry<Transaction>;

/** The list of all policies */
allPolicies: OnyxCollection<Policy>;
};

type IOURequestStartPageProps = IOURequestStartPageOnyxProps & WithWritableReportOrNotFoundProps<typeof SCREENS.MONEY_REQUEST.CREATE>;
Expand All @@ -60,7 +55,6 @@ function IOURequestStartPage({
},
selectedTab,
transaction,
allPolicies,
}: IOURequestStartPageProps) {
const styles = useThemeStyles();
const {translate} = useLocalize();
Expand Down Expand Up @@ -105,9 +99,6 @@ function IOURequestStartPage({
const isExpenseReport = ReportUtils.isExpenseReport(report);
const shouldDisplayDistanceRequest = (!!canUseP2PDistanceRequests || isExpenseChat || isExpenseReport || isFromGlobalCreate) && iouType !== CONST.IOU.TYPE.SPLIT;

// Allow the user to submit the expense if we are submitting the expense in global menu or the report can create the exoense
const isAllowedToCreateRequest = isEmptyObject(report?.reportID) || ReportUtils.canCreateRequest(report, policy, iouType) || PolicyUtils.canSendInvoice(allPolicies);

const navigateBack = () => {
Navigation.closeRHPFlow();
};
Expand All @@ -126,15 +117,20 @@ function IOURequestStartPage({
}

return (
<ScreenWrapper
includeSafeAreaPaddingBottom={false}
shouldEnableKeyboardAvoidingView={false}
shouldEnableMinHeight={DeviceCapabilities.canUseTouchScreen()}
headerGapStyles={isDraggingOver ? [styles.receiptDropHeaderGap] : []}
testID={IOURequestStartPage.displayName}
<AccessOrNotFoundWrapper
reportID={reportID}
iouType={iouType}
policyID={policy?.id}
accessVariants={[CONST.IOU.ACCESS_VARIANTS.CREATE]}
>
{({safeAreaPaddingBottomStyle}) => (
<FullPageNotFoundView shouldShow={!IOUUtils.isValidMoneyRequestType(iouType) || !isAllowedToCreateRequest}>
<ScreenWrapper
includeSafeAreaPaddingBottom={false}
shouldEnableKeyboardAvoidingView={false}
shouldEnableMinHeight={DeviceCapabilities.canUseTouchScreen()}
headerGapStyles={isDraggingOver ? [styles.receiptDropHeaderGap] : []}
testID={IOURequestStartPage.displayName}
>
{({safeAreaPaddingBottomStyle}) => (
<DragAndDropProvider
setIsDraggingOver={setIsDraggingOver}
isDisabled={selectedTab !== CONST.TAB_REQUEST.SCAN}
Expand All @@ -159,9 +155,9 @@ function IOURequestStartPage({
)}
</View>
</DragAndDropProvider>
</FullPageNotFoundView>
)}
</ScreenWrapper>
)}
</ScreenWrapper>
</AccessOrNotFoundWrapper>
);
}

Expand All @@ -180,7 +176,4 @@ export default withOnyx<IOURequestStartPageProps, IOURequestStartPageOnyxProps>(
transaction: {
key: ({route}) => `${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${route?.params.transactionID ?? 0}`,
},
allPolicies: {
key: ONYXKEYS.COLLECTION.POLICY,
},
})(IOURequestStartPage);
61 changes: 45 additions & 16 deletions src/pages/workspace/AccessOrNotFoundWrapper.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
/* eslint-disable rulesdir/no-negated-variables */
import React, {useEffect} from 'react';
import type {OnyxEntry} from 'react-native-onyx';
import type {OnyxCollection, OnyxEntry} from 'react-native-onyx';
import {withOnyx} from 'react-native-onyx';
import type {FullPageNotFoundViewProps} from '@components/BlockingViews/FullPageNotFoundView';
import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView';
import FullscreenLoadingIndicator from '@components/FullscreenLoadingIndicator';
import * as IOUUtils from '@libs/IOUUtils';
import Navigation from '@libs/Navigation/Navigation';
import * as PolicyUtils from '@libs/PolicyUtils';
import * as ReportUtils from '@libs/ReportUtils';
import NotFoundPage from '@pages/ErrorPage/NotFoundPage';
import * as Policy from '@userActions/Policy';
import type {IOUType} from '@src/CONST';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';
Expand All @@ -17,29 +20,44 @@ import type {PolicyFeatureName} from '@src/types/onyx/Policy';
import callOrReturn from '@src/types/utils/callOrReturn';
import {isEmptyObject} from '@src/types/utils/EmptyObject';

const POLICY_ACCESS_VARIANTS = {
const ACCESS_VARIANTS = {
[CONST.POLICY.ACCESS_VARIANTS.PAID]: (policy: OnyxEntry<OnyxTypes.Policy>) => PolicyUtils.isPaidGroupPolicy(policy) && !!policy?.isPolicyExpenseChatEnabled,
[CONST.POLICY.ACCESS_VARIANTS.ADMIN]: (policy: OnyxEntry<OnyxTypes.Policy>) => PolicyUtils.isPolicyAdmin(policy),
} as const satisfies Record<string, (policy: OnyxTypes.Policy) => boolean>;

type PolicyAccessVariant = keyof typeof POLICY_ACCESS_VARIANTS;
[CONST.IOU.ACCESS_VARIANTS.CREATE]: (policy: OnyxEntry<OnyxTypes.Policy>, report: OnyxEntry<OnyxTypes.Report>, allPolicies: OnyxCollection<OnyxTypes.Policy>, iouType?: IOUType) =>
!!iouType &&
IOUUtils.isValidMoneyRequestType(iouType) &&
// Allow the user to submit the expense if we are submitting the expense in global menu or the report can create the expense
(isEmptyObject(report?.reportID) || ReportUtils.canCreateRequest(report, policy, iouType)) &&
(iouType !== CONST.IOU.TYPE.INVOICE || PolicyUtils.canSendInvoice(allPolicies)),
} as const satisfies Record<string, (policy: OnyxTypes.Policy, report: OnyxTypes.Report, allPolicies: OnyxCollection<OnyxTypes.Policy>, iouType?: IOUType) => boolean>;

type AccessVariant = keyof typeof ACCESS_VARIANTS;
type AccessOrNotFoundWrapperOnyxProps = {
/** The report that holds the transaction */
report: OnyxEntry<OnyxTypes.Report>;

/** The report currently being looked at */
policy: OnyxEntry<OnyxTypes.Policy>;

/** Indicated whether the report data is loading */
isLoadingReportData: OnyxEntry<boolean>;

/** The list of all policies */
allPolicies: OnyxCollection<OnyxTypes.Policy>;
};

type AccessOrNotFoundWrapperProps = AccessOrNotFoundWrapperOnyxProps & {
/** The children to render */
children: ((props: AccessOrNotFoundWrapperOnyxProps) => React.ReactNode) | React.ReactNode;

/** The id of the report that holds the transaction */
reportID?: string;

/** The report currently being looked at */
policyID: string;
policyID?: string;

/** Defines which types of access should be verified */
accessVariants?: PolicyAccessVariant[];
accessVariants?: AccessVariant[];

/** The current feature name that the user tries to get access to */
featureName?: PolicyFeatureName;
Expand All @@ -49,6 +67,9 @@ type AccessOrNotFoundWrapperProps = AccessOrNotFoundWrapperOnyxProps & {

/** Whether or not to block user from accessing the page */
shouldBeBlocked?: boolean;

/** The type of the transaction */
iouType?: IOUType;
} & Pick<FullPageNotFoundViewProps, 'subtitleKey' | 'onLinkPress'>;

type PageNotFoundFallbackProps = Pick<AccessOrNotFoundWrapperProps, 'policyID' | 'fullPageNotFoundViewProps'> & {shouldShowFullScreenFallback: boolean};
Expand All @@ -64,17 +85,19 @@ function PageNotFoundFallback({policyID, shouldShowFullScreenFallback, fullPageN
/>
) : (
<NotFoundPage
onBackButtonPress={() => Navigation.goBack(ROUTES.WORKSPACE_PROFILE.getRoute(policyID))}
onBackButtonPress={() => Navigation.goBack(policyID ? ROUTES.WORKSPACE_PROFILE.getRoute(policyID) : ROUTES.HOME)}
// eslint-disable-next-line react/jsx-props-no-spreading
{...fullPageNotFoundViewProps}
/>
);
}

function AccessOrNotFoundWrapper({accessVariants = [], fullPageNotFoundViewProps, shouldBeBlocked, ...props}: AccessOrNotFoundWrapperProps) {
const {policy, policyID, featureName, isLoadingReportData} = props;
const {policy, policyID, report, iouType, allPolicies, featureName, isLoadingReportData} = props;

const isPolicyIDInRoute = !!policyID?.length;
const isMoneyRequest = !!iouType && IOUUtils.isValidMoneyRequestType(iouType);
const isFromGlobalCreate = isEmptyObject(report?.reportID);

useEffect(() => {
if (!isPolicyIDInRoute || !isEmptyObject(policy)) {
Expand All @@ -86,17 +109,17 @@ function AccessOrNotFoundWrapper({accessVariants = [], fullPageNotFoundViewProps
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isPolicyIDInRoute, policyID]);

const shouldShowFullScreenLoadingIndicator = isLoadingReportData !== false && (!Object.entries(policy ?? {}).length || !policy?.id);
const shouldShowFullScreenLoadingIndicator = !isMoneyRequest && isLoadingReportData !== false && (!Object.entries(policy ?? {}).length || !policy?.id);

const isFeatureEnabled = featureName ? PolicyUtils.isPolicyFeatureEnabled(policy, featureName) : true;

const isPageAccessible = accessVariants.reduce((acc, variant) => {
const accessFunction = POLICY_ACCESS_VARIANTS[variant];
return acc && accessFunction(policy);
const accessFunction = ACCESS_VARIANTS[variant];
return acc && accessFunction(policy, report, allPolicies, iouType);
}, true);

const shouldShowNotFoundPage =
isEmptyObject(policy) || (Object.keys(policy).length === 1 && !isEmptyObject(policy.errors)) || !policy?.id || !isPageAccessible || !isFeatureEnabled || shouldBeBlocked;
const isPolicyNotAccessible = isEmptyObject(policy) || (Object.keys(policy).length === 1 && !isEmptyObject(policy.errors)) || !policy?.id;
const shouldShowNotFoundPage = (!isMoneyRequest && !isFromGlobalCreate && isPolicyNotAccessible) || !isPageAccessible || !isFeatureEnabled || shouldBeBlocked;

if (shouldShowFullScreenLoadingIndicator) {
return <FullscreenLoadingIndicator />;
Expand All @@ -115,13 +138,19 @@ function AccessOrNotFoundWrapper({accessVariants = [], fullPageNotFoundViewProps
return callOrReturn(props.children, props);
}

export type {PolicyAccessVariant};
export type {AccessVariant};

export default withOnyx<AccessOrNotFoundWrapperProps, AccessOrNotFoundWrapperOnyxProps>({
report: {
key: ({reportID}) => `${ONYXKEYS.COLLECTION.REPORT}${reportID}`,
},
policy: {
key: ({policyID}) => `${ONYXKEYS.COLLECTION.POLICY}${policyID ?? ''}`,
key: ({policyID}) => `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
},
isLoadingReportData: {
key: ONYXKEYS.IS_LOADING_REPORT_DATA,
},
allPolicies: {
key: ONYXKEYS.COLLECTION.POLICY,
},
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@cristipaval @tienifr I don't think subscribing to allPolicies is a good strategy here. Cause other components are also using this HOC.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agree. I passed allPolicies from IOURequestStartPage.

})(AccessOrNotFoundWrapper);
Loading