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

[MMI] interactive replacement token notification (#18620)

* adds the component and styles

* adds tests

* story file update

* clean up

* lint and prettier

* lint & prettier fix

* adds review changes

* adds necessary dependencies

* runs lint and prettier
This commit is contained in:
António Regadas 2023-04-21 09:58:03 +01:00 committed by GitHub
parent 8632acbba3
commit 290353da9b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 392 additions and 0 deletions

View File

@ -951,6 +951,9 @@
"custodyRefreshTokenModalTitle": { "custodyRefreshTokenModalTitle": {
"message": "Your custodian session has expired" "message": "Your custodian session has expired"
}, },
"custodySessionExpired": {
"message": "Custodian session expired."
},
"custom": { "custom": {
"message": "Advanced" "message": "Advanced"
}, },

View File

@ -0,0 +1,11 @@
/* eslint-disable no-undef */
export async function sha256(str: string): Promise<string> {
const buf = await crypto.subtle.digest(
'SHA-256',
new TextEncoder().encode(str),
);
return Array.prototype.map
.call(new Uint8Array(buf), (x: number) => `00${x.toString(16)}`.slice(-2))
.join('');
}

View File

@ -0,0 +1 @@
export { default } from './interactive-replacement-token-notification';

View File

@ -0,0 +1,143 @@
import React, { useState, useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import PropTypes from 'prop-types';
import { getCurrentKeyring, getSelectedAddress } from '../../../selectors';
import { getInteractiveReplacementToken } from '../../../selectors/institutional/selectors';
import { getIsUnlocked } from '../../../ducks/metamask/metamask';
import { useI18nContext } from '../../../hooks/useI18nContext';
import { mmiActionsFactory } from '../../../store/institutional/institution-background';
import { sha256 } from '../../../../shared/modules/hash.utils';
import {
Size,
IconColor,
AlignItems,
DISPLAY,
BLOCK_SIZES,
JustifyContent,
TextColor,
TextVariant,
BackgroundColor,
} from '../../../helpers/constants/design-system';
import {
Icon,
IconName,
IconSize,
ButtonLink,
Text,
} from '../../component-library';
import Box from '../../ui/box';
const InteractiveReplacementTokenNotification = ({ isVisible }) => {
const t = useI18nContext();
const dispatch = useDispatch();
const mmiActions = mmiActionsFactory();
const keyring = useSelector(getCurrentKeyring);
const address = useSelector(getSelectedAddress);
const isUnlocked = useSelector(getIsUnlocked);
const interactiveReplacementToken = useSelector(
getInteractiveReplacementToken,
);
const [showNotification, setShowNotification] = useState(isVisible);
useEffect(() => {
const handleShowNotification = async () => {
const hasInteractiveReplacementToken =
interactiveReplacementToken &&
Boolean(Object.keys(interactiveReplacementToken).length);
if (!/^Custody/u.test(keyring.type)) {
setShowNotification(false);
return;
} else if (!hasInteractiveReplacementToken) {
setShowNotification(false);
return;
}
const token = await dispatch(mmiActions.getCustodianToken());
const custodyAccountDetails = await dispatch(
mmiActions.getAllCustodianAccountsWithToken(
keyring.type.split(' - ')[1],
token,
),
);
const showNotificationValue =
isUnlocked &&
interactiveReplacementToken.oldRefreshToken &&
custodyAccountDetails &&
Boolean(Object.keys(custodyAccountDetails).length);
let tokenAccount;
if (Array.isArray(custodyAccountDetails)) {
tokenAccount = custodyAccountDetails
.filter(
(item) => item.address.toLowerCase() === address.toLowerCase(),
)
.map((item) => ({
token: item.authDetails?.refreshToken,
}))[0];
}
const refreshTokenAccount = await sha256(
tokenAccount?.token + interactiveReplacementToken.url,
);
setShowNotification(
showNotificationValue &&
refreshTokenAccount === interactiveReplacementToken.oldRefreshToken,
);
};
handleShowNotification();
}, [
dispatch,
address,
interactiveReplacementToken,
isUnlocked,
keyring,
mmiActions,
]);
return showNotification ? (
<Box
width={BLOCK_SIZES.FULL}
display={DISPLAY.FLEX}
justifyContent={JustifyContent.center}
alignItems={AlignItems.center}
padding={[1, 2]}
backgroundColor={BackgroundColor.backgroundAlternative}
marginBottom={1}
className="interactive-replacement-token-notification"
data-testid="interactive-replacement-token-notification"
>
<Icon
name={IconName.Danger}
color={IconColor.errorDefault}
size={IconSize.Xl}
/>
<Text variant={TextVariant.bodyXs} gap={2} color={TextColor.errorDefault}>
{t('custodySessionExpired')}
</Text>
<ButtonLink
data-testid="show-modal"
size={Size.auto}
marginLeft={1}
onClick={() => {
dispatch(mmiActions.showInteractiveReplacementTokenModal());
}}
>
{t('learnMore')}
</ButtonLink>
</Box>
) : null;
};
export default InteractiveReplacementTokenNotification;
InteractiveReplacementTokenNotification.propTypes = {
isVisible: PropTypes.bool,
};

View File

@ -0,0 +1,15 @@
.interactive-replacement-token-notification {
height: 24px;
@media screen and (min-width: $break-large) {
width: 85vw;
}
@media screen and (min-width: 768px) {
width: 80vw;
}
@media screen and (min-width: 1280px) {
width: 62vw;
}
}

View File

@ -0,0 +1,59 @@
import React from 'react';
import { Provider } from 'react-redux';
import configureStore from '../../../store/store';
import testData from '../../../../.storybook/test-data';
import InteractiveReplacementTokenNotification from '.';
const customData = {
...testData,
metamask: {
...testData.metamask,
provider: {
type: 'test',
},
selectedAddress: '0xca8f1F0245530118D0cf14a06b01Daf8f76Cf281',
identities: {
'0xca8f1F0245530118D0cf14a06b01Daf8f76Cf281': {
address: '0xca8f1F0245530118D0cf14a06b01Daf8f76Cf281',
name: 'Custodian A',
},
},
isUnlocked: true,
interactiveReplacementToken: {
oldRefreshToken:
'81f96a88b6cbc5f50d3864122349fa9a9755833ee82a7e3cf6f268c78aab51ab',
url: 'url',
},
preferences: {
useNativeCurrencyAsPrimaryCurrency: true,
},
keyrings: [
{
type: 'Custody - Saturn',
accounts: ['0xca8f1F0245530118D0cf14a06b01Daf8f76Cf281'],
},
],
},
};
const store = configureStore(customData);
export default {
title: 'Components/Institutional/InteractiveReplacementToken-Notification',
decorators: [(story) => <Provider store={store}>{story()}</Provider>],
component: InteractiveReplacementTokenNotification,
args: {
isVisible: true,
},
argTypes: {
onClick: {
action: 'onClick',
},
},
};
export const DefaultStory = (args) => (
<InteractiveReplacementTokenNotification {...args} />
);
DefaultStory.storyName = 'InteractiveReplacementTokenNotification';

View File

@ -0,0 +1,156 @@
import React from 'react';
import configureMockStore from 'redux-mock-store';
import thunk from 'redux-thunk';
import { screen, fireEvent } from '@testing-library/react';
import { act } from 'react-dom/test-utils';
import { sha256 } from '../../../../shared/modules/hash.utils';
import { KeyringType } from '../../../../shared/constants/keyring';
import { renderWithProvider } from '../../../../test/lib/render-helpers';
import InteractiveReplacementTokenNotification from './interactive-replacement-token-notification';
jest.mock('../../../../shared/modules/hash.utils');
const mockedShowInteractiveReplacementTokenModal = jest
.fn()
.mockReturnValue({ type: 'TYPE' });
const mockedGetCustodianToken = jest
.fn()
.mockReturnValue({ type: 'Custody', payload: 'token' });
const mockedGetAllCustodianAccountsWithToken = jest.fn().mockReturnValue({
type: 'TYPE',
payload: [
{
address: '0xca8f1F0245530118D0cf14a06b01Daf8f76Cf281',
authDetails: { refreshToken: 'def' },
},
],
});
jest.mock('../../../store/institutional/institution-background', () => ({
mmiActionsFactory: () => ({
getCustodianToken: mockedGetCustodianToken,
getAllCustodianAccountsWithToken: mockedGetAllCustodianAccountsWithToken,
showInteractiveReplacementTokenModal:
mockedShowInteractiveReplacementTokenModal,
}),
}));
describe('Interactive Replacement Token Notification', () => {
const selectedAddress = '0xca8f1F0245530118D0cf14a06b01Daf8f76Cf281';
const identities = {
'0xca8f1F0245530118D0cf14a06b01Daf8f76Cf281': {
address: '0xca8f1F0245530118D0cf14a06b01Daf8f76Cf281',
name: 'Custodian A',
},
};
const mockStore = {
metamask: {
provider: {
type: 'test',
},
selectedAddress,
identities,
isUnlocked: false,
interactiveReplacementToken: { oldRefreshToken: 'abc' },
preferences: {
useNativeCurrencyAsPrimaryCurrency: true,
},
keyrings: [
{
type: KeyringType.imported,
accounts: ['0xca8f1F0245530118D0cf14a06b01Daf8f76Cf281', '0x2'],
},
{
type: KeyringType.ledger,
accounts: [],
},
],
},
};
it('should not render if show notification is false', () => {
const store = configureMockStore([thunk])(mockStore);
renderWithProvider(<InteractiveReplacementTokenNotification />, store);
expect(
screen.queryByTestId('interactive-replacement-token-notification'),
).not.toBeInTheDocument();
});
it('should render if show notification is true and click on learn more', async () => {
const customMockStore = {
...mockStore,
metamask: {
...mockStore.metamask,
isUnlocked: true,
interactiveReplacementToken: { oldRefreshToken: 'def', url: 'url' },
keyrings: [
{
type: 'Custody - Saturn',
accounts: ['0xca8f1F0245530118D0cf14a06b01Daf8f76Cf281'],
},
],
},
};
const store = configureMockStore([thunk])(customMockStore);
sha256.mockReturnValue('def');
await act(async () => {
renderWithProvider(<InteractiveReplacementTokenNotification />, store);
});
expect(
screen.getByTestId('interactive-replacement-token-notification'),
).toBeInTheDocument();
expect(screen.getByTestId('show-modal')).toBeInTheDocument();
await act(async () => {
fireEvent.click(screen.getByTestId('show-modal'));
});
expect(mockedShowInteractiveReplacementTokenModal).toHaveBeenCalled();
});
it('should render and call showNotification when component starts', async () => {
const customMockStore = {
...mockStore,
metamask: {
...mockStore.metamask,
isUnlocked: true,
interactiveReplacementToken: { oldRefreshToken: 'def', url: 'url' },
keyrings: [
{
type: 'Custody - Saturn',
accounts: ['0xca8f1F0245530118D0cf14a06b01Daf8f76Cf281', '0x2'],
},
{
type: KeyringType.ledger,
accounts: [],
},
],
},
};
const store = configureMockStore([thunk])(customMockStore);
sha256.mockReturnValue('def');
await act(async () => {
renderWithProvider(<InteractiveReplacementTokenNotification />, store);
});
expect(mockedGetCustodianToken).toHaveBeenCalled();
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
expect(
screen.getByTestId('interactive-replacement-token-notification'),
).toBeInTheDocument();
});
});

View File

@ -62,3 +62,7 @@ export function getIsCustodianSupportedChain(state) {
) )
: true; : true;
} }
export function getInteractiveReplacementToken(state) {
return state.metamask.interactiveReplacementToken || {};
}