From 667ddb98912db0ab2da5a55fbf39906c8722d8af Mon Sep 17 00:00:00 2001 From: Owen Craston Date: Wed, 25 Sep 2024 16:52:41 -0700 Subject: [PATCH 01/20] feat: remove account snap warning - when a user tries to remove an account snap while they have snap accounts in the wallet, we will warn them and prompt them to back up these accounts. - these accounts are not managed by MetaMask so they cannot be recovered --- .../KeyringSnapRemovalWarning.constants.ts | 7 + .../KeyringSnapRemovalWarning.styles.ts | 36 +++ .../KeyringSnapRemovalWarning.tsx | 208 ++++++++++++++++++ .../Snaps/KeyringSnapRemovalWarning/index.ts | 3 + .../Views/Snaps/SnapSettings/SnapSettings.tsx | 154 +++++++++---- .../KeyringAccountListItem.styles.ts | 36 +++ .../KeyringAccountListItem.tsx | 53 +++++ .../KeyringAccountListItem/index.ts | 1 + app/core/Engine.ts | 5 + .../SnapKeyring/utils/getAccountsBySnapId.ts | 16 ++ ios/MetaMask.xcodeproj/project.pbxproj | 12 +- locales/languages/en.json | 18 +- 12 files changed, 497 insertions(+), 52 deletions(-) create mode 100644 app/components/Views/Snaps/KeyringSnapRemovalWarning/KeyringSnapRemovalWarning.constants.ts create mode 100644 app/components/Views/Snaps/KeyringSnapRemovalWarning/KeyringSnapRemovalWarning.styles.ts create mode 100644 app/components/Views/Snaps/KeyringSnapRemovalWarning/KeyringSnapRemovalWarning.tsx create mode 100644 app/components/Views/Snaps/KeyringSnapRemovalWarning/index.ts create mode 100644 app/components/Views/Snaps/components/KeyringAccountListItem/KeyringAccountListItem.styles.ts create mode 100644 app/components/Views/Snaps/components/KeyringAccountListItem/KeyringAccountListItem.tsx create mode 100644 app/components/Views/Snaps/components/KeyringAccountListItem/index.ts create mode 100644 app/core/SnapKeyring/utils/getAccountsBySnapId.ts diff --git a/app/components/Views/Snaps/KeyringSnapRemovalWarning/KeyringSnapRemovalWarning.constants.ts b/app/components/Views/Snaps/KeyringSnapRemovalWarning/KeyringSnapRemovalWarning.constants.ts new file mode 100644 index 00000000000..9c6d522643f --- /dev/null +++ b/app/components/Views/Snaps/KeyringSnapRemovalWarning/KeyringSnapRemovalWarning.constants.ts @@ -0,0 +1,7 @@ +///: BEGIN:ONLY_INCLUDE_IF(keyring-snaps) +export const KEYRING_SNAP_REMOVAL_WARNING = 'keyring-snap-removal-warning'; +export const KEYRING_SNAP_REMOVAL_WARNING_CONTINUE = + 'keyring-snap-removal-warning-continue'; +export const KEYRING_SNAP_REMOVAL_WARNING_CANCEL = + 'keyring-snap-removal-warning-cancel'; +///: END:ONLY_INCLUDE_IF diff --git a/app/components/Views/Snaps/KeyringSnapRemovalWarning/KeyringSnapRemovalWarning.styles.ts b/app/components/Views/Snaps/KeyringSnapRemovalWarning/KeyringSnapRemovalWarning.styles.ts new file mode 100644 index 00000000000..e7397ff04fd --- /dev/null +++ b/app/components/Views/Snaps/KeyringSnapRemovalWarning/KeyringSnapRemovalWarning.styles.ts @@ -0,0 +1,36 @@ +///: BEGIN:ONLY_INCLUDE_IF(keyring-snaps) +import { StyleSheet } from 'react-native'; +import { Theme } from '../../../../util/theme/models'; + +const styleSheet = (params: { theme: Theme }) => { + const { theme } = params; + const { colors } = theme; + + return StyleSheet.create({ + container: { + paddingHorizontal: 16, + }, + description: { + paddingTop: 16, + }, + buttonContainer: { + paddingTop: 16, + }, + input: { + borderWidth: 1, + borderColor: colors.border.default, + borderRadius: 4, + padding: 10, + marginVertical: 10, + }, + errorText: { + color: colors.error.default, + }, + placeholderText: { + color: colors.text.muted, + }, + }); +}; + +export default styleSheet; +///: END:ONLY_INCLUDE_IF diff --git a/app/components/Views/Snaps/KeyringSnapRemovalWarning/KeyringSnapRemovalWarning.tsx b/app/components/Views/Snaps/KeyringSnapRemovalWarning/KeyringSnapRemovalWarning.tsx new file mode 100644 index 00000000000..aa9a16f11f2 --- /dev/null +++ b/app/components/Views/Snaps/KeyringSnapRemovalWarning/KeyringSnapRemovalWarning.tsx @@ -0,0 +1,208 @@ +///: BEGIN:ONLY_INCLUDE_IF(keyring-snaps) +import React, { + useEffect, + useRef, + useState, + useCallback, + useMemo, +} from 'react'; +import { Snap } from '@metamask/snaps-utils'; +import BottomSheet, { + BottomSheetRef, +} from '../../../../component-library/components/BottomSheets/BottomSheet'; +import Text, { + TextVariant, +} from '../../../../component-library/components/Texts/Text'; +import { InternalAccount } from '@metamask/keyring-api'; +import BannerAlert from '../../../../component-library/components/Banners/Banner/variants/BannerAlert'; +import { BannerAlertSeverity } from '../../../../component-library/components/Banners/Banner'; +import BottomSheetHeader from '../../../../component-library/components/BottomSheets/BottomSheetHeader'; +import { ScrollView, View, TextInput } from 'react-native'; +import { useStyles } from '../../../hooks/useStyles'; +import stylesheet from './KeyringSnapRemovalWarning.styles'; +import { strings } from '../../../../../locales/i18n'; +import { KeyringAccountListItem } from '../components/KeyringAccountListItem'; +import { getAccountLink } from '@metamask/etherscan-link'; +import { useSelector } from 'react-redux'; +import { selectProviderConfig } from '../../../../selectors/networkController'; +import BottomSheetFooter, { + ButtonsAlignment, +} from '../../../../component-library/components/BottomSheets/BottomSheetFooter'; +import { + ButtonProps, + ButtonSize, + ButtonVariants, +} from '../../../../component-library/components/Buttons/Button/Button.types'; +import { + KEYRING_SNAP_REMOVAL_WARNING, + KEYRING_SNAP_REMOVAL_WARNING_CANCEL, + KEYRING_SNAP_REMOVAL_WARNING_CONTINUE, +} from './KeyringSnapRemovalWarning.constants'; + +interface KeyringSnapRemovalWarningProps { + snap: Snap; + keyringAccounts: InternalAccount[]; + onCancel: () => void; + onClose: () => void; + onSubmit: () => void; +} + +export default function KeyringSnapRemovalWarning({ + snap, + keyringAccounts, + onCancel, + onClose, + onSubmit, +}: KeyringSnapRemovalWarningProps) { + const [showConfirmation, setShowConfirmation] = useState(false); + const [confirmedRemoval, setConfirmedRemoval] = useState(false); + const [confirmationInput, setConfirmationInput] = useState(''); + const [error, setError] = useState(false); + const { chainId } = useSelector(selectProviderConfig); + const { styles } = useStyles(stylesheet, {}); + const bottomSheetRef = useRef(null); + + useEffect(() => { + setShowConfirmation(keyringAccounts.length === 0); + }, [keyringAccounts]); + + const validateConfirmationInput = useCallback( + (input: string): boolean => input === snap.manifest.proposedName, + [snap.manifest.proposedName], + ); + + const handleConfirmationInputChange = useCallback( + (text: string) => { + setConfirmationInput(text); + setConfirmedRemoval(validateConfirmationInput(text)); + }, + [validateConfirmationInput], + ); + + const handleContinuePress = useCallback(() => { + if (!showConfirmation) { + setShowConfirmation(true); + } else if (confirmedRemoval) { + try { + onSubmit(); + } catch (e) { + setError(true); + } + } + }, [showConfirmation, confirmedRemoval, onSubmit]); + + const cancelButtonProps: ButtonProps = useMemo( + () => ({ + variant: ButtonVariants.Secondary, + label: strings( + 'app_settings.snaps.snap_settings.remove_account_snap_warning.cancel_button', + ), + size: ButtonSize.Lg, + onPress: onCancel, + testID: KEYRING_SNAP_REMOVAL_WARNING_CANCEL, + }), + [onCancel], + ); + + const continueButtonProps: ButtonProps = useMemo( + () => ({ + variant: ButtonVariants.Primary, + label: showConfirmation + ? strings( + 'app_settings.snaps.snap_settings.remove_account_snap_warning.remove_snap_button', + ) + : strings( + 'app_settings.snaps.snap_settings.remove_account_snap_warning.continue_button', + ), + size: ButtonSize.Lg, + onPress: handleContinuePress, + isDisabled: showConfirmation && !confirmedRemoval, + isDanger: showConfirmation, + testID: KEYRING_SNAP_REMOVAL_WARNING_CONTINUE, + }), + [showConfirmation, confirmedRemoval, handleContinuePress], + ); + + const accountListItems = useMemo( + () => + keyringAccounts.map((account, index) => ( + + )), + [keyringAccounts, chainId], + ); + + return ( + + + + + {strings( + 'app_settings.snaps.snap_settings.remove_account_snap_warning.title', + )} + + + + {showConfirmation ? ( + <> + + {strings( + 'app_settings.snaps.snap_settings.remove_account_snap_warning.remove_account_snap_alert_description_1', + )} + + {snap.manifest.proposedName} + + {strings( + 'app_settings.snaps.snap_settings.remove_account_snap_warning.remove_account_snap_alert_description_2', + )} + + + {error && ( + + {strings( + 'app_settings.snaps.snap_settings.remove_account_snap_warning.remove_snap_error', + { + snapName: snap.manifest.proposedName, + }, + )} + + )} + + ) : ( + <> + + {strings( + 'app_settings.snaps.snap_settings.remove_account_snap_warning.description', + )} + + {accountListItems} + + )} + + + + ); +} +///: END:ONLY_INCLUDE_IF diff --git a/app/components/Views/Snaps/KeyringSnapRemovalWarning/index.ts b/app/components/Views/Snaps/KeyringSnapRemovalWarning/index.ts new file mode 100644 index 00000000000..94d58b3d412 --- /dev/null +++ b/app/components/Views/Snaps/KeyringSnapRemovalWarning/index.ts @@ -0,0 +1,3 @@ +///: BEGIN:ONLY_INCLUDE_IF(keyring-snaps) +export { default as KeyringSnapRemovalWarning } from './KeyringSnapRemovalWarning'; +///: END:ONLY_INCLUDE_IF diff --git a/app/components/Views/Snaps/SnapSettings/SnapSettings.tsx b/app/components/Views/Snaps/SnapSettings/SnapSettings.tsx index 19f739400eb..2828035e7c2 100644 --- a/app/components/Views/Snaps/SnapSettings/SnapSettings.tsx +++ b/app/components/Views/Snaps/SnapSettings/SnapSettings.tsx @@ -1,5 +1,5 @@ -///: BEGIN:ONLY_INCLUDE_IF(external-snaps) -import React, { useCallback, useEffect } from 'react'; +///: BEGIN:ONLY_INCLUDE_IF(external-snaps,keyring-snaps) +import React, { useCallback, useEffect, useState } from 'react'; import { View, ScrollView, SafeAreaView } from 'react-native'; import Engine from '../../../../core/Engine'; @@ -28,7 +28,10 @@ import { useStyles } from '../../../hooks/useStyles'; import { useSelector } from 'react-redux'; import SNAP_SETTINGS_REMOVE_BUTTON from './SnapSettings.constants'; import { selectPermissionControllerState } from '../../../../selectors/snaps/permissionController'; - +import KeyringSnapRemovalWarning from '../KeyringSnapRemovalWarning/KeyringSnapRemovalWarning'; +import { getAccountsBySnapId } from '../../../../core/SnapKeyring/utils/getAccountsBySnapId'; +import { selectInternalAccounts } from '../../../../selectors/accountsController'; +import { InternalAccount } from '@metamask/keyring-api'; interface SnapSettingsProps { snap: Snap; } @@ -42,9 +45,16 @@ const SnapSettings = () => { const navigation = useNavigation(); const { snap } = useParams(); - const permissionsState = useSelector(selectPermissionControllerState); + const [ + isShowingSnapKeyringRemoveWarning, + setIsShowingSnapKeyringRemoveWarning, + ] = useState(false); + + const [keyringAccounts, setKeyringAccounts] = useState([]); + const internalAccounts = useSelector(selectInternalAccounts); + // TODO: Replace "any" with type // eslint-disable-next-line @typescript-eslint/no-explicit-any function getPermissionSubjects(state: any) { @@ -71,52 +81,106 @@ const SnapSettings = () => { }, [colors, navigation, snap.manifest.proposedName]); const removeSnap = useCallback(async () => { - // TODO: Replace "any" with type - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const { SnapController } = Engine.context as any; + const { SnapController } = Engine.context; await SnapController.removeSnap(snap.id); navigation.goBack(); }, [navigation, snap.id]); + const isKeyringSnap = Boolean(permissionsFromController?.snap_manageAccounts); + + useEffect(() => { + if (isKeyringSnap) { + (async () => { + const addresses = await getAccountsBySnapId(snap.id); + const snapIdentities = Object.values(internalAccounts).filter( + (internalAccount) => + addresses.includes(internalAccount.address.toLowerCase()), + ); + setKeyringAccounts(snapIdentities); + })(); + } + }, [snap?.id, internalAccounts, isKeyringSnap]); + + const handleKeyringSnapRemovalWarningClose = useCallback(() => { + setIsShowingSnapKeyringRemoveWarning(false); + }, []); + + const handleRemoveSnap = useCallback(() => { + if (isKeyringSnap) { + setIsShowingSnapKeyringRemoveWarning(true); + } else { + removeSnap(); + } + }, [isKeyringSnap, removeSnap]); + + + const handleRemoveSnapKeyring = useCallback(() => { + try { + setIsShowingSnapKeyringRemoveWarning(true); + removeSnap(); + setIsShowingSnapKeyringRemoveWarning(false); + } catch { + setIsShowingSnapKeyringRemoveWarning(false); + } finally { + setIsShowingSnapKeyringRemoveWarning(false); + } + }, [removeSnap]); + + const shouldRenderRemoveSnapAccountWarning = + isShowingSnapKeyringRemoveWarning && + isKeyringSnap && + keyringAccounts.length > 0; + return ( - - - - - - - - - - - - {strings( - 'app_settings.snaps.snap_settings.remove_snap_section_title', - )} - - - {strings( - 'app_settings.snaps.snap_settings.remove_snap_section_description', - )} - -