1
0
mirror of https://github.com/kremalicious/metamask-extension.git synced 2024-11-22 01:47:00 +01:00

[FLASK] Add Snaps privacy warning on snap install (#18835)

* Add Snaps privacy warning on snap install

Add snap install warning status to storage

Add storybook

Add test for snap-privacy-warning

Resolve button type issue

Fix popup display logic

Update fixture

Update popup information and read more handling

Replace deprecated button

Update unit test

* Update buttons and add cancel flow

* Refactoring (review 1)

* Add more unit tests
This commit is contained in:
David Drazic 2023-05-31 14:43:39 +02:00 committed by GitHub
parent 49f8052b15
commit f788121c3b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 432 additions and 0 deletions

View File

@ -654,6 +654,9 @@
"clearActivityDescription": {
"message": "This resets the account's nonce and erases data from the activity tab in your wallet. Only the current account and network will be affected. Your balances and incoming transactions won't change."
},
"click": {
"message": "Click"
},
"clickToConnectLedgerViaWebHID": {
"message": "Click here to connect your Ledger via WebHID",
"description": "Text that can be clicked to open a browser popup for connecting the ledger device via webhid"
@ -1562,6 +1565,10 @@
"followUsOnTwitter": {
"message": "Follow us on Twitter"
},
"forMoreDetails": {
"message": "for more details.",
"description": "Click for more details message in popup modal displayed when installing a snap for the first time."
},
"forbiddenIpfsGateway": {
"message": "Forbidden IPFS Gateway: Please specify a CID gateway"
},
@ -3843,9 +3850,29 @@
"snapsNoInsight": {
"message": "The snap didn't return any insight"
},
"snapsPrivacyWarningFirstMessage": {
"message": "Installing a snap retrieves data from third parties. They may collect your personal information.",
"description": "First part of a message in popup modal displayed when installing a snap for the first time."
},
"snapsPrivacyWarningSecondMessage": {
"message": "MetaMask has no access to this information.",
"description": "Second part of a message in popup modal displayed when installing a snap for the first time."
},
"snapsSettingsDescription": {
"message": "Manage your Snaps"
},
"snapsThirdPartyNoticeReadMorePartOne": {
"message": "Any information you share with third-party-developed snaps will be collected directly by those snaps in accordance with their privacy policies. ",
"description": "First part of a tooltip content in popup modal displayed when installing a snap for the first time."
},
"snapsThirdPartyNoticeReadMorePartThree": {
"message": "MetaMask has no access to information you share with these third parties.",
"description": "Third part of a tooltip content in popup modal displayed when installing a snap for the first time."
},
"snapsThirdPartyNoticeReadMorePartTwo": {
"message": "During the installation of a snap, npmjs (npmjs.com) and AWS (aws.amazon.com) may collect your IP address. Please refer to their privacy policies for more information.",
"description": "Second part of a tooltip content in popup modal displayed when installing a snap for the first time."
},
"snapsToggle": {
"message": "A snap will only run if it is enabled"
},
@ -4466,6 +4493,10 @@
"thingsToKeep": {
"message": "Things to keep in mind:"
},
"thirdPartySoftware": {
"message": "Third party software",
"description": "Title of a popup modal displayed when installing a snap for the first time."
},
"thisCollection": {
"message": "this collection"
},

View File

@ -183,6 +183,20 @@ export default class AppStateController extends EventEmitter {
});
}
///: BEGIN:ONLY_INCLUDE_IN(snaps)
/**
* Record if popover for snaps privacy warning has been shown
* on the first install of a snap.
*
* @param {boolean} shown - shown status
*/
setSnapsInstallPrivacyWarningShownStatus(shown) {
this.store.updateState({
snapsInstallPrivacyWarningShown: shown,
});
}
///: END:ONLY_INCLUDE_IN
/**
* Record the timestamp of the last time the user has seen the outdated browser warning
*

View File

@ -346,4 +346,23 @@ describe('AppStateController', () => {
);
});
});
describe('setSnapsInstallPrivacyWarningShownStatus', () => {
it('updates the status of snaps install privacy warning', () => {
appStateController = createAppStateController();
const updateStateSpy = jest.spyOn(
appStateController.store,
'updateState',
);
appStateController.setSnapsInstallPrivacyWarningShownStatus(true);
expect(updateStateSpy).toHaveBeenCalledTimes(1);
expect(updateStateSpy).toHaveBeenCalledWith({
snapsInstallPrivacyWarningShown: true,
});
updateStateSpy.mockRestore();
});
});
});

View File

@ -2207,6 +2207,12 @@ export default class MetamaskController extends EventEmitter {
),
setTermsOfUseLastAgreed:
appStateController.setTermsOfUseLastAgreed.bind(appStateController),
///: BEGIN:ONLY_INCLUDE_IN(snaps)
setSnapsInstallPrivacyWarningShownStatus:
appStateController.setSnapsInstallPrivacyWarningShownStatus.bind(
appStateController,
),
///: END:ONLY_INCLUDE_IN
setOutdatedBrowserWarningLastShown:
appStateController.setOutdatedBrowserWarningLastShown.bind(
appStateController,

View File

@ -158,6 +158,7 @@ function defaultFixture() {
[CHAIN_IDS.GOERLI]: true,
[CHAIN_IDS.LOCALHOST]: true,
},
snapsInstallPrivacyWarningShown: true,
},
CachedBalancesController: {
cachedBalances: {

View File

@ -13,6 +13,7 @@ import { PageContainerFooter } from '../../ui/page-container';
import PermissionsConnectFooter from '../permissions-connect-footer';
///: BEGIN:ONLY_INCLUDE_IN(snaps)
import { RestrictedMethods } from '../../../../shared/constants/permissions';
import SnapPrivacyWarning from '../snaps/snap-privacy-warning';
///: END:ONLY_INCLUDE_IN
import { PermissionPageContainerContent } from '.';
@ -24,6 +25,8 @@ export default class PermissionPageContainer extends Component {
allIdentitiesSelected: PropTypes.bool,
///: BEGIN:ONLY_INCLUDE_IN(snaps)
currentPermissions: PropTypes.object,
snapsInstallPrivacyWarningShown: PropTypes.bool.isRequired,
setSnapsInstallPrivacyWarningShownStatus: PropTypes.func,
///: END:ONLY_INCLUDE_IN
request: PropTypes.object,
requestMetadata: PropTypes.object,
@ -108,6 +111,12 @@ export default class PermissionPageContainer extends Component {
caveats: [{ type: SnapCaveatType.SnapIds, value: dedupedCaveats }],
};
}
showSnapsPrivacyWarning() {
this.setState({
isShowingSnapsPrivacyWarning: true,
});
}
///: END:ONLY_INCLUDE_IN
getRequestedMethodNames(props) {
@ -123,6 +132,14 @@ export default class PermissionPageContainer extends Component {
legacy_event: true,
},
});
///: BEGIN:ONLY_INCLUDE_IN(snaps)
if (this.props.request.permissions[WALLET_SNAP_PERMISSION_KEY]) {
if (this.props.snapsInstallPrivacyWarningShown === false) {
this.showSnapsPrivacyWarning();
}
}
///: END:ONLY_INCLUDE_IN
}
onCancel = () => {
@ -167,8 +184,33 @@ export default class PermissionPageContainer extends Component {
allIdentitiesSelected,
} = this.props;
///: BEGIN:ONLY_INCLUDE_IN(snaps)
const setIsShowingSnapsPrivacyWarning = (value) => {
this.setState({
isShowingSnapsPrivacyWarning: value,
});
};
const confirmSnapsPrivacyWarning = () => {
setIsShowingSnapsPrivacyWarning(false);
this.props.setSnapsInstallPrivacyWarningShownStatus(true);
};
///: END:ONLY_INCLUDE_IN
return (
<div className="page-container permission-approval-container">
{
///: BEGIN:ONLY_INCLUDE_IN(snaps)
<>
{this.state.isShowingSnapsPrivacyWarning && (
<SnapPrivacyWarning
onAccepted={() => confirmSnapsPrivacyWarning()}
onCanceled={() => this.onCancel()}
/>
)}
</>
///: END:ONLY_INCLUDE_IN
}
<PermissionPageContainerContent
requestMetadata={requestMetadata}
subjectMetadata={targetSubjectMetadata}

View File

@ -0,0 +1 @@
export { default } from './snap-privacy-warning';

View File

@ -0,0 +1,140 @@
import React, { useState } from 'react';
import PropTypes from 'prop-types';
import { useI18nContext } from '../../../../hooks/useI18nContext';
import Box from '../../../ui/box/box';
import Popover from '../../../ui/popover';
import {
AvatarIcon,
Button,
BUTTON_LINK_SIZES,
BUTTON_PRIMARY_SIZES,
BUTTON_VARIANT,
ButtonLink,
IconName,
IconSize,
Text,
} from '../../../component-library';
import {
AlignItems,
BackgroundColor,
BLOCK_SIZES,
DISPLAY,
IconColor,
JustifyContent,
TextVariant,
} from '../../../../helpers/constants/design-system';
export default function SnapPrivacyWarning({ onAccepted, onCanceled }) {
const t = useI18nContext();
const [isDescriptionOpen, setIsDescriptionOpen] = useState(false);
const handleReadMoreClick = () => {
setIsDescriptionOpen(true);
};
return (
<Popover className="snap-privacy-warning">
<Box padding={4}>
<Box
className="snap-privacy-warning__info-icon"
display={DISPLAY.FLEX}
justifyContent={JustifyContent.center}
alignItems={AlignItems.center}
>
<AvatarIcon
iconName={IconName.Info}
color={IconColor.infoDefault}
backgroundColor={BackgroundColor.primaryMuted}
size={IconSize.Md}
/>
</Box>
<Box
className="snap-privacy-warning__title"
marginTop={4}
marginBottom={6}
display={DISPLAY.FLEX}
justifyContent={JustifyContent.center}
alignItems={AlignItems.center}
>
<Text variant={TextVariant.headingMd}>{t('thirdPartySoftware')}</Text>
</Box>
<Box className="snap-privacy-warning__message">
<Text variant={TextVariant.bodyMd}>
{t('snapsPrivacyWarningFirstMessage')}
</Text>
{!isDescriptionOpen && (
<>
<Text variant={TextVariant.bodyMd} paddingTop={6}>
{t('snapsPrivacyWarningSecondMessage')}
</Text>
<Text
variant={TextVariant.bodyMd}
className="snap-privacy-warning__more-details"
>
{t('click')}
<ButtonLink
size={BUTTON_LINK_SIZES.INHERIT}
onClick={handleReadMoreClick}
data-testid="snapsPrivacyPopup_readMoreButton"
>
&nbsp;{t('here')}&nbsp;
</ButtonLink>
{t('forMoreDetails')}
</Text>
</>
)}
{isDescriptionOpen && (
<>
<Text variant={TextVariant.bodyMd} paddingTop={6}>
{t('snapsThirdPartyNoticeReadMorePartOne')}
</Text>
<Text variant={TextVariant.bodyMd} paddingTop={6}>
{t('snapsThirdPartyNoticeReadMorePartTwo')}
</Text>
<Text variant={TextVariant.bodyMd} paddingTop={6}>
{t('snapsThirdPartyNoticeReadMorePartThree')}
</Text>
</>
)}
</Box>
<Box
className="snap-privacy-warning__ok-button"
marginTop={6}
display={DISPLAY.FLEX}
>
<Button
variant={BUTTON_VARIANT.SECONDARY}
size={BUTTON_PRIMARY_SIZES.LG}
width={BLOCK_SIZES.FULL}
className="snap-privacy-warning__cancel-button"
onClick={onCanceled}
marginRight={2}
>
{t('cancel')}
</Button>
<Button
variant={BUTTON_VARIANT.PRIMARY}
size={BUTTON_PRIMARY_SIZES.LG}
width={BLOCK_SIZES.FULL}
className="snap-privacy-warning__ok-button"
onClick={onAccepted}
marginLeft={2}
>
{t('accept')}
</Button>
</Box>
</Box>
</Popover>
);
}
SnapPrivacyWarning.propTypes = {
/**
* onAccepted handler
*/
onAccepted: PropTypes.func.isRequired,
/**
* onCanceled handler
*/
onCanceled: PropTypes.func.isRequired,
};

View File

@ -0,0 +1,22 @@
import React from 'react';
import SnapPrivacyWarning from '.';
export default {
title: 'Components/App/snaps/SnapPrivacyWarning',
component: SnapPrivacyWarning,
argTypes: {
onAccepted: {
action: 'onAccepted',
},
onCanceled: {
action: 'onCanceled',
},
},
};
export const DefaultStory = (args) => <SnapPrivacyWarning {...args} />;
DefaultStory.storyName = 'Default';
DefaultStory.args = {};

View File

@ -0,0 +1,77 @@
import React from 'react';
import { screen } from '@testing-library/react';
import { renderWithProvider } from '../../../../../test/jest';
import SnapPrivacyWarning from './snap-privacy-warning';
describe('Snap Privacy Warning Popover', () => {
it('renders snaps privacy warning popover and works with accept flow', () => {
const mockOnAcceptCallback = jest.fn();
const { getByTestId } = renderWithProvider(
<SnapPrivacyWarning
onAccepted={mockOnAcceptCallback}
onCanceled={jest.fn()}
/>,
);
expect(screen.getByText('Third party software')).toBeInTheDocument();
expect(
screen.getByText(
'Installing a snap retrieves data from third parties. They may collect your personal information.',
),
).toBeInTheDocument();
expect(
screen.getByText('MetaMask has no access to this information.'),
).toBeInTheDocument();
const clickHereToReadMoreButton = getByTestId(
'snapsPrivacyPopup_readMoreButton',
);
expect(clickHereToReadMoreButton).toBeDefined();
clickHereToReadMoreButton.click();
expect(
screen.getByText(
'Any information you share with third-party-developed snaps will be collected directly by those snaps in accordance with their privacy policies.',
),
).toBeInTheDocument();
expect(
screen.getByText(
'During the installation of a snap, npmjs (npmjs.com) and AWS (aws.amazon.com) may collect your IP address. Please refer to their privacy policies for more information.',
),
).toBeInTheDocument();
expect(
screen.getByRole('button', {
name: /Accept/iu,
}),
).toBeInTheDocument();
screen
.getByRole('button', {
name: /Accept/iu,
})
.click();
expect(mockOnAcceptCallback).toHaveBeenCalled();
});
it('renders snaps privacy warning popover and works with cancel flow', () => {
const mockOnAcceptCallback = jest.fn();
const mockOnCanceledCallback = jest.fn();
renderWithProvider(
<SnapPrivacyWarning
onAccepted={mockOnAcceptCallback}
onCanceled={mockOnCanceledCallback}
/>,
);
expect(screen.getByText('Third party software')).toBeInTheDocument();
expect(
screen.getByRole('button', {
name: /Cancel/iu,
}),
).toBeInTheDocument();
screen
.getByRole('button', {
name: /Cancel/iu,
})
.click();
expect(mockOnCanceledCallback).toHaveBeenCalled();
expect(mockOnAcceptCallback).not.toHaveBeenCalled();
});
});

View File

@ -67,6 +67,9 @@ interface AppState {
customTokenAmount: string;
txId: number | null;
accountDetailsAddress: string;
///: BEGIN:ONLY_INCLUDE_IN(snaps)
snapsInstallPrivacyWarningShown: boolean;
///: END:ONLY_INCLUDE_IN
}
interface AppSliceState {
@ -132,6 +135,9 @@ const initialState: AppState = {
scrollToBottom: true,
txId: null,
accountDetailsAddress: '',
///: BEGIN:ONLY_INCLUDE_IN(snaps)
snapsInstallPrivacyWarningShown: false,
///: END:ONLY_INCLUDE_IN
};
export default function reduceApp(

View File

@ -46,6 +46,8 @@ export default class PermissionConnect extends Component {
requestState: PropTypes.object.isRequired,
approvePendingApproval: PropTypes.func.isRequired,
rejectPendingApproval: PropTypes.func.isRequired,
setSnapsInstallPrivacyWarningShownStatus: PropTypes.func.isRequired,
snapsInstallPrivacyWarningShown: PropTypes.bool.isRequired,
///: END:ONLY_INCLUDE_IN
totalPages: PropTypes.string.isRequired,
page: PropTypes.string.isRequired,
@ -76,6 +78,9 @@ export default class PermissionConnect extends Component {
permissionsApproved: null,
origin: this.props.origin,
targetSubjectMetadata: this.props.targetSubjectMetadata || {},
///: BEGIN:ONLY_INCLUDE_IN(snaps)
snapsInstallPrivacyWarningShown: this.props.snapsInstallPrivacyWarningShown,
///: END:ONLY_INCLUDE_IN
};
beforeUnload = () => {
@ -298,6 +303,7 @@ export default class PermissionConnect extends Component {
requestState,
approvePendingApproval,
rejectPendingApproval,
setSnapsInstallPrivacyWarningShownStatus,
///: END:ONLY_INCLUDE_IN
} = this.props;
const {
@ -305,6 +311,9 @@ export default class PermissionConnect extends Component {
permissionsApproved,
redirecting,
targetSubjectMetadata,
///: BEGIN:ONLY_INCLUDE_IN(snaps)
snapsInstallPrivacyWarningShown,
///: END:ONLY_INCLUDE_IN
} = this.state;
return (
@ -356,6 +365,14 @@ export default class PermissionConnect extends Component {
selectedAccountAddresses.has(account.address),
)}
targetSubjectMetadata={targetSubjectMetadata}
///: BEGIN:ONLY_INCLUDE_IN(snaps)
snapsInstallPrivacyWarningShown={
snapsInstallPrivacyWarningShown
}
setSnapsInstallPrivacyWarningShownStatus={
setSnapsInstallPrivacyWarningShownStatus
}
///: END:ONLY_INCLUDE_IN
/>
)}
/>

View File

@ -9,6 +9,7 @@ import {
///: BEGIN:ONLY_INCLUDE_IN(snaps)
getSnapInstallOrUpdateRequests,
getRequestState,
getSnapsInstallPrivacyWarningShown,
///: END:ONLY_INCLUDE_IN
getRequestType,
getTargetSubjectMetadata,
@ -24,6 +25,7 @@ import {
///: BEGIN:ONLY_INCLUDE_IN(snaps)
resolvePendingApproval,
rejectPendingApproval,
setSnapsInstallPrivacyWarningShownStatus,
///: END:ONLY_INCLUDE_IN
} from '../../store/actions';
import {
@ -133,6 +135,7 @@ const mapStateToProps = (state, ownProps) => {
snapResultPath,
requestState,
isSnap,
snapsInstallPrivacyWarningShown: getSnapsInstallPrivacyWarningShown(state),
///: END:ONLY_INCLUDE_IN
permissionsRequest,
permissionsRequestId,
@ -162,6 +165,9 @@ const mapDispatchToProps = (dispatch) => {
dispatch(resolvePendingApproval(id, value)),
rejectPendingApproval: (id, error) =>
dispatch(rejectPendingApproval(id, error)),
setSnapsInstallPrivacyWarningShownStatus: (shown) => {
dispatch(setSnapsInstallPrivacyWarningShownStatus(shown));
},
///: END:ONLY_INCLUDE_IN
showNewAccountModal: ({ onCreateNewAccount, newAccountNumber }) => {
return dispatch(

View File

@ -1492,4 +1492,23 @@ export function getSnapsList(state) {
};
});
}
/**
* To get the state of snaps privacy warning popover.
*
* @param state - Redux state object.
* @returns True if popover has been shown, false otherwise.
*/
export function getSnapsInstallPrivacyWarningShown(state) {
const { snapsInstallPrivacyWarningShown } = state.metamask;
if (
snapsInstallPrivacyWarningShown === undefined ||
snapsInstallPrivacyWarningShown === null
) {
return false;
}
return snapsInstallPrivacyWarningShown;
}
///: END:ONLY_INCLUDE_IN

View File

@ -469,4 +469,18 @@ describe('Selectors', () => {
)(mockState);
expect(isFantomTokenSupported).toBeFalsy();
});
it('returns proper values for snaps privacy warning shown status', () => {
mockState.metamask.snapsInstallPrivacyWarningShown = false;
expect(selectors.getSnapsInstallPrivacyWarningShown(mockState)).toBe(false);
mockState.metamask.snapsInstallPrivacyWarningShown = true;
expect(selectors.getSnapsInstallPrivacyWarningShown(mockState)).toBe(true);
mockState.metamask.snapsInstallPrivacyWarningShown = undefined;
expect(selectors.getSnapsInstallPrivacyWarningShown(mockState)).toBe(false);
mockState.metamask.snapsInstallPrivacyWarningShown = null;
expect(selectors.getSnapsInstallPrivacyWarningShown(mockState)).toBe(false);
});
});

View File

@ -4678,3 +4678,20 @@ export async function getCurrentNetworkEIP1559Compatibility(): Promise<
}
return networkEIP1559Compatibility;
}
///: BEGIN:ONLY_INCLUDE_IN(snaps)
/**
* Set status of popover warning for the first snap installation.
*
* @param shown - True if popover has been shown.
* @returns Promise Resolved on successfully submitted background request.
*/
export function setSnapsInstallPrivacyWarningShownStatus(shown: boolean) {
return async () => {
await submitRequestToBackground(
'setSnapsInstallPrivacyWarningShownStatus',
[shown],
);
};
}
///: END:ONLY_INCLUDE_IN