Add Snaps via Flask (#13462)
This PR adds `snaps` under Flask build flags to the extension. This branch is mostly equivalent to the current production version of Flask, excepting some bug fixes and tweaks. Closes #11626feature/default_network_editable
parent
2b5b787ca9
commit
35ac762e10
@ -0,0 +1,34 @@ |
||||
import { |
||||
restrictedMethodPermissionBuilders, |
||||
selectHooks, |
||||
} from '@metamask/rpc-methods'; |
||||
import { endowmentPermissionBuilders } from '@metamask/snap-controllers'; |
||||
|
||||
/** |
||||
* @returns {Record<string, Record<string, unknown>>} All endowment permission |
||||
* specifications. |
||||
*/ |
||||
export const buildSnapEndowmentSpecifications = () => |
||||
Object.values(endowmentPermissionBuilders).reduce( |
||||
(allSpecifications, { targetKey, specificationBuilder }) => { |
||||
allSpecifications[targetKey] = specificationBuilder(); |
||||
return allSpecifications; |
||||
}, |
||||
{}, |
||||
); |
||||
|
||||
/** |
||||
* @param {Record<string, Function>} hooks - The hooks for the Snap |
||||
* restricted method implementations. |
||||
*/ |
||||
export function buildSnapRestrictedMethodSpecifications(hooks) { |
||||
return Object.values(restrictedMethodPermissionBuilders).reduce( |
||||
(specifications, { targetKey, specificationBuilder, methodHooks }) => { |
||||
specifications[targetKey] = specificationBuilder({ |
||||
methodHooks: selectHooks(hooks, methodHooks), |
||||
}); |
||||
return specifications; |
||||
}, |
||||
{}, |
||||
); |
||||
} |
@ -0,0 +1,46 @@ |
||||
import { |
||||
EndowmentPermissions, |
||||
RestrictedMethods, |
||||
} from '../../../../../shared/constants/permissions'; |
||||
import { |
||||
buildSnapEndowmentSpecifications, |
||||
buildSnapRestrictedMethodSpecifications, |
||||
} from './snap-permissions'; |
||||
|
||||
describe('buildSnapRestrictedMethodSpecifications', () => { |
||||
it('creates valid permission specification objects', () => { |
||||
const hooks = { |
||||
addSnap: () => undefined, |
||||
clearSnapState: () => undefined, |
||||
getMnemonic: () => undefined, |
||||
getSnap: () => undefined, |
||||
getSnapRpcHandler: () => undefined, |
||||
getSnapState: () => undefined, |
||||
showConfirmation: () => undefined, |
||||
updateSnapState: () => undefined, |
||||
}; |
||||
|
||||
const specifications = buildSnapRestrictedMethodSpecifications(hooks); |
||||
|
||||
const allRestrictedMethods = Object.keys(RestrictedMethods); |
||||
Object.keys(specifications).forEach((permissionKey) => |
||||
expect(allRestrictedMethods).toContain(permissionKey), |
||||
); |
||||
|
||||
Object.values(specifications).forEach((specification) => { |
||||
expect(specification).toMatchObject({ |
||||
targetKey: expect.stringMatching(/^(snap_|wallet_)/u), |
||||
methodImplementation: expect.any(Function), |
||||
allowedCaveats: null, |
||||
}); |
||||
}); |
||||
}); |
||||
}); |
||||
|
||||
describe('buildSnapEndowmentSpecifications', () => { |
||||
it('creates valid permission specification objects', () => { |
||||
expect( |
||||
Object.keys(buildSnapEndowmentSpecifications()).sort(), |
||||
).toStrictEqual(Object.keys(EndowmentPermissions).sort()); |
||||
}); |
||||
}); |
@ -1 +1 @@ |
||||
export { default } from './createMethodMiddleware'; |
||||
export * from './createMethodMiddleware'; |
||||
|
@ -0,0 +1,22 @@ |
||||
import { endowmentPermissionBuilders } from '@metamask/snap-controllers'; |
||||
import { restrictedMethodPermissionBuilders } from '@metamask/rpc-methods'; |
||||
import { EndowmentPermissions, RestrictedMethods } from './permissions'; |
||||
|
||||
describe('EndowmentPermissions', () => { |
||||
it('has the expected permission keys', () => { |
||||
expect(Object.keys(EndowmentPermissions).sort()).toStrictEqual( |
||||
Object.keys(endowmentPermissionBuilders).sort(), |
||||
); |
||||
}); |
||||
}); |
||||
|
||||
describe('RestrictedMethods', () => { |
||||
it('has the expected permission keys', () => { |
||||
expect(Object.keys(RestrictedMethods).sort()).toStrictEqual( |
||||
[ |
||||
'eth_accounts', |
||||
...Object.keys(restrictedMethodPermissionBuilders), |
||||
].sort(), |
||||
); |
||||
}); |
||||
}); |
@ -0,0 +1 @@ |
||||
export { default } from './snap-install-warning'; |
@ -0,0 +1,30 @@ |
||||
.snap-install-warning { |
||||
.checkbox-label { |
||||
@include H7; |
||||
|
||||
display: flex; |
||||
align-items: flex-start; |
||||
gap: 0 16px; |
||||
} |
||||
|
||||
&__content { |
||||
padding: 0 16px 24px; |
||||
} |
||||
|
||||
&__footer { |
||||
display: flex; |
||||
flex-flow: row; |
||||
justify-content: center; |
||||
flex: 0 0 auto; |
||||
width: 100%; |
||||
max-height: 40px; |
||||
} |
||||
|
||||
&__footer-button { |
||||
margin-right: 16px; |
||||
|
||||
&:last-of-type { |
||||
margin-right: 0; |
||||
} |
||||
} |
||||
} |
@ -0,0 +1,79 @@ |
||||
import React, { useCallback, useState } from 'react'; |
||||
import PropTypes from 'prop-types'; |
||||
import { useI18nContext } from '../../../../hooks/useI18nContext'; |
||||
import CheckBox from '../../../ui/check-box/check-box.component'; |
||||
import Typography from '../../../ui/typography/typography'; |
||||
import { TYPOGRAPHY } from '../../../../helpers/constants/design-system'; |
||||
import Popover from '../../../ui/popover'; |
||||
import Button from '../../../ui/button'; |
||||
|
||||
export default function SnapInstallWarning({ onCancel, onSubmit, snapName }) { |
||||
const t = useI18nContext(); |
||||
const [isConfirmed, setIsConfirmed] = useState(false); |
||||
|
||||
const onCheckboxClicked = useCallback( |
||||
() => setIsConfirmed((confirmedState) => !confirmedState), |
||||
[], |
||||
); |
||||
|
||||
const SnapInstallWarningFooter = () => { |
||||
return ( |
||||
<div className="snap-install-warning__footer"> |
||||
<Button |
||||
className="snap-install-warning__footer-button" |
||||
type="default" |
||||
onClick={onCancel} |
||||
> |
||||
{t('cancel')} |
||||
</Button> |
||||
<Button |
||||
className="snap-install-warning__footer-button" |
||||
type="primary" |
||||
disabled={!isConfirmed} |
||||
onClick={onSubmit} |
||||
> |
||||
{t('confirm')} |
||||
</Button> |
||||
</div> |
||||
); |
||||
}; |
||||
|
||||
return ( |
||||
<Popover |
||||
className="snap-install-warning" |
||||
title={t('areYouSure')} |
||||
footer={<SnapInstallWarningFooter />} |
||||
> |
||||
<div className="snap-install-warning__content"> |
||||
<Typography variant={TYPOGRAPHY.H6} boxProps={{ paddingBottom: 4 }}> |
||||
{t('snapInstallWarningCheck')} |
||||
</Typography> |
||||
<div className="checkbox-label"> |
||||
<CheckBox |
||||
checked={isConfirmed} |
||||
id="warning-accept" |
||||
onClick={onCheckboxClicked} |
||||
/> |
||||
<label htmlFor="warning-accept"> |
||||
{t('snapInstallWarningKeyAccess', [snapName])} |
||||
</label> |
||||
</div> |
||||
</div> |
||||
</Popover> |
||||
); |
||||
} |
||||
|
||||
SnapInstallWarning.propTypes = { |
||||
/** |
||||
* onCancel handler |
||||
*/ |
||||
onCancel: PropTypes.func, |
||||
/** |
||||
* onSubmit handler |
||||
*/ |
||||
onSubmit: PropTypes.func, |
||||
/** |
||||
* Name of snap |
||||
*/ |
||||
snapName: PropTypes.string, |
||||
}; |
@ -1,33 +1,129 @@ |
||||
import React, { useMemo } from 'react'; |
||||
import PropTypes from 'prop-types'; |
||||
import { |
||||
RestrictedMethods, |
||||
///: BEGIN:ONLY_INCLUDE_IN(flask)
|
||||
EndowmentPermissions, |
||||
PermissionNamespaces, |
||||
///: END:ONLY_INCLUDE_IN
|
||||
} from '../../../../shared/constants/permissions'; |
||||
import { useI18nContext } from '../../../hooks/useI18nContext'; |
||||
///: BEGIN:ONLY_INCLUDE_IN(flask)
|
||||
import { coinTypeToProtocolName } from '../../../helpers/utils/util'; |
||||
///: END:ONLY_INCLUDE_IN
|
||||
|
||||
const UNKNOWN_PERMISSION = Symbol('unknown'); |
||||
|
||||
/** |
||||
* @typedef {Object} PermissionLabelObject |
||||
* @property {string} label - The text label. |
||||
* @property {string} leftIcon - The left icon. |
||||
* @property {string} rightIcon - The right icon. |
||||
*/ |
||||
|
||||
/** |
||||
* Gets the permission list label dictionary key for the specified permission |
||||
* name. |
||||
* |
||||
* @param {string} permissionName - The name of the permission whose key to |
||||
* retrieve. |
||||
* @param {Record<string, PermissionLabelObject>} permissionDictionary - The |
||||
* dictionary object mapping permission keys to label objects. |
||||
*/ |
||||
function getPermissionKey(permissionName, permissionDictionary) { |
||||
if (Object.hasOwnProperty.call(permissionDictionary, permissionName)) { |
||||
return permissionName; |
||||
} |
||||
///: BEGIN:ONLY_INCLUDE_IN(flask)
|
||||
for (const namespace of Object.keys(PermissionNamespaces)) { |
||||
if (permissionName.startsWith(namespace)) { |
||||
return PermissionNamespaces[namespace]; |
||||
} |
||||
} |
||||
///: END:ONLY_INCLUDE_IN
|
||||
|
||||
return UNKNOWN_PERMISSION; |
||||
} |
||||
|
||||
export default function PermissionsConnectPermissionList({ permissions }) { |
||||
const t = useI18nContext(); |
||||
|
||||
const PERMISSION_TYPES = useMemo(() => { |
||||
const PERMISSION_LIST_VALUES = useMemo(() => { |
||||
return { |
||||
eth_accounts: { |
||||
[RestrictedMethods.eth_accounts]: { |
||||
leftIcon: 'fas fa-eye', |
||||
label: t('eth_accounts'), |
||||
label: t('permission_ethereumAccounts'), |
||||
rightIcon: null, |
||||
}, |
||||
///: BEGIN:ONLY_INCLUDE_IN(flask)
|
||||
[RestrictedMethods.snap_confirm]: { |
||||
leftIcon: 'fas fa-user-check', |
||||
label: t('permission_customConfirmation'), |
||||
rightIcon: null, |
||||
}, |
||||
[RestrictedMethods['snap_getBip44Entropy_*']]: (permissionName) => { |
||||
const coinType = permissionName.split('_').slice(-1); |
||||
return { |
||||
leftIcon: 'fas fa-door-open', |
||||
label: t('permission_manageBip44Keys', [ |
||||
coinTypeToProtocolName(coinType) || |
||||
`${coinType} (Unrecognized protocol)`, |
||||
]), |
||||
rightIcon: null, |
||||
}; |
||||
}, |
||||
[RestrictedMethods.snap_manageState]: { |
||||
leftIcon: 'fas fa-download', |
||||
label: t('permission_manageState'), |
||||
rightIcon: null, |
||||
}, |
||||
[RestrictedMethods['wallet_snap_*']]: (permissionName) => { |
||||
const snapId = permissionName.split('_').slice(-1); |
||||
return { |
||||
leftIcon: 'fas fa-bolt', |
||||
label: t('permission_accessSnap', [snapId]), |
||||
rightIcon: null, |
||||
}; |
||||
}, |
||||
[EndowmentPermissions['endowment:network-access']]: { |
||||
leftIcon: 'fas fa-wifi', |
||||
label: t('permission_accessNetwork'), |
||||
rightIcon: null, |
||||
}, |
||||
///: END:ONLY_INCLUDE_IN
|
||||
[UNKNOWN_PERMISSION]: (permissionName) => { |
||||
return { |
||||
leftIcon: 'fas fa-times-circle', |
||||
label: t('permission_unknown', [permissionName ?? 'undefined']), |
||||
rightIcon: null, |
||||
}; |
||||
}, |
||||
}; |
||||
}, [t]); |
||||
|
||||
return ( |
||||
<div className="permissions-connect-permission-list"> |
||||
{Object.keys(permissions).map((permission) => ( |
||||
<div className="permission" key={PERMISSION_TYPES[permission].label}> |
||||
<i className={PERMISSION_TYPES[permission].leftIcon} /> |
||||
{PERMISSION_TYPES[permission].label} |
||||
<i className={PERMISSION_TYPES[permission].rightIcon} /> |
||||
</div> |
||||
))} |
||||
{Object.keys(permissions).map((permission) => { |
||||
const listValue = |
||||
PERMISSION_LIST_VALUES[ |
||||
getPermissionKey(permission, PERMISSION_LIST_VALUES) |
||||
]; |
||||
|
||||
const { label, leftIcon, rightIcon } = |
||||
typeof listValue === 'function' ? listValue(permission) : listValue; |
||||
|
||||
return ( |
||||
<div className="permission" key={permission}> |
||||
<i className={leftIcon} /> |
||||
{label} |
||||
{rightIcon && <i className={rightIcon} />} |
||||
</div> |
||||
); |
||||
})} |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
PermissionsConnectPermissionList.propTypes = { |
||||
permissions: PropTypes.objectOf(PropTypes.bool).isRequired, |
||||
permissions: PropTypes.object.isRequired, |
||||
}; |
||||
|
@ -0,0 +1,10 @@ |
||||
.snap-confirm { |
||||
padding: 16px 32px; |
||||
border-top: 1px solid var(--Grey-100); |
||||
border-bottom: 1px solid var(--Grey-100); |
||||
margin: 24px 0 16px 0; |
||||
|
||||
.text { |
||||
font-family: monospace; |
||||
} |
||||
} |
@ -0,0 +1,105 @@ |
||||
import { |
||||
RESIZE, |
||||
TYPOGRAPHY, |
||||
} from '../../../../../helpers/constants/design-system'; |
||||
|
||||
function getValues(pendingApproval, t, actions) { |
||||
const { prompt, description, textAreaContent } = pendingApproval.requestData; |
||||
|
||||
return { |
||||
content: [ |
||||
{ |
||||
element: 'Typography', |
||||
key: 'title', |
||||
children: prompt, |
||||
props: { |
||||
variant: TYPOGRAPHY.H3, |
||||
align: 'center', |
||||
fontWeight: 'bold', |
||||
boxProps: { |
||||
margin: [0, 0, 4], |
||||
}, |
||||
}, |
||||
}, |
||||
...(description |
||||
? [ |
||||
{ |
||||
element: 'Typography', |
||||
key: 'subtitle', |
||||
children: description, |
||||
props: { |
||||
variant: TYPOGRAPHY.H6, |
||||
align: 'center', |
||||
boxProps: { |
||||
margin: [0, 0, 4], |
||||
}, |
||||
}, |
||||
}, |
||||
] |
||||
: []), |
||||
...(textAreaContent |
||||
? [ |
||||
{ |
||||
element: 'div', |
||||
key: 'text-area', |
||||
children: { |
||||
element: 'TextArea', |
||||
props: { |
||||
// TODO(ritave): Terrible hard-coded height hack. Fixing this to adjust automatically to current window height would
|
||||
// mean allowing template compoments to change global css, and since the intended use of the template
|
||||
// renderer was to allow users to build their own UIs, this would be a big no-no.
|
||||
height: '238px', |
||||
value: textAreaContent, |
||||
readOnly: true, |
||||
resize: RESIZE.VERTICAL, |
||||
scrollable: true, |
||||
className: 'text', |
||||
}, |
||||
}, |
||||
props: { |
||||
className: 'snap-confirm', |
||||
}, |
||||
}, |
||||
] |
||||
: []), |
||||
{ |
||||
element: 'Typography', |
||||
key: 'only-interact-with-entities-you-trust', |
||||
children: [ |
||||
{ |
||||
element: 'span', |
||||
key: 'only-connect-trust', |
||||
children: `${t('onlyConnectTrust')} `, |
||||
}, |
||||
{ |
||||
element: 'a', |
||||
children: t('learnMore'), |
||||
key: 'learnMore-a-href', |
||||
props: { |
||||
href: |
||||
'https://metamask.zendesk.com/hc/en-us/articles/4405506066331-User-guide-Dapps', |
||||
target: '__blank', |
||||
}, |
||||
}, |
||||
], |
||||
props: { |
||||
variant: TYPOGRAPHY.H7, |
||||
align: 'center', |
||||
boxProps: { |
||||
margin: 0, |
||||
}, |
||||
}, |
||||
}, |
||||
], |
||||
approvalText: t('approveButtonText'), |
||||
cancelText: t('reject'), |
||||
onApprove: () => actions.resolvePendingApproval(pendingApproval.id, true), |
||||
onCancel: () => actions.resolvePendingApproval(pendingApproval.id, false), |
||||
}; |
||||
} |
||||
|
||||
const snapConfirm = { |
||||
getValues, |
||||
}; |
||||
|
||||
export default snapConfirm; |
@ -0,0 +1 @@ |
||||
export { default } from './snap-install'; |
@ -0,0 +1,28 @@ |
||||
.snap-install { |
||||
box-shadow: none; |
||||
|
||||
.permissions-connect-permission-list { |
||||
padding: 0 24px; |
||||
|
||||
.permission { |
||||
padding: 8px 0; |
||||
} |
||||
} |
||||
|
||||
.source-code { |
||||
@include H7; |
||||
|
||||
display: flex; |
||||
color: var(--ui-4); |
||||
|
||||
.link { |
||||
color: var(--primary-blue); |
||||
margin-inline-start: 4px; |
||||
cursor: pointer; |
||||
} |
||||
} |
||||
|
||||
.page-container__footer { |
||||
width: 100%; |
||||
} |
||||
} |
@ -0,0 +1,153 @@ |
||||
import PropTypes from 'prop-types'; |
||||
import React, { useCallback, useMemo, useState } from 'react'; |
||||
import { PageContainerFooter } from '../../../../components/ui/page-container'; |
||||
import PermissionsConnectPermissionList from '../../../../components/app/permissions-connect-permission-list'; |
||||
import PermissionsConnectFooter from '../../../../components/app/permissions-connect-footer'; |
||||
import PermissionConnectHeader from '../../../../components/app/permissions-connect-header'; |
||||
import { useI18nContext } from '../../../../hooks/useI18nContext'; |
||||
import SnapInstallWarning from '../../../../components/app/flask/snap-install-warning'; |
||||
import Box from '../../../../components/ui/box/box'; |
||||
import { |
||||
ALIGN_ITEMS, |
||||
BLOCK_SIZES, |
||||
BORDER_STYLE, |
||||
FLEX_DIRECTION, |
||||
JUSTIFY_CONTENT, |
||||
TYPOGRAPHY, |
||||
} from '../../../../helpers/constants/design-system'; |
||||
import Typography from '../../../../components/ui/typography'; |
||||
|
||||
export default function SnapInstall({ |
||||
request, |
||||
approveSnapInstall, |
||||
rejectSnapInstall, |
||||
targetSubjectMetadata, |
||||
}) { |
||||
const t = useI18nContext(); |
||||
|
||||
const [isShowingWarning, setIsShowingWarning] = useState(false); |
||||
|
||||
const onCancel = useCallback(() => rejectSnapInstall(request.metadata.id), [ |
||||
request, |
||||
rejectSnapInstall, |
||||
]); |
||||
|
||||
const onSubmit = useCallback(() => approveSnapInstall(request), [ |
||||
request, |
||||
approveSnapInstall, |
||||
]); |
||||
|
||||
const npmId = useMemo(() => { |
||||
if (!targetSubjectMetadata.origin.startsWith('npm:')) { |
||||
return undefined; |
||||
} |
||||
return targetSubjectMetadata.origin.substring(4); |
||||
}, [targetSubjectMetadata]); |
||||
|
||||
const shouldShowWarning = useMemo( |
||||
() => |
||||
Boolean( |
||||
request.permissions && |
||||
Object.keys(request.permissions).find((v) => |
||||
v.startsWith('snap_getBip44Entropy_'), |
||||
), |
||||
), |
||||
[request.permissions], |
||||
); |
||||
|
||||
return ( |
||||
<Box |
||||
className="page-container snap-install" |
||||
justifyContent={JUSTIFY_CONTENT.SPACE_BETWEEN} |
||||
height={BLOCK_SIZES.FULL} |
||||
borderStyle={BORDER_STYLE.NONE} |
||||
flexDirection={FLEX_DIRECTION.COLUMN} |
||||
> |
||||
<Box |
||||
className="headers" |
||||
alignItems={ALIGN_ITEMS.CENTER} |
||||
flexDirection={FLEX_DIRECTION.COLUMN} |
||||
> |
||||
<PermissionConnectHeader |
||||
icon={targetSubjectMetadata.iconUrl} |
||||
iconName={targetSubjectMetadata.name} |
||||
headerTitle={t('snapInstall')} |
||||
headerText={null} // TODO(ritave): Add header text when snaps support description
|
||||
siteOrigin={targetSubjectMetadata.origin} |
||||
npmPackageName={npmId} |
||||
boxProps={{ alignItems: ALIGN_ITEMS.CENTER }} |
||||
/> |
||||
<Typography></Typography> |
||||
<Box |
||||
className="snap-requests-permission" |
||||
padding={4} |
||||
tag={TYPOGRAPHY.H7} |
||||
> |
||||
<span>{t('snapRequestsPermission')}</span> |
||||
</Box> |
||||
<PermissionsConnectPermissionList |
||||
permissions={request.permissions || {}} |
||||
/> |
||||
</Box> |
||||
<Box |
||||
className="footers" |
||||
alignItems={ALIGN_ITEMS.CENTER} |
||||
flexDirection={FLEX_DIRECTION.COLUMN} |
||||
> |
||||
{targetSubjectMetadata.sourceCode ? ( |
||||
<> |
||||
<div className="source-code"> |
||||
<div className="text">{t('areYouDeveloper')}</div> |
||||
<div |
||||
className="link" |
||||
onClick={() => |
||||
global.platform.openTab({ |
||||
url: targetSubjectMetadata.sourceCode, |
||||
}) |
||||
} |
||||
> |
||||
{t('openSourceCode')} |
||||
</div> |
||||
</div> |
||||
<Box paddingBottom={4}> |
||||
<PermissionsConnectFooter /> |
||||
</Box> |
||||
</> |
||||
) : ( |
||||
<Box className="snap-install__footer--no-source-code" paddingTop={4}> |
||||
<PermissionsConnectFooter /> |
||||
</Box> |
||||
)} |
||||
|
||||
<PageContainerFooter |
||||
cancelButtonType="default" |
||||
onCancel={onCancel} |
||||
cancelText={t('cancel')} |
||||
onSubmit={ |
||||
shouldShowWarning ? () => setIsShowingWarning(true) : onSubmit |
||||
} |
||||
submitText={t('approveAndInstall')} |
||||
/> |
||||
</Box> |
||||
{isShowingWarning && ( |
||||
<SnapInstallWarning |
||||
onCancel={() => setIsShowingWarning(false)} |
||||
onSubmit={onSubmit} |
||||
snapName={targetSubjectMetadata.name} |
||||
/> |
||||
)} |
||||
</Box> |
||||
); |
||||
} |
||||
|
||||
SnapInstall.propTypes = { |
||||
request: PropTypes.object.isRequired, |
||||
approveSnapInstall: PropTypes.func.isRequired, |
||||
rejectSnapInstall: PropTypes.func.isRequired, |
||||
targetSubjectMetadata: PropTypes.shape({ |
||||
iconUrl: PropTypes.string, |
||||
name: PropTypes.string, |
||||
origin: PropTypes.string.isRequired, |
||||
sourceCode: PropTypes.string, |
||||
}).isRequired, |
||||
}; |
@ -0,0 +1 @@ |
||||
export { default } from './snap-list-tab'; |
@ -0,0 +1,21 @@ |
||||
.snap-list-tab { |
||||
width: 100%; |
||||
height: 100%; |
||||
|
||||
&__wrapper { |
||||
width: auto; |
||||
} |
||||
|
||||
&__body { |
||||
padding: 12px 18px; |
||||
|
||||
@media screen and (min-width: $break-large) { |
||||
padding: 12px; |
||||
} |
||||
} |
||||
|
||||
.snap-settings-card { |
||||
margin: 8px 0; |
||||
max-width: 344px; |
||||
} |
||||
} |
@ -0,0 +1,94 @@ |
||||
import React from 'react'; |
||||
import { useDispatch, useSelector } from 'react-redux'; |
||||
import { useHistory } from 'react-router-dom'; |
||||
import SnapSettingsCard from '../../../../components/app/flask/snap-settings-card'; |
||||
import { useI18nContext } from '../../../../hooks/useI18nContext'; |
||||
import Typography from '../../../../components/ui/typography/typography'; |
||||
import { |
||||
TYPOGRAPHY, |
||||
COLORS, |
||||
FLEX_DIRECTION, |
||||
JUSTIFY_CONTENT, |
||||
ALIGN_ITEMS, |
||||
} from '../../../../helpers/constants/design-system'; |
||||
import Box from '../../../../components/ui/box'; |
||||
import { SNAPS_VIEW_ROUTE } from '../../../../helpers/constants/routes'; |
||||
import { disableSnap, enableSnap } from '../../../../store/actions'; |
||||
import { getSnaps } from '../../../../selectors'; |
||||
|
||||
const SnapListTab = () => { |
||||
const t = useI18nContext(); |
||||
const history = useHistory(); |
||||
const dispatch = useDispatch(); |
||||
const snaps = useSelector(getSnaps); |
||||
const onClick = (snap) => { |
||||
const route = `${SNAPS_VIEW_ROUTE}/${window.btoa( |
||||
unescape(encodeURIComponent(snap.id)), |
||||
)}`;
|
||||
history.push(route); |
||||
}; |
||||
const onToggle = (snap) => { |
||||
if (snap.enabled) { |
||||
dispatch(disableSnap(snap.id)); |
||||
} else { |
||||
dispatch(enableSnap(snap.id)); |
||||
} |
||||
}; |
||||
|
||||
return ( |
||||
<div className="snap-list-tab"> |
||||
{Object.entries(snaps).length ? ( |
||||
<div className="snap-list-tab__body"> |
||||
<Box display="flex" flexDirection={FLEX_DIRECTION.COLUMN}> |
||||
<Typography variant={TYPOGRAPHY.H5} marginBottom={2}> |
||||
{t('expandExperience')} |
||||
</Typography> |
||||
<Typography |
||||
variant={TYPOGRAPHY.H6} |
||||
color={COLORS.UI4} |
||||
marginBottom={2} |
||||
> |
||||
{t('manageSnaps')} |
||||
</Typography> |
||||
</Box> |
||||
<div className="snap-list-tab__wrapper"> |
||||
{Object.entries(snaps).map(([key, snap]) => { |
||||
return ( |
||||
<SnapSettingsCard |
||||
className="snap-settings-card" |
||||
isEnabled={snap.enabled} |
||||
key={key} |
||||
onToggle={() => { |
||||
onToggle(snap); |
||||
}} |
||||
description={snap.manifest.description} |
||||
url={snap.id} |
||||
name={snap.manifest.proposedName} |
||||
status={snap.status} |
||||
version={snap.version} |
||||
onClick={() => { |
||||
onClick(snap); |
||||
}} |
||||
/> |
||||
); |
||||
})} |
||||
</div> |
||||
</div> |
||||
) : ( |
||||
<Box |
||||
className="snap-list-tab__container--no-snaps" |
||||
width="full" |
||||
height="full" |
||||
justifyContent={JUSTIFY_CONTENT.CENTER} |
||||
alignItems={ALIGN_ITEMS.CENTER} |
||||
> |
||||
<Typography variant={TYPOGRAPHY.H4} color={COLORS.UI4}> |
||||
<span>{t('noSnaps')}</span> |
||||
</Typography> |
||||
</Box> |
||||
)} |
||||
</div> |
||||
); |
||||
}; |
||||
|
||||
export default SnapListTab; |
@ -0,0 +1,50 @@ |
||||
import React, { useState } from 'react'; |
||||
import { Provider } from 'react-redux'; |
||||
import configureStore from '../../../../store/store'; |
||||
import testData from '../../../../../.storybook/test-data'; |
||||
import SnapListTab from './snap-list-tab'; |
||||
|
||||
// Using Test Data For Redux
|
||||
const store = configureStore(testData); |
||||
|
||||
export default { |
||||
title: 'Pages/Settings/SnapListTab', |
||||
id: __filename, |
||||
decorators: [(story) => <Provider store={store}>{story()}</Provider>], |
||||
argTypes: { |
||||
onToggle: { |
||||
action: 'onToggle', |
||||
}, |
||||
onRemove: { |
||||
action: 'onRemove', |
||||
}, |
||||
}, |
||||
}; |
||||
export const DefaultStory = (args) => { |
||||
const state = store.getState(); |
||||
const [viewingSnap, setViewingSnap] = useState(); |
||||
const [snap, setSnap] = useState(); |
||||
|
||||
return ( |
||||
<div> |
||||
<SnapListTab |
||||
{...args} |
||||
snaps={state.metamask.snaps} |
||||
viewingSnap={viewingSnap} |
||||
currentSnap={snap} |
||||
onToggle={args.onToggle} |
||||
onRemove={args.onRemove} |
||||
onClick={(_, s) => { |
||||
setSnap(s); |
||||
setViewingSnap(true); |
||||
}} |
||||
/> |
||||
</div> |
||||
); |
||||
}; |
||||
const state = store.getState(); |
||||
DefaultStory.args = { |
||||
snaps: state.metamask.snaps, |
||||
viewingSnap: false, |
||||
}; |
||||
DefaultStory.storyName = 'Default'; |
@ -0,0 +1 @@ |
||||
export { default } from './view-snap'; |
@ -0,0 +1,114 @@ |
||||
.view-snap { |
||||
padding: 12px 18px; |
||||
|
||||
@media screen and (min-width: $break-large) { |
||||
padding: 12px; |
||||
} |
||||
|
||||
&__subheader { |
||||
@include H4; |
||||
|
||||
padding: 16px 4px; |
||||
border-bottom: 1px solid var(--alto); |
||||
margin-right: 24px; |
||||
height: 72px; |
||||
align-items: center; |
||||
display: flex; |
||||
flex-flow: row nowrap; |
||||
|
||||
@media screen and (max-width: $break-small) { |
||||
margin-right: 0; |
||||
padding: 0 0 16px; |
||||
flex-direction: column; |
||||
align-items: center; |
||||
gap: 8px; |
||||
height: max-content; |
||||
} |
||||
} |
||||
|
||||
&__title { |
||||
@media screen and (max-width: $break-small) { |
||||
padding-bottom: 16px; |
||||
} |
||||
} |
||||
|
||||
&__pill-toggle-container { |
||||
align-items: center; |
||||
display: flex; |
||||
flex-grow: 1; |
||||
|
||||
@media screen and (max-width: $break-small) { |
||||
width: 100%; |
||||
justify-content: space-between; |
||||
} |
||||
} |
||||
|
||||
&__pill-container { |
||||
@media screen and (max-width: $break-small) { |
||||
padding-left: 0; |
||||
display: inline-block; |
||||
} |
||||
} |
||||
|
||||
&__toggle-container { |
||||
@media screen and (max-width: $break-small) { |
||||
padding-left: 0; |
||||
display: inline-block; |
||||
} |
||||
} |
||||
|
||||
&__content-container { |
||||
@media screen and (max-width: $break-small) { |
||||
width: 100%; |
||||
} |
||||
} |
||||
|
||||
&__section { |
||||
flex: 1; |
||||
min-width: 0; |
||||
display: flex; |
||||
flex-direction: column; |
||||
border-bottom: 1px solid var(--Grey-100); |
||||
padding-bottom: 16px; |
||||
margin-bottom: 16px; |
||||
|
||||
@media screen and (max-width: $break-small) { |
||||
height: initial; |
||||
padding: 5px 0 16px; |
||||
} |
||||
|
||||
.connected-sites-list__content-row { |
||||
border-top: none; |
||||
border-bottom: 1px solid var(--ui-2); |
||||
|
||||
&:last-child { |
||||
border-bottom: none; |
||||
} |
||||
} |
||||
|
||||
&:last-child { |
||||
margin-bottom: 0; |
||||
border-bottom: none; |
||||
} |
||||
} |
||||
|
||||
&__permission-list { |
||||
padding-bottom: 0; |
||||
|
||||
.permission { |
||||
padding-top: 16px; |
||||
|
||||
&:last-child { |
||||
border-bottom: none; |
||||
} |
||||
} |
||||
} |
||||
|
||||
&__remove-button { |
||||
max-width: 175px; |
||||
|
||||
@media screen and (max-width: $break-small) { |
||||
align-self: center; |
||||
} |
||||
} |
||||
} |
@ -0,0 +1,171 @@ |
||||
import React, { useEffect } from 'react'; |
||||
import { useHistory, useLocation } from 'react-router-dom'; |
||||
import { useDispatch, useSelector } from 'react-redux'; |
||||
import Button from '../../../../components/ui/button'; |
||||
import Typography from '../../../../components/ui/typography'; |
||||
import { useI18nContext } from '../../../../hooks/useI18nContext'; |
||||
import { |
||||
TYPOGRAPHY, |
||||
COLORS, |
||||
TEXT_ALIGN, |
||||
FRACTIONS, |
||||
} from '../../../../helpers/constants/design-system'; |
||||
import SnapsAuthorshipPill from '../../../../components/app/flask/snaps-authorship-pill'; |
||||
import Box from '../../../../components/ui/box'; |
||||
import ToggleButton from '../../../../components/ui/toggle-button'; |
||||
import PermissionsConnectPermissionList from '../../../../components/app/permissions-connect-permission-list/permissions-connect-permission-list'; |
||||
import ConnectedSitesList from '../../../../components/app/connected-sites-list'; |
||||
import Tooltip from '../../../../components/ui/tooltip'; |
||||
import { SNAPS_LIST_ROUTE } from '../../../../helpers/constants/routes'; |
||||
import { |
||||
disableSnap, |
||||
enableSnap, |
||||
removeSnap, |
||||
removePermissionsFor, |
||||
} from '../../../../store/actions'; |
||||
import { getSnaps, getSubjectsWithPermission } from '../../../../selectors'; |
||||
|
||||
function ViewSnap() { |
||||
const t = useI18nContext(); |
||||
const history = useHistory(); |
||||
const location = useLocation(); |
||||
const { pathname } = location; |
||||
const pathNameTail = pathname.match(/[^/]+$/u)[0]; |
||||
const snaps = useSelector(getSnaps); |
||||
const snap = Object.entries(snaps) |
||||
.map(([_, snapState]) => snapState) |
||||
.find((snapState) => { |
||||
const decoded = decodeURIComponent(escape(window.atob(pathNameTail))); |
||||
return snapState.id === decoded; |
||||
}); |
||||
|
||||
useEffect(() => { |
||||
if (!snap) { |
||||
history.push(SNAPS_LIST_ROUTE); |
||||
} |
||||
}, [history, snap]); |
||||
|
||||
const authorshipPillUrl = `https://npmjs.com/package/${snap?.manifest.source.location.npm.packageName}`; |
||||
const connectedSubjects = useSelector((state) => |
||||
getSubjectsWithPermission(state, snap?.permissionName), |
||||
); |
||||
const dispatch = useDispatch(); |
||||
const onDisconnect = (connectedOrigin, snapPermissionName) => { |
||||
dispatch( |
||||
removePermissionsFor({ |
||||
[connectedOrigin]: [snapPermissionName], |
||||
}), |
||||
); |
||||
}; |
||||
const onToggle = () => { |
||||
if (snap.enabled) { |
||||
dispatch(disableSnap(snap.id)); |
||||
} else { |
||||
dispatch(enableSnap(snap.id)); |
||||
} |
||||
}; |
||||
|
||||
if (!snap) { |
||||
return null; |
||||
} |
||||
return ( |
||||
<div className="view-snap"> |
||||
<div className="settings-page__content-row"> |
||||
<div className="view-snap__subheader"> |
||||
<Typography |
||||
className="view-snap__title" |
||||
variant={TYPOGRAPHY.H3} |
||||
boxProps={{ textAlign: TEXT_ALIGN.CENTER }} |
||||
> |
||||
{snap.manifest.proposedName} |
||||
</Typography> |
||||
<Box className="view-snap__pill-toggle-container"> |
||||
<Box className="view-snap__pill-container" paddingLeft={2}> |
||||
<SnapsAuthorshipPill |
||||
packageName={snap.id} |
||||
url={authorshipPillUrl} |
||||
/> |
||||
</Box> |
||||
<Box |
||||
paddingLeft={4} |
||||
className="snap-settings-card__toggle-container view-snap__toggle-container" |
||||
> |
||||
<Tooltip interactive position="bottom" html={t('snapsToggle')}> |
||||
<ToggleButton |
||||
value={snap.enabled} |
||||
onToggle={onToggle} |
||||
className="snap-settings-card__toggle-container__toggle-button" |
||||
/> |
||||
</Tooltip> |
||||
</Box> |
||||
</Box> |
||||
</div> |
||||
<Box |
||||
className="view-snap__content-container" |
||||
width={FRACTIONS.SEVEN_TWELFTHS} |
||||
> |
||||
<div className="view-snap__section"> |
||||
<Typography |
||||
variant={TYPOGRAPHY.H6} |
||||
color={COLORS.UI4} |
||||
boxProps={{ marginTop: 5 }} |
||||
> |
||||
{snap.manifest.description} |
||||
</Typography> |
||||
</div> |
||||
<div className="view-snap__section view-snap__permission-list"> |
||||
<Typography variant={TYPOGRAPHY.H4}>{t('permissions')}</Typography> |
||||
<Typography variant={TYPOGRAPHY.H6} color={COLORS.UI4}> |
||||
{t('snapAccess', [snap.manifest.proposedName])} |
||||
</Typography> |
||||
<Box width={FRACTIONS.TEN_TWELFTHS}> |
||||
<PermissionsConnectPermissionList |
||||
permissions={snap.manifest.initialPermissions} |
||||
/> |
||||
</Box> |
||||
</div> |
||||
<div className="view-snap__section"> |
||||
<Box width="11/12"> |
||||
<Typography variant={TYPOGRAPHY.H4}> |
||||
{t('connectedSites')} |
||||
</Typography> |
||||
<Typography variant={TYPOGRAPHY.H6} color={COLORS.UI4}> |
||||
{t('connectedSnapSites', [snap.manifest.proposedName])} |
||||
</Typography> |
||||
<ConnectedSitesList |
||||
connectedSubjects={connectedSubjects} |
||||
onDisconnect={(origin) => { |
||||
onDisconnect(origin, snap.permissionName); |
||||
}} |
||||
/> |
||||
</Box> |
||||
</div> |
||||
<div className="view-snap__section"> |
||||
<Typography variant={TYPOGRAPHY.H4}>{t('removeSnap')}</Typography> |
||||
<Typography |
||||
variant={TYPOGRAPHY.H6} |
||||
color={COLORS.UI4} |
||||
boxProps={{ paddingBottom: 3 }} |
||||
> |
||||
{t('removeSnapDescription')} |
||||
</Typography> |
||||
<Button |
||||
className="view-snap__remove-button" |
||||
type="danger" |
||||
css={{ |
||||
maxWidth: '175px', |
||||
}} |
||||
onClick={async () => { |
||||
await dispatch(removeSnap(snap)); |
||||
}} |
||||
> |
||||
{t('removeSnap')} |
||||
</Button> |
||||
</div> |
||||
</Box> |
||||
</div> |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
export default React.memo(ViewSnap); |
Loading…
Reference in new issue