mirror of
https://github.com/pezkuwichain/pezkuwi-extension.git
synced 2026-07-23 22:45:40 +00:00
Update domain references to pezkuwichain.app and rebrand from polkadot
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { MetadataDef } from '@pezkuwi/extension-inject/types';
|
||||
|
||||
const metadataGets = new Map<string, Promise<MetadataDef | null>>();
|
||||
|
||||
export function getSavedMeta (genesisHash: string): Promise<MetadataDef | null> | undefined {
|
||||
return metadataGets.get(genesisHash);
|
||||
}
|
||||
|
||||
export function setSavedMeta (genesisHash: string, def: Promise<MetadataDef | null>): Map<string, Promise<MetadataDef | null>> {
|
||||
return metadataGets.set(genesisHash, def);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import '@pezkuwi/extension-mocks/chrome';
|
||||
|
||||
import type { ReactWrapper } from 'enzyme';
|
||||
import type * as _ from '@pezkuwi/dev-test/globals.d.ts';
|
||||
|
||||
import Adapter from '@wojtekmaj/enzyme-adapter-react-17';
|
||||
import enzyme from 'enzyme';
|
||||
import React from 'react';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { MemoryRouter } from 'react-router';
|
||||
|
||||
import * as messaging from '../../messaging.js';
|
||||
import { flushAllPromises } from '../../testHelpers.js';
|
||||
import Account from './Account.js';
|
||||
|
||||
const { configure, mount } = enzyme;
|
||||
|
||||
// // NOTE Required for spyOn when using @swc/jest
|
||||
// // https://github.com/swc-project/swc/issues/3843
|
||||
// jest.mock('../../messaging', (): Record<string, unknown> => ({
|
||||
// __esModule: true,
|
||||
// ...jest.requireActual('../../messaging')
|
||||
// }));
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment,@typescript-eslint/no-unsafe-call
|
||||
configure({ adapter: new Adapter() });
|
||||
|
||||
jest.spyOn(messaging, 'getAllMetadata').mockImplementation(() => Promise.resolve([]));
|
||||
|
||||
describe('Account component', () => {
|
||||
let wrapper: ReactWrapper;
|
||||
const VALID_ADDRESS = 'HjoBp62cvsWDA3vtNMWxz6c9q13ReEHi9UGHK7JbZweH5g5';
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
||||
const mountAccountComponent = (additionalAccountProperties: Record<string, unknown>): ReactWrapper => mount(
|
||||
<MemoryRouter>
|
||||
<Account
|
||||
{...{ address: VALID_ADDRESS, ...additionalAccountProperties }}
|
||||
/>
|
||||
</MemoryRouter>);
|
||||
|
||||
it('shows Export option if account is not external', async () => {
|
||||
wrapper = mountAccountComponent({ isExternal: false, type: 'ed25519' });
|
||||
wrapper.find('.settings').first().simulate('click');
|
||||
await act(flushAllPromises);
|
||||
|
||||
expect(wrapper.find('a.menuItem').length).toBe(4);
|
||||
expect(wrapper.find('a.menuItem').at(0).text()).toBe('Rename');
|
||||
expect(wrapper.find('a.menuItem').at(1).text()).toBe('Derive New Account');
|
||||
expect(wrapper.find('a.menuItem').at(2).text()).toBe('Export Account');
|
||||
expect(wrapper.find('a.menuItem').at(3).text()).toBe('Forget Account');
|
||||
expect(wrapper.find('.genesisSelection').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('does not show Export option if account is external', async () => {
|
||||
wrapper = mountAccountComponent({ isExternal: true, type: 'ed25519' });
|
||||
wrapper.find('.settings').first().simulate('click');
|
||||
await act(flushAllPromises);
|
||||
|
||||
expect(wrapper.find('a.menuItem').length).toBe(2);
|
||||
expect(wrapper.find('a.menuItem').at(0).text()).toBe('Rename');
|
||||
expect(wrapper.find('a.menuItem').at(1).text()).toBe('Forget Account');
|
||||
expect(wrapper.find('.genesisSelection').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('shows Derive option if account is of ethereum type', async () => {
|
||||
wrapper = mountAccountComponent({ isExternal: false, type: 'ethereum' });
|
||||
wrapper.find('.settings').first().simulate('click');
|
||||
await act(flushAllPromises);
|
||||
|
||||
expect(wrapper.find('a.menuItem').length).toBe(4);
|
||||
expect(wrapper.find('a.menuItem').at(0).text()).toBe('Rename');
|
||||
expect(wrapper.find('a.menuItem').at(1).text()).toBe('Derive New Account');
|
||||
expect(wrapper.find('a.menuItem').at(2).text()).toBe('Export Account');
|
||||
expect(wrapper.find('a.menuItem').at(3).text()).toBe('Forget Account');
|
||||
expect(wrapper.find('.genesisSelection').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('does not show genesis hash selection dropsown if account is hardware', async () => {
|
||||
wrapper = mountAccountComponent({ isExternal: true, isHardware: true });
|
||||
wrapper.find('.settings').first().simulate('click');
|
||||
await act(flushAllPromises);
|
||||
|
||||
expect(wrapper.find('a.menuItem').length).toBe(2);
|
||||
expect(wrapper.find('a.menuItem').at(0).text()).toBe('Rename');
|
||||
expect(wrapper.find('a.menuItem').at(1).text()).toBe('Forget Account');
|
||||
expect(wrapper.find('.genesisSelection').exists()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,204 @@
|
||||
// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { AccountJson } from '@pezkuwi/extension-base/background/types';
|
||||
import type { HexString } from '@pezkuwi/util/types';
|
||||
|
||||
import React, { useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { canDerive } from '@pezkuwi/extension-base/utils';
|
||||
|
||||
import { AccountContext, Address, Checkbox, Dropdown, Link, MenuDivider } from '../../components/index.js';
|
||||
import { useGenesisHashOptions, useTranslation } from '../../hooks/index.js';
|
||||
import { editAccount, tieAccount } from '../../messaging.js';
|
||||
import { Name } from '../../partials/index.js';
|
||||
import { styled } from '../../styled.js';
|
||||
|
||||
interface Props extends AccountJson {
|
||||
className?: string;
|
||||
parentName?: string;
|
||||
showVisibilityAction?: boolean;
|
||||
withCheckbox?: boolean;
|
||||
withMenu?: boolean
|
||||
}
|
||||
|
||||
interface EditState {
|
||||
isEditing: boolean;
|
||||
toggleActions: number;
|
||||
}
|
||||
|
||||
function Account ({ address, className, genesisHash, isExternal, isHardware, isHidden, name, parentName, showVisibilityAction, suri, type, withCheckbox = false, withMenu = true }: Props): React.ReactElement<Props> {
|
||||
const { t } = useTranslation();
|
||||
const [{ isEditing, toggleActions }, setEditing] = useState<EditState>({ isEditing: false, toggleActions: 0 });
|
||||
const [editedName, setName] = useState<string | undefined | null>(name);
|
||||
const [checked, setChecked] = useState(false);
|
||||
const genesisOptions = useGenesisHashOptions();
|
||||
const { selectedAccounts = [], setSelectedAccounts } = useContext(AccountContext);
|
||||
const isSelected = useMemo(() => selectedAccounts?.includes(address) || false, [address, selectedAccounts]);
|
||||
|
||||
useEffect(() => {
|
||||
setChecked(isSelected);
|
||||
}, [isSelected]);
|
||||
|
||||
const _onCheckboxChange = useCallback(() => {
|
||||
const newList = selectedAccounts?.includes(address)
|
||||
? selectedAccounts.filter((account) => account !== address)
|
||||
: [...selectedAccounts, address];
|
||||
|
||||
setSelectedAccounts && setSelectedAccounts(newList);
|
||||
}, [address, selectedAccounts, setSelectedAccounts]);
|
||||
|
||||
const _onChangeGenesis = useCallback(
|
||||
(genesisHash?: HexString | null): void => {
|
||||
tieAccount(address, genesisHash ?? null)
|
||||
.catch(console.error);
|
||||
},
|
||||
[address]
|
||||
);
|
||||
|
||||
const _toggleEdit = useCallback(
|
||||
(): void => setEditing(({ toggleActions }) => ({ isEditing: !isEditing, toggleActions: ++toggleActions })),
|
||||
[isEditing]
|
||||
);
|
||||
|
||||
const _saveChanges = useCallback(
|
||||
(): void => {
|
||||
editedName &&
|
||||
editAccount(address, editedName)
|
||||
.catch(console.error);
|
||||
|
||||
_toggleEdit();
|
||||
},
|
||||
[editedName, address, _toggleEdit]
|
||||
);
|
||||
|
||||
const _actions = useMemo(() => (
|
||||
<>
|
||||
<Link
|
||||
className='menuItem'
|
||||
onClick={_toggleEdit}
|
||||
>
|
||||
{t('Rename')}
|
||||
</Link>
|
||||
{!isExternal && canDerive(type) && (
|
||||
<Link
|
||||
className='menuItem'
|
||||
to={`/account/derive/${address}/locked`}
|
||||
>
|
||||
{t('Derive New Account')}
|
||||
</Link>
|
||||
)}
|
||||
<MenuDivider />
|
||||
{!isExternal && (
|
||||
<Link
|
||||
className='menuItem'
|
||||
isDanger
|
||||
to={`/account/export/${address}`}
|
||||
>
|
||||
{t('Export Account')}
|
||||
</Link>
|
||||
)}
|
||||
<Link
|
||||
className='menuItem'
|
||||
isDanger
|
||||
to={`/account/forget/${address}`}
|
||||
>
|
||||
{t('Forget Account')}
|
||||
</Link>
|
||||
{!isHardware && (
|
||||
<>
|
||||
<MenuDivider />
|
||||
<div className='menuItem'>
|
||||
<Dropdown
|
||||
className='genesisSelection'
|
||||
label=''
|
||||
onChange={_onChangeGenesis}
|
||||
options={genesisOptions}
|
||||
value={genesisHash || ''}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
), [_onChangeGenesis, _toggleEdit, address, genesisHash, genesisOptions, isExternal, isHardware, t, type]);
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
{withCheckbox && (
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
className='accountTree-checkbox'
|
||||
label=''
|
||||
onChange={_onCheckboxChange}
|
||||
/>
|
||||
)}
|
||||
<Address
|
||||
actions={withMenu ? _actions : null}
|
||||
address={address}
|
||||
className='address'
|
||||
genesisHash={genesisHash}
|
||||
isExternal={isExternal}
|
||||
isHidden={isHidden}
|
||||
name={editedName}
|
||||
parentName={parentName}
|
||||
showVisibilityAction={showVisibilityAction}
|
||||
suri={suri}
|
||||
toggleActions={toggleActions}
|
||||
>
|
||||
{isEditing && (
|
||||
<Name
|
||||
address={address}
|
||||
className={`editName ${parentName ? 'withParent' : ''}`}
|
||||
isFocused
|
||||
label={' '}
|
||||
onBlur={_saveChanges}
|
||||
onChange={setName}
|
||||
/>
|
||||
)}
|
||||
</Address>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default styled(Account)<Props>`
|
||||
.address {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.editName {
|
||||
position: absolute;
|
||||
flex: 1;
|
||||
left: 70px;
|
||||
top: 10px;
|
||||
width: 350px;
|
||||
|
||||
.danger {
|
||||
background-color: var(--bodyColor);
|
||||
margin-top: -13px;
|
||||
width: 330px;
|
||||
}
|
||||
|
||||
input {
|
||||
height : 30px;
|
||||
width: 350px;
|
||||
}
|
||||
|
||||
&.withParent {
|
||||
top: 16px
|
||||
}
|
||||
}
|
||||
|
||||
.menuItem {
|
||||
border-radius: 8px;
|
||||
display: block;
|
||||
font-size: 15px;
|
||||
line-height: 20px;
|
||||
margin: 0;
|
||||
min-width: 13rem;
|
||||
padding: 4px 16px;
|
||||
|
||||
.genesisSelection {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,60 @@
|
||||
// Copyright 2019-2025 @pezkuwi/extension authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { AccountWithChildren } from '@pezkuwi/extension-base/background/types';
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { styled } from '../../styled.js';
|
||||
import Account from './Account.js';
|
||||
|
||||
interface Props extends AccountWithChildren {
|
||||
className?: string
|
||||
parentName?: string;
|
||||
withCheckbox?: boolean
|
||||
withMenu?: boolean
|
||||
showHidden?: boolean
|
||||
}
|
||||
|
||||
function AccountsTree ({ className, parentName, showHidden = true, suri, withCheckbox = false, withMenu = true, ...account }: Props): React.ReactElement<Props> {
|
||||
return (
|
||||
<div className={className}>
|
||||
{ (showHidden || !account.isHidden) && (
|
||||
<Account
|
||||
{...account}
|
||||
className={withCheckbox ? 'accountWichCheckbox' : ''}
|
||||
parentName={parentName}
|
||||
showVisibilityAction={showHidden}
|
||||
suri={suri}
|
||||
withCheckbox={withCheckbox}
|
||||
withMenu={withMenu}
|
||||
/>
|
||||
)}
|
||||
{account?.children?.map((child, index) => (
|
||||
<AccountsTree
|
||||
key={`${index}:${child.address}`}
|
||||
{...child}
|
||||
parentName={account.name}
|
||||
showHidden={showHidden}
|
||||
withCheckbox={withCheckbox}
|
||||
withMenu={withMenu}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default styled(AccountsTree)<Props>`
|
||||
.accountWichCheckbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
& .address {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
& .accountTree-checkbox label span {
|
||||
top: -12px;
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,68 @@
|
||||
// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import React, { useCallback, useContext } from 'react';
|
||||
|
||||
import { ActionContext } from '../../components/index.js';
|
||||
import { useTranslation } from '../../hooks/index.js';
|
||||
import Header from '../../partials/Header.js';
|
||||
import { styled } from '../../styled.js';
|
||||
import AddAccountImage from './AddAccountImage.js';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function AddAccount ({ className }: Props): React.ReactElement<Props> {
|
||||
const { t } = useTranslation();
|
||||
const onAction = useContext(ActionContext);
|
||||
const _onClick = useCallback(
|
||||
() => onAction('/account/create'),
|
||||
[onAction]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header
|
||||
showAdd
|
||||
showSettings
|
||||
text={t('Add Account')}
|
||||
/>
|
||||
<div className={className}>
|
||||
<div className='image'>
|
||||
<AddAccountImage onClick={_onClick} />
|
||||
</div>
|
||||
<div className='no-accounts'>
|
||||
<p>{t("You currently don't have any accounts. Create your first account to get started.")}</p>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(styled(AddAccount)<Props>`
|
||||
color: var(--textColor);
|
||||
height: 100%;
|
||||
|
||||
h3 {
|
||||
color: var(--textColor);
|
||||
margin-top: 0;
|
||||
font-weight: normal;
|
||||
font-size: 24px;
|
||||
line-height: 33px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
> .image {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
> .no-accounts p {
|
||||
text-align: center;
|
||||
font-size: 16px;
|
||||
line-height: 26px;
|
||||
margin: 0 30px;
|
||||
color: var(--subTextColor);
|
||||
}
|
||||
`);
|
||||
@@ -0,0 +1,99 @@
|
||||
// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// We _could_ reformat, but just keep it as-is, since this is actually
|
||||
// externally generated and not really user-editable
|
||||
|
||||
/* eslint-disable react/jsx-sort-props */
|
||||
/* eslint-disable react/jsx-max-props-per-line */
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { styled } from '../../styled.js';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
function AddAccountImage ({ className, onClick }: Props): React.ReactElement<Props> {
|
||||
return (
|
||||
<svg className={className} width='265' height='265' viewBox='0 0 265 265' fill='none' xmlns='http://www.w3.org/2000/svg'>
|
||||
<mask
|
||||
id='mask0'
|
||||
maskUnits='userSpaceOnUse'
|
||||
x='40'
|
||||
y='40'
|
||||
width='185'
|
||||
height='185'
|
||||
>
|
||||
<circle cx='132.5' cy='132.5' r='92' fill='white' fillOpacity='0.9' stroke='#E3E7ED' />
|
||||
</mask>
|
||||
<g mask='url(#mask0)'>
|
||||
<circle
|
||||
cx='132.5'
|
||||
cy='132.5' r='92'
|
||||
strokeWidth='1px'
|
||||
fill='#1A1B20'
|
||||
stroke='#E3E7ED'
|
||||
onClick={onClick}
|
||||
/>
|
||||
<g filter='url(#filter0_f)'>
|
||||
<circle cx='132.5' cy='73.1154' r='12.1154' fill='#FEE05F' />
|
||||
<circle cx='132.5' cy='104.269' r='12.1154' fill='#F5836B' />
|
||||
<circle cx='132.5' cy='135.423' r='12.1154' fill='#EE6C89' />
|
||||
<circle cx='132.5' cy='166.577' r='12.1154' fill='#397893' />
|
||||
<circle cx='132.5' cy='166.577' r='12.1154' fill='#E2327D' />
|
||||
<circle cx='77.1154' cy='104.269' r='12.1154' fill='#F0512A' />
|
||||
<circle cx='77.1154' cy='135.423' r='12.1154' fill='#966EAC' />
|
||||
<circle cx='77.1154' cy='166.577' r='12.1154' fill='#397893' />
|
||||
<circle cx='77.1154' cy='166.577' r='12.1154' fill='#9195C9' />
|
||||
<circle cx='187.884' cy='104.269' r='12.1154' fill='#FFFACA' />
|
||||
<circle cx='187.884' cy='135.423' r='12.1154' fill='#ECCCDB' />
|
||||
<circle cx='187.884' cy='166.577' r='12.1154' fill='#397893' />
|
||||
<circle cx='187.884' cy='166.577' r='12.1154' fill='#ED3432' />
|
||||
<circle cx='132.5' cy='197.731' r='12.1154' fill='#B24594' />
|
||||
<circle cx='160.193' cy='86.9615' r='12.1154' fill='#FFBB50' />
|
||||
<circle cx='160.193' cy='118.115' r='12.1154' fill='#F6E768' />
|
||||
<circle cx='160.193' cy='149.269' r='12.1154' fill='#FF5A5A' />
|
||||
<circle cx='160.193' cy='180.423' r='12.1154' fill='#397893' />
|
||||
<circle cx='160.193' cy='180.423' r='12.1154' fill='#D6234A' />
|
||||
<circle cx='104.808' cy='86.9615' r='12.1154' fill='#FFA800' />
|
||||
<circle cx='104.808' cy='118.115' r='12.1154' fill='#F78D53' />
|
||||
<circle cx='104.808' cy='149.269' r='12.1154' fill='#BD87BB' />
|
||||
<circle cx='104.808' cy='180.423' r='12.1154' fill='#397893' />
|
||||
<circle cx='104.808' cy='180.423' r='12.1154' fill='#AF71AF' />
|
||||
</g>
|
||||
<path
|
||||
d='M145.768 133.776C146.323 133.776 146.792 133.968 147.176 134.352C147.603 134.736 147.816 135.205 147.816 135.76C147.816 136.315 147.624 136.805 147.24 137.232C146.856 137.616 146.365 137.808 145.768 137.808H134.184V149.328C134.184 149.925 133.992 150.416 133.608 150.8C133.224 151.184 132.755 151.376 132.2 151.376C131.645 151.376 131.176 151.184 130.792 150.8C130.408 150.373 130.216 149.883 130.216 149.328V137.808H118.632C118.077 137.808 117.587 137.616 117.16 137.232C116.776 136.805 116.584 136.315 116.584 135.76C116.584 135.205 116.776 134.736 117.16 134.352C117.587 133.968 118.077 133.776 118.632 133.776H130.216V122.256C130.216 121.659 130.408 121.168 130.792 120.784C131.176 120.357 131.645 120.144 132.2 120.144C132.755 120.144 133.224 120.357 133.608 120.784C133.992 121.168 134.184 121.659 134.184 122.256V133.776H145.768Z'
|
||||
fill='#242529' onClick={onClick}
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<filter
|
||||
id='filter0_f' x='-3' y='-7' width='271' height='284.846' filterUnits='userSpaceOnUse'
|
||||
colorInterpolationFilters='sRGB'
|
||||
>
|
||||
<feFlood floodOpacity='0' result='BackgroundImageFix' />
|
||||
<feBlend mode='normal' in='SourceGraphic' in2='BackgroundImageFix' result='shape' />
|
||||
<feGaussianBlur stdDeviation='10' result='effect1_foregroundBlur' />
|
||||
</filter>
|
||||
</defs>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(styled(AddAccountImage)<Props>`
|
||||
circle, path {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
path {
|
||||
fill: var(--textColor);
|
||||
}
|
||||
|
||||
& > g > circle {
|
||||
stroke: var(--inputBorderColor);
|
||||
fill: var(--addAccountImageBackground);
|
||||
}
|
||||
`);
|
||||
@@ -0,0 +1,83 @@
|
||||
// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { AccountWithChildren } from '@pezkuwi/extension-base/background/types';
|
||||
|
||||
import React, { useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import getNetworkMap from '@pezkuwi/extension-ui/util/getNetworkMap';
|
||||
|
||||
import { AccountContext } from '../../components/index.js';
|
||||
import { useTranslation } from '../../hooks/index.js';
|
||||
import { Header } from '../../partials/index.js';
|
||||
import { styled } from '../../styled.js';
|
||||
import AccountsTree from './AccountsTree.js';
|
||||
import AddAccount from './AddAccount.js';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function Accounts ({ className }: Props): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const [filter, setFilter] = useState('');
|
||||
const [filteredAccount, setFilteredAccount] = useState<AccountWithChildren[]>([]);
|
||||
const { hierarchy } = useContext(AccountContext);
|
||||
const networkMap = useMemo(() => getNetworkMap(), []);
|
||||
|
||||
useEffect(() => {
|
||||
setFilteredAccount(
|
||||
filter
|
||||
? hierarchy.filter((account) =>
|
||||
account.name?.toLowerCase().includes(filter) ||
|
||||
(account.genesisHash && networkMap.get(account.genesisHash)?.toLowerCase().includes(filter)) ||
|
||||
account.address.toLowerCase().includes(filter)
|
||||
)
|
||||
: hierarchy
|
||||
);
|
||||
}, [filter, hierarchy, networkMap]);
|
||||
|
||||
const _onFilter = useCallback((filter: string) => {
|
||||
setFilter(filter.toLowerCase());
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
{(hierarchy.length === 0)
|
||||
? <AddAccount />
|
||||
: (
|
||||
<>
|
||||
<Header
|
||||
onFilter={_onFilter}
|
||||
showAdd
|
||||
showConnectedAccounts
|
||||
showSearch
|
||||
showSettings
|
||||
text={t('Accounts')}
|
||||
/>
|
||||
<div className={className}>
|
||||
{filteredAccount.map((json, index): React.ReactNode => (
|
||||
<AccountsTree
|
||||
{...json}
|
||||
key={`${index}:${json.address}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default styled(Accounts)<Props>`
|
||||
height: calc(100vh - 2px);
|
||||
overflow-y: scroll;
|
||||
margin-top: -25px;
|
||||
padding-top: 25px;
|
||||
scrollbar-width: none;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,81 @@
|
||||
// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import React, { useCallback, useContext } from 'react';
|
||||
|
||||
import { ActionContext, Box, Button, ButtonArea, List } from '../components/index.js';
|
||||
import { useTranslation } from '../hooks/index.js';
|
||||
import { Header } from '../partials/index.js';
|
||||
import { styled } from '../styled.js';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function AssetHubMigration ({ className }: Props): React.ReactElement<Props> {
|
||||
const { t } = useTranslation();
|
||||
const onAction = useContext(ActionContext);
|
||||
|
||||
const _onClick = useCallback(
|
||||
(): void => {
|
||||
window.localStorage.setItem('asset_hub_migration_read', 'ok');
|
||||
onAction();
|
||||
},
|
||||
[onAction]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header text={t('Asset Hub Migration Notice')} />
|
||||
<div className={className}>
|
||||
<p>{t('The Asset Hub migration has been completed. Please note the following important changes:')}</p>
|
||||
<Box>
|
||||
<List>
|
||||
<li>{t('All balances have been migrated from the Relay Chain to Asset Hub')}</li>
|
||||
<li>{t('All on-chain functionality has been moved to Asset Hub')}</li>
|
||||
<li>{t('Asset Hub now holds user balances and provides general functionality')}</li>
|
||||
</List>
|
||||
</Box>
|
||||
<p className='warning'>{t('Do not teleport balances to the Relay Chain unless:')}</p>
|
||||
<Box>
|
||||
<List>
|
||||
<li>{t('You are opening HRMP channels, or')}</li>
|
||||
<li>{t('You are starting a Parachain')}</li>
|
||||
</List>
|
||||
</Box>
|
||||
<p>{t('For all other operations, your balances are already on Asset Hub.')}</p>
|
||||
</div>
|
||||
<ButtonArea>
|
||||
<Button onClick={_onClick}>{t('I Understand')}</Button>
|
||||
</ButtonArea>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default styled(AssetHubMigration)<Props>`
|
||||
p {
|
||||
color: var(--subTextColor);
|
||||
margin-bottom: 4px;
|
||||
margin-top: 0;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
p.warning {
|
||||
color: var(--errorColor);
|
||||
font-weight: 600;
|
||||
font-size: 1.1em;
|
||||
margin-top: 6px;
|
||||
margin-bottom: 2px;
|
||||
text-transform: uppercase;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
article {
|
||||
margin: 0.4rem 24px;
|
||||
padding: 8px 20px;
|
||||
}
|
||||
|
||||
ul {
|
||||
margin: 0;
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,96 @@
|
||||
// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import React, { useCallback, useContext, useEffect } from 'react';
|
||||
import { useParams } from 'react-router';
|
||||
|
||||
import { AccountContext, ActionContext, Button } from '../../components/index.js';
|
||||
import { useTranslation } from '../../hooks/index.js';
|
||||
import { getAuthList, updateAuthorization } from '../../messaging.js';
|
||||
import { AccountSelection, Header } from '../../partials/index.js';
|
||||
import { styled } from '../../styled.js';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function AccountManagement ({ className }: Props): React.ReactElement<Props> {
|
||||
const { url } = useParams<{url: string}>();
|
||||
const decodedUrl = decodeURIComponent(url);
|
||||
const { selectedAccounts = [], setSelectedAccounts } = useContext(AccountContext);
|
||||
const { t } = useTranslation();
|
||||
const onAction = useContext(ActionContext);
|
||||
|
||||
useEffect(() => {
|
||||
getAuthList()
|
||||
.then(({ list }) => {
|
||||
if (!list[decodedUrl]) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedAccounts && setSelectedAccounts(list[decodedUrl].authorizedAccounts);
|
||||
})
|
||||
.catch(console.error);
|
||||
}, [setSelectedAccounts, decodedUrl]);
|
||||
|
||||
const _onApprove = useCallback(
|
||||
(): void => {
|
||||
updateAuthorization(selectedAccounts, decodedUrl)
|
||||
.then(() => onAction('../index.js'))
|
||||
.catch(console.error);
|
||||
},
|
||||
[onAction, selectedAccounts, decodedUrl]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={`${className}`}>
|
||||
<Header
|
||||
showBackArrow
|
||||
smallMargin={true}
|
||||
text={t('Accounts connected to {{url}}', { replace: { url: decodedUrl } })}
|
||||
/>
|
||||
<div className='content'>
|
||||
<AccountSelection
|
||||
origin={decodedUrl}
|
||||
showHidden={true}
|
||||
url={decodedUrl}
|
||||
withWarning={false}
|
||||
/>
|
||||
<div className='footer'>
|
||||
<Button
|
||||
className='acceptButton'
|
||||
onClick={_onApprove}
|
||||
>
|
||||
{t('Connect {{total}} account(s)', { replace: {
|
||||
total: selectedAccounts.length
|
||||
} })}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default styled(AccountManagement)<Props>`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 550px;
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.footer {
|
||||
background: var(--background);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.acceptButton {
|
||||
width: 90%;
|
||||
margin: 0.5rem auto;
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,70 @@
|
||||
// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { AuthUrlInfo } from '@pezkuwi/extension-base/background/types';
|
||||
|
||||
import React, { useCallback } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import RemoveAuth from '../../components/RemoveAuth.js';
|
||||
import { useTranslation } from '../../hooks/index.js';
|
||||
import { styled } from '../../styled.js';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
info: AuthUrlInfo;
|
||||
removeAuth: (url: string) => void;
|
||||
url: string;
|
||||
}
|
||||
|
||||
function WebsiteEntry ({ className = '', info: { authorizedAccounts, isAllowed }, removeAuth, url }: Props): React.ReactElement<Props> {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const _removeAuth = useCallback(
|
||||
() => removeAuth(url),
|
||||
[removeAuth, url]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<RemoveAuth onRemove={_removeAuth} />
|
||||
<div className='url'>
|
||||
{url}
|
||||
</div>
|
||||
<Link
|
||||
className='connectedAccounts'
|
||||
to={`/url/manage/${encodeURIComponent(url)}`}
|
||||
>{
|
||||
authorizedAccounts?.length
|
||||
? t('{{total}} accounts', {
|
||||
replace: {
|
||||
total: authorizedAccounts.length
|
||||
}
|
||||
})
|
||||
: isAllowed
|
||||
? t('all accounts')
|
||||
: t('no accounts')
|
||||
}</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default styled(WebsiteEntry)<Props>`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-top: .2rem;
|
||||
|
||||
.url{
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.connectedAccounts{
|
||||
margin-left: .5rem;
|
||||
background-color: var(--primaryColor);
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
padding: 0 0.5rem;
|
||||
border-radius: 4px;
|
||||
text-decoration: none;
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,98 @@
|
||||
// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { AuthUrlInfo, AuthUrls } from '@pezkuwi/extension-base/background/types';
|
||||
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import InputFilter from '../../components/InputFilter.js';
|
||||
import { useTranslation } from '../../hooks/index.js';
|
||||
import { getAuthList, removeAuthorization } from '../../messaging.js';
|
||||
import { Header } from '../../partials/index.js';
|
||||
import { styled } from '../../styled.js';
|
||||
import WebsiteEntry from './WebsiteEntry.js';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function AuthManagement ({ className }: Props): React.ReactElement<Props> {
|
||||
const { t } = useTranslation();
|
||||
const [authList, setAuthList] = useState<AuthUrls | null>(null);
|
||||
const [filter, setFilter] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
getAuthList()
|
||||
.then(({ list }) => setAuthList(list))
|
||||
.catch((e) => console.error(e));
|
||||
}, []);
|
||||
|
||||
const hasAuthList = useMemo(
|
||||
() => !!authList && !!Object.keys(authList).length,
|
||||
[authList]
|
||||
);
|
||||
|
||||
const _onChangeFilter = useCallback((filter: string) => {
|
||||
setFilter(filter);
|
||||
}, []);
|
||||
|
||||
const removeAuth = useCallback((url: string) => {
|
||||
removeAuthorization(url)
|
||||
.then(({ list }) => setAuthList(list))
|
||||
.catch(console.error);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header
|
||||
showBackArrow
|
||||
smallMargin
|
||||
text={t('Manage Website Access')}
|
||||
/>
|
||||
<div className={className}>
|
||||
<InputFilter
|
||||
className='inputFilter'
|
||||
onChange={_onChangeFilter}
|
||||
placeholder={t('example.com')}
|
||||
value={filter}
|
||||
withReset
|
||||
/>
|
||||
{
|
||||
!authList || !hasAuthList
|
||||
? <div className='empty-list'>{t('No website request yet!')}</div>
|
||||
: (
|
||||
<>
|
||||
<div className='website-list'>
|
||||
{Object
|
||||
.entries<AuthUrlInfo>(authList)
|
||||
.filter(([url]) => url.includes(filter))
|
||||
.map(([url, info]) =>
|
||||
<WebsiteEntry
|
||||
info={info}
|
||||
key={url}
|
||||
removeAuth={removeAuth}
|
||||
url={url}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default styled(AuthManagement)<Props>`
|
||||
height: calc(100vh - 2px);
|
||||
overflow-y: auto;
|
||||
|
||||
.empty-list{
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.inputFilter{
|
||||
margin-bottom: 0.8rem;
|
||||
padding: 0 !important;
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,113 @@
|
||||
// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import '@pezkuwi/extension-mocks/chrome';
|
||||
|
||||
import type { ReactWrapper } from 'enzyme';
|
||||
import type * as _ from '@pezkuwi/dev-test/globals.d.ts';
|
||||
import type { AccountJson, AuthorizeRequest } from '@pezkuwi/extension-base/background/types';
|
||||
|
||||
import Adapter from '@wojtekmaj/enzyme-adapter-react-17';
|
||||
import enzyme from 'enzyme';
|
||||
import React from 'react';
|
||||
|
||||
import { AccountContext, AuthorizeReqContext, Warning } from '../../components/index.js';
|
||||
import { Header } from '../../partials/index.js';
|
||||
import { buildHierarchy } from '../../util/buildHierarchy.js';
|
||||
import Account from '../Accounts/Account.js';
|
||||
import Authorize from './index.js';
|
||||
import Request from './Request.js';
|
||||
|
||||
const { configure, mount } = enzyme;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment,@typescript-eslint/no-unsafe-call
|
||||
configure({ adapter: new Adapter() });
|
||||
|
||||
const oneRequest = [{ id: '1', request: { origin: '???' }, url: 'http://polkadot.org' }];
|
||||
|
||||
const twoRequests = [
|
||||
...oneRequest,
|
||||
{ id: '2', request: { origin: 'abc' }, url: 'http://polkadot.pl' }
|
||||
];
|
||||
|
||||
const oneAccount = [
|
||||
{ address: '5FjgD3Ns2UpnHJPVeRViMhCttuemaRXEqaD8V5z4vxcsUByA', name: 'A', type: 'sr25519' }
|
||||
] as AccountJson[];
|
||||
|
||||
const twoAccountsOnehidden = [
|
||||
...oneAccount,
|
||||
{ address: '5GYmFzQCuC5u3tQNiMZNbFGakrz3Jq31NmMg4D2QAkSoQ2g5', isHidden: true, name: 'B', type: 'sr25519' }
|
||||
] as AccountJson[];
|
||||
|
||||
const threeAccountsOnehidden = [
|
||||
...twoAccountsOnehidden,
|
||||
{ address: '5D2TPhGEy2FhznvzaNYW9AkuMBbg3cyRemnPsBvBY4ZhkZXA', name: 'BB', parentAddress: twoAccountsOnehidden[1].address, type: 'sr25519' }
|
||||
] as AccountJson[];
|
||||
|
||||
describe('Authorize', () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
||||
const mountAuthorize = (authorizeRequests: AuthorizeRequest[] = [], accounts: AccountJson[] = oneAccount): ReactWrapper => mount(
|
||||
<AuthorizeReqContext.Provider value={authorizeRequests}>
|
||||
<AccountContext.Provider
|
||||
value={{
|
||||
accounts,
|
||||
hierarchy: accounts ? buildHierarchy(accounts) : []
|
||||
}}
|
||||
>
|
||||
<Authorize />
|
||||
</AccountContext.Provider>
|
||||
</AuthorizeReqContext.Provider>);
|
||||
|
||||
it('render component', () => {
|
||||
const wrapper = mountAuthorize();
|
||||
|
||||
expect(wrapper.find(Header).text()).toBe('Account connection request');
|
||||
expect(wrapper.find(Request).length).toBe(0);
|
||||
});
|
||||
|
||||
it('render requests', () => {
|
||||
const wrapper = mountAuthorize(oneRequest);
|
||||
|
||||
expect(wrapper.find(Request).length).toBe(1);
|
||||
expect(wrapper.find(Request).find('.warning-message').text()).toBe('An application, self-identifying as ??? is requesting access from http://polkadot.org');
|
||||
});
|
||||
|
||||
it('render more request but just one accept button', () => {
|
||||
const wrapper = mountAuthorize(twoRequests);
|
||||
|
||||
expect(wrapper.find(Request).length).toBe(2);
|
||||
expect(wrapper.find(Warning).length).toBe(2);
|
||||
expect(wrapper.find(Request).at(1).find('.warning-message').text()).toBe('An application, self-identifying as abc is requesting access from http://polkadot.pl');
|
||||
expect(wrapper.find('button.acceptButton').length).toBe(1);
|
||||
});
|
||||
|
||||
it('render a warning and explication text when there is no account', () => {
|
||||
const wrapper = mountAuthorize(oneRequest, []);
|
||||
|
||||
expect(wrapper.find(Request).length).toBe(1);
|
||||
expect(wrapper.find(Request).find('.warning-message').text()).toBe("You do not have any account. Please create an account and refresh the application's page.");
|
||||
expect(wrapper.find('button.acceptButton').length).toBe(1);
|
||||
});
|
||||
|
||||
it('show the right amount of accounts', () => {
|
||||
const wrapper = mountAuthorize(oneRequest);
|
||||
|
||||
expect(wrapper.find(Request).length).toBe(1);
|
||||
expect(wrapper.find(Account).length).toBe(1);
|
||||
expect(wrapper.find('button.acceptButton').length).toBe(1);
|
||||
});
|
||||
|
||||
it('does not show the hidden accounts', () => {
|
||||
const wrapper = mountAuthorize(oneRequest, twoAccountsOnehidden);
|
||||
|
||||
expect(wrapper.find(Request).length).toBe(1);
|
||||
expect(wrapper.find(Account).length).toBe(1);
|
||||
});
|
||||
|
||||
it('shows the children of hidden accounts', () => {
|
||||
const wrapper = mountAuthorize(oneRequest, threeAccountsOnehidden);
|
||||
|
||||
expect(wrapper.find(Request).length).toBe(1);
|
||||
expect(wrapper.find(Account).length).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import { t } from 'i18next';
|
||||
import React, { useCallback } from 'react';
|
||||
import { Trans } from 'react-i18next';
|
||||
|
||||
import { Button, Warning } from '../../components/index.js';
|
||||
import { rejectAuthRequest } from '../../messaging.js';
|
||||
import { styled } from '../../styled.js';
|
||||
|
||||
interface Props {
|
||||
authId: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function NoAccount ({ authId, className }: Props): React.ReactElement<Props> {
|
||||
const _onClick = useCallback(() => {
|
||||
rejectAuthRequest(authId).catch(console.error);
|
||||
}, [authId]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<Warning className='warningMargin'>
|
||||
<Trans>You do not have any account. Please create an account and refresh the application's page.</Trans>
|
||||
</Warning>
|
||||
<Button
|
||||
className='acceptButton'
|
||||
onClick={_onClick}
|
||||
>
|
||||
{t('Understood')}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default styled(NoAccount)<Props>`
|
||||
.acceptButton {
|
||||
width: 90%;
|
||||
margin: 25px auto 0;
|
||||
}
|
||||
|
||||
.warningMargin {
|
||||
margin: 1rem 24px 0 1.45rem;
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,138 @@
|
||||
// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { RequestAuthorizeTab } from '@pezkuwi/extension-base/background/types';
|
||||
|
||||
import React, { useCallback, useContext, useEffect, useState } from 'react';
|
||||
|
||||
import { AccountContext, ActionContext, Button } from '../../components/index.js';
|
||||
import { useTranslation } from '../../hooks/index.js';
|
||||
import { approveAuthRequest, cancelAuthRequest, rejectAuthRequest } from '../../messaging.js';
|
||||
import { AccountSelection } from '../../partials/index.js';
|
||||
import { styled } from '../../styled.js';
|
||||
import NoAccount from './NoAccount.js';
|
||||
|
||||
interface Props {
|
||||
authId: string;
|
||||
className?: string;
|
||||
request: RequestAuthorizeTab;
|
||||
url: string;
|
||||
}
|
||||
|
||||
function Request ({ authId, className, request: { origin }, url }: Props): React.ReactElement<Props> {
|
||||
const { accounts, selectedAccounts = [], setSelectedAccounts } = useContext(AccountContext);
|
||||
const { t } = useTranslation();
|
||||
const onAction = useContext(ActionContext);
|
||||
const [dontAskAgain, setDontAskAgain] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const defaultAccountSelection = accounts
|
||||
.filter(({ isDefaultAuthSelected }) => !!isDefaultAuthSelected)
|
||||
.map(({ address }) => address);
|
||||
|
||||
setSelectedAccounts && setSelectedAccounts(defaultAccountSelection);
|
||||
}, [accounts, setSelectedAccounts]);
|
||||
|
||||
const _onApprove = useCallback(
|
||||
(): void => {
|
||||
approveAuthRequest(authId, selectedAccounts)
|
||||
.then(() => onAction())
|
||||
.catch((error: Error) => console.error(error));
|
||||
},
|
||||
[authId, onAction, selectedAccounts]
|
||||
);
|
||||
|
||||
const _onReject = useCallback(
|
||||
(): void => {
|
||||
const rejectFunction = dontAskAgain ? rejectAuthRequest : cancelAuthRequest;
|
||||
|
||||
rejectFunction(authId)
|
||||
.then(() => onAction())
|
||||
.catch((error: Error) => console.error(error));
|
||||
},
|
||||
[authId, onAction, dontAskAgain]
|
||||
);
|
||||
|
||||
const _onToggleDontAskAgain = useCallback(
|
||||
(): void => {
|
||||
setDontAskAgain((prev) => !prev);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
if (!accounts.length) {
|
||||
return <NoAccount authId={authId} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<AccountSelection
|
||||
origin={origin}
|
||||
url={url}
|
||||
/>
|
||||
<div className='footer'>
|
||||
<div className='buttonContainer'>
|
||||
<Button
|
||||
className='acceptButton'
|
||||
onClick={_onApprove}
|
||||
>
|
||||
{t('Connect {{total}} account(s)', { replace: {
|
||||
total: selectedAccounts.length
|
||||
} })}
|
||||
</Button>
|
||||
<Button
|
||||
className='rejectButton'
|
||||
isDanger
|
||||
onClick={_onReject}
|
||||
>
|
||||
{t('Reject')}
|
||||
</Button>
|
||||
</div>
|
||||
<div className='dontAskAgainContainer'>
|
||||
<input
|
||||
checked={dontAskAgain}
|
||||
onChange={_onToggleDontAskAgain}
|
||||
type='checkbox'
|
||||
/>
|
||||
<label>{t("Don't ask again")}</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
);
|
||||
}
|
||||
|
||||
export default styled(Request)<Props>`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
|
||||
.footer {
|
||||
padding: 1rem 1rem 0rem 1rem;
|
||||
background: var(--background);
|
||||
}
|
||||
|
||||
.buttonContainer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.acceptButton, .rejectButton {
|
||||
width: 48%;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.dontAskAgainContainer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
|
||||
input {
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,124 @@
|
||||
// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import React, { useCallback, useContext, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { AuthorizeReqContext } from '../../components/index.js';
|
||||
import { useTranslation } from '../../hooks/index.js';
|
||||
import { Header } from '../../partials/index.js';
|
||||
import { styled } from '../../styled.js';
|
||||
import Request from './Request.js';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function Authorize ({ className = '' }: Props): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const requests = useContext(AuthorizeReqContext);
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
const currentIndexRef = useRef(currentIndex);
|
||||
|
||||
useEffect(() => {
|
||||
if (requests.length <= currentIndexRef.current) {
|
||||
setCurrentIndex((prevIndex) => Math.max(prevIndex - 1, 0));
|
||||
}
|
||||
}, [requests.length]);
|
||||
|
||||
const handleNext = useCallback(() => {
|
||||
setCurrentIndex((prevIndex) => {
|
||||
const newIndex = Math.min(prevIndex + 1, requests.length - 1);
|
||||
|
||||
currentIndexRef.current = newIndex;
|
||||
|
||||
return newIndex;
|
||||
});
|
||||
}, [requests.length]);
|
||||
|
||||
const handlePrevious = useCallback(() => {
|
||||
setCurrentIndex((prevIndex) => {
|
||||
const newIndex = Math.max(prevIndex - 1, 0);
|
||||
|
||||
currentIndexRef.current = newIndex;
|
||||
|
||||
return newIndex;
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={`${className}`}>
|
||||
<Header
|
||||
smallMargin={true}
|
||||
text={t('Account connection request')}
|
||||
/>
|
||||
{requests.length > 1 && (
|
||||
<div className='pagination'>
|
||||
<button
|
||||
className={currentIndex === 0 ? 'hidden' : ''}
|
||||
onClick={handlePrevious}
|
||||
>
|
||||
{t('Previous')}
|
||||
</button>
|
||||
<span>{`${currentIndex + 1} / ${requests.length}`}</span>
|
||||
<button
|
||||
className={currentIndex === requests.length - 1 ? 'hidden' : ''}
|
||||
onClick={handleNext}
|
||||
>
|
||||
{t('Next')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{requests.length > 0 && requests[currentIndex] && (
|
||||
<Request
|
||||
authId={requests[currentIndex].id}
|
||||
className='request'
|
||||
key={requests[currentIndex].id}
|
||||
request={requests[currentIndex].request}
|
||||
url={requests[currentIndex].url}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default styled(Authorize)<Props>`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
|
||||
&& {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.request {
|
||||
padding: 0 24px;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: auto;
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
background: var(--background);
|
||||
|
||||
button {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--textColor);
|
||||
cursor: pointer;
|
||||
padding: 0.5rem 1rem;
|
||||
|
||||
&.hidden {
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
span {
|
||||
align-self: center;
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,124 @@
|
||||
// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import '@pezkuwi/extension-mocks/chrome';
|
||||
|
||||
import type { ReactWrapper } from 'enzyme';
|
||||
import type * as _ from '@pezkuwi/dev-test/globals.d.ts';
|
||||
|
||||
import Adapter from '@wojtekmaj/enzyme-adapter-react-17';
|
||||
import enzyme from 'enzyme';
|
||||
import React from 'react';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
|
||||
import { ActionContext, ActionText, Button } from '../../components/index.js';
|
||||
import * as messaging from '../../messaging.js';
|
||||
import { Header } from '../../partials/index.js';
|
||||
import { flushAllPromises } from '../../testHelpers.js';
|
||||
import CreateAccount from './index.js';
|
||||
|
||||
const { configure, mount } = enzyme;
|
||||
|
||||
// // NOTE Required for spyOn when using @swc/jest
|
||||
// // https://github.com/swc-project/swc/issues/3843
|
||||
// jest.mock('../../messaging', (): Record<string, unknown> => ({
|
||||
// __esModule: true,
|
||||
// ...jest.requireActual('../../messaging')
|
||||
// }));
|
||||
|
||||
// For this file, there are a lot of them
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-argument */
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment,@typescript-eslint/no-unsafe-call
|
||||
configure({ adapter: new Adapter() });
|
||||
|
||||
describe('Create Account', () => {
|
||||
let wrapper: ReactWrapper;
|
||||
let onActionStub: ReturnType<typeof jest.fn>;
|
||||
const exampleAccount = {
|
||||
address: 'HjoBp62cvsWDA3vtNMWxz6c9q13ReEHi9UGHK7JbZweH5g5',
|
||||
seed: 'horse battery staple correct'
|
||||
};
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
||||
const mountComponent = (): ReactWrapper => mount(
|
||||
<ActionContext.Provider value={onActionStub}>
|
||||
<CreateAccount />
|
||||
</ActionContext.Provider>
|
||||
);
|
||||
|
||||
const check = (input: ReactWrapper): unknown => input.simulate('change', { target: { checked: true } });
|
||||
|
||||
const type = async (input: ReactWrapper, value: string): Promise<void> => {
|
||||
input.simulate('change', { target: { value } });
|
||||
await act(flushAllPromises);
|
||||
wrapper.update();
|
||||
};
|
||||
|
||||
const enterName = (name: string): Promise<void> => type(wrapper.find('input').first(), name);
|
||||
const password = (password: string) => (): Promise<void> => type(wrapper.find('input[type="password"]').first(), password);
|
||||
const repeat = (password: string) => (): Promise<void> => type(wrapper.find('input[type="password"]').last(), password);
|
||||
|
||||
beforeEach(async () => {
|
||||
onActionStub = jest.fn();
|
||||
jest.spyOn(messaging, 'createSeed').mockImplementation(() => Promise.resolve(exampleAccount));
|
||||
jest.spyOn(messaging, 'createAccountSuri').mockImplementation(() => Promise.resolve(true));
|
||||
wrapper = mountComponent();
|
||||
await act(flushAllPromises);
|
||||
wrapper.update();
|
||||
});
|
||||
|
||||
describe('Phase 1', () => {
|
||||
it('shows seed phrase in a span inside a div', () => {
|
||||
expect(wrapper.find('.seedBox span').text()).toBe(exampleAccount.seed);
|
||||
});
|
||||
|
||||
it('next step button is disabled when checkbox is not checked', () => {
|
||||
expect(wrapper.find(Button).prop('isDisabled')).toBe(true);
|
||||
});
|
||||
|
||||
it('action text is "Cancel"', () => {
|
||||
expect(wrapper.find(Header).find(ActionText).text()).toBe('Cancel');
|
||||
});
|
||||
|
||||
it('clicking "Cancel" redirects to main screen', () => {
|
||||
wrapper.find(Header).find(ActionText).simulate('click');
|
||||
expect(onActionStub).toHaveBeenCalledWith('/');
|
||||
});
|
||||
|
||||
it('checking the checkbox enables the Next button', () => {
|
||||
check(wrapper.find('input[type="checkbox"]'));
|
||||
|
||||
expect(wrapper.find(Button).prop('isDisabled')).toBe(false);
|
||||
});
|
||||
|
||||
it('clicking on Next activates phase 2', () => {
|
||||
check(wrapper.find('input[type="checkbox"]'));
|
||||
wrapper.find('button').simulate('click');
|
||||
expect(wrapper.find(Header).text()).toBe('Create an account2/2Cancel');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Phase 2', () => {
|
||||
beforeEach(async () => {
|
||||
check(wrapper.find('input[type="checkbox"]'));
|
||||
wrapper.find('button').simulate('click');
|
||||
await act(flushAllPromises);
|
||||
wrapper.update();
|
||||
});
|
||||
|
||||
it('saves account with provided network, name and password', async () => {
|
||||
const kusamaGenesis = '0xb0a8d493285c2df73290dfb7e61f870f17b41801197a149ca93654499ea3dafe';
|
||||
|
||||
wrapper.find('select').simulate('change', { target: { value: kusamaGenesis } });
|
||||
await act(flushAllPromises);
|
||||
wrapper.update();
|
||||
|
||||
await enterName('abc').then(password('abcdef')).then(repeat('abcdef'));
|
||||
wrapper.find('[data-button-action="add new root"] button').simulate('click');
|
||||
await act(flushAllPromises);
|
||||
|
||||
expect(messaging.createAccountSuri).toHaveBeenCalledWith('abc', 'abcdef', exampleAccount.seed, 'sr25519', kusamaGenesis);
|
||||
expect(onActionStub).toHaveBeenCalledWith('/');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import React, { useCallback, useState } from 'react';
|
||||
|
||||
import { ButtonArea, Checkbox, MnemonicSeed, NextStepButton, VerticalSpace, Warning } from '../../components/index.js';
|
||||
import { useToast, useTranslation } from '../../hooks/index.js';
|
||||
|
||||
interface Props {
|
||||
onNextStep: () => void;
|
||||
seed: string;
|
||||
}
|
||||
|
||||
function Mnemonic ({ onNextStep, seed }: Props): React.ReactElement<Props> {
|
||||
const { t } = useTranslation();
|
||||
const [isMnemonicSaved, setIsMnemonicSaved] = useState(false);
|
||||
const { show } = useToast();
|
||||
|
||||
const _onCopy = useCallback((): void => {
|
||||
show(t('Copied'));
|
||||
}, [show, t]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<MnemonicSeed
|
||||
onCopy={_onCopy}
|
||||
seed={seed}
|
||||
/>
|
||||
<Warning>
|
||||
{t("Please write down your wallet's mnemonic seed and keep it in a safe place. The mnemonic can be used to restore your wallet. Keep it carefully to not lose your assets.")}
|
||||
</Warning>
|
||||
<VerticalSpace />
|
||||
<Checkbox
|
||||
checked={isMnemonicSaved}
|
||||
label={t('I have saved my mnemonic seed safely.')}
|
||||
onChange={setIsMnemonicSaved}
|
||||
/>
|
||||
<ButtonArea>
|
||||
<NextStepButton
|
||||
isDisabled={!isMnemonicSaved}
|
||||
onClick={onNextStep}
|
||||
>
|
||||
{t('Next step')}
|
||||
</NextStepButton>
|
||||
</ButtonArea>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(Mnemonic);
|
||||
@@ -0,0 +1,141 @@
|
||||
// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { HexString } from '@pezkuwi/util/types';
|
||||
|
||||
import React, { useCallback, useContext, useEffect, useState } from 'react';
|
||||
|
||||
import AccountNamePasswordCreation from '../../components/AccountNamePasswordCreation.js';
|
||||
import { ActionContext, Address, Dropdown, Loading } from '../../components/index.js';
|
||||
import { useGenesisHashOptions, useMetadata, useTranslation } from '../../hooks/index.js';
|
||||
import { createAccountSuri, createSeed, validateSeed } from '../../messaging.js';
|
||||
import { HeaderWithSteps } from '../../partials/index.js';
|
||||
import { styled } from '../../styled.js';
|
||||
import { DEFAULT_TYPE } from '../../util/defaultType.js';
|
||||
import Mnemonic from './Mnemonic.js';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function CreateAccount ({ className }: Props): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const onAction = useContext(ActionContext);
|
||||
const [isBusy, setIsBusy] = useState(false);
|
||||
const [step, setStep] = useState(1);
|
||||
const [address, setAddress] = useState<null | string>(null);
|
||||
const [seed, setSeed] = useState<null | string>(null);
|
||||
const [type, setType] = useState(DEFAULT_TYPE);
|
||||
const [name, setName] = useState('');
|
||||
const options = useGenesisHashOptions();
|
||||
const [genesisHash, setGenesis] = useState<HexString | null>(null);
|
||||
const chain = useMetadata(genesisHash, true);
|
||||
|
||||
useEffect((): void => {
|
||||
createSeed(undefined)
|
||||
.then(({ address, seed }): void => {
|
||||
setAddress(address);
|
||||
setSeed(seed);
|
||||
})
|
||||
.catch(console.error);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect((): void => {
|
||||
if (seed) {
|
||||
const type = chain && chain.definition.chainType === 'ethereum'
|
||||
? 'ethereum'
|
||||
: DEFAULT_TYPE;
|
||||
|
||||
setType(type);
|
||||
validateSeed(seed, type)
|
||||
.then(({ address }) => setAddress(address))
|
||||
.catch(console.error);
|
||||
}
|
||||
}, [seed, chain]);
|
||||
|
||||
const _onCreate = useCallback(
|
||||
(name: string, password: string): void => {
|
||||
// this should always be the case
|
||||
if (name && password && seed) {
|
||||
setIsBusy(true);
|
||||
|
||||
createAccountSuri(name, password, seed, type, genesisHash)
|
||||
.then(() => onAction('/'))
|
||||
.catch((error: Error): void => {
|
||||
setIsBusy(false);
|
||||
console.error(error);
|
||||
});
|
||||
}
|
||||
},
|
||||
[genesisHash, onAction, seed, type]
|
||||
);
|
||||
|
||||
const _onNextStep = useCallback(
|
||||
() => setStep((step) => step + 1),
|
||||
[]
|
||||
);
|
||||
|
||||
const _onPreviousStep = useCallback(
|
||||
() => setStep((step) => step - 1),
|
||||
[]
|
||||
);
|
||||
|
||||
const _onChangeNetwork = useCallback(
|
||||
(newGenesisHash: HexString) => setGenesis(newGenesisHash),
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<HeaderWithSteps
|
||||
step={step}
|
||||
text={t('Create an account')}
|
||||
/>
|
||||
<Loading>
|
||||
<div>
|
||||
<Address
|
||||
address={address}
|
||||
genesisHash={genesisHash}
|
||||
name={name}
|
||||
/>
|
||||
</div>
|
||||
{seed && (
|
||||
step === 1
|
||||
? (
|
||||
<Mnemonic
|
||||
onNextStep={_onNextStep}
|
||||
seed={seed}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<>
|
||||
<Dropdown
|
||||
className={className}
|
||||
label={t('Network')}
|
||||
onChange={_onChangeNetwork}
|
||||
options={options}
|
||||
value={genesisHash}
|
||||
/>
|
||||
<AccountNamePasswordCreation
|
||||
buttonLabel={t('Add the account with the generated seed')}
|
||||
isBusy={isBusy}
|
||||
onBackClick={_onPreviousStep}
|
||||
onCreate={_onCreate}
|
||||
onNameChange={setName}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
)}
|
||||
</Loading>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default styled(CreateAccount)<Props>`
|
||||
margin-bottom: 16px;
|
||||
|
||||
label::after {
|
||||
right: 36px;
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,106 @@
|
||||
// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import React, { useCallback, useRef, useState } from 'react';
|
||||
|
||||
import arrow from '../../assets/arrow-down.svg';
|
||||
import { Address } from '../../components/index.js';
|
||||
import { useOutsideClick } from '../../hooks/index.js';
|
||||
import { styled } from '../../styled.js';
|
||||
|
||||
interface Props {
|
||||
allAddresses: [string, string | null][];
|
||||
className?: string;
|
||||
onSelect: (address: string) => void;
|
||||
selectedAddress: string;
|
||||
selectedGenesis: string | null;
|
||||
}
|
||||
|
||||
function AddressDropdown ({ allAddresses, className, onSelect, selectedAddress, selectedGenesis }: Props): React.ReactElement<Props> {
|
||||
const [isDropdownVisible, setDropdownVisible] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
const _hideDropdown = useCallback(() => setDropdownVisible(false), []);
|
||||
const _toggleDropdown = useCallback(() => setDropdownVisible(!isDropdownVisible), [isDropdownVisible]);
|
||||
const _selectParent = useCallback((newParent: string) => () => onSelect(newParent), [onSelect]);
|
||||
|
||||
useOutsideClick([ref], _hideDropdown);
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<div
|
||||
onClick={_toggleDropdown}
|
||||
ref={ref}
|
||||
>
|
||||
<Address
|
||||
address={selectedAddress}
|
||||
className='address'
|
||||
genesisHash={selectedGenesis}
|
||||
/>
|
||||
</div>
|
||||
<div className={`dropdown ${isDropdownVisible ? 'visible' : ''}`}>
|
||||
{allAddresses.map(([address, genesisHash]) => (
|
||||
<div
|
||||
data-parent-option
|
||||
key={address}
|
||||
onClick={_selectParent(address)}
|
||||
>
|
||||
<Address
|
||||
address={address}
|
||||
className='address'
|
||||
genesisHash={genesisHash}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default styled(AddressDropdown)<Props>`
|
||||
margin-bottom: 16px;
|
||||
cursor: pointer;
|
||||
|
||||
& > div:first-child > .address::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 66%;
|
||||
transform: translateY(-50%);
|
||||
right: 11px;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
background: url(${arrow}) center no-repeat;
|
||||
background-color: var(--inputBackground);
|
||||
pointer-events: none;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--boxBorderColor);
|
||||
}
|
||||
|
||||
.address .copyIcon {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.dropdown {
|
||||
position: absolute;
|
||||
visibility: hidden;
|
||||
width: 510px;
|
||||
z-index: 100;
|
||||
background: var(--bodyColor);
|
||||
max-height: 0;
|
||||
overflow: auto;
|
||||
padding: 5px;
|
||||
border: 1px solid var(--boxBorderColor);
|
||||
box-sizing: border-box;
|
||||
border-radius: 4px;
|
||||
margin-top: -8px;
|
||||
|
||||
&.visible{
|
||||
visibility: visible;
|
||||
max-height: 200px;
|
||||
}
|
||||
|
||||
& > div {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,110 @@
|
||||
// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import { faLock, faLockOpen } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { Button, InputWithLabel } from '../../components/index.js';
|
||||
import { useTranslation } from '../../hooks/index.js';
|
||||
import { styled } from '../../styled.js';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
defaultPath: string;
|
||||
isError: boolean;
|
||||
onChange: (suri: string) => void;
|
||||
parentAddress: string;
|
||||
parentPassword: string;
|
||||
withSoftPath: boolean;
|
||||
}
|
||||
|
||||
function DerivationPath ({ className, defaultPath, isError, onChange, withSoftPath }: Props): React.ReactElement<Props> {
|
||||
const { t } = useTranslation();
|
||||
const [path, setPath] = useState<string>(defaultPath);
|
||||
const [isDisabled, setIsDisabled] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
setPath(defaultPath);
|
||||
}, [defaultPath]);
|
||||
|
||||
const _onExpand = useCallback(() => setIsDisabled(!isDisabled), [isDisabled]);
|
||||
|
||||
const _onChange = useCallback((newPath: string): void => {
|
||||
setPath(newPath);
|
||||
onChange(newPath);
|
||||
}, [onChange]);
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className='container'>
|
||||
<div className={`pathInput ${isDisabled ? 'locked' : ''}`}>
|
||||
<InputWithLabel
|
||||
data-input-suri
|
||||
disabled={isDisabled}
|
||||
isError={isError || !path}
|
||||
label={
|
||||
isDisabled
|
||||
? t('Derivation Path (unlock to edit)')
|
||||
: t('Derivation Path')
|
||||
}
|
||||
onChange={_onChange}
|
||||
placeholder={withSoftPath
|
||||
? t('//hard/soft')
|
||||
: t('//hard')
|
||||
}
|
||||
value={path}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
className='lockButton'
|
||||
onClick={_onExpand}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
className='lockIcon'
|
||||
icon={isDisabled ? faLock : faLockOpen}
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(styled(DerivationPath)<Props>`
|
||||
> .container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.lockButton {
|
||||
background: none;
|
||||
height: 14px;
|
||||
margin: 36px 2px 0 10px;
|
||||
padding: 3px;
|
||||
width: 11px;
|
||||
|
||||
&:not(:disabled):hover {
|
||||
background: none;
|
||||
}
|
||||
|
||||
&:active, &:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
&::-moz-focus-inner {
|
||||
border: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.lockIcon {
|
||||
color: var(--iconNeutralColor)
|
||||
}
|
||||
|
||||
.pathInput {
|
||||
width: 100%;
|
||||
|
||||
&.locked input {
|
||||
opacity: 50%;
|
||||
}
|
||||
}
|
||||
`);
|
||||
@@ -0,0 +1,331 @@
|
||||
// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import '@pezkuwi/extension-mocks/chrome';
|
||||
|
||||
import type { ReactWrapper } from 'enzyme';
|
||||
import type * as _ from '@pezkuwi/dev-test/globals.d.ts';
|
||||
import type { AccountJson, ResponseDeriveValidate } from '@pezkuwi/extension-base/background/types';
|
||||
|
||||
import Adapter from '@wojtekmaj/enzyme-adapter-react-17';
|
||||
import enzyme from 'enzyme';
|
||||
import React from 'react';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { MemoryRouter, Route } from 'react-router';
|
||||
|
||||
import { AccountContext, ActionContext } from '../../components/index.js';
|
||||
import * as messaging from '../../messaging.js';
|
||||
import { flushAllPromises } from '../../testHelpers.js';
|
||||
import { buildHierarchy } from '../../util/buildHierarchy.js';
|
||||
import AddressDropdown from './AddressDropdown.js';
|
||||
import Derive from './index.js';
|
||||
|
||||
const { configure, mount } = enzyme;
|
||||
|
||||
// // NOTE Required for spyOn when using @swc/jest
|
||||
// // https://github.com/swc-project/swc/issues/3843
|
||||
// jest.mock('../../messaging', (): Record<string, unknown> => ({
|
||||
// __esModule: true,
|
||||
// ...jest.requireActual('../../messaging')
|
||||
// }));
|
||||
|
||||
// For this file, there are a lot of them
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-argument */
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment,@typescript-eslint/no-unsafe-call
|
||||
configure({ adapter: new Adapter() });
|
||||
|
||||
const parentPassword = 'pass';
|
||||
const westendGenesis = '0xe143f23803ac50e8f6f8e62695d1ce9e4e1d68aa36c1cd2cfd15340213f3423e';
|
||||
const defaultDerivation = '//0';
|
||||
const derivedAddress = '5GYQRJj3NUznYDzCduENRcocMsyxmb6tjb5xW87ZMErBe9R7';
|
||||
|
||||
const accounts = [
|
||||
{ address: '5FjgD3Ns2UpnHJPVeRViMhCttuemaRXEqaD8V5z4vxcsUByA', name: 'A', type: 'sr25519' },
|
||||
{ address: '5GYmFzQCuC5u3tQNiMZNbFGakrz3Jq31NmMg4D2QAkSoQ2g5', genesisHash: westendGenesis, name: 'B', type: 'sr25519' },
|
||||
{ address: '5D2TPhGEy2FhznvzaNYW9AkuMBbg3cyRemnPsBvBY4ZhkZXA', name: 'BB', parentAddress: '5GYmFzQCuC5u3tQNiMZNbFGakrz3Jq31NmMg4D2QAkSoQ2g5', type: 'sr25519' },
|
||||
{ address: '5GhGENSJBWQZ8d8mARKgqEkiAxiW3hHeznQDW2iG4XzNieb6', isExternal: true, name: 'C', type: 'sr25519' },
|
||||
{ address: '0xd5D81CD4236a43F48A983fc5B895975c511f634D', name: 'Ethereum', type: 'ethereum' },
|
||||
{ address: '5EeaoDj4VDk8V6yQngKBaCD5MpJUCHrhYjVhBjgMHXoYon1s', isExternal: false, name: 'D', type: 'ed25519' },
|
||||
{ address: '5HRKYp5anSNGtqC7cq9ftiaq4y8Mk7uHk7keaXUrQwZqDWLJ', name: 'DD', parentAddress: '5EeaoDj4VDk8V6yQngKBaCD5MpJUCHrhYjVhBjgMHXoYon1s', type: 'ed25519' }
|
||||
] as AccountJson[];
|
||||
|
||||
describe('Derive', () => {
|
||||
const mountComponent = async (locked = false, account = 1): Promise<{
|
||||
wrapper: ReactWrapper;
|
||||
onActionStub: ReturnType<typeof jest.fn>;
|
||||
}> => {
|
||||
const onActionStub = jest.fn();
|
||||
|
||||
const wrapper = mount(
|
||||
<MemoryRouter initialEntries={ [`/account/derive/${accounts[account].address}`] }>
|
||||
<ActionContext.Provider value={onActionStub}>
|
||||
<AccountContext.Provider
|
||||
value={{
|
||||
accounts,
|
||||
hierarchy: buildHierarchy(accounts)
|
||||
}}
|
||||
>
|
||||
<Route path='/account/derive/:address'>
|
||||
<Derive isLocked={locked} />
|
||||
</Route>
|
||||
</AccountContext.Provider>
|
||||
</ActionContext.Provider>
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
await act(flushAllPromises);
|
||||
|
||||
return { onActionStub, wrapper };
|
||||
};
|
||||
|
||||
let wrapper: ReactWrapper;
|
||||
let onActionStub: ReturnType<typeof jest.fn>;
|
||||
|
||||
const type = async (input: ReactWrapper, value: string): Promise<void> => {
|
||||
input.simulate('change', { target: { value } });
|
||||
await act(flushAllPromises);
|
||||
input.update();
|
||||
};
|
||||
|
||||
const enterName = (name: string): Promise<void> => type(wrapper.find('input').first(), name);
|
||||
const password = (password: string) => (): Promise<void> => type(wrapper.find('input[type="password"]').first(), password);
|
||||
const repeat = (password: string) => (): Promise<void> => type(wrapper.find('input[type="password"]').last(), password);
|
||||
|
||||
describe('Parent selection screen', () => {
|
||||
beforeEach(async () => {
|
||||
const mountedComponent = await mountComponent();
|
||||
|
||||
wrapper = mountedComponent.wrapper;
|
||||
onActionStub = mountedComponent.onActionStub;
|
||||
});
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/require-await
|
||||
jest.spyOn(messaging, 'validateAccount').mockImplementation(async (_, pass) => pass === parentPassword);
|
||||
// silencing the following expected console.error
|
||||
console.error = jest.fn();
|
||||
// eslint-disable-next-line @typescript-eslint/require-await
|
||||
jest.spyOn(messaging, 'validateDerivationPath').mockImplementation(async (_, path) => {
|
||||
if (path === '//') {
|
||||
throw new Error('wrong suri');
|
||||
}
|
||||
|
||||
return { address: derivedAddress, suri: defaultDerivation } as ResponseDeriveValidate;
|
||||
});
|
||||
|
||||
it('Button is disabled and password field visible, path field is hidden', () => {
|
||||
const button = wrapper.find('[data-button-action="create derived account"] button');
|
||||
|
||||
expect(button.exists()).toBe(true);
|
||||
expect(button.prop('disabled')).toBe(true);
|
||||
expect(wrapper.find('.pathInput').exists()).toBe(false);
|
||||
});
|
||||
|
||||
it('Password field is visible and not in error state', () => {
|
||||
const passwordField = wrapper.find('[data-input-password]').first();
|
||||
|
||||
expect(passwordField.exists()).toBe(true);
|
||||
expect(passwordField.prop('isError')).toBe(false);
|
||||
});
|
||||
|
||||
it('No error is visible when first loading the page', () => {
|
||||
expect(wrapper.find('Warning')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('An error is visible, input higlighted and the button disabled when password is incorrect', async () => {
|
||||
await type(wrapper.find('input[type="password"]'), 'wrong_pass');
|
||||
wrapper.find('[data-button-action="create derived account"] button').simulate('click');
|
||||
await act(flushAllPromises);
|
||||
wrapper.update();
|
||||
|
||||
const button = wrapper.find('[data-button-action="create derived account"] button');
|
||||
|
||||
expect(button.prop('disabled')).toBe(true);
|
||||
expect(wrapper.find('[data-input-password]').first().prop('isError')).toBe(true);
|
||||
expect(wrapper.find('.warning-message')).toHaveLength(1);
|
||||
expect(wrapper.find('.warning-message').first().text()).toEqual('Wrong password');
|
||||
});
|
||||
|
||||
it('The error disappears when typing a new password and "Create derived account" is enabled', async () => {
|
||||
await type(wrapper.find('input[type="password"]'), 'wrong_pass');
|
||||
wrapper.find('[data-button-action="create derived account"] button').simulate('click');
|
||||
await act(flushAllPromises);
|
||||
wrapper.update();
|
||||
|
||||
await type(wrapper.find('input[type="password"]'), 'new_attempt');
|
||||
|
||||
const button = wrapper.find('[data-button-action="create derived account"] button');
|
||||
|
||||
expect(button.prop('disabled')).toBe(false);
|
||||
expect(wrapper.find('[data-input-password]').first().prop('isError')).toBe(false);
|
||||
expect(wrapper.find('.warning-message')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('Button is enabled when password is set', async () => {
|
||||
await type(wrapper.find('input[type="password"]'), parentPassword);
|
||||
|
||||
const button = wrapper.find('[data-button-action="create derived account"] button');
|
||||
|
||||
expect(button.prop('disabled')).toBe(false);
|
||||
expect(wrapper.find('.warning-message')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('Derivation path gets visible, is set and locked', async () => {
|
||||
await type(wrapper.find('input[type="password"]'), 'wrong_pass');
|
||||
|
||||
expect(wrapper.find('.pathInput.locked input').prop('disabled')).toBe(true);
|
||||
expect(wrapper.find('.pathInput.locked input').prop('value')).toBe('//1');
|
||||
});
|
||||
|
||||
it('Derivation path can be unlocked', async () => {
|
||||
await type(wrapper.find('input[type="password"]'), 'wrong_pass');
|
||||
wrapper.find('FontAwesomeIcon.lockIcon').simulate('click');
|
||||
await act(flushAllPromises);
|
||||
wrapper.update();
|
||||
|
||||
expect(wrapper.find('.pathInput').exists()).toBe(true);
|
||||
expect(wrapper.find('.pathInput input').prop('disabled')).toBe(false);
|
||||
});
|
||||
|
||||
it('Derivation path placeholder contains //hard/soft', async () => {
|
||||
await type(wrapper.find('input[type="password"]'), parentPassword);
|
||||
const pathInput = wrapper.find('[data-input-suri] input');
|
||||
|
||||
expect(pathInput.first().prop('placeholder')).toEqual('//hard/soft');
|
||||
});
|
||||
|
||||
it('An error is visible and the button is disabled when suri is incorrect', async () => {
|
||||
await type(wrapper.find('input[type="password"]'), parentPassword);
|
||||
await type(wrapper.find('[data-input-suri] input'), '//');
|
||||
wrapper.find('[data-button-action="create derived account"] button').simulate('click');
|
||||
await act(flushAllPromises);
|
||||
wrapper.update();
|
||||
|
||||
const button = wrapper.find('[data-button-action="create derived account"] button');
|
||||
|
||||
expect(button.prop('disabled')).toBe(true);
|
||||
expect(wrapper.find('.warning-message')).toHaveLength(1);
|
||||
expect(wrapper.find('.warning-message').first().text()).toEqual('Invalid derivation path');
|
||||
});
|
||||
|
||||
it('An error is visible and the button is disabled when suri contains `///`', async () => {
|
||||
await type(wrapper.find('input[type="password"]'), parentPassword);
|
||||
await type(wrapper.find('[data-input-suri] input'), '///');
|
||||
|
||||
const button = wrapper.find('[data-button-action="create derived account"] button');
|
||||
|
||||
expect(button.prop('disabled')).toBe(true);
|
||||
expect(wrapper.find('.warning-message')).toHaveLength(1);
|
||||
// eslint-disable-next-line quotes
|
||||
expect(wrapper.find('.warning-message').first().text()).toEqual("`///password` not supported for derivation");
|
||||
});
|
||||
|
||||
it('No error is shown when suri contains soft derivation `/` with sr25519', async () => {
|
||||
await type(wrapper.find('input[type="password"]'), parentPassword);
|
||||
await type(wrapper.find('[data-input-suri] input'), '//somehard/soft');
|
||||
|
||||
const button = wrapper.find('[data-button-action="create derived account"] button');
|
||||
|
||||
expect(button.prop('disabled')).toBe(false);
|
||||
expect(wrapper.find('.warning-message')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('The error disappears and "Create derived account" is enabled when typing a new suri', async () => {
|
||||
await type(wrapper.find('input[type="password"]'), parentPassword);
|
||||
await type(wrapper.find('[data-input-suri] input'), '//');
|
||||
wrapper.find('[data-button-action="create derived account"] button').simulate('click');
|
||||
await act(flushAllPromises);
|
||||
wrapper.update();
|
||||
await type(wrapper.find('[data-input-suri] input'), 'new');
|
||||
|
||||
const button = wrapper.find('[data-button-action="create derived account"] button');
|
||||
|
||||
expect(button.prop('disabled')).toBe(false);
|
||||
expect(wrapper.find('Warning')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('takes selected address from URL as parent account', () => {
|
||||
expect(wrapper.find('[data-field="name"]').first().text()).toBe('B');
|
||||
});
|
||||
|
||||
it('selects internal root accounts as other options, no external and no Ethereum account', () => {
|
||||
const options = wrapper.find('[data-parent-option] [data-field="name"]').map((el) => el.text());
|
||||
|
||||
expect(options).toEqual(['A', 'B', 'D', 'Ethereum']);
|
||||
});
|
||||
|
||||
it('redirects to derive from next account when other option is selected', () => {
|
||||
wrapper.find('[data-parent-option]').first().simulate('click');
|
||||
|
||||
expect(onActionStub).toHaveBeenCalledWith(`/account/derive/${accounts[0].address}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Locked parent selection', () => {
|
||||
beforeAll(async () => {
|
||||
const mountedComponent = (await mountComponent(true));
|
||||
|
||||
wrapper = mountedComponent.wrapper;
|
||||
onActionStub = mountedComponent.onActionStub;
|
||||
});
|
||||
|
||||
it('address dropdown does not exist', () => {
|
||||
expect(wrapper.exists(AddressDropdown)).toBe(false);
|
||||
});
|
||||
|
||||
it('parent is taken from URL', () => {
|
||||
expect(wrapper.find('[data-field="name"]').first().text()).toBe('B');
|
||||
});
|
||||
|
||||
describe('Second phase', () => {
|
||||
it('correctly creates the derived account', async () => {
|
||||
const newAccount = {
|
||||
name: 'newName',
|
||||
password: 'somePassword'
|
||||
};
|
||||
const deriveMock = jest.spyOn(messaging, 'deriveAccount');
|
||||
|
||||
await type(wrapper.find('input[type="password"]'), parentPassword);
|
||||
wrapper.find('[data-button-action="create derived account"] button').simulate('click');
|
||||
await act(flushAllPromises);
|
||||
wrapper.update();
|
||||
await enterName(newAccount.name).then(password(newAccount.password)).then(repeat(newAccount.password));
|
||||
wrapper.find('[data-button-action="add new root"] button').simulate('click');
|
||||
await act(flushAllPromises);
|
||||
wrapper.update();
|
||||
|
||||
expect(deriveMock).toHaveBeenCalledWith(accounts[1].address, defaultDerivation, parentPassword, newAccount.name, newAccount.password, westendGenesis);
|
||||
expect(onActionStub).toHaveBeenCalledWith('/');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Ed25519 Parent', () => {
|
||||
beforeEach(async () => {
|
||||
const mountedComponent = await mountComponent(false, 5);
|
||||
|
||||
wrapper = mountedComponent.wrapper;
|
||||
onActionStub = mountedComponent.onActionStub;
|
||||
await type(wrapper.find('input[type="password"]'), parentPassword);
|
||||
});
|
||||
|
||||
it('Derivation path placeholder only contains //hard', () => {
|
||||
const pathInput = wrapper.find('[data-input-suri] input');
|
||||
|
||||
expect(pathInput.first().prop('placeholder')).toEqual('//hard');
|
||||
});
|
||||
|
||||
it('An error is shown when suri contains soft derivation `/` with ed25519', async () => {
|
||||
const pathInput = wrapper.find('[data-input-suri] input');
|
||||
|
||||
await type(pathInput, '//somehard/soft');
|
||||
|
||||
const button = wrapper.find('[data-button-action="create derived account"] button');
|
||||
|
||||
expect(button.prop('disabled')).toBe(true);
|
||||
expect(wrapper.find('[data-input-suri]').first().prop('isError')).toBe(true);
|
||||
expect(wrapper.find('.warning-message')).toHaveLength(1);
|
||||
expect(wrapper.find('.warning-message').first().text()).toEqual('Soft derivation is only allowed for sr25519 accounts');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,195 @@
|
||||
// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import React, { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { canDerive } from '@pezkuwi/extension-base/utils';
|
||||
|
||||
import { AccountContext, ActionContext, Address, ButtonArea, InputWithLabel, Label, NextStepButton, VerticalSpace, Warning } from '../../components/index.js';
|
||||
import { useTranslation } from '../../hooks/index.js';
|
||||
import { validateAccount, validateDerivationPath } from '../../messaging.js';
|
||||
import { nextDerivationPath } from '../../util/nextDerivationPath.js';
|
||||
import AddressDropdown from './AddressDropdown.js';
|
||||
import DerivationPath from './DerivationPath.js';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
isLocked?: boolean;
|
||||
parentAddress: string;
|
||||
parentGenesis: string | null;
|
||||
onDerivationConfirmed: (derivation: { account: { address: string; suri: string }; parentPassword: string }) => void;
|
||||
}
|
||||
|
||||
// match any single slash
|
||||
const singleSlashRegex = /([^/]|^)\/([^/]|$)/;
|
||||
|
||||
export default function SelectParent ({ className, isLocked, onDerivationConfirmed, parentAddress, parentGenesis }: Props): React.ReactElement<Props> {
|
||||
const { t } = useTranslation();
|
||||
const onAction = useContext(ActionContext);
|
||||
const [isBusy, setIsBusy] = useState(false);
|
||||
const { accounts, hierarchy } = useContext(AccountContext);
|
||||
const defaultPath = useMemo(() => nextDerivationPath(accounts, parentAddress), [accounts, parentAddress]);
|
||||
const [suriPath, setSuriPath] = useState<null | string>(defaultPath);
|
||||
const [parentPassword, setParentPassword] = useState<string>('');
|
||||
const [isProperParentPassword, setIsProperParentPassword] = useState(false);
|
||||
const [pathError, setPathError] = useState('');
|
||||
const passwordInputRef = useRef<HTMLDivElement>(null);
|
||||
const allowSoftDerivation = useMemo(() => {
|
||||
const parent = accounts.find(({ address }) => address === parentAddress);
|
||||
|
||||
return parent?.type === 'sr25519';
|
||||
}, [accounts, parentAddress]);
|
||||
|
||||
// reset the password field if the parent address changes
|
||||
useEffect(() => {
|
||||
setParentPassword('');
|
||||
}, [parentAddress]);
|
||||
|
||||
useEffect(() => {
|
||||
// forbid the use of password since Keyring ignores it
|
||||
if (suriPath?.includes('///')) {
|
||||
setPathError(t('`///password` not supported for derivation'));
|
||||
}
|
||||
|
||||
if (!allowSoftDerivation && suriPath && singleSlashRegex.test(suriPath)) {
|
||||
setPathError(t('Soft derivation is only allowed for sr25519 accounts'));
|
||||
}
|
||||
}, [allowSoftDerivation, suriPath, t]);
|
||||
|
||||
const allAddresses = useMemo(
|
||||
() => hierarchy
|
||||
.filter(({ isExternal }) => !isExternal)
|
||||
.filter(({ type }) => canDerive(type))
|
||||
.map(({ address, genesisHash }): [string, string | null] => [address, genesisHash || null]),
|
||||
[hierarchy]
|
||||
);
|
||||
|
||||
const _onParentPasswordEnter = useCallback(
|
||||
(parentPassword: string): void => {
|
||||
setParentPassword(parentPassword);
|
||||
setIsProperParentPassword(!!parentPassword);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const _onSuriPathChange = useCallback(
|
||||
(path: string): void => {
|
||||
setSuriPath(path);
|
||||
setPathError('');
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const _onParentChange = useCallback(
|
||||
(address: string) => onAction(`/account/derive/${address}`),
|
||||
[onAction]
|
||||
);
|
||||
|
||||
const _onSubmit = useCallback(
|
||||
async (): Promise<void> => {
|
||||
if (suriPath && parentAddress && parentPassword) {
|
||||
setIsBusy(true);
|
||||
|
||||
const isUnlockable = await validateAccount(parentAddress, parentPassword);
|
||||
|
||||
if (isUnlockable) {
|
||||
try {
|
||||
const account = await validateDerivationPath(parentAddress, suriPath, parentPassword);
|
||||
|
||||
onDerivationConfirmed({ account, parentPassword });
|
||||
} catch (error) {
|
||||
setIsBusy(false);
|
||||
setPathError(t('Invalid derivation path'));
|
||||
console.error(error);
|
||||
}
|
||||
} else {
|
||||
setIsBusy(false);
|
||||
setIsProperParentPassword(false);
|
||||
}
|
||||
}
|
||||
},
|
||||
[parentAddress, parentPassword, onDerivationConfirmed, suriPath, t]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setParentPassword('');
|
||||
setIsProperParentPassword(false);
|
||||
|
||||
passwordInputRef.current?.querySelector('input')?.focus();
|
||||
}, [_onParentPasswordEnter]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={className}>
|
||||
{isLocked
|
||||
? (
|
||||
<Address
|
||||
address={parentAddress}
|
||||
genesisHash={parentGenesis}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<Label label={t('Choose Parent Account:')}>
|
||||
<AddressDropdown
|
||||
allAddresses={allAddresses}
|
||||
onSelect={_onParentChange}
|
||||
selectedAddress={parentAddress}
|
||||
selectedGenesis={parentGenesis}
|
||||
/>
|
||||
</Label>
|
||||
)
|
||||
}
|
||||
<div ref={passwordInputRef}>
|
||||
<InputWithLabel
|
||||
data-input-password
|
||||
isError={!!parentPassword && !isProperParentPassword}
|
||||
isFocused
|
||||
label={t('enter the password for the account you want to derive from')}
|
||||
onChange={_onParentPasswordEnter}
|
||||
type='password'
|
||||
value={parentPassword}
|
||||
/>
|
||||
{!!parentPassword && !isProperParentPassword && (
|
||||
<Warning
|
||||
isBelowInput
|
||||
isDanger
|
||||
>
|
||||
{t('Wrong password')}
|
||||
</Warning>
|
||||
)}
|
||||
</div>
|
||||
{isProperParentPassword && (
|
||||
<>
|
||||
<DerivationPath
|
||||
defaultPath={defaultPath}
|
||||
isError={!!pathError}
|
||||
onChange={_onSuriPathChange}
|
||||
parentAddress={parentAddress}
|
||||
parentPassword={parentPassword}
|
||||
withSoftPath={allowSoftDerivation}
|
||||
/>
|
||||
{(!!pathError) && (
|
||||
<Warning
|
||||
isBelowInput
|
||||
isDanger
|
||||
>
|
||||
{pathError}
|
||||
</Warning>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<VerticalSpace />
|
||||
<ButtonArea>
|
||||
<NextStepButton
|
||||
data-button-action='create derived account'
|
||||
isBusy={isBusy}
|
||||
isDisabled={!isProperParentPassword || !!pathError}
|
||||
onClick={_onSubmit}
|
||||
>
|
||||
{t('Create a derived account')}
|
||||
</NextStepButton>
|
||||
</ButtonArea>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import React, { useCallback, useContext, useMemo, useState } from 'react';
|
||||
import { useParams } from 'react-router';
|
||||
|
||||
import { AccountContext, AccountNamePasswordCreation, ActionContext, Address } from '../../components/index.js';
|
||||
import { useTranslation } from '../../hooks/index.js';
|
||||
import { deriveAccount } from '../../messaging.js';
|
||||
import { HeaderWithSteps } from '../../partials/index.js';
|
||||
import SelectParent from './SelectParent.js';
|
||||
|
||||
interface Props {
|
||||
isLocked?: boolean;
|
||||
}
|
||||
|
||||
interface AddressState {
|
||||
address: string;
|
||||
}
|
||||
|
||||
interface PathState extends AddressState {
|
||||
suri: string;
|
||||
}
|
||||
|
||||
interface ConfirmState {
|
||||
account: PathState;
|
||||
parentPassword: string;
|
||||
}
|
||||
|
||||
function Derive ({ isLocked }: Props): React.ReactElement<Props> {
|
||||
const { t } = useTranslation();
|
||||
const onAction = useContext(ActionContext);
|
||||
const { accounts } = useContext(AccountContext);
|
||||
const { address: parentAddress } = useParams<AddressState>();
|
||||
const [isBusy, setIsBusy] = useState(false);
|
||||
const [account, setAccount] = useState<null | PathState>(null);
|
||||
const [name, setName] = useState<string | null>(null);
|
||||
const [parentPassword, setParentPassword] = useState<string | null>(null);
|
||||
|
||||
const parentGenesis = useMemo(
|
||||
() => accounts.find((a) => a.address === parentAddress)?.genesisHash || null,
|
||||
[accounts, parentAddress]
|
||||
);
|
||||
|
||||
const _onCreate = useCallback((name: string, password: string) => {
|
||||
if (!account || !name || !password || !parentPassword) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsBusy(true);
|
||||
deriveAccount(parentAddress, account.suri, parentPassword, name, password, parentGenesis)
|
||||
.then(() => onAction('/'))
|
||||
.catch((error): void => {
|
||||
setIsBusy(false);
|
||||
console.error(error);
|
||||
});
|
||||
}, [account, onAction, parentAddress, parentGenesis, parentPassword]);
|
||||
|
||||
const _onDerivationConfirmed = useCallback(({ account, parentPassword }: ConfirmState) => {
|
||||
setAccount(account);
|
||||
setParentPassword(parentPassword);
|
||||
}, []);
|
||||
|
||||
const _onBackClick = useCallback(() => {
|
||||
setAccount(null);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<HeaderWithSteps
|
||||
step={account ? 2 : 1}
|
||||
text={t('Add new account')}
|
||||
/>
|
||||
{!account && (
|
||||
<SelectParent
|
||||
isLocked={isLocked}
|
||||
onDerivationConfirmed={_onDerivationConfirmed}
|
||||
parentAddress={parentAddress}
|
||||
parentGenesis={parentGenesis}
|
||||
/>
|
||||
)}
|
||||
{account && (
|
||||
<>
|
||||
<div>
|
||||
<Address
|
||||
address={account.address}
|
||||
genesisHash={parentGenesis}
|
||||
name={name}
|
||||
/>
|
||||
</div>
|
||||
<AccountNamePasswordCreation
|
||||
buttonLabel={t('Create derived account')}
|
||||
isBusy={isBusy}
|
||||
onBackClick={_onBackClick}
|
||||
onCreate={_onCreate}
|
||||
onNameChange={setName}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(Derive);
|
||||
@@ -0,0 +1,107 @@
|
||||
// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import '@pezkuwi/extension-mocks/chrome';
|
||||
|
||||
import type { ReactWrapper } from 'enzyme';
|
||||
import type * as _ from '@pezkuwi/dev-test/globals.d.ts';
|
||||
import type { KeyringPair$Json } from '@pezkuwi/keyring/types';
|
||||
|
||||
import Adapter from '@wojtekmaj/enzyme-adapter-react-17';
|
||||
import enzyme from 'enzyme';
|
||||
import React from 'react';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { MemoryRouter, Route } from 'react-router';
|
||||
|
||||
import { Button } from '../components/index.js';
|
||||
import * as messaging from '../messaging.js';
|
||||
import { flushAllPromises } from '../testHelpers.js';
|
||||
import Export from './Export.js';
|
||||
|
||||
const { configure, mount } = enzyme;
|
||||
|
||||
// // NOTE Required for spyOn when using @swc/jest
|
||||
// // https://github.com/swc-project/swc/issues/3843
|
||||
// jest.mock('../messaging', (): Record<string, unknown> => ({
|
||||
// __esModule: true,
|
||||
// ...jest.requireActual('../messaging')
|
||||
// }));
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment,@typescript-eslint/no-unsafe-call
|
||||
configure({ adapter: new Adapter() });
|
||||
|
||||
describe('Export component', () => {
|
||||
let wrapper: ReactWrapper;
|
||||
const VALID_ADDRESS = 'HjoBp62cvsWDA3vtNMWxz6c9q13ReEHi9UGHK7JbZweH5g5';
|
||||
|
||||
const enterPassword = (password = 'any password'): void => {
|
||||
wrapper.find('[data-export-password] input').simulate('change', { target: { value: password } });
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.spyOn(messaging, 'exportAccount').mockImplementation(() => Promise.resolve({ exportedJson: { meta: { name: 'account_name' } } as unknown as KeyringPair$Json }));
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
wrapper = mount(
|
||||
<MemoryRouter initialEntries={ [`/account/export/${VALID_ADDRESS}`] }>
|
||||
<Route path='/account/export/:address'><Export /></Route>
|
||||
</MemoryRouter>
|
||||
);
|
||||
});
|
||||
|
||||
it('creates export message on button press', async () => {
|
||||
enterPassword('passw0rd');
|
||||
wrapper.find('[data-export-button] button').simulate('click');
|
||||
await act(flushAllPromises);
|
||||
|
||||
expect(messaging.exportAccount).toHaveBeenCalledWith(VALID_ADDRESS, 'passw0rd');
|
||||
});
|
||||
|
||||
it('button is disabled before any password is typed', () => {
|
||||
expect(wrapper.find(Button).prop('isDisabled')).toBe(true);
|
||||
});
|
||||
|
||||
it('shows an error if the password is wrong', async () => {
|
||||
// silencing the following expected console.error
|
||||
console.error = jest.fn();
|
||||
// eslint-disable-next-line @typescript-eslint/require-await
|
||||
jest.spyOn(messaging, 'exportAccount').mockImplementation(async () => {
|
||||
throw new Error('Unable to decode using the supplied passphrase');
|
||||
});
|
||||
enterPassword();
|
||||
wrapper.find('[data-export-button] button').simulate('click');
|
||||
await act(flushAllPromises);
|
||||
wrapper.update();
|
||||
|
||||
// the first message is "You are exporting your account. Keep it safe and don't share it with anyone."
|
||||
expect(wrapper.find('.warning-message').at(1).text()).toBe('Unable to decode using the supplied passphrase');
|
||||
expect(wrapper.find(Button).prop('isDisabled')).toBe(true);
|
||||
expect(wrapper.find('InputWithLabel').first().prop('isError')).toBe(true);
|
||||
});
|
||||
|
||||
it('shows no error when typing again after a wrong password', async () => {
|
||||
// silencing the following expected console.error
|
||||
console.error = jest.fn();
|
||||
// eslint-disable-next-line @typescript-eslint/require-await
|
||||
jest.spyOn(messaging, 'exportAccount').mockImplementation(async () => {
|
||||
throw new Error('Unable to decode using the supplied passphrase');
|
||||
});
|
||||
enterPassword();
|
||||
wrapper.find('[data-export-button] button').simulate('click');
|
||||
await act(flushAllPromises);
|
||||
wrapper.update();
|
||||
enterPassword();
|
||||
|
||||
// the first message is "You are exporting your account. Keep it safe and don't share it with anyone."
|
||||
expect(wrapper.find('.warning-message')).toHaveLength(1);
|
||||
expect(wrapper.find(Button).prop('isDisabled')).toBe(false);
|
||||
expect(wrapper.find('InputWithLabel').first().prop('isError')).toBe(false);
|
||||
});
|
||||
|
||||
it('button is enabled after password is typed', async () => {
|
||||
enterPassword();
|
||||
await act(flushAllPromises);
|
||||
|
||||
expect(wrapper.find(Button).prop('isDisabled')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,135 @@
|
||||
// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { RouteComponentProps } from 'react-router';
|
||||
|
||||
import fileSaver from 'file-saver';
|
||||
import React, { useCallback, useContext, useState } from 'react';
|
||||
import { withRouter } from 'react-router';
|
||||
|
||||
import { ActionBar, ActionContext, ActionText, Address, Button, InputWithLabel, Warning } from '../components/index.js';
|
||||
import { useTranslation } from '../hooks/index.js';
|
||||
import { exportAccount } from '../messaging.js';
|
||||
import { Header } from '../partials/index.js';
|
||||
import { styled } from '../styled.js';
|
||||
|
||||
const MIN_LENGTH = 6;
|
||||
|
||||
interface Props extends RouteComponentProps<{address: string}> {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function Export ({ className, match: { params: { address } } }: Props): React.ReactElement<Props> {
|
||||
const { t } = useTranslation();
|
||||
const onAction = useContext(ActionContext);
|
||||
const [isBusy, setIsBusy] = useState(false);
|
||||
const [pass, setPass] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const _goHome = useCallback(
|
||||
() => onAction('/'),
|
||||
[onAction]
|
||||
);
|
||||
|
||||
const onPassChange = useCallback(
|
||||
(password: string) => {
|
||||
setPass(password);
|
||||
setError('');
|
||||
}
|
||||
, []);
|
||||
|
||||
const _onExportButtonClick = useCallback(
|
||||
(): void => {
|
||||
setIsBusy(true);
|
||||
|
||||
exportAccount(address, pass)
|
||||
.then(({ exportedJson }) => {
|
||||
const blob = new Blob([JSON.stringify(exportedJson)], { type: 'application/json; charset=utf-8' });
|
||||
|
||||
// eslint-disable-next-line deprecation/deprecation
|
||||
fileSaver.saveAs(blob, `${address}.json`);
|
||||
|
||||
onAction('/');
|
||||
})
|
||||
.catch((error: Error) => {
|
||||
console.error(error);
|
||||
setError(error.message);
|
||||
setIsBusy(false);
|
||||
});
|
||||
},
|
||||
[address, onAction, pass]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header
|
||||
showBackArrow
|
||||
text={t('Export account')}
|
||||
/>
|
||||
<div className={className}>
|
||||
<Address address={address}>
|
||||
<Warning className='movedWarning'>
|
||||
{t("You are exporting your account. Keep it safe and don't share it with anyone.")}
|
||||
</Warning>
|
||||
<div className='actionArea'>
|
||||
<InputWithLabel
|
||||
data-export-password
|
||||
disabled={isBusy}
|
||||
isError={pass.length < MIN_LENGTH || !!error}
|
||||
label={t('password for this account')}
|
||||
onChange={onPassChange}
|
||||
type='password'
|
||||
/>
|
||||
{error && (
|
||||
<Warning
|
||||
isBelowInput
|
||||
isDanger
|
||||
>
|
||||
{error}
|
||||
</Warning>
|
||||
)}
|
||||
<Button
|
||||
className='export-button'
|
||||
data-export-button
|
||||
isBusy={isBusy}
|
||||
isDanger
|
||||
isDisabled={pass.length === 0 || !!error}
|
||||
onClick={_onExportButtonClick}
|
||||
>
|
||||
{t('I want to export this account')}
|
||||
</Button>
|
||||
<ActionBar className='withMarginTop'>
|
||||
<ActionText
|
||||
className='center'
|
||||
onClick={_goHome}
|
||||
text={t('Cancel')}
|
||||
/>
|
||||
</ActionBar>
|
||||
</div>
|
||||
</Address>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default withRouter(styled(Export)`
|
||||
.actionArea {
|
||||
padding: 10px 24px;
|
||||
}
|
||||
|
||||
.center {
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
.export-button {
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.movedWarning {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.withMarginTop {
|
||||
margin-top: 4px;
|
||||
}
|
||||
`);
|
||||
@@ -0,0 +1,131 @@
|
||||
// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { RouteComponentProps } from 'react-router';
|
||||
|
||||
import fileSaver from 'file-saver';
|
||||
import React, { useCallback, useContext, useState } from 'react';
|
||||
import { withRouter } from 'react-router';
|
||||
|
||||
import { AccountContext, ActionBar, ActionContext, ActionText, Button, InputWithLabel, Warning } from '../components/index.js';
|
||||
import { useTranslation } from '../hooks/index.js';
|
||||
import { exportAccounts } from '../messaging.js';
|
||||
import { Header } from '../partials/index.js';
|
||||
import { styled } from '../styled.js';
|
||||
|
||||
const MIN_LENGTH = 6;
|
||||
|
||||
interface Props extends RouteComponentProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function ExportAll ({ className }: Props): React.ReactElement<Props> {
|
||||
const { t } = useTranslation();
|
||||
const { accounts } = useContext(AccountContext);
|
||||
const onAction = useContext(ActionContext);
|
||||
const [isBusy, setIsBusy] = useState(false);
|
||||
const [pass, setPass] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const _goHome = useCallback(
|
||||
() => onAction('/'),
|
||||
[onAction]
|
||||
);
|
||||
|
||||
const onPassChange = useCallback(
|
||||
(password: string) => {
|
||||
setPass(password);
|
||||
setError('');
|
||||
}
|
||||
, []);
|
||||
|
||||
const _onExportAllButtonClick = useCallback(
|
||||
(): void => {
|
||||
setIsBusy(true);
|
||||
|
||||
exportAccounts(accounts.map((account) => account.address), pass)
|
||||
.then(({ exportedJson }) => {
|
||||
const blob = new Blob([JSON.stringify(exportedJson)], { type: 'application/json; charset=utf-8' });
|
||||
|
||||
// eslint-disable-next-line deprecation/deprecation
|
||||
fileSaver.saveAs(blob, `batch_exported_account_${Date.now()}.json`);
|
||||
|
||||
onAction('/');
|
||||
})
|
||||
.catch((error: Error) => {
|
||||
console.error(error);
|
||||
setError(error.message);
|
||||
setIsBusy(false);
|
||||
});
|
||||
},
|
||||
[accounts, onAction, pass]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header
|
||||
showBackArrow
|
||||
text={t('All account')}
|
||||
/>
|
||||
<div className={className}>
|
||||
<div className='actionArea'>
|
||||
<InputWithLabel
|
||||
data-export-all-password
|
||||
disabled={isBusy}
|
||||
isError={pass.length < MIN_LENGTH || !!error}
|
||||
label={t('password for encrypting all accounts')}
|
||||
onChange={onPassChange}
|
||||
type='password'
|
||||
/>
|
||||
{error && (
|
||||
<Warning
|
||||
isBelowInput
|
||||
isDanger
|
||||
>
|
||||
{error}
|
||||
</Warning>
|
||||
)}
|
||||
<Button
|
||||
className='export-button'
|
||||
data-export-button
|
||||
isBusy={isBusy}
|
||||
isDanger
|
||||
isDisabled={pass.length === 0 || !!error}
|
||||
onClick={_onExportAllButtonClick}
|
||||
>
|
||||
{t('I want to export all my accounts')}
|
||||
</Button>
|
||||
<ActionBar className='withMarginTop'>
|
||||
<ActionText
|
||||
className='center'
|
||||
onClick={_goHome}
|
||||
text={t('Cancel')}
|
||||
/>
|
||||
</ActionBar>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default withRouter(styled(ExportAll)`
|
||||
.actionArea {
|
||||
padding: 10px 24px;
|
||||
}
|
||||
|
||||
.center {
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
.export-button {
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.movedWarning {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.withMarginTop {
|
||||
margin-top: 4px;
|
||||
}
|
||||
`);
|
||||
@@ -0,0 +1,94 @@
|
||||
// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { RouteComponentProps } from 'react-router';
|
||||
|
||||
import React, { useCallback, useContext, useState } from 'react';
|
||||
import { withRouter } from 'react-router';
|
||||
|
||||
import { ActionBar, ActionContext, ActionText, Address, Button, Warning } from '../components/index.js';
|
||||
import { useTranslation } from '../hooks/index.js';
|
||||
import { forgetAccount } from '../messaging.js';
|
||||
import { Header } from '../partials/index.js';
|
||||
import { styled } from '../styled.js';
|
||||
|
||||
interface Props extends RouteComponentProps<{ address: string }> {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function Forget ({ className, match: { params: { address } } }: Props): React.ReactElement<Props> {
|
||||
const { t } = useTranslation();
|
||||
const onAction = useContext(ActionContext);
|
||||
const [isBusy, setIsBusy] = useState(false);
|
||||
|
||||
const _goHome = useCallback(
|
||||
() => onAction('/'),
|
||||
[onAction]
|
||||
);
|
||||
|
||||
const _onClick = useCallback(
|
||||
(): void => {
|
||||
setIsBusy(true);
|
||||
forgetAccount(address)
|
||||
.then(() => {
|
||||
setIsBusy(false);
|
||||
onAction('/');
|
||||
})
|
||||
.catch((error: Error) => {
|
||||
setIsBusy(false);
|
||||
console.error(error);
|
||||
});
|
||||
},
|
||||
[address, onAction]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header
|
||||
showBackArrow
|
||||
text={t('Forget account')}
|
||||
/>
|
||||
<div className={className}>
|
||||
<Address address={address}>
|
||||
<Warning className='movedWarning'>
|
||||
{t('You are about to remove the account. This means that you will not be able to access it via this extension anymore. If you wish to recover it, you would need to use the seed.')}
|
||||
</Warning>
|
||||
<div className='actionArea'>
|
||||
<Button
|
||||
isBusy={isBusy}
|
||||
isDanger
|
||||
onClick={_onClick}
|
||||
>
|
||||
{t('I want to forget this account')}
|
||||
</Button>
|
||||
<ActionBar className='withMarginTop'>
|
||||
<ActionText
|
||||
className='center'
|
||||
onClick={_goHome}
|
||||
text={t('Cancel')}
|
||||
/>
|
||||
</ActionBar>
|
||||
</div>
|
||||
</Address>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default withRouter(styled(Forget)`
|
||||
.actionArea {
|
||||
padding: 10px 24px;
|
||||
}
|
||||
|
||||
.center {
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
.movedWarning {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.withMarginTop {
|
||||
margin-top: 4px;
|
||||
}
|
||||
`);
|
||||
@@ -0,0 +1,201 @@
|
||||
// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { HexString } from '@pezkuwi/util/types';
|
||||
|
||||
import { faSync } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import React, { useCallback, useContext, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { settings } from '@pezkuwi/ui-settings';
|
||||
|
||||
import { ActionContext, Address, Button, ButtonArea, Dropdown, Switch, VerticalSpace, Warning } from '../components/index.js';
|
||||
import { useLedger, useTranslation } from '../hooks/index.js';
|
||||
import { createAccountHardware } from '../messaging.js';
|
||||
import { Header, Name } from '../partials/index.js';
|
||||
import { styled } from '../styled.js';
|
||||
import ledgerChains from '../util/legerChains.js';
|
||||
|
||||
interface AccOption {
|
||||
text: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
interface NetworkOption {
|
||||
text: string;
|
||||
value: string | null;
|
||||
}
|
||||
|
||||
const AVAIL: number[] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19];
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function ImportLedger ({ className }: Props): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const [accountIndex, setAccountIndex] = useState<number>(0);
|
||||
const [addressOffset, setAddressOffset] = useState<number>(0);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isBusy, setIsBusy] = useState(false);
|
||||
const [genesis, setGenesis] = useState<HexString | null>(null);
|
||||
const [isEthereum, setIsEthereum] = useState(false);
|
||||
const onAction = useContext(ActionContext);
|
||||
const [name, setName] = useState<string | null>(null);
|
||||
const { address, error: ledgerError, isLoading: ledgerLoading, isLocked: ledgerLocked, refresh, type, warning: ledgerWarning } = useLedger(genesis, accountIndex, addressOffset, isEthereum);
|
||||
|
||||
useEffect(() => {
|
||||
if (address) {
|
||||
settings.set({ ledgerConn: 'webusb' });
|
||||
}
|
||||
}, [address]);
|
||||
|
||||
const accOps = useRef(AVAIL.map((value): AccOption => ({
|
||||
text: t('Account type {{index}}', { replace: { index: value } }),
|
||||
value
|
||||
})));
|
||||
|
||||
const addOps = useRef(AVAIL.map((value): AccOption => ({
|
||||
text: t('Address index {{index}}', { replace: { index: value } }),
|
||||
value
|
||||
})));
|
||||
|
||||
const networkOps = useRef(
|
||||
[{
|
||||
text: t('Select network'),
|
||||
value: ''
|
||||
},
|
||||
...ledgerChains.map(({ displayName, genesisHash }): NetworkOption => ({
|
||||
text: displayName,
|
||||
value: genesisHash[0]
|
||||
}))]
|
||||
);
|
||||
|
||||
const _onSave = useCallback(
|
||||
() => {
|
||||
if (address && genesis && name && type) {
|
||||
setIsBusy(true);
|
||||
|
||||
createAccountHardware(address, 'ledger', accountIndex, addressOffset, name, genesis, type)
|
||||
.then(() => onAction('/'))
|
||||
.catch((error: Error) => {
|
||||
console.error(error);
|
||||
|
||||
setIsBusy(false);
|
||||
setError(error.message);
|
||||
});
|
||||
}
|
||||
},
|
||||
[accountIndex, address, addressOffset, genesis, name, onAction, type]
|
||||
);
|
||||
|
||||
// select element is returning a string
|
||||
const _onSetAccountIndex = useCallback((value: number) => setAccountIndex(Number(value)), []);
|
||||
const _onSetAddressOffset = useCallback((value: number) => setAddressOffset(Number(value)), []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header
|
||||
showBackArrow
|
||||
text={t('Import Ledger Account')}
|
||||
/>
|
||||
<div className={className}>
|
||||
<Address
|
||||
address={address}
|
||||
genesisHash={genesis}
|
||||
isExternal
|
||||
isHardware
|
||||
name={name}
|
||||
type={type ?? undefined}
|
||||
/>
|
||||
<div className='ethereum-toggle'>
|
||||
<Switch
|
||||
checked={isEthereum}
|
||||
checkedLabel={t('Ethereum Account')}
|
||||
onChange={setIsEthereum}
|
||||
uncheckedLabel={t('ED25519 Account')}
|
||||
/>
|
||||
</div>
|
||||
<Dropdown
|
||||
className='network'
|
||||
label={t('Network')}
|
||||
onChange={setGenesis}
|
||||
options={networkOps.current}
|
||||
value={genesis}
|
||||
/>
|
||||
{!!genesis && !!address && !ledgerError && (
|
||||
<Name
|
||||
onChange={setName}
|
||||
value={name || ''}
|
||||
/>
|
||||
)}
|
||||
{!!name && (
|
||||
<>
|
||||
<Dropdown
|
||||
className='accountType'
|
||||
isDisabled={ledgerLoading}
|
||||
label={t('account type')}
|
||||
onChange={_onSetAccountIndex}
|
||||
options={accOps.current}
|
||||
value={accountIndex}
|
||||
/>
|
||||
<Dropdown
|
||||
className='accountIndex'
|
||||
isDisabled={ledgerLoading}
|
||||
label={t('address index')}
|
||||
onChange={_onSetAddressOffset}
|
||||
options={addOps.current}
|
||||
value={addressOffset}
|
||||
/>
|
||||
|
||||
</>
|
||||
)}
|
||||
{!!ledgerWarning && (
|
||||
<Warning>
|
||||
{ledgerWarning}
|
||||
</Warning>
|
||||
)}
|
||||
{(!!error || !!ledgerError) && (
|
||||
<Warning
|
||||
isDanger
|
||||
>
|
||||
{error || ledgerError}
|
||||
</Warning>
|
||||
)}
|
||||
</div>
|
||||
<VerticalSpace />
|
||||
<ButtonArea>
|
||||
{ledgerLocked
|
||||
? (
|
||||
<Button
|
||||
isBusy={ledgerLoading || isBusy}
|
||||
onClick={refresh}
|
||||
>
|
||||
<FontAwesomeIcon icon={faSync} />
|
||||
{t('Refresh')}
|
||||
</Button>
|
||||
)
|
||||
: (
|
||||
<Button
|
||||
isBusy={ledgerLoading || isBusy}
|
||||
isDisabled={!!error || !!ledgerError || !address || !genesis}
|
||||
onClick={_onSave}
|
||||
>
|
||||
{t('Import Account')}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
</ButtonArea>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default styled(ImportLedger)<Props>`
|
||||
.refreshIcon {
|
||||
margin-right: 0.3rem;
|
||||
}
|
||||
|
||||
.ethereum-toggle {
|
||||
margin: 1rem 0;
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,140 @@
|
||||
// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import '@pezkuwi/extension-mocks/chrome';
|
||||
|
||||
import type { ReactWrapper } from 'enzyme';
|
||||
import type * as _ from '@pezkuwi/dev-test/globals.d.ts';
|
||||
|
||||
import Adapter from '@wojtekmaj/enzyme-adapter-react-17';
|
||||
import enzyme from 'enzyme';
|
||||
import React from 'react';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { MemoryRouter } from 'react-router';
|
||||
|
||||
import { Button } from '../components/index.js';
|
||||
import * as messaging from '../messaging.js';
|
||||
import { flushAllPromises } from '../testHelpers.js';
|
||||
import ImportQr from './ImportQr.js';
|
||||
|
||||
const { configure, mount } = enzyme;
|
||||
|
||||
const mockedAccount = {
|
||||
content: '12bxf6QJS5hMJgwbJMDjFot1sq93EvgQwyuPWENr9SzJfxtN',
|
||||
expectedBannerChain: 'Polkadot',
|
||||
genesisHash: '0x91b171bb158e2d3848fa23a9f1c25182fb8e20313b2c1eb49219da7a70ce90c3',
|
||||
isAddress: true,
|
||||
name: 'My Polkadot Account'
|
||||
};
|
||||
|
||||
interface ScanType {
|
||||
isAddress: boolean;
|
||||
content: string;
|
||||
genesisHash: string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
interface QrScanAddressProps {
|
||||
className?: string;
|
||||
onError?: (error: Error) => void;
|
||||
onScan: (scanned: ScanType) => void;
|
||||
size?: string | number;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
// // NOTE Required for spyOn when using @swc/jest
|
||||
// // https://github.com/swc-project/swc/issues/3843
|
||||
// jest.mock('../messaging', (): Record<string, unknown> => ({
|
||||
// __esModule: true,
|
||||
// ...jest.requireActual('../messaging')
|
||||
// }));
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment,@typescript-eslint/no-unsafe-call
|
||||
configure({ adapter: new Adapter() });
|
||||
|
||||
const typeName = async (wrapper: ReactWrapper, value: string) => {
|
||||
wrapper.find('input').first().simulate('change', { target: { value } });
|
||||
await act(flushAllPromises);
|
||||
wrapper.update();
|
||||
};
|
||||
|
||||
// jest.mock('@pezkuwi/react-qr', () => {
|
||||
// return {
|
||||
// QrScanAddress: (_: QrScanAddressProps): null => {
|
||||
// return null;
|
||||
// }
|
||||
// };
|
||||
// });
|
||||
|
||||
describe('ImportQr component', () => {
|
||||
let wrapper: ReactWrapper;
|
||||
|
||||
beforeEach(async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
wrapper = mount(
|
||||
<MemoryRouter>
|
||||
<ImportQr />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
act(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
|
||||
(wrapper.find('QrScanAddress').first().prop('onScan') as unknown as QrScanAddressProps['onScan'])(mockedAccount);
|
||||
});
|
||||
await act(flushAllPromises);
|
||||
wrapper.update();
|
||||
});
|
||||
|
||||
describe('Address component', () => {
|
||||
it('shows account as external', () => {
|
||||
expect(wrapper.find('Name').find('FontAwesomeIcon [data-icon="qrcode"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('shows the correct name', () => {
|
||||
expect(wrapper.find('Name span').text()).toEqual(mockedAccount.name);
|
||||
});
|
||||
|
||||
it('shows the correct address', () => {
|
||||
expect(wrapper.find('[data-field="address"]').text()).toEqual(mockedAccount.content);
|
||||
});
|
||||
|
||||
it('shows the correct banner', () => {
|
||||
expect(wrapper.find('[data-field="chain"]').text()).toEqual(mockedAccount.expectedBannerChain);
|
||||
});
|
||||
});
|
||||
|
||||
it('has the button enabled', () => {
|
||||
expect(wrapper.find(Button).prop('isDisabled')).toBe(false);
|
||||
});
|
||||
|
||||
it('displays and error and the button is disabled with a short name', async () => {
|
||||
await typeName(wrapper, 'a');
|
||||
|
||||
expect(wrapper.find('.warning-message').first().text()).toBe('Account name is too short');
|
||||
expect(wrapper.find(Button).prop('isDisabled')).toBe(true);
|
||||
});
|
||||
|
||||
it('has no error message and button enabled with a long name', async () => {
|
||||
const longName = 'aaa';
|
||||
|
||||
await typeName(wrapper, 'a');
|
||||
await typeName(wrapper, longName);
|
||||
|
||||
expect(wrapper.find('.warning-message')).toHaveLength(0);
|
||||
expect(wrapper.find(Button).prop('isDisabled')).toBe(false);
|
||||
expect(wrapper.find('Name span').text()).toEqual(longName);
|
||||
});
|
||||
|
||||
it('shows the external name in the input field', () => {
|
||||
expect(wrapper.find('input').prop('value')).toBe(mockedAccount.name);
|
||||
});
|
||||
|
||||
it('creates the external account', async () => {
|
||||
jest.spyOn(messaging, 'createAccountExternal').mockImplementation(() => Promise.resolve(false));
|
||||
wrapper.find(Button).simulate('click');
|
||||
await act(flushAllPromises);
|
||||
|
||||
expect(messaging.createAccountExternal).toHaveBeenCalledWith(mockedAccount.name, mockedAccount.content, mockedAccount.genesisHash);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { HexString } from '@pezkuwi/util/types';
|
||||
|
||||
import React, { useCallback, useContext, useState } from 'react';
|
||||
|
||||
import { QrScanAddress } from '@pezkuwi/react-qr';
|
||||
|
||||
import AccountNamePasswordCreation from '../components/AccountNamePasswordCreation.js';
|
||||
import { ActionContext, Address, ButtonArea, NextStepButton, VerticalSpace } from '../components/index.js';
|
||||
import { useTranslation } from '../hooks/index.js';
|
||||
import { createAccountExternal, createAccountSuri, createSeed } from '../messaging.js';
|
||||
import { Header, Name } from '../partials/index.js';
|
||||
|
||||
interface QrAccount {
|
||||
content: string;
|
||||
genesisHash: HexString | null;
|
||||
isAddress: boolean;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export default function ImportQr (): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const onAction = useContext(ActionContext);
|
||||
const [account, setAccount] = useState<QrAccount | null>(null);
|
||||
const [address, setAddress] = useState<string | null>(null);
|
||||
const [name, setName] = useState<string | null>(null);
|
||||
const [password, setPassword] = useState<string | null>(null);
|
||||
|
||||
const _setAccount = useCallback(
|
||||
(qrAccount: QrAccount) => {
|
||||
setAccount(qrAccount);
|
||||
setName(qrAccount?.name || null);
|
||||
|
||||
if (qrAccount.isAddress) {
|
||||
setAddress(qrAccount.content);
|
||||
} else {
|
||||
createSeed(undefined, qrAccount.content)
|
||||
.then(({ address }) => setAddress(address))
|
||||
.catch(console.error);
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const _onCreate = useCallback(
|
||||
(): void => {
|
||||
if (account && name) {
|
||||
if (account.isAddress) {
|
||||
createAccountExternal(name, account.content, account.genesisHash)
|
||||
.then(() => onAction('/'))
|
||||
.catch((error: Error) => console.error(error));
|
||||
} else if (password) {
|
||||
createAccountSuri(name, password, account.content, 'sr25519', account.genesisHash)
|
||||
.then(() => onAction('/'))
|
||||
.catch((error: Error) => console.error(error));
|
||||
}
|
||||
}
|
||||
},
|
||||
[account, name, onAction, password]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header
|
||||
showBackArrow
|
||||
text={t('Scan Address Qr')}
|
||||
/>
|
||||
{!account && (
|
||||
<div>
|
||||
<QrScanAddress onScan={_setAccount} />
|
||||
</div>
|
||||
)}
|
||||
{account && (
|
||||
<>
|
||||
<div>
|
||||
<Address
|
||||
{...account}
|
||||
address={address}
|
||||
isExternal={true}
|
||||
name={name}
|
||||
/>
|
||||
</div>
|
||||
{account.isAddress
|
||||
? (
|
||||
<Name
|
||||
isFocused
|
||||
onChange={setName}
|
||||
value={name || ''}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<AccountNamePasswordCreation
|
||||
isBusy={false}
|
||||
onCreate={_onCreate}
|
||||
onNameChange={setName}
|
||||
onPasswordChange={setPassword}
|
||||
/>
|
||||
)
|
||||
}
|
||||
<VerticalSpace />
|
||||
<ButtonArea>
|
||||
<NextStepButton
|
||||
isDisabled={!name || (!account.isAddress && !password)}
|
||||
onClick={_onCreate}
|
||||
>
|
||||
{t('Add the account with identified address')}
|
||||
</NextStepButton>
|
||||
</ButtonArea>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import '@pezkuwi/extension-mocks/chrome';
|
||||
|
||||
import type { ReactWrapper } from 'enzyme';
|
||||
import type * as _ from '@pezkuwi/dev-test/globals.d.ts';
|
||||
|
||||
import Adapter from '@wojtekmaj/enzyme-adapter-react-17';
|
||||
import enzyme from 'enzyme';
|
||||
import React from 'react';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { MemoryRouter } from 'react-router';
|
||||
|
||||
import { ActionContext, Button, Warning } from '../../components/index.js';
|
||||
import * as messaging from '../../messaging.js';
|
||||
import { flushAllPromises } from '../../testHelpers.js';
|
||||
import ImportSeed from './index.js';
|
||||
|
||||
const { configure, mount } = enzyme;
|
||||
|
||||
const account = {
|
||||
derivation: '/1',
|
||||
expectedAddress: '5GNg7RWeAAJuya4wTxb8aZf19bCWJroKuJNrhk4N3iYHNqTm',
|
||||
expectedAddressWithDerivation: '5DV3x9zgaXREUMTX7GgkP3ETeW4DQAznWTxg4kx2WivGuQLQ',
|
||||
name: 'My Polkadot Account',
|
||||
password: 'somePassword',
|
||||
seed: 'upgrade multiply predict hip multiply march leg devote social outer oppose debris'
|
||||
};
|
||||
|
||||
// // NOTE Required for spyOn when using @swc/jest
|
||||
// // https://github.com/swc-project/swc/issues/3843
|
||||
// jest.mock('../../messaging', (): Record<string, unknown> => ({
|
||||
// __esModule: true,
|
||||
// ...jest.requireActual('../../messaging')
|
||||
// }));
|
||||
|
||||
// For this file, there are a lot of them
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-argument */
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment,@typescript-eslint/no-unsafe-call
|
||||
configure({ adapter: new Adapter() });
|
||||
|
||||
jest.spyOn(messaging, 'getAllMetadata').mockImplementation(() => Promise.resolve([]));
|
||||
|
||||
const typeSeed = async (wrapper: ReactWrapper, value: string) => {
|
||||
wrapper.find('textarea').first().simulate('change', { target: { value } });
|
||||
await act(flushAllPromises);
|
||||
wrapper.update();
|
||||
};
|
||||
|
||||
const typeDerivationPath = async (wrapper: ReactWrapper, value: string) => {
|
||||
wrapper.find('input').first().simulate('change', { target: { value } });
|
||||
await act(flushAllPromises);
|
||||
wrapper.update();
|
||||
};
|
||||
|
||||
// FIXME hanging
|
||||
// eslint-disable-next-line jest/no-disabled-tests
|
||||
describe.skip('ImportSeed', () => {
|
||||
let wrapper: ReactWrapper;
|
||||
const onActionStub = jest.fn();
|
||||
|
||||
const type = async (input: ReactWrapper, value: string): Promise<void> => {
|
||||
input.simulate('change', { target: { value } });
|
||||
await act(flushAllPromises);
|
||||
wrapper.update();
|
||||
};
|
||||
|
||||
const enterName = (name: string): Promise<void> => type(wrapper.find('input').first(), name);
|
||||
const password = (password: string) => (): Promise<void> => type(wrapper.find('input[type="password"]').first(), password);
|
||||
const repeat = (password: string) => (): Promise<void> => type(wrapper.find('input[type="password"]').last(), password);
|
||||
|
||||
beforeEach(async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
wrapper = mount(
|
||||
<ActionContext.Provider value={onActionStub}>
|
||||
<MemoryRouter>
|
||||
<ImportSeed />
|
||||
</MemoryRouter>
|
||||
</ActionContext.Provider>
|
||||
);
|
||||
|
||||
await act(flushAllPromises);
|
||||
wrapper.update();
|
||||
});
|
||||
|
||||
describe('Step 1', () => {
|
||||
it('first shows no error, no account, and next step button is disabled', () => {
|
||||
expect(wrapper.find('Name span').text()).toEqual('<unknown>');
|
||||
expect(wrapper.find('[data-field="address"]').text()).toEqual('<unknown>');
|
||||
expect(wrapper.find('.derivationPath').exists()).toBe(false);
|
||||
expect(wrapper.find(Warning).exists()).toBe(false);
|
||||
expect(wrapper.find(Button).prop('isDisabled')).toBe(true);
|
||||
});
|
||||
|
||||
it('shows the expected account when correct seed is typed and next step button is enabled', async () => {
|
||||
jest.spyOn(messaging, 'validateSeed').mockImplementation(() => Promise.resolve({ address: account.expectedAddress, suri: account.seed }));
|
||||
await typeSeed(wrapper, account.seed);
|
||||
|
||||
expect(wrapper.find(Warning).exists()).toBe(false);
|
||||
expect(wrapper.find(Button).prop('isDisabled')).toBe(false);
|
||||
expect(wrapper.find('Name span').text()).toEqual('<unknown>');
|
||||
expect(wrapper.find('[data-field="address"]').text()).toEqual(account.expectedAddress);
|
||||
});
|
||||
|
||||
it('shows an error when incorrect seed is typed and next step button is enabled', async () => {
|
||||
// silencing the following expected console.error
|
||||
console.error = jest.fn();
|
||||
// eslint-disable-next-line @typescript-eslint/require-await
|
||||
jest.spyOn(messaging, 'validateSeed').mockImplementation(async () => {
|
||||
throw new Error('Some test error message');
|
||||
});
|
||||
await typeSeed(wrapper, 'this is an invalid mnemonic seed');
|
||||
|
||||
expect(wrapper.find(Warning).find('.warning-message').text()).toEqual('Invalid mnemonic seed');
|
||||
expect(wrapper.find(Button).prop('isDisabled')).toBe(true);
|
||||
expect(wrapper.find('Name span').text()).toEqual('<unknown>');
|
||||
expect(wrapper.find('[data-field="address"]').text()).toEqual('<unknown>');
|
||||
});
|
||||
|
||||
it('shows an error when the seed is removed', async () => {
|
||||
await typeSeed(wrapper, 'asdf');
|
||||
await typeSeed(wrapper, '');
|
||||
|
||||
expect(wrapper.find(Warning).find('.warning-message').text()).toEqual('Mnemonic needs to contain 12, 15, 18, 21, 24 words');
|
||||
expect(wrapper.find(Button).prop('isDisabled')).toBe(true);
|
||||
});
|
||||
|
||||
it('shows the expected account with derivation when correct seed is typed and next step button is enabled', async () => {
|
||||
const suri = `${account.seed}${account.derivation}`;
|
||||
const validateCall = jest.spyOn(messaging, 'validateSeed').mockImplementation(() => Promise.resolve({ address: account.expectedAddressWithDerivation, suri }));
|
||||
|
||||
await typeSeed(wrapper, account.seed);
|
||||
wrapper.find('.advancedToggle').simulate('click');
|
||||
await typeDerivationPath(wrapper, account.derivation);
|
||||
|
||||
expect(validateCall).toHaveBeenLastCalledWith(suri);
|
||||
expect(wrapper.find(Warning).exists()).toBe(false);
|
||||
expect(wrapper.find(Button).prop('isDisabled')).toBe(false);
|
||||
expect(wrapper.find('Name span').text()).toEqual('<unknown>');
|
||||
expect(wrapper.find('[data-field="address"]').text()).toEqual(account.expectedAddressWithDerivation);
|
||||
});
|
||||
|
||||
it('shows an error when derivation path is incorrect and next step button is disabled', async () => {
|
||||
const wrongPath = 'wrong';
|
||||
const suri = `${account.seed}${wrongPath}`;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/require-await
|
||||
const validateCall = jest.spyOn(messaging, 'validateSeed').mockImplementation(async () => {
|
||||
throw new Error('Some test error message');
|
||||
});
|
||||
|
||||
await typeSeed(wrapper, account.seed);
|
||||
wrapper.find('.advancedToggle').simulate('click');
|
||||
await typeDerivationPath(wrapper, wrongPath);
|
||||
|
||||
expect(validateCall).toHaveBeenLastCalledWith(suri);
|
||||
expect(wrapper.find(Warning).find('.warning-message').text()).toEqual('Invalid mnemonic seed or derivation path');
|
||||
expect(wrapper.find(Button).prop('isDisabled')).toBe(true);
|
||||
expect(wrapper.find('Name span').text()).toEqual('<unknown>');
|
||||
expect(wrapper.find('[data-field="address"]').text()).toEqual('<unknown>');
|
||||
});
|
||||
|
||||
it('moves to the second step', async () => {
|
||||
jest.spyOn(messaging, 'validateSeed').mockImplementation(() => Promise.resolve({ address: account.expectedAddress, suri: account.seed }));
|
||||
await typeSeed(wrapper, account.seed);
|
||||
wrapper.find(Button).simulate('click');
|
||||
await act(flushAllPromises);
|
||||
wrapper.update();
|
||||
|
||||
expect(wrapper.find(Button)).toHaveLength(2);
|
||||
expect(wrapper.find('Name span').text()).toEqual('<unknown>');
|
||||
expect(wrapper.find('[data-field="address"]').text()).toEqual(account.expectedAddress);
|
||||
});
|
||||
|
||||
describe('Phase 2', () => {
|
||||
const suri = `${account.seed}${account.derivation}`;
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.spyOn(messaging, 'createAccountSuri').mockImplementation(() => Promise.resolve(true));
|
||||
jest.spyOn(messaging, 'validateSeed').mockImplementation(() => Promise.resolve({ address: account.expectedAddressWithDerivation, suri }));
|
||||
|
||||
await typeSeed(wrapper, account.seed);
|
||||
wrapper.find('.advancedToggle').simulate('click');
|
||||
await typeDerivationPath(wrapper, account.derivation);
|
||||
wrapper.find(Button).simulate('click');
|
||||
|
||||
await act(flushAllPromises);
|
||||
wrapper.update();
|
||||
});
|
||||
|
||||
it('saves account with provided name and password', async () => {
|
||||
await enterName(account.name).then(password(account.password)).then(repeat(account.password));
|
||||
wrapper.find('[data-button-action="add new root"] button').simulate('click');
|
||||
await act(flushAllPromises);
|
||||
wrapper.update();
|
||||
|
||||
expect(messaging.createAccountSuri).toHaveBeenCalledWith(account.name, account.password, suri, undefined, '');
|
||||
expect(onActionStub).toHaveBeenCalledWith('/');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,163 @@
|
||||
// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { KeypairType } from '@pezkuwi/util-crypto/types';
|
||||
import type { AccountInfo } from './index.js';
|
||||
|
||||
import { faCaretDown, faCaretRight } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { validateSeed } from '@pezkuwi/extension-ui/messaging';
|
||||
import { objectSpread } from '@pezkuwi/util';
|
||||
|
||||
import { ButtonArea, Dropdown, InputWithLabel, NextStepButton, TextAreaWithLabel, VerticalSpace, Warning } from '../../components/index.js';
|
||||
import { useGenesisHashOptions, useTranslation } from '../../hooks/index.js';
|
||||
import { styled } from '../../styled.js';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
onNextStep: () => void;
|
||||
onAccountChange: (account: AccountInfo | null) => void;
|
||||
type: KeypairType;
|
||||
}
|
||||
|
||||
function SeedAndPath ({ className, onAccountChange, onNextStep, type }: Props): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const genesisOptions = useGenesisHashOptions();
|
||||
const [address, setAddress] = useState('');
|
||||
const [seed, setSeed] = useState<string | null>(null);
|
||||
const [path, setPath] = useState<string | null>(null);
|
||||
const [advanced, setAdvances] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [genesis, setGenesis] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
// No need to validate an empty seed
|
||||
// we have a dedicated error for this
|
||||
if (!seed) {
|
||||
onAccountChange(null);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const suri = `${seed || ''}${path || ''}`;
|
||||
|
||||
validateSeed(suri, type)
|
||||
.then((validatedAccount) => {
|
||||
setError('');
|
||||
setAddress(validatedAccount.address);
|
||||
onAccountChange(
|
||||
objectSpread<AccountInfo>({}, validatedAccount, { genesis, type })
|
||||
);
|
||||
})
|
||||
.catch(() => {
|
||||
setAddress('');
|
||||
onAccountChange(null);
|
||||
setError(path
|
||||
? t('Invalid mnemonic seed or derivation path')
|
||||
: t('Invalid mnemonic seed')
|
||||
);
|
||||
});
|
||||
}, [t, genesis, seed, path, onAccountChange, type]);
|
||||
|
||||
const _onToggleAdvanced = useCallback(() => {
|
||||
setAdvances(!advanced);
|
||||
}, [advanced]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={className}>
|
||||
<TextAreaWithLabel
|
||||
className='seedInput'
|
||||
isError={!!error}
|
||||
isFocused
|
||||
label={t('existing 12 or 24-word mnemonic seed')}
|
||||
onChange={setSeed}
|
||||
rowsCount={2}
|
||||
value={seed || ''}
|
||||
/>
|
||||
{!!error && !seed && (
|
||||
<Warning
|
||||
className='seedError'
|
||||
isBelowInput
|
||||
isDanger
|
||||
>
|
||||
{t('Mnemonic needs to contain 12, 15, 18, 21, 24 words')}
|
||||
</Warning>
|
||||
)}
|
||||
<Dropdown
|
||||
className='genesisSelection'
|
||||
label={t('Network')}
|
||||
onChange={setGenesis}
|
||||
options={genesisOptions}
|
||||
value={genesis}
|
||||
/>
|
||||
<div
|
||||
className='advancedToggle'
|
||||
onClick={_onToggleAdvanced}
|
||||
>
|
||||
<FontAwesomeIcon icon={advanced ? faCaretDown : faCaretRight} />
|
||||
<span>{t('advanced')}</span>
|
||||
</div>
|
||||
{ advanced && (
|
||||
<InputWithLabel
|
||||
className='derivationPath'
|
||||
isError={!!path && !!error}
|
||||
label={t('derivation path')}
|
||||
onChange={setPath}
|
||||
value={path || ''}
|
||||
/>
|
||||
)}
|
||||
{!!error && !!seed && (
|
||||
<Warning
|
||||
isDanger
|
||||
>
|
||||
{error}
|
||||
</Warning>
|
||||
)}
|
||||
</div>
|
||||
<VerticalSpace />
|
||||
<ButtonArea>
|
||||
<NextStepButton
|
||||
isDisabled={!address || !!error}
|
||||
onClick={onNextStep}
|
||||
>
|
||||
{t('Next')}
|
||||
</NextStepButton>
|
||||
</ButtonArea>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default styled(SeedAndPath)<Props>`
|
||||
.advancedToggle {
|
||||
color: var(--textColor);
|
||||
cursor: pointer;
|
||||
line-height: var(--lineHeight);
|
||||
letter-spacing: 0.04em;
|
||||
opacity: 0.65;
|
||||
text-transform: uppercase;
|
||||
|
||||
> span {
|
||||
font-size: var(--inputLabelFontSize);
|
||||
margin-left: .5rem;
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
|
||||
.genesisSelection {
|
||||
margin-bottom: var(--fontSize);
|
||||
}
|
||||
|
||||
.seedInput {
|
||||
margin-bottom: var(--fontSize);
|
||||
textarea {
|
||||
height: unset;
|
||||
}
|
||||
}
|
||||
|
||||
.seedError {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,104 @@
|
||||
// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { HexString } from '@pezkuwi/util/types';
|
||||
|
||||
import React, { useCallback, useContext, useEffect, useState } from 'react';
|
||||
|
||||
import AccountNamePasswordCreation from '../../components/AccountNamePasswordCreation.js';
|
||||
import { AccountContext, ActionContext, Address } from '../../components/index.js';
|
||||
import { useMetadata, useTranslation } from '../../hooks/index.js';
|
||||
import { createAccountSuri } from '../../messaging.js';
|
||||
import { HeaderWithSteps } from '../../partials/index.js';
|
||||
import { DEFAULT_TYPE } from '../../util/defaultType.js';
|
||||
import SeedAndPath from './SeedAndPath.js';
|
||||
|
||||
export interface AccountInfo {
|
||||
address: string;
|
||||
genesis?: HexString;
|
||||
suri: string;
|
||||
}
|
||||
|
||||
function ImportSeed (): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const { accounts } = useContext(AccountContext);
|
||||
const onAction = useContext(ActionContext);
|
||||
const [isBusy, setIsBusy] = useState(false);
|
||||
const [account, setAccount] = useState<AccountInfo | null>(null);
|
||||
const [name, setName] = useState<string | null>(null);
|
||||
const [step1, setStep1] = useState(true);
|
||||
const [type, setType] = useState(DEFAULT_TYPE);
|
||||
const chain = useMetadata(account?.genesis, true);
|
||||
|
||||
useEffect((): void => {
|
||||
!accounts.length && onAction();
|
||||
}, [accounts, onAction]);
|
||||
|
||||
useEffect((): void => {
|
||||
setType(
|
||||
chain && chain.definition.chainType === 'ethereum'
|
||||
? 'ethereum'
|
||||
: DEFAULT_TYPE
|
||||
);
|
||||
}, [chain]);
|
||||
|
||||
const _onCreate = useCallback((name: string, password: string): void => {
|
||||
// this should always be the case
|
||||
if (name && password && account) {
|
||||
setIsBusy(true);
|
||||
|
||||
createAccountSuri(name, password, account.suri, type, account.genesis)
|
||||
.then(() => onAction('/'))
|
||||
.catch((error): void => {
|
||||
setIsBusy(false);
|
||||
console.error(error);
|
||||
});
|
||||
}
|
||||
}, [account, onAction, type]);
|
||||
|
||||
const _onNextStep = useCallback(
|
||||
() => setStep1(false),
|
||||
[]
|
||||
);
|
||||
|
||||
const _onBackClick = useCallback(
|
||||
() => setStep1(true),
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<HeaderWithSteps
|
||||
step={step1 ? 1 : 2}
|
||||
text={t('Import account')}
|
||||
/>
|
||||
<div>
|
||||
<Address
|
||||
address={account?.address}
|
||||
genesisHash={account?.genesis}
|
||||
name={name}
|
||||
/>
|
||||
</div>
|
||||
{step1
|
||||
? (
|
||||
<SeedAndPath
|
||||
onAccountChange={setAccount}
|
||||
onNextStep={_onNextStep}
|
||||
type={type}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<AccountNamePasswordCreation
|
||||
buttonLabel={t('Add the account with the supplied seed')}
|
||||
isBusy={isBusy}
|
||||
onBackClick={_onBackClick}
|
||||
onCreate={_onCreate}
|
||||
onNameChange={setName}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default ImportSeed;
|
||||
@@ -0,0 +1,129 @@
|
||||
// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { MetadataDef } from '@pezkuwi/extension-inject/types';
|
||||
|
||||
import React, { useCallback, useContext } from 'react';
|
||||
|
||||
import { ActionBar, ActionContext, Button, Link, Table, Warning } from '../../components/index.js';
|
||||
import { useMetadata, useTranslation } from '../../hooks/index.js';
|
||||
import { approveMetaRequest, rejectMetaRequest } from '../../messaging.js';
|
||||
import { styled } from '../../styled.js';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
request: MetadataDef;
|
||||
metaId: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
function Request ({ className, metaId, request, url }: Props): React.ReactElement<Props> {
|
||||
const { t } = useTranslation();
|
||||
const chain = useMetadata(request.genesisHash);
|
||||
const onAction = useContext(ActionContext);
|
||||
|
||||
const _onApprove = useCallback(
|
||||
(): void => {
|
||||
approveMetaRequest(metaId)
|
||||
.then(() => onAction())
|
||||
.catch(console.error);
|
||||
},
|
||||
[metaId, onAction]
|
||||
);
|
||||
|
||||
const _onReject = useCallback(
|
||||
(): void => {
|
||||
rejectMetaRequest(metaId)
|
||||
.then(() => onAction())
|
||||
.catch(console.error);
|
||||
},
|
||||
[metaId, onAction]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<Table>
|
||||
<tr>
|
||||
<td className='label'>{t('from')}</td>
|
||||
<td className='data'>{url}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className='label'>{t('chain')}</td>
|
||||
<td className='data'>{request.chain}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className='label'>{t('icon')}</td>
|
||||
<td className='data'>{request.icon}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className='label'>{t('decimals')}</td>
|
||||
<td className='data'>{request.tokenDecimals}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className='label'>{t('symbol')}</td>
|
||||
<td className='data'>{request.tokenSymbol}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className='label'>{t('upgrade')}</td>
|
||||
<td className='data'>{chain ? chain.specVersion : t('<unknown>')} -> {request.specVersion}</td>
|
||||
</tr>
|
||||
</Table>
|
||||
<div className='requestInfo'>
|
||||
<Warning className='requestWarning'>
|
||||
{t('This approval will add the metadata to your extension instance, allowing future requests to be decoded using this metadata. It will also allow the use of Ledger\'s Generic Polkadot App.')}
|
||||
</Warning>
|
||||
<Button
|
||||
className='btnAccept'
|
||||
onClick={_onApprove}
|
||||
>
|
||||
{t('Yes, do this metadata update')}
|
||||
</Button>
|
||||
<ActionBar className='btnReject'>
|
||||
<Link
|
||||
isDanger
|
||||
onClick={_onReject}
|
||||
>
|
||||
{t('Reject')}
|
||||
</Link>
|
||||
</ActionBar>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default styled(Request)<Props>`
|
||||
.btnAccept {
|
||||
margin: 25px auto 0;
|
||||
width: 90%;
|
||||
}
|
||||
|
||||
.btnReject {
|
||||
margin: 8px 0 15px 0;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.icon {
|
||||
background: var(--buttonBackgroundDanger);
|
||||
color: white;
|
||||
min-width: 18px;
|
||||
width: 14px;
|
||||
height: 18px;
|
||||
font-size: 10px;
|
||||
line-height: 20px;
|
||||
margin: 16px 15px 0 1.35rem;
|
||||
font-weight: 800;
|
||||
padding-left: 0.5px;
|
||||
}
|
||||
|
||||
.requestInfo {
|
||||
align-items: center;
|
||||
background: var(--highlightedAreaBackground);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.requestWarning {
|
||||
margin: 24px 24px 0 1.45rem;
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,31 @@
|
||||
// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import React, { useContext } from 'react';
|
||||
|
||||
import { Loading, MetadataReqContext } from '../../components/index.js';
|
||||
import { useTranslation } from '../../hooks/index.js';
|
||||
import { Header } from '../../partials/index.js';
|
||||
import Request from './Request.js';
|
||||
|
||||
export default function Metadata (): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const requests = useContext(MetadataReqContext);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header text={t('Metadata')} />
|
||||
{requests[0]
|
||||
? (
|
||||
<Request
|
||||
key={requests[0].id}
|
||||
metaId={requests[0].id}
|
||||
request={requests[0].request}
|
||||
url={requests[0].url}
|
||||
/>
|
||||
)
|
||||
: <Loading />
|
||||
}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import React from 'react';
|
||||
import { Trans } from 'react-i18next';
|
||||
import { useParams } from 'react-router';
|
||||
|
||||
import { useTranslation } from '../hooks/index.js';
|
||||
import { Header } from '../partials/index.js';
|
||||
import { styled } from '../styled.js';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface WebsiteState {
|
||||
website: string;
|
||||
}
|
||||
|
||||
function PhishingDetected ({ className }: Props): React.ReactElement<Props> {
|
||||
const { t } = useTranslation();
|
||||
const { website } = useParams<WebsiteState>();
|
||||
const decodedWebsite = decodeURIComponent(website);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header text={t('Phishing detected')} />
|
||||
<div className={className}>
|
||||
<p>
|
||||
{t('You have been redirected because the Polkadot{.js} extension believes that this website could compromise the security of your accounts and your tokens.')}
|
||||
</p>
|
||||
<p className='websiteAddress'>
|
||||
{decodedWebsite}
|
||||
</p>
|
||||
<p>
|
||||
<Trans i18nKey='phishing.incorrect'>
|
||||
Note that this website was reported on a community-driven, curated list. It might be incomplete or inaccurate. If you think that this website was flagged incorrectly, <a href='https://github.com/polkadot-js/phishing/issues/new'>please open an issue by clicking here</a>.
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default styled(PhishingDetected)<Props>`
|
||||
p {
|
||||
color: var(--subTextColor);
|
||||
margin-bottom: 1rem;
|
||||
margin-top: 0;
|
||||
|
||||
a {
|
||||
color: var(--subTextColor);
|
||||
}
|
||||
|
||||
&.websiteAddress {
|
||||
font-size: 1.3rem;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,181 @@
|
||||
// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { ResponseJsonGetAccountInfo } from '@pezkuwi/extension-base/background/types';
|
||||
import type { KeyringPair$Json } from '@pezkuwi/keyring/types';
|
||||
import type { KeyringPairs$Json } from '@pezkuwi/ui-keyring/types';
|
||||
|
||||
import React, { useCallback, useContext, useEffect, useState } from 'react';
|
||||
|
||||
import { u8aToString } from '@pezkuwi/util';
|
||||
|
||||
import { AccountContext, ActionContext, Address, Button, InputFileWithLabel, InputWithLabel, Warning } from '../components/index.js';
|
||||
import { useTranslation } from '../hooks/index.js';
|
||||
import { batchRestore, jsonGetAccountInfo, jsonRestore } from '../messaging.js';
|
||||
import { Header } from '../partials/index.js';
|
||||
import { styled } from '../styled.js';
|
||||
import { DEFAULT_TYPE } from '../util/defaultType.js';
|
||||
import { isKeyringPairs$Json } from '../util/typeGuards.js';
|
||||
|
||||
const acceptedFormats = ['application/json', 'text/plain'].join(', ');
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function Upload ({ className }: Props): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const { accounts } = useContext(AccountContext);
|
||||
const onAction = useContext(ActionContext);
|
||||
const [isBusy, setIsBusy] = useState(false);
|
||||
const [accountsInfo, setAccountsInfo] = useState<ResponseJsonGetAccountInfo[]>([]);
|
||||
const [password, setPassword] = useState<string>('');
|
||||
const [isFileError, setFileError] = useState(false);
|
||||
const [requirePassword, setRequirePassword] = useState(false);
|
||||
const [isPasswordError, setIsPasswordError] = useState(false);
|
||||
// don't use the info from the file directly
|
||||
// rather use what comes from the background from jsonGetAccountInfo
|
||||
const [file, setFile] = useState<KeyringPair$Json | KeyringPairs$Json | undefined>(undefined);
|
||||
|
||||
useEffect((): void => {
|
||||
!accounts.length && onAction();
|
||||
}, [accounts, onAction]);
|
||||
|
||||
const _onChangePass = useCallback(
|
||||
(pass: string): void => {
|
||||
setPassword(pass);
|
||||
setIsPasswordError(false);
|
||||
}, []
|
||||
);
|
||||
|
||||
const _onChangeFile = useCallback(
|
||||
(file: Uint8Array): void => {
|
||||
setAccountsInfo(() => []);
|
||||
|
||||
let json: KeyringPair$Json | KeyringPairs$Json | undefined;
|
||||
|
||||
try {
|
||||
json = JSON.parse(u8aToString(file)) as KeyringPair$Json | KeyringPairs$Json;
|
||||
setFile(json);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
setFileError(true);
|
||||
}
|
||||
|
||||
if (json === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isKeyringPairs$Json(json)) {
|
||||
setRequirePassword(true);
|
||||
json.accounts.forEach((account) => {
|
||||
setAccountsInfo((old) => [...old, {
|
||||
address: account.address,
|
||||
genesisHash: account.meta.genesisHash,
|
||||
name: account.meta.name
|
||||
} as ResponseJsonGetAccountInfo]);
|
||||
});
|
||||
} else {
|
||||
setRequirePassword(true);
|
||||
jsonGetAccountInfo(json)
|
||||
.then((accountInfo) => setAccountsInfo((old) => [...old, accountInfo]))
|
||||
.catch((e) => {
|
||||
setFileError(true);
|
||||
console.error(e);
|
||||
});
|
||||
}
|
||||
}, []
|
||||
);
|
||||
|
||||
const _onRestore = useCallback(
|
||||
(): void => {
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (requirePassword && !password) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsBusy(true);
|
||||
|
||||
(isKeyringPairs$Json(file) ? batchRestore(file, password) : jsonRestore(file, password))
|
||||
.then(() => {
|
||||
onAction('/');
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
setIsBusy(false);
|
||||
setIsPasswordError(true);
|
||||
});
|
||||
},
|
||||
[file, onAction, password, requirePassword]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header
|
||||
showBackArrow
|
||||
smallMargin
|
||||
text={t('Restore from JSON')}
|
||||
/>
|
||||
<div className={className}>
|
||||
{accountsInfo.map(({ address, genesisHash, name, type = DEFAULT_TYPE }, index) => (
|
||||
<Address
|
||||
address={address}
|
||||
genesisHash={genesisHash}
|
||||
key={`${index}:${address}`}
|
||||
name={name}
|
||||
type={type}
|
||||
/>
|
||||
))}
|
||||
<InputFileWithLabel
|
||||
accept={acceptedFormats}
|
||||
isError={isFileError}
|
||||
label={t('backup file')}
|
||||
onChange={_onChangeFile}
|
||||
withLabel
|
||||
/>
|
||||
{isFileError && (
|
||||
<Warning
|
||||
isDanger
|
||||
>
|
||||
{t('Invalid Json file')}
|
||||
</Warning>
|
||||
)}
|
||||
{requirePassword && (
|
||||
<div>
|
||||
<InputWithLabel
|
||||
isError={isPasswordError}
|
||||
label={t('Password for this file')}
|
||||
onChange={_onChangePass}
|
||||
type='password'
|
||||
/>
|
||||
{isPasswordError && (
|
||||
<Warning
|
||||
isBelowInput
|
||||
isDanger
|
||||
>
|
||||
{t('Unable to decode using the supplied passphrase')}
|
||||
</Warning>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
className='restoreButton'
|
||||
isBusy={isBusy}
|
||||
isDisabled={isFileError || isPasswordError}
|
||||
onClick={_onRestore}
|
||||
>
|
||||
{t('Restore')}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default styled(Upload)<Props>`
|
||||
.restoreButton {
|
||||
margin-top: 16px;
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,84 @@
|
||||
// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
|
||||
import { isAscii, u8aToString, u8aUnwrapBytes } from '@pezkuwi/util';
|
||||
|
||||
import { useTranslation } from '../../hooks/index.js';
|
||||
import { styled } from '../../styled.js';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
bytes: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
function Bytes ({ bytes, className, url }: Props): React.ReactElement<Props> {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const text = useMemo(
|
||||
() => isAscii(bytes)
|
||||
? u8aToString(u8aUnwrapBytes(bytes))
|
||||
: bytes,
|
||||
[bytes]
|
||||
);
|
||||
|
||||
return (
|
||||
<table className={className}>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td className='label'>{t('from')}</td>
|
||||
<td className='data'>{url}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className='label'>{t('bytes')}</td>
|
||||
<td className='data pre'><div>{text}</div></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
|
||||
export default styled(Bytes)<Props>`
|
||||
border: 0;
|
||||
display: block;
|
||||
font-size: 0.75rem;
|
||||
margin-top: 0.75rem;
|
||||
|
||||
td.data {
|
||||
max-width: 0;
|
||||
overflow: hidden;
|
||||
text-align: left;
|
||||
text-overflow: ellipsis;
|
||||
vertical-align: middle;
|
||||
width: 100%;
|
||||
padding: 0.15rem;
|
||||
|
||||
&.pre {
|
||||
padding: 0px;
|
||||
|
||||
div {
|
||||
padding: 0.15rem;
|
||||
font-family: inherit;
|
||||
font-size: 0.75rem;
|
||||
margin: 0;
|
||||
white-space: pre;
|
||||
overflow: auto;
|
||||
max-height: calc(100vh - 480px);
|
||||
min-height: var(--boxLineHeight);
|
||||
border: 1px solid var(--boxBorderColor);
|
||||
background: var(--boxBackground);
|
||||
line-height: var(--boxLineHeight);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
td.label {
|
||||
opacity: 0.5;
|
||||
padding: 0 0.5rem;
|
||||
text-align: right;
|
||||
vertical-align: middle;
|
||||
white-space: nowrap;
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,180 @@
|
||||
// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { Chain } from '@pezkuwi/extension-chains/types';
|
||||
import type { Call, ExtrinsicEra, ExtrinsicPayload } from '@pezkuwi/types/interfaces';
|
||||
import type { AnyJson, SignerPayloadJSON } from '@pezkuwi/types/types';
|
||||
import type { BN } from '@pezkuwi/util';
|
||||
import type { TFunction } from '../../hooks/useTranslation.js';
|
||||
|
||||
import { convertMultilocationToUrl } from '@paraspell/xcm-analyser';
|
||||
import React, { useMemo, useRef } from 'react';
|
||||
|
||||
import { bnToBn, formatNumber } from '@pezkuwi/util';
|
||||
|
||||
import { Table } from '../../components/index.js';
|
||||
import { useMetadata, useTranslation } from '../../hooks/index.js';
|
||||
|
||||
interface Decoded {
|
||||
args: AnyJson | null;
|
||||
method: Call | null;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
payload: ExtrinsicPayload;
|
||||
request: SignerPayloadJSON;
|
||||
url: string;
|
||||
}
|
||||
|
||||
function displayDecodeVersion (message: string, chain: Chain, specVersion: BN): string {
|
||||
return `${message}: chain=${chain.name}, specVersion=${chain.specVersion.toString()} (request specVersion=${specVersion.toString()})`;
|
||||
}
|
||||
|
||||
function decodeMethod (data: string, chain: Chain, specVersion: BN): Decoded {
|
||||
let args: AnyJson | null = null;
|
||||
let method: Call | null = null;
|
||||
|
||||
try {
|
||||
if (specVersion.eqn(chain.specVersion)) {
|
||||
method = chain.registry.createType('Call', data);
|
||||
args = (method.toHuman() as { args: AnyJson }).args;
|
||||
} else {
|
||||
console.log(displayDecodeVersion('Outdated metadata to decode', chain, specVersion));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`${displayDecodeVersion('Error decoding method', chain, specVersion)}:: ${(error as Error).message}`);
|
||||
|
||||
args = null;
|
||||
method = null;
|
||||
}
|
||||
|
||||
return { args, method };
|
||||
}
|
||||
|
||||
function renderMethod (data: string, { args, method }: Decoded, t: TFunction): React.ReactNode {
|
||||
if (!args || !method) {
|
||||
return (
|
||||
<tr>
|
||||
<td className='label'>{t('method data')}</td>
|
||||
<td className='data'>{data}</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<tr>
|
||||
<td className='label'>{t('method')}</td>
|
||||
<td className='data'>
|
||||
<details>
|
||||
<summary>{method.section}.{method.method}{
|
||||
method.meta
|
||||
? `(${method.meta.args.map(({ name }) => name).join(', ')})`
|
||||
: ''
|
||||
}</summary>
|
||||
<pre>{JSON.stringify(args, null, 2)}</pre>
|
||||
</details>
|
||||
</td>
|
||||
</tr>
|
||||
{method.meta && (
|
||||
<tr>
|
||||
<td className='label'>{t('info')}</td>
|
||||
<td className='data'>
|
||||
<details>
|
||||
<summary>{method.meta.docs.map((d) => d.toString().trim()).join(' ')}</summary>
|
||||
</details>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function mortalityAsString (era: ExtrinsicEra, hexBlockNumber: string, t: TFunction): string {
|
||||
if (era.isImmortalEra) {
|
||||
return t('immortal');
|
||||
}
|
||||
|
||||
const blockNumber = bnToBn(hexBlockNumber);
|
||||
const mortal = era.asMortalEra;
|
||||
|
||||
return t('mortal, valid from {{birth}} to {{death}}', {
|
||||
replace: {
|
||||
birth: formatNumber(mortal.birth(blockNumber)),
|
||||
death: formatNumber(mortal.death(blockNumber))
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function getHumanReadableAssetId (assetId: unknown): string | undefined {
|
||||
try {
|
||||
return convertMultilocationToUrl(assetId);
|
||||
} catch (_) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function Extrinsic ({ className, payload, request: { blockNumber, genesisHash, method, specVersion: hexSpec }, url }: Props): React.ReactElement<Props> {
|
||||
const { t } = useTranslation();
|
||||
const chain = useMetadata(genesisHash);
|
||||
const specVersion = useRef(bnToBn(hexSpec)).current;
|
||||
const decoded = useMemo(
|
||||
() => chain && chain.hasMetadata
|
||||
? decodeMethod(method, chain, specVersion)
|
||||
: { args: null, method: null },
|
||||
[method, chain, specVersion]
|
||||
);
|
||||
|
||||
const humanReadablePayload = payload.toHuman() as Record<string, unknown>;
|
||||
const assetId = humanReadablePayload['assetId'];
|
||||
const humanReadableAssetId = getHumanReadableAssetId(assetId);
|
||||
|
||||
return (
|
||||
<Table
|
||||
className={className}
|
||||
isFull
|
||||
>
|
||||
<tr>
|
||||
<td className='label'>{t('from')}</td>
|
||||
<td className='data'>{url}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className='label'>{chain ? t('chain') : t('genesis')}</td>
|
||||
<td className='data'>{chain ? chain.name : genesisHash}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className='label'>{t('version')}</td>
|
||||
<td className='data'>{specVersion.toNumber()}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className='label'>{t('nonce')}</td>
|
||||
<td className='data'>{formatNumber(payload.nonce)}</td>
|
||||
</tr>
|
||||
{!payload.tip.isEmpty && (
|
||||
<tr>
|
||||
<td className='label'>{t('tip')}</td>
|
||||
<td className='data'>{formatNumber(payload.tip)}</td>
|
||||
</tr>
|
||||
)}
|
||||
{!!assetId && (
|
||||
<tr>
|
||||
<td className='label'>{t('assetId')}</td>
|
||||
<td className='data'>
|
||||
<details>
|
||||
<summary>{humanReadableAssetId || '{...}'}</summary>
|
||||
<pre>{JSON.stringify(assetId, null, 2)}</pre>
|
||||
</details>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{renderMethod(method, decoded, t)}
|
||||
<tr>
|
||||
<td className='label'>{t('lifetime')}</td>
|
||||
<td className='data'>{mortalityAsString(payload.era, blockNumber, t)}</td>
|
||||
</tr>
|
||||
</Table>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(Extrinsic);
|
||||
@@ -0,0 +1,205 @@
|
||||
// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
/* eslint-disable deprecation/deprecation */
|
||||
|
||||
import type { Chain } from '@pezkuwi/extension-chains/types';
|
||||
import type { Ledger, LedgerGeneric } from '@pezkuwi/hw-ledger';
|
||||
import type { ExtrinsicPayload } from '@pezkuwi/types/interfaces';
|
||||
import type { SignerPayloadJSON } from '@pezkuwi/types/types';
|
||||
import type { HexString } from '@pezkuwi/util/types';
|
||||
|
||||
import { faSync } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import settings from '@pezkuwi/ui-settings';
|
||||
import { assert, objectSpread, u8aToHex } from '@pezkuwi/util';
|
||||
import { merkleizeMetadata } from '@polkadot-api/merkleize-metadata';
|
||||
|
||||
import { Button, Warning } from '../../components/index.js';
|
||||
import { useLedger, useMetadata, useTranslation } from '../../hooks/index.js';
|
||||
import { styled } from '../../styled.js';
|
||||
|
||||
interface Props {
|
||||
accountIndex?: number;
|
||||
addressOffset?: number;
|
||||
className?: string;
|
||||
error: string | null;
|
||||
genesisHash?: string;
|
||||
isEthereum?: boolean;
|
||||
onSignature?: ({ signature }: { signature: HexString }, signedTransaction?: HexString) => void;
|
||||
payloadJson?: SignerPayloadJSON;
|
||||
payloadExt?: ExtrinsicPayload
|
||||
setError: (value: string | null) => void;
|
||||
}
|
||||
|
||||
function getMetadataProof (chain: Chain, payload: SignerPayloadJSON) {
|
||||
const m = chain.definition.rawMetadata;
|
||||
|
||||
assert(m, 'To sign with Ledger\'s Polkadot Generic App, the metadata must be present in the extension.');
|
||||
|
||||
const merkleizedMetadata = merkleizeMetadata(m, {
|
||||
base58Prefix: chain.ss58Format,
|
||||
decimals: chain.tokenDecimals,
|
||||
specName: chain.name.toLowerCase(),
|
||||
specVersion: chain.specVersion,
|
||||
tokenSymbol: chain.tokenSymbol
|
||||
});
|
||||
const metadataHash = u8aToHex(merkleizedMetadata.digest());
|
||||
const newPayload = objectSpread<SignerPayloadJSON>({}, payload, { metadataHash, mode: 1 });
|
||||
const raw = chain.registry.createType('ExtrinsicPayload', newPayload);
|
||||
|
||||
return {
|
||||
raw,
|
||||
txMetadata: merkleizedMetadata.getProofForExtrinsicPayload(u8aToHex(raw.toU8a(true)))
|
||||
};
|
||||
}
|
||||
|
||||
function LedgerSign ({ accountIndex, addressOffset, className, error, genesisHash, isEthereum = false, onSignature, payloadExt, payloadJson, setError }: Props): React.ReactElement<Props> {
|
||||
const [isBusy, setIsBusy] = useState(false);
|
||||
const { t } = useTranslation();
|
||||
const chain = useMetadata(genesisHash);
|
||||
const { error: ledgerError, isLoading: ledgerLoading, isLocked: ledgerLocked, ledger, refresh, type: _ledgerType, warning: ledgerWarning } = useLedger(genesisHash, accountIndex, addressOffset, isEthereum);
|
||||
|
||||
useEffect(() => {
|
||||
if (ledgerError) {
|
||||
setError(ledgerError);
|
||||
}
|
||||
}, [chain, ledgerError, setError]);
|
||||
|
||||
const _onRefresh = useCallback(() => {
|
||||
refresh();
|
||||
setError(null);
|
||||
}, [refresh, setError]);
|
||||
|
||||
const _onSignLedger = useCallback(
|
||||
(): void => {
|
||||
if (!ledger || !payloadJson || !onSignature || !chain || !payloadExt) {
|
||||
if (!chain) {
|
||||
setError('No chain information found. You may need to update/upload the metadata.');
|
||||
setIsBusy(false);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
setError(null);
|
||||
setIsBusy(true);
|
||||
|
||||
const currApp = settings.get().ledgerApp;
|
||||
|
||||
if (currApp === 'generic' || currApp === 'migration') {
|
||||
if (!chain?.definition.rawMetadata) {
|
||||
setError('No metadata found for this chain. You must upload the metadata to the extension in order to use Ledger.');
|
||||
}
|
||||
|
||||
const { raw, txMetadata } = getMetadataProof(chain, payloadJson);
|
||||
const metaBuff = Buffer.from(txMetadata);
|
||||
|
||||
if (isEthereum) {
|
||||
(ledger as LedgerGeneric).signWithMetadataEcdsa(raw.toU8a(true), accountIndex, addressOffset, { metadata: metaBuff })
|
||||
.then(({ signature }) => {
|
||||
const extrinsic = chain.registry.createType(
|
||||
'Extrinsic',
|
||||
{ method: raw.method },
|
||||
{ version: 4 }
|
||||
);
|
||||
|
||||
(ledger as LedgerGeneric).getAddressEcdsa(false, accountIndex, addressOffset)
|
||||
.then(({ address }) => {
|
||||
extrinsic.addSignature(`0x${address}`, signature, raw.toHex());
|
||||
onSignature({ signature }, extrinsic.toHex());
|
||||
})
|
||||
.catch((e: Error) => {
|
||||
setError(e.message);
|
||||
setIsBusy(false);
|
||||
});
|
||||
}).catch((e: Error) => {
|
||||
setError(e.message);
|
||||
setIsBusy(false);
|
||||
});
|
||||
} else {
|
||||
(ledger as LedgerGeneric).signWithMetadata(raw.toU8a(true), accountIndex, addressOffset, { metadata: metaBuff })
|
||||
.then((signature) => {
|
||||
const extrinsic = chain.registry.createType(
|
||||
'Extrinsic',
|
||||
{ method: raw.method },
|
||||
{ version: 4 }
|
||||
);
|
||||
|
||||
(ledger as LedgerGeneric).getAddress(chain.ss58Format, false, accountIndex, addressOffset)
|
||||
.then(({ address }) => {
|
||||
extrinsic.addSignature(address, signature.signature, raw.toHex());
|
||||
onSignature(signature, extrinsic.toHex());
|
||||
})
|
||||
.catch((e: Error) => {
|
||||
setError(e.message);
|
||||
setIsBusy(false);
|
||||
});
|
||||
}).catch((e: Error) => {
|
||||
setError(e.message);
|
||||
setIsBusy(false);
|
||||
});
|
||||
}
|
||||
} else if (currApp === 'chainSpecific') {
|
||||
(ledger as Ledger).sign(payloadExt.toU8a(true), accountIndex, addressOffset)
|
||||
.then((signature) => {
|
||||
onSignature(signature);
|
||||
}).catch((e: Error) => {
|
||||
setError(e.message);
|
||||
setIsBusy(false);
|
||||
});
|
||||
}
|
||||
},
|
||||
[accountIndex, addressOffset, chain, ledger, onSignature, payloadJson, payloadExt, setError, isEthereum]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
{!!ledgerWarning && (
|
||||
<Warning>
|
||||
{ledgerWarning}
|
||||
</Warning>
|
||||
)}
|
||||
{error && (
|
||||
<Warning isDanger>
|
||||
{error}
|
||||
</Warning>
|
||||
)}
|
||||
{
|
||||
<Warning>
|
||||
{`You are using the Ledger ${settings.ledgerApp.toUpperCase()} App. If you would like to switch it, please go to "MANAGE LEDGER APP" in the extension's settings.`}
|
||||
</Warning>
|
||||
}
|
||||
{(ledgerLocked || error)
|
||||
? (
|
||||
<Button
|
||||
isBusy={isBusy || ledgerLoading}
|
||||
onClick={_onRefresh}
|
||||
>
|
||||
<FontAwesomeIcon icon={faSync} />
|
||||
{t('Refresh')}
|
||||
</Button>
|
||||
)
|
||||
: (
|
||||
<Button
|
||||
isBusy={isBusy || ledgerLoading}
|
||||
onClick={_onSignLedger}
|
||||
>
|
||||
{t('Sign on Ledger')}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default styled(LedgerSign) <Props>`
|
||||
flex-direction: column;
|
||||
padding: 6px 24px;
|
||||
|
||||
.danger {
|
||||
margin-bottom: .5rem;
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,103 @@
|
||||
// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { ExtrinsicPayload } from '@pezkuwi/types/interfaces';
|
||||
import type { HexString } from '@pezkuwi/util/types';
|
||||
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
|
||||
import { QrDisplayPayload, QrScanSignature } from '@pezkuwi/react-qr';
|
||||
import { u8aWrapBytes } from '@pezkuwi/util';
|
||||
|
||||
import { Button } from '../../components/index.js';
|
||||
import { useTranslation } from '../../hooks/index.js';
|
||||
import { styled } from '../../styled.js';
|
||||
import { CMD_MORTAL, CMD_SIGN_MESSAGE } from './Request/index.js';
|
||||
|
||||
interface Props {
|
||||
address: string;
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
cmd: number;
|
||||
genesisHash: string;
|
||||
onSignature: ({ signature }: { signature: HexString }) => void;
|
||||
payload: ExtrinsicPayload | string;
|
||||
|
||||
}
|
||||
|
||||
function Qr ({ address, className, cmd, genesisHash, onSignature, payload }: Props): React.ReactElement<Props> {
|
||||
const { t } = useTranslation();
|
||||
const [isScanning, setIsScanning] = useState(false);
|
||||
|
||||
const payloadU8a = useMemo(
|
||||
() => {
|
||||
switch (cmd) {
|
||||
case CMD_MORTAL:
|
||||
return (payload as ExtrinsicPayload).toU8a();
|
||||
case CMD_SIGN_MESSAGE:
|
||||
return u8aWrapBytes(payload as string);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
},
|
||||
[cmd, payload]
|
||||
);
|
||||
|
||||
const _onShowQr = useCallback(
|
||||
() => setIsScanning(true),
|
||||
[]
|
||||
);
|
||||
|
||||
if (!payloadU8a) {
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className='qrContainer'>
|
||||
Transaction command:{cmd} not supported.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className='qrContainer'>
|
||||
{isScanning
|
||||
? <QrScanSignature onScan={onSignature} />
|
||||
: (
|
||||
<QrDisplayPayload
|
||||
address={address}
|
||||
cmd={cmd}
|
||||
genesisHash={genesisHash}
|
||||
payload={payloadU8a}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
{!isScanning && (
|
||||
<Button
|
||||
className='scanButton'
|
||||
onClick={_onShowQr}
|
||||
>
|
||||
{t('Scan signature via camera')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default styled(Qr)<Props>`
|
||||
height: 100%;
|
||||
|
||||
.qrContainer {
|
||||
margin: 5px auto 10px auto;
|
||||
width: 65%;
|
||||
|
||||
img {
|
||||
border: white solid 1px;
|
||||
}
|
||||
}
|
||||
|
||||
.scanButton {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,144 @@
|
||||
// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import React, { useCallback, useContext, useEffect, useState } from 'react';
|
||||
|
||||
import { PASSWORD_EXPIRY_MIN } from '@pezkuwi/extension-base/defaults';
|
||||
|
||||
import { ActionBar, ActionContext, Button, ButtonArea, Checkbox, Link } from '../../../components/index.js';
|
||||
import { useTranslation } from '../../../hooks/index.js';
|
||||
import { approveSignPassword, cancelSignRequest, isSignLocked } from '../../../messaging.js';
|
||||
import { styled } from '../../../styled.js';
|
||||
import Unlock from '../Unlock.js';
|
||||
|
||||
interface Props {
|
||||
buttonText: string;
|
||||
className?: string;
|
||||
error: string | null;
|
||||
isExternal?: boolean;
|
||||
isFirst: boolean;
|
||||
setError: (value: string | null) => void;
|
||||
signId: string;
|
||||
}
|
||||
|
||||
function SignArea ({ buttonText, className, error, isExternal, isFirst, setError, signId }: Props): React.ReactElement {
|
||||
const [savePass, setSavePass] = useState(false);
|
||||
const [isLocked, setIsLocked] = useState<boolean | null>(null);
|
||||
const [password, setPassword] = useState('');
|
||||
const [isBusy, setIsBusy] = useState(false);
|
||||
const onAction = useContext(ActionContext);
|
||||
const { t } = useTranslation();
|
||||
|
||||
useEffect(() => {
|
||||
setIsLocked(null);
|
||||
let timeout: ReturnType<typeof setTimeout>;
|
||||
|
||||
!isExternal && isSignLocked(signId)
|
||||
.then(({ isLocked, remainingTime }) => {
|
||||
setIsLocked(isLocked);
|
||||
timeout = setTimeout(() => {
|
||||
setIsLocked(true);
|
||||
}, remainingTime);
|
||||
|
||||
!isLocked && setSavePass(true);
|
||||
})
|
||||
.catch((error: Error) => console.error(error));
|
||||
|
||||
return () => {
|
||||
!!timeout && clearTimeout(timeout);
|
||||
};
|
||||
}, [isExternal, signId]);
|
||||
|
||||
const _onSign = useCallback(
|
||||
(): void => {
|
||||
setIsBusy(true);
|
||||
approveSignPassword(signId, savePass, password)
|
||||
.then((): void => {
|
||||
setIsBusy(false);
|
||||
onAction();
|
||||
})
|
||||
.catch((error: Error): void => {
|
||||
setIsBusy(false);
|
||||
setError(error.message);
|
||||
console.error(error);
|
||||
});
|
||||
},
|
||||
[onAction, password, savePass, setError, setIsBusy, signId]
|
||||
);
|
||||
|
||||
const _onCancel = useCallback(
|
||||
(): void => {
|
||||
cancelSignRequest(signId)
|
||||
.then(() => onAction())
|
||||
.catch((error: Error) => console.error(error));
|
||||
},
|
||||
[onAction, signId]
|
||||
);
|
||||
|
||||
const RememberPasswordCheckbox = () => (
|
||||
<Checkbox
|
||||
checked={savePass}
|
||||
label={isLocked
|
||||
? t(
|
||||
'Remember my password for the next {{expiration}} minutes',
|
||||
{ replace: { expiration: PASSWORD_EXPIRY_MIN } }
|
||||
)
|
||||
: t(
|
||||
'Extend the period without password by {{expiration}} minutes',
|
||||
{ replace: { expiration: PASSWORD_EXPIRY_MIN } }
|
||||
)
|
||||
}
|
||||
onChange={setSavePass}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<ButtonArea className={className}>
|
||||
{isFirst && !isExternal && (
|
||||
<>
|
||||
{isLocked && (
|
||||
<Unlock
|
||||
error={error}
|
||||
isBusy={isBusy}
|
||||
onSign={_onSign}
|
||||
password={password}
|
||||
setError={setError}
|
||||
setPassword={setPassword}
|
||||
/>
|
||||
)}
|
||||
<RememberPasswordCheckbox />
|
||||
<Button
|
||||
isBusy={isBusy}
|
||||
isDisabled={(!!isLocked && !password) || !!error}
|
||||
onClick={_onSign}
|
||||
>
|
||||
{buttonText}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<ActionBar className='cancelButton'>
|
||||
<Link
|
||||
isDanger
|
||||
onClick={_onCancel}
|
||||
>
|
||||
{t('Cancel')}
|
||||
</Link>
|
||||
</ActionBar>
|
||||
</ButtonArea>
|
||||
);
|
||||
}
|
||||
|
||||
export default styled(SignArea) <Props>`
|
||||
flex-direction: column;
|
||||
padding: 6px 24px;
|
||||
|
||||
.cancelButton {
|
||||
margin-top: 4px;
|
||||
margin-bottom: 4px;
|
||||
text-decoration: underline;
|
||||
|
||||
a {
|
||||
margin: auto;
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,197 @@
|
||||
// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { AccountJson, RequestSign } from '@pezkuwi/extension-base/background/types';
|
||||
import type { ExtrinsicPayload } from '@pezkuwi/types/interfaces';
|
||||
import type { SignerPayloadJSON, SignerPayloadRaw } from '@pezkuwi/types/types';
|
||||
import type { HexString } from '@pezkuwi/util/types';
|
||||
|
||||
import React, { useCallback, useContext, useEffect, useState } from 'react';
|
||||
|
||||
import { TypeRegistry } from '@pezkuwi/types';
|
||||
|
||||
import { ActionContext, Address, VerticalSpace, Warning } from '../../../components/index.js';
|
||||
import { useMetadata, useTranslation } from '../../../hooks/index.js';
|
||||
import { approveSignSignature } from '../../../messaging.js';
|
||||
import Bytes from '../Bytes.js';
|
||||
import Extrinsic from '../Extrinsic.js';
|
||||
import LedgerSign from '../LedgerSign.js';
|
||||
import Qr from '../Qr.js';
|
||||
import SignArea from './SignArea.js';
|
||||
|
||||
interface Props {
|
||||
account: AccountJson;
|
||||
buttonText: string;
|
||||
isFirst: boolean;
|
||||
request: RequestSign;
|
||||
signId: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
interface Data {
|
||||
hexBytes: string | null;
|
||||
payload: ExtrinsicPayload | null;
|
||||
}
|
||||
|
||||
export const CMD_MORTAL = 2;
|
||||
export const CMD_SIGN_MESSAGE = 3;
|
||||
|
||||
// keep it global, we can and will re-use this across requests
|
||||
const registry = new TypeRegistry();
|
||||
|
||||
function isRawPayload (payload: SignerPayloadJSON | SignerPayloadRaw): payload is SignerPayloadRaw {
|
||||
return !!(payload as SignerPayloadRaw).data;
|
||||
}
|
||||
|
||||
export default function Request ({ account: { accountIndex, addressOffset, genesisHash, isExternal, isHardware, type }, buttonText, isFirst, request, signId, url }: Props): React.ReactElement<Props> | null {
|
||||
const onAction = useContext(ActionContext);
|
||||
const [{ hexBytes, payload }, setData] = useState<Data>({ hexBytes: null, payload: null });
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const { t } = useTranslation();
|
||||
const chain = useMetadata(genesisHash);
|
||||
|
||||
useEffect((): void => {
|
||||
// When the chain and request are ready, configure the chain's registry.
|
||||
// This will be picked up by LedgerSign.
|
||||
if (chain && !isRawPayload(request.payload)) {
|
||||
chain.registry.setSignedExtensions(request.payload.signedExtensions, chain.definition.userExtensions);
|
||||
}
|
||||
}, [chain, request]);
|
||||
|
||||
useEffect((): void => {
|
||||
const payload = request.payload;
|
||||
|
||||
if (isRawPayload(payload)) {
|
||||
setData({
|
||||
hexBytes: payload.data,
|
||||
payload: null
|
||||
});
|
||||
} else {
|
||||
registry.setSignedExtensions(payload.signedExtensions);
|
||||
|
||||
setData({
|
||||
hexBytes: null,
|
||||
payload: registry.createType('ExtrinsicPayload', payload, { version: payload.version })
|
||||
});
|
||||
}
|
||||
}, [request]);
|
||||
|
||||
const _onSignature = useCallback(
|
||||
({ signature }: { signature: HexString }, signedTransaction?: HexString): void => {
|
||||
approveSignSignature(signId, signature, signedTransaction)
|
||||
.then(() => onAction())
|
||||
.catch((error: Error): void => {
|
||||
setError(error.message);
|
||||
console.error(error);
|
||||
});
|
||||
},
|
||||
[onAction, signId]
|
||||
);
|
||||
|
||||
if (payload !== null) {
|
||||
const json = request.payload as SignerPayloadJSON;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<Address
|
||||
address={json.address}
|
||||
genesisHash={json.genesisHash}
|
||||
isExternal={isExternal}
|
||||
isHardware={isHardware}
|
||||
/>
|
||||
</div>
|
||||
{isExternal && !isHardware
|
||||
? (
|
||||
<Qr
|
||||
address={json.address}
|
||||
cmd={CMD_MORTAL}
|
||||
genesisHash={json.genesisHash}
|
||||
onSignature={_onSignature}
|
||||
payload={payload}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<Extrinsic
|
||||
payload={payload}
|
||||
request={json}
|
||||
url={url}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{isHardware && (
|
||||
<LedgerSign
|
||||
accountIndex={accountIndex || 0}
|
||||
addressOffset={addressOffset || 0}
|
||||
error={error}
|
||||
genesisHash={json.genesisHash}
|
||||
isEthereum={type === 'ethereum'}
|
||||
onSignature={_onSignature}
|
||||
payloadExt={payload}
|
||||
payloadJson={json}
|
||||
setError={setError}
|
||||
/>
|
||||
)}
|
||||
<SignArea
|
||||
buttonText={buttonText}
|
||||
error={error}
|
||||
isExternal={isExternal}
|
||||
isFirst={isFirst}
|
||||
setError={setError}
|
||||
signId={signId}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
} else if (hexBytes !== null) {
|
||||
const { address, data } = request.payload as SignerPayloadRaw;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<Address
|
||||
address={address}
|
||||
isExternal={isExternal}
|
||||
/>
|
||||
</div>
|
||||
{isExternal && !isHardware && genesisHash
|
||||
? (
|
||||
<Qr
|
||||
address={address}
|
||||
cmd={CMD_SIGN_MESSAGE}
|
||||
genesisHash={genesisHash}
|
||||
onSignature={_onSignature}
|
||||
payload={data}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<Bytes
|
||||
bytes={data}
|
||||
url={url}
|
||||
/>
|
||||
)
|
||||
}
|
||||
<VerticalSpace />
|
||||
{isExternal && !isHardware && !genesisHash && (
|
||||
<>
|
||||
<Warning isDanger>{t('"Allow use on any network" is not supported to show a QR code. You must associate this account with a network.')}</Warning>
|
||||
<VerticalSpace />
|
||||
</>
|
||||
)}
|
||||
{isHardware && <>
|
||||
<Warning>{t('Message signing is not supported for hardware wallets.')}</Warning>
|
||||
<VerticalSpace />
|
||||
</>}
|
||||
<SignArea
|
||||
buttonText={buttonText}
|
||||
error={error}
|
||||
isExternal={isExternal}
|
||||
isFirst={isFirst}
|
||||
setError={setError}
|
||||
signId={signId}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,359 @@
|
||||
// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import '@pezkuwi/extension-mocks/chrome';
|
||||
|
||||
import type { ReactWrapper } from 'enzyme';
|
||||
import type * as _ from '@pezkuwi/dev-test/globals.d.ts';
|
||||
import type { SigningRequest } from '@pezkuwi/extension-base/background/types';
|
||||
|
||||
import Adapter from '@wojtekmaj/enzyme-adapter-react-17';
|
||||
import enzyme from 'enzyme';
|
||||
import { EventEmitter } from 'events';
|
||||
import React, { useState } from 'react';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
|
||||
import { ActionContext, Address, Button, Input, SigningReqContext } from '../../components/index.js';
|
||||
import * as messaging from '../../messaging.js';
|
||||
import * as MetadataCache from '../../MetadataCache.js';
|
||||
import { flushAllPromises } from '../../testHelpers.js';
|
||||
import Request from './Request/index.js';
|
||||
import Extrinsic from './Extrinsic.js';
|
||||
import Signing from './index.js';
|
||||
import { westendMetadata } from './metadataMock.js';
|
||||
import Qr from './Qr.js';
|
||||
import TransactionIndex from './TransactionIndex.js';
|
||||
|
||||
const { configure, mount } = enzyme;
|
||||
|
||||
// // NOTE Required for spyOn when using @swc/jest
|
||||
// // https://github.com/swc-project/swc/issues/3843
|
||||
// jest.mock('../../messaging', (): Record<string, unknown> => ({
|
||||
// __esModule: true,
|
||||
// ...jest.requireActual('../../messaging')
|
||||
// }));
|
||||
|
||||
// jest.mock('../../MetadataCache', (): Record<string, unknown> => ({
|
||||
// __esModule: true,
|
||||
// ...jest.requireActual('../../MetadataCache')
|
||||
// }));
|
||||
|
||||
// For this file, there are a lot of them
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-argument */
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment,@typescript-eslint/no-unsafe-call
|
||||
configure({ adapter: new Adapter() });
|
||||
|
||||
describe('Signing requests', () => {
|
||||
let wrapper: ReactWrapper;
|
||||
let onActionStub: ReturnType<typeof jest.fn>;
|
||||
let signRequests: SigningRequest[] = [];
|
||||
|
||||
const emitter = new EventEmitter();
|
||||
|
||||
function MockRequestsProvider (): React.ReactElement {
|
||||
const [requests, setRequests] = useState(signRequests);
|
||||
|
||||
emitter.on('request', setRequests);
|
||||
|
||||
return (
|
||||
<SigningReqContext.Provider value={requests}>
|
||||
<Signing />
|
||||
</SigningReqContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
const mountComponent = async (): Promise<void> => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
wrapper = mount(
|
||||
<ActionContext.Provider value={onActionStub}>
|
||||
<MockRequestsProvider />
|
||||
</ActionContext.Provider>
|
||||
);
|
||||
await act(flushAllPromises);
|
||||
wrapper.update();
|
||||
};
|
||||
|
||||
const check = (input: ReactWrapper): unknown => input.simulate('change', { target: { checked: true } });
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.spyOn(messaging, 'cancelSignRequest').mockImplementation(() => Promise.resolve(true));
|
||||
jest.spyOn(messaging, 'approveSignPassword').mockImplementation(() => Promise.resolve(true));
|
||||
jest.spyOn(messaging, 'isSignLocked').mockImplementation(() => Promise.resolve({ isLocked: true, remainingTime: 0 }));
|
||||
jest.spyOn(MetadataCache, 'getSavedMeta').mockImplementation(() => Promise.resolve(westendMetadata));
|
||||
|
||||
signRequests = [
|
||||
{
|
||||
account: {
|
||||
address: '5D4bqjQRPgdMBK8bNvhX4tSuCtSGZS7rZjD5XH5SoKcFeKn5',
|
||||
genesisHash: null,
|
||||
isHidden: false,
|
||||
name: 'acc1',
|
||||
parentAddress: '5Ggap6soAPaP5UeNaiJsgqQwdVhhNnm6ez7Ba1w9jJ62LM2Q',
|
||||
suri: '//0',
|
||||
whenCreated: 1602001346486
|
||||
},
|
||||
id: '1607347015530.2',
|
||||
request: {
|
||||
payload: {
|
||||
address: '5D4bqjQRPgdMBK8bNvhX4tSuCtSGZS7rZjD5XH5SoKcFeKn5',
|
||||
blockHash: '0x661f57d206d4fecda0408943427d4d25436518acbff543735e7569da9db6bdd7',
|
||||
blockNumber: '0x0033fa6b',
|
||||
era: '0xb502',
|
||||
genesisHash: '0xe143f23803ac50e8f6f8e62695d1ce9e4e1d68aa36c1cd2cfd15340213f3423e',
|
||||
method: '0x0403c6111b239376e5e8b983dc2d2459cbb6caed64cc1d21723973d061ae0861ef690b00b04e2bde6f',
|
||||
nonce: '0x00000003',
|
||||
signedExtensions: [
|
||||
'CheckSpecVersion',
|
||||
'CheckTxVersion',
|
||||
'CheckGenesis',
|
||||
'CheckMortality',
|
||||
'CheckNonce',
|
||||
'CheckWeight',
|
||||
'ChargeTransactionPayment'
|
||||
],
|
||||
specVersion: '0x0000002d',
|
||||
tip: '0x00000000000000000000000000000000',
|
||||
transactionVersion: '0x00000003',
|
||||
version: 4
|
||||
},
|
||||
sign: jest.fn()
|
||||
},
|
||||
url: 'https://js.pezkuwichain.app/apps/?rpc=wss%3A%2F%2Fwestend-rpc.polkadot.io#/accounts'
|
||||
},
|
||||
{
|
||||
account: {
|
||||
address: '5Ggap6soAPaP5UeNaiJsgqQwdVhhNnm6ez7Ba1w9jJ62LM2Q',
|
||||
genesisHash: null,
|
||||
isHidden: false,
|
||||
name: 'acc 2',
|
||||
suri: '//0',
|
||||
whenCreated: 1602001346486
|
||||
},
|
||||
id: '1607356155395.3',
|
||||
request: {
|
||||
payload: {
|
||||
address: '5Ggap6soAPaP5UeNaiJsgqQwdVhhNnm6ez7Ba1w9jJ62LM2Q',
|
||||
blockHash: '0xcf69b7935b785f90b22d2b36f2227132ef9c5dd33db1dbac9ecdafac05bf9476',
|
||||
blockNumber: '0x0036269a',
|
||||
era: '0xa501',
|
||||
genesisHash: '0xe143f23803ac50e8f6f8e62695d1ce9e4e1d68aa36c1cd2cfd15340213f3423e',
|
||||
method: '0x0400cc4e0e2848c488896dd0a24f153070e85e3c83f6199cfc942ab6de29c56c2d7b0700d0ed902e',
|
||||
nonce: '0x00000003',
|
||||
signedExtensions: [
|
||||
'CheckSpecVersion',
|
||||
'CheckTxVersion',
|
||||
'CheckGenesis',
|
||||
'CheckMortality',
|
||||
'CheckNonce',
|
||||
'CheckWeight',
|
||||
'ChargeTransactionPayment'
|
||||
],
|
||||
specVersion: '0x0000002d',
|
||||
tip: '0x00000000000000000000000000000000',
|
||||
transactionVersion: '0x00000003',
|
||||
version: 4
|
||||
},
|
||||
sign: jest.fn()
|
||||
},
|
||||
url: 'https://js.pezkuwichain.app/apps'
|
||||
}
|
||||
];
|
||||
onActionStub = jest.fn();
|
||||
await mountComponent();
|
||||
});
|
||||
|
||||
describe('Switching between requests', () => {
|
||||
it('initially first request should be shown', () => {
|
||||
expect(wrapper.find(TransactionIndex).text()).toBe('1/2');
|
||||
expect(wrapper.find(Request).prop('signId')).toBe(signRequests[0].id);
|
||||
});
|
||||
|
||||
it('only the right arrow should be active on first screen', async () => {
|
||||
expect(wrapper.find('FontAwesomeIcon.arrowLeft')).toHaveLength(1);
|
||||
expect(wrapper.find('FontAwesomeIcon.arrowLeft.active')).toHaveLength(0);
|
||||
expect(wrapper.find('FontAwesomeIcon.arrowRight.active')).toHaveLength(1);
|
||||
wrapper.find('FontAwesomeIcon.arrowLeft').simulate('click');
|
||||
await act(flushAllPromises);
|
||||
|
||||
expect(wrapper.find(TransactionIndex).text()).toBe('1/2');
|
||||
});
|
||||
|
||||
it('should display second request after clicking right arrow', async () => {
|
||||
wrapper.find('FontAwesomeIcon.arrowRight').simulate('click');
|
||||
await act(flushAllPromises);
|
||||
|
||||
expect(wrapper.find(TransactionIndex).text()).toBe('2/2');
|
||||
expect(wrapper.find(Request).prop('signId')).toBe(signRequests[1].id);
|
||||
});
|
||||
|
||||
it('only the left should be active on second screen', async () => {
|
||||
wrapper.find('FontAwesomeIcon.arrowRight').simulate('click');
|
||||
await act(flushAllPromises);
|
||||
|
||||
expect(wrapper.find('FontAwesomeIcon.arrowLeft.active')).toHaveLength(1);
|
||||
expect(wrapper.find('FontAwesomeIcon.arrowRight')).toHaveLength(1);
|
||||
expect(wrapper.find('FontAwesomeIcon.arrowRight.active')).toHaveLength(0);
|
||||
expect(wrapper.find(TransactionIndex).text()).toBe('2/2');
|
||||
});
|
||||
|
||||
it('should display previous request after the left arrow has been clicked', async () => {
|
||||
wrapper.find('FontAwesomeIcon.arrowRight').simulate('click');
|
||||
await act(flushAllPromises);
|
||||
wrapper.find('FontAwesomeIcon.arrowLeft').simulate('click');
|
||||
await act(flushAllPromises);
|
||||
|
||||
expect(wrapper.find(TransactionIndex).text()).toBe('1/2');
|
||||
expect(wrapper.find(Request).prop('signId')).toBe(signRequests[0].id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('External account', () => {
|
||||
it('shows Qr scanner for external accounts', async () => {
|
||||
signRequests = [{
|
||||
account: {
|
||||
address: '5Cf1CGZas62RWwce3d2EPqUvSoi1txaXKd9M5w9bEFSsQtRe',
|
||||
genesisHash: null,
|
||||
isExternal: true,
|
||||
isHidden: false,
|
||||
name: 'Dave account on Signer ',
|
||||
whenCreated: 1602085704296
|
||||
},
|
||||
id: '1607357806151.5',
|
||||
request: {
|
||||
payload: {
|
||||
address: '5Cf1CGZas62RWwce3d2EPqUvSoi1txaXKd9M5w9bEFSsQtRe',
|
||||
blockHash: '0xd2f2dfb56c16af1d0faf5b454153d3199aeb6647537f4161c26a34541c591ec8',
|
||||
blockNumber: '0x00340171',
|
||||
era: '0x1503',
|
||||
genesisHash: '0xe143f23803ac50e8f6f8e62695d1ce9e4e1d68aa36c1cd2cfd15340213f3423e',
|
||||
method: '0x0403c6111b239376e5e8b983dc2d2459cbb6caed64cc1d21723973d061ae0861ef690b00b04e2bde6f',
|
||||
nonce: '0x00000000',
|
||||
signedExtensions: [
|
||||
'CheckSpecVersion',
|
||||
'CheckTxVersion',
|
||||
'CheckGenesis',
|
||||
'CheckMortality',
|
||||
'CheckNonce',
|
||||
'CheckWeight',
|
||||
'ChargeTransactionPayment'
|
||||
],
|
||||
specVersion: '0x0000002d',
|
||||
tip: '0x00000000000000000000000000000000',
|
||||
transactionVersion: '0x00000003',
|
||||
version: 4
|
||||
},
|
||||
sign: jest.fn()
|
||||
},
|
||||
url: 'https://js.pezkuwichain.app/apps/?rpc=wss%3A%2F%2Fwestend-rpc.polkadot.io#/accounts'
|
||||
}];
|
||||
await mountComponent();
|
||||
expect(wrapper.find(Extrinsic)).toHaveLength(0);
|
||||
expect(wrapper.find(Qr)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Request rendering', () => {
|
||||
it('correctly displays request 1', () => {
|
||||
expect(wrapper.find(Address).find('.fullAddress').text()).toBe(signRequests[0].account.address);
|
||||
expect(wrapper.find(Extrinsic).find('td.data').map((el): string => el.text())).toEqual([
|
||||
'https://js.pezkuwichain.app/apps/?rpc=wss%3A%2F%2Fwestend-rpc.polkadot.io#/accounts',
|
||||
'Westend',
|
||||
'45',
|
||||
'3',
|
||||
`balances.transferKeepAlive(dest, value){
|
||||
"dest": "5GYQRJj3NUznYDzCduENRcocMsyxmb6tjb5xW87ZMErBe9R7",
|
||||
"value": "123.0000 WND"
|
||||
}`,
|
||||
'Same as the [`transfer`] call, but with a check that the transfer will not kill the origin account.',
|
||||
'mortal, valid from {{birth}} to {{death}}'
|
||||
]);
|
||||
});
|
||||
|
||||
it('correctly displays request 2', async () => {
|
||||
wrapper.find('FontAwesomeIcon.arrowRight').simulate('click');
|
||||
await act(flushAllPromises);
|
||||
|
||||
expect(wrapper.find(Address).find('.fullAddress').text()).toBe(signRequests[1].account.address);
|
||||
expect(wrapper.find(Extrinsic).find('td.data').map((el): string => el.text())).toEqual([
|
||||
'https://js.pezkuwichain.app/apps',
|
||||
'Westend',
|
||||
'45',
|
||||
'3',
|
||||
`balances.transfer(dest, value){
|
||||
"dest": "5Ggap6soAPaP5UeNaiJsgqQwdVhhNnm6ez7Ba1w9jJ62LM2Q",
|
||||
"value": "200.0000 mWND"
|
||||
}`,
|
||||
'Transfer some liquid free balance to another account.',
|
||||
'mortal, valid from {{birth}} to {{death}}'
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Submitting', () => {
|
||||
it('passes request id to cancel call', async () => {
|
||||
wrapper.find('.cancelButton').find('a').simulate('click');
|
||||
await act(flushAllPromises);
|
||||
|
||||
expect(messaging.cancelSignRequest).toHaveBeenCalledWith(signRequests[0].id);
|
||||
});
|
||||
|
||||
it('passes request id and password to approve call', async () => {
|
||||
wrapper.find(Input).simulate('change', { target: { value: 'hunter1' } });
|
||||
await act(flushAllPromises);
|
||||
|
||||
wrapper.find(Button).find('button').simulate('click');
|
||||
await act(flushAllPromises);
|
||||
wrapper.update();
|
||||
|
||||
expect(messaging.approveSignPassword).toHaveBeenCalledWith(signRequests[0].id, false, 'hunter1');
|
||||
});
|
||||
|
||||
it('asks the background to cache the password when the relevant checkbox is checked', async () => {
|
||||
check(wrapper.find('input[type="checkbox"]'));
|
||||
await act(flushAllPromises);
|
||||
|
||||
wrapper.find(Input).simulate('change', { target: { value: 'hunter1' } });
|
||||
await act(flushAllPromises);
|
||||
|
||||
wrapper.find(Button).find('button').simulate('click');
|
||||
await act(flushAllPromises);
|
||||
wrapper.update();
|
||||
|
||||
expect(messaging.approveSignPassword).toHaveBeenCalledWith(signRequests[0].id, true, 'hunter1');
|
||||
});
|
||||
|
||||
it('shows an error when the password is wrong', async () => {
|
||||
// silencing the following expected console.error
|
||||
console.error = jest.fn();
|
||||
// eslint-disable-next-line @typescript-eslint/require-await
|
||||
jest.spyOn(messaging, 'approveSignPassword').mockImplementation(async () => {
|
||||
throw new Error('Unable to decode using the supplied passphrase');
|
||||
});
|
||||
|
||||
wrapper.find(Input).simulate('change', { target: { value: 'anything' } });
|
||||
await act(flushAllPromises);
|
||||
|
||||
wrapper.find(Button).find('button').simulate('click');
|
||||
await act(flushAllPromises);
|
||||
wrapper.update();
|
||||
|
||||
expect(wrapper.find('.warning-message').first().text()).toBe('Unable to decode using the supplied passphrase');
|
||||
});
|
||||
|
||||
it('when last request has been removed/cancelled, shows the previous one', async () => {
|
||||
wrapper.find('FontAwesomeIcon.arrowRight').simulate('click');
|
||||
await act(flushAllPromises);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
act(() => {
|
||||
emitter.emit('request', [signRequests[0]]);
|
||||
});
|
||||
await act(flushAllPromises);
|
||||
wrapper.update();
|
||||
|
||||
expect(wrapper.find(TransactionIndex)).toHaveLength(0);
|
||||
expect(wrapper.find(Request).prop('signId')).toBe(signRequests[0].id);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import { faArrowLeft, faArrowRight } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import React, { useCallback } from 'react';
|
||||
|
||||
import { styled } from '../../styled.js';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
index: number;
|
||||
totalItems: number;
|
||||
onNextClick: () => void;
|
||||
onPreviousClick: () => void;
|
||||
}
|
||||
|
||||
function TransactionIndex ({ className, index, onNextClick, onPreviousClick, totalItems }: Props): React.ReactElement<Props> {
|
||||
const previousClickActive = index !== 0;
|
||||
const nextClickActive = index < totalItems - 1;
|
||||
|
||||
const prevClick = useCallback(
|
||||
(): void => {
|
||||
previousClickActive && onPreviousClick();
|
||||
},
|
||||
[onPreviousClick, previousClickActive]
|
||||
);
|
||||
|
||||
const nextClick = useCallback(
|
||||
(): void => {
|
||||
nextClickActive && onNextClick();
|
||||
},
|
||||
[nextClickActive, onNextClick]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<div>
|
||||
<span className='currentStep'>{index + 1}</span>
|
||||
<span className='totalSteps'>/{totalItems}</span>
|
||||
</div>
|
||||
<div>
|
||||
<FontAwesomeIcon
|
||||
className={`arrowLeft ${previousClickActive ? 'active' : ''}`}
|
||||
icon={faArrowLeft}
|
||||
onClick={prevClick}
|
||||
size='sm'
|
||||
/>
|
||||
<FontAwesomeIcon
|
||||
className={`arrowRight ${nextClickActive ? 'active' : ''}`}
|
||||
icon={faArrowRight}
|
||||
onClick={nextClick}
|
||||
size='sm'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default styled(TransactionIndex)<Props>`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
flex-grow: 1;
|
||||
padding-right: 24px;
|
||||
|
||||
.arrowLeft, .arrowRight {
|
||||
display: inline-block;
|
||||
color: var(--iconNeutralColor);
|
||||
|
||||
&.active {
|
||||
color: var(--primaryColor);
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.arrowRight {
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
.currentStep {
|
||||
color: var(--primaryColor);
|
||||
font-size: var(--labelFontSize);
|
||||
line-height: var(--labelLineHeight);
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.totalSteps {
|
||||
font-size: var(--labelFontSize);
|
||||
line-height: var(--labelLineHeight);
|
||||
color: var(--textColor);
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,55 @@
|
||||
// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import React, { useCallback } from 'react';
|
||||
|
||||
import { InputWithLabel, Warning } from '../../components/index.js';
|
||||
import { useTranslation } from '../../hooks/index.js';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
error?: string | null;
|
||||
isBusy: boolean;
|
||||
onSign: () => void;
|
||||
password: string;
|
||||
setError: (error: string | null) => void;
|
||||
setPassword: (password: string) => void;
|
||||
}
|
||||
|
||||
function Unlock ({ className, error, isBusy, onSign, password, setError, setPassword }: Props): React.ReactElement<Props> {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const _onChangePassword = useCallback(
|
||||
(password: string): void => {
|
||||
setPassword(password);
|
||||
setError(null);
|
||||
},
|
||||
[setError, setPassword]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<InputWithLabel
|
||||
disabled={isBusy}
|
||||
isError={!password || !!error}
|
||||
isFocused
|
||||
label={t('Password for this account')}
|
||||
onChange={_onChangePassword}
|
||||
onEnter={onSign}
|
||||
type='password'
|
||||
value={password}
|
||||
withoutMargin={true}
|
||||
/>
|
||||
{error && (
|
||||
<Warning
|
||||
isBelowInput
|
||||
isDanger
|
||||
>
|
||||
{error}
|
||||
</Warning>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(Unlock);
|
||||
@@ -0,0 +1,71 @@
|
||||
// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { SignerPayloadJSON } from '@pezkuwi/types/types';
|
||||
|
||||
import React, { useCallback, useContext, useEffect, useState } from 'react';
|
||||
|
||||
import { Loading, SigningReqContext } from '../../components/index.js';
|
||||
import { useTranslation } from '../../hooks/index.js';
|
||||
import { Header } from '../../partials/index.js';
|
||||
import Request from './Request/index.js';
|
||||
import TransactionIndex from './TransactionIndex.js';
|
||||
|
||||
export default function Signing (): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const requests = useContext(SigningReqContext);
|
||||
const [requestIndex, setRequestIndex] = useState(0);
|
||||
|
||||
const _onNextClick = useCallback(
|
||||
() => setRequestIndex((requestIndex) => requestIndex + 1),
|
||||
[]
|
||||
);
|
||||
|
||||
const _onPreviousClick = useCallback(
|
||||
() => setRequestIndex((requestIndex) => requestIndex - 1),
|
||||
[]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setRequestIndex(
|
||||
(requestIndex) => requestIndex < requests.length
|
||||
? requestIndex
|
||||
: requests.length - 1
|
||||
);
|
||||
}, [requests]);
|
||||
|
||||
// protect against removal overflows/underflows
|
||||
const request = requests.length !== 0
|
||||
? requestIndex >= 0
|
||||
? requestIndex < requests.length
|
||||
? requests[requestIndex]
|
||||
: requests[requests.length - 1]
|
||||
: requests[0]
|
||||
: null;
|
||||
const isTransaction = !!((request?.request?.payload as SignerPayloadJSON)?.blockNumber);
|
||||
|
||||
return request
|
||||
? (
|
||||
<>
|
||||
<Header text={isTransaction ? t('Transaction') : t('Sign message')}>
|
||||
{requests.length > 1 && (
|
||||
<TransactionIndex
|
||||
index={requestIndex}
|
||||
onNextClick={_onNextClick}
|
||||
onPreviousClick={_onPreviousClick}
|
||||
totalItems={requests.length}
|
||||
/>
|
||||
)}
|
||||
</Header>
|
||||
<Request
|
||||
account={request.account}
|
||||
buttonText={isTransaction ? t('Sign the transaction') : t('Sign the message')}
|
||||
isFirst={requestIndex === 0}
|
||||
request={request.request}
|
||||
signId={request.id}
|
||||
url={request.url}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
: <Loading />;
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,55 @@
|
||||
// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import React, { useCallback, useContext } from 'react';
|
||||
|
||||
import { ActionContext, Box, Button, ButtonArea, List, VerticalSpace } from '../components/index.js';
|
||||
import { useTranslation } from '../hooks/index.js';
|
||||
import { Header } from '../partials/index.js';
|
||||
import { styled } from '../styled.js';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function Welcome ({ className }: Props): React.ReactElement<Props> {
|
||||
const { t } = useTranslation();
|
||||
const onAction = useContext(ActionContext);
|
||||
|
||||
const _onClick = useCallback(
|
||||
(): void => {
|
||||
window.localStorage.setItem('welcome_read', 'ok');
|
||||
onAction();
|
||||
},
|
||||
[onAction]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header text={t('Welcome')} />
|
||||
<div className={className}>
|
||||
<p>{t('Before we start, just a couple of notes regarding use:')}</p>
|
||||
<Box>
|
||||
<List>
|
||||
<li>{t('We do not send any clicks, pageviews or events to a central server')}</li>
|
||||
<li>{t('We do not use any trackers or analytics')}</li>
|
||||
<li>{t("We don't collect keys, addresses or any information - your information never leaves this machine")}</li>
|
||||
</List>
|
||||
</Box>
|
||||
<p>{t('... we are not in the information collection business (even anonymized).')}</p>
|
||||
</div>
|
||||
<VerticalSpace />
|
||||
<ButtonArea>
|
||||
<Button onClick={_onClick}>{t('Understood, let me continue')}</Button>
|
||||
</ButtonArea>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default styled(Welcome)<Props>`
|
||||
p {
|
||||
color: var(--subTextColor);
|
||||
margin-bottom: 6px;
|
||||
margin-top: 0;
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,189 @@
|
||||
// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { AccountJson, AccountsContext, AuthorizeRequest, MetadataRequest, SigningRequest } from '@pezkuwi/extension-base/background/types';
|
||||
import type { SettingsStruct } from '@pezkuwi/ui-settings/types';
|
||||
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { Route, Switch, useHistory } from 'react-router';
|
||||
|
||||
import { PHISHING_PAGE_REDIRECT } from '@pezkuwi/extension-base/defaults';
|
||||
import { canDerive } from '@pezkuwi/extension-base/utils';
|
||||
import { settings } from '@pezkuwi/ui-settings';
|
||||
|
||||
import { AccountContext, ActionContext, AuthorizeReqContext, MediaContext, MetadataReqContext, SettingsContext, SigningReqContext } from '../components/contexts.js';
|
||||
import { ErrorBoundary, Loading } from '../components/index.js';
|
||||
import ToastProvider from '../components/Toast/ToastProvider.js';
|
||||
import { ping, subscribeAccounts, subscribeAuthorizeRequests, subscribeMetadataRequests, subscribeSigningRequests } from '../messaging.js';
|
||||
import { buildHierarchy } from '../util/buildHierarchy.js';
|
||||
import Accounts from './Accounts/index.js';
|
||||
import AccountManagement from './AuthManagement/AccountManagement.js';
|
||||
import AuthList from './AuthManagement/index.js';
|
||||
import Authorize from './Authorize/index.js';
|
||||
import CreateAccount from './CreateAccount/index.js';
|
||||
import Derive from './Derive/index.js';
|
||||
import ImportSeed from './ImportSeed/index.js';
|
||||
import Metadata from './Metadata/index.js';
|
||||
import Signing from './Signing/index.js';
|
||||
import AssetHubMigration from './AssetHubMigration.js';
|
||||
import Export from './Export.js';
|
||||
import ExportAll from './ExportAll.js';
|
||||
import Forget from './Forget.js';
|
||||
import ImportLedger from './ImportLedger.js';
|
||||
import ImportQr from './ImportQr.js';
|
||||
import PhishingDetected from './PhishingDetected.js';
|
||||
import RestoreJson from './RestoreJson.js';
|
||||
import Welcome from './Welcome.js';
|
||||
|
||||
const startSettings = settings.get();
|
||||
|
||||
// Request permission for video, based on access we can hide/show import
|
||||
async function requestMediaAccess (cameraOn: boolean): Promise<boolean> {
|
||||
if (!cameraOn) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
await navigator.mediaDevices.getUserMedia({ video: true });
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Permission for video declined', (error as Error).message);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function initAccountContext ({ accounts, selectedAccounts, setSelectedAccounts }: Omit<AccountsContext, 'hierarchy' | 'master'>): AccountsContext {
|
||||
const hierarchy = buildHierarchy(accounts);
|
||||
const master = hierarchy.find(({ isExternal, type }) => !isExternal && canDerive(type));
|
||||
|
||||
return {
|
||||
accounts,
|
||||
hierarchy,
|
||||
master,
|
||||
selectedAccounts,
|
||||
setSelectedAccounts
|
||||
};
|
||||
}
|
||||
|
||||
export default function Popup (): React.ReactElement {
|
||||
const [accounts, setAccounts] = useState<null | AccountJson[]>(null);
|
||||
const [accountCtx, setAccountCtx] = useState<AccountsContext>({ accounts: [], hierarchy: [] });
|
||||
const [selectedAccounts, setSelectedAccounts] = useState<AccountJson['address'][]>([]);
|
||||
const [authRequests, setAuthRequests] = useState<null | AuthorizeRequest[]>(null);
|
||||
const [cameraOn, setCameraOn] = useState(startSettings.camera === 'on');
|
||||
const [mediaAllowed, setMediaAllowed] = useState(false);
|
||||
const [metaRequests, setMetaRequests] = useState<null | MetadataRequest[]>(null);
|
||||
const [signRequests, setSignRequests] = useState<null | SigningRequest[]>(null);
|
||||
const [isWelcomeDone, setWelcomeDone] = useState(false);
|
||||
const [isMigrationDone, setMigrationDone] = useState(false);
|
||||
const [settingsCtx, setSettingsCtx] = useState<SettingsStruct>(startSettings);
|
||||
const history = useHistory();
|
||||
|
||||
const _onAction = useCallback(
|
||||
(to?: string): void => {
|
||||
setWelcomeDone(window.localStorage.getItem('welcome_read') === 'ok');
|
||||
setMigrationDone(window.localStorage.getItem('asset_hub_migration_read') === 'ok');
|
||||
|
||||
if (!to) {
|
||||
return;
|
||||
}
|
||||
|
||||
to === '../index.js'
|
||||
// if we can't go gack from there, go to the home
|
||||
? history.length === 1
|
||||
? history.push('/')
|
||||
: history.goBack()
|
||||
: window.location.hash = to;
|
||||
},
|
||||
[history]
|
||||
);
|
||||
|
||||
useEffect((): void => {
|
||||
// initially send a ping message to create a port that will be reused for subsequent
|
||||
// messages. This ensure onConnect event is fired only once
|
||||
ping().then(() => Promise.all([
|
||||
subscribeAccounts(setAccounts),
|
||||
subscribeAuthorizeRequests(setAuthRequests),
|
||||
subscribeMetadataRequests(setMetaRequests),
|
||||
subscribeSigningRequests(setSignRequests)
|
||||
])).catch(console.error);
|
||||
|
||||
settings.on('change', (settings): void => {
|
||||
setSettingsCtx(settings);
|
||||
setCameraOn(settings.camera === 'on');
|
||||
});
|
||||
|
||||
_onAction();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect((): void => {
|
||||
setAccountCtx(initAccountContext({ accounts: accounts || [], selectedAccounts, setSelectedAccounts }));
|
||||
}, [accounts, selectedAccounts]);
|
||||
|
||||
useEffect((): void => {
|
||||
requestMediaAccess(cameraOn)
|
||||
.then(setMediaAllowed)
|
||||
.catch(console.error);
|
||||
}, [cameraOn]);
|
||||
|
||||
function wrapWithErrorBoundary (component: React.ReactElement, trigger?: string): React.ReactElement {
|
||||
return <ErrorBoundary trigger={trigger}>{component}</ErrorBoundary>;
|
||||
}
|
||||
|
||||
const Root = !isWelcomeDone
|
||||
? wrapWithErrorBoundary(<Welcome />, 'welcome')
|
||||
: !isMigrationDone
|
||||
? wrapWithErrorBoundary(<AssetHubMigration />, 'asset-hub-migration')
|
||||
: authRequests?.length
|
||||
? wrapWithErrorBoundary(<Authorize />, 'authorize')
|
||||
: metaRequests?.length
|
||||
? wrapWithErrorBoundary(<Metadata />, 'metadata')
|
||||
: signRequests?.length
|
||||
? wrapWithErrorBoundary(<Signing />, 'signing')
|
||||
: wrapWithErrorBoundary(<Accounts />, 'accounts');
|
||||
|
||||
return (
|
||||
<Loading>{accounts && authRequests && metaRequests && signRequests && (
|
||||
<ActionContext.Provider value={_onAction}>
|
||||
<SettingsContext.Provider value={settingsCtx}>
|
||||
<AccountContext.Provider value={accountCtx}>
|
||||
<AuthorizeReqContext.Provider value={authRequests}>
|
||||
<MediaContext.Provider value={cameraOn && mediaAllowed}>
|
||||
<MetadataReqContext.Provider value={metaRequests}>
|
||||
<SigningReqContext.Provider value={signRequests}>
|
||||
<ToastProvider>
|
||||
<Switch>
|
||||
<Route path='/auth-list'>{wrapWithErrorBoundary(<AuthList />, 'auth-list')}</Route>
|
||||
<Route path='/account/create'>{wrapWithErrorBoundary(<CreateAccount />, 'account-creation')}</Route>
|
||||
<Route path='/account/forget/:address'>{wrapWithErrorBoundary(<Forget />, 'forget-address')}</Route>
|
||||
<Route path='/account/export/:address'>{wrapWithErrorBoundary(<Export />, 'export-address')}</Route>
|
||||
<Route path='/account/export-all'>{wrapWithErrorBoundary(<ExportAll />, 'export-all-address')}</Route>
|
||||
<Route path='/account/import-ledger'>{wrapWithErrorBoundary(<ImportLedger />, 'import-ledger')}</Route>
|
||||
<Route path='/account/import-qr'>{wrapWithErrorBoundary(<ImportQr />, 'import-qr')}</Route>
|
||||
<Route path='/account/import-seed'>{wrapWithErrorBoundary(<ImportSeed />, 'import-seed')}</Route>
|
||||
<Route path='/account/restore-json'>{wrapWithErrorBoundary(<RestoreJson />, 'restore-json')}</Route>
|
||||
<Route path='/account/derive/:address/locked'>{wrapWithErrorBoundary(<Derive isLocked />, 'derived-address-locked')}</Route>
|
||||
<Route path='/account/derive/:address'>{wrapWithErrorBoundary(<Derive />, 'derive-address')}</Route>
|
||||
<Route path='/url/manage/:url'>{wrapWithErrorBoundary(<AccountManagement />, 'manage-url')}</Route>
|
||||
<Route path={`${PHISHING_PAGE_REDIRECT}/:website`}>{wrapWithErrorBoundary(<PhishingDetected />, 'phishing-page-redirect')}</Route>
|
||||
<Route
|
||||
exact
|
||||
path='/'
|
||||
>
|
||||
{Root}
|
||||
</Route>
|
||||
</Switch>
|
||||
</ToastProvider>
|
||||
</SigningReqContext.Provider>
|
||||
</MetadataReqContext.Provider>
|
||||
</MediaContext.Provider>
|
||||
</AuthorizeReqContext.Provider>
|
||||
</AccountContext.Provider>
|
||||
</SettingsContext.Provider>
|
||||
</ActionContext.Provider>
|
||||
)}</Loading>
|
||||
);
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
declare module '@wojtekmaj/enzyme-adapter-react-17';
|
||||
@@ -0,0 +1 @@
|
||||
<svg width="8" height="6" viewBox="0 0 8 6" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M8 0L3.97 6L0 0H8Z" fill="#585858"/></svg>
|
||||
|
After Width: | Height: | Size: 143 B |
@@ -0,0 +1 @@
|
||||
<svg width="13" height="10" viewBox="0 0 13 10" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M12.9902 2.00008L11.5802 0.580078L4.99023 7.17008L2.41023 4.60008L0.990234 6.01008L4.99023 10.0001L12.9902 2.00008Z" fill="#FF8B00"/></svg>
|
||||
|
After Width: | Height: | Size: 244 B |
@@ -0,0 +1 @@
|
||||
<svg width="3" height="19" viewBox="0 0 3 19" fill="none" xmlns="http://www.w3.org/2000/svg"><circle cx="1.5" cy="1.5" r="1.5" fill="#8E8E8E"/><circle cx="1.5" cy="9.5" r="1.5" fill="#8E8E8E"/><circle cx="1.5" cy="17.5" r="1.5" fill="#8E8E8E"/></svg>
|
||||
|
After Width: | Height: | Size: 251 B |
+22
@@ -0,0 +1,22 @@
|
||||
// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
declare module '*.svg' {
|
||||
const url: string;
|
||||
export default url;
|
||||
}
|
||||
|
||||
declare module '*.png' {
|
||||
const url: string;
|
||||
export default url;
|
||||
}
|
||||
|
||||
declare module '*.woff' {
|
||||
const url: string;
|
||||
export default url;
|
||||
}
|
||||
|
||||
declare module '*.woff2' {
|
||||
const url: string;
|
||||
export default url;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<?xml version="1.0" encoding="utf-8" standalone="yes"?><svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" id="Layer_1" x="0px" y="0px" viewBox="15 15 140 140" style="enable-background:new 0 0 170 170;zoom: 1;" xml:space="preserve"><style type="text/css">.bg0{fill:#FF8C00} .st0{fill:#FFFFFF}</style><g><circle class="bg0" cx="85" cy="85" r="70"></circle><g><path class="st0" d="M85,34.7c-20.8,0-37.8,16.9-37.8,37.8c0,4.2,0.7,8.3,2,12.3c0.9,2.7,3.9,4.2,6.7,3.3c2.7-0.9,4.2-3.9,3.3-6.7 c-1.1-3.1-1.6-6.4-1.5-9.7C58.1,57.6,69.5,46,83.6,45.3c15.7-0.8,28.7,11.7,28.7,27.2c0,14.5-11.4,26.4-25.7,27.2 c0,0-5.3,0.3-7.9,0.7c-1.3,0.2-2.3,0.4-3,0.5c-0.3,0.1-0.6-0.2-0.5-0.5l0.9-4.4L81,73.4c0.6-2.8-1.2-5.6-4-6.2 c-2.8-0.6-5.6,1.2-6.2,4c0,0-11.8,55-11.9,55.6c-0.6,2.8,1.2,5.6,4,6.2c2.8,0.6,5.6-1.2,6.2-4c0.1-0.6,1.7-7.9,1.7-7.9 c1.2-5.6,5.8-9.7,11.2-10.4c1.2-0.2,5.9-0.5,5.9-0.5c19.5-1.5,34.9-17.8,34.9-37.7C122.8,51.6,105.8,34.7,85,34.7z M87.7,121.7 c-3.4-0.7-6.8,1.4-7.5,4.9c-0.7,3.4,1.4,6.8,4.9,7.5c3.4,0.7,6.8-1.4,7.5-4.9C93.3,125.7,91.2,122.4,87.7,121.7z"></path></g></g></svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 143 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 144 KiB |
@@ -0,0 +1,204 @@
|
||||
// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import '@pezkuwi/extension-mocks/chrome';
|
||||
|
||||
import type { ReactWrapper } from 'enzyme';
|
||||
import type * as _ from '@pezkuwi/dev-test/globals.d.ts';
|
||||
|
||||
import Adapter from '@wojtekmaj/enzyme-adapter-react-17';
|
||||
import enzyme from 'enzyme';
|
||||
import React from 'react';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
|
||||
import { flushAllPromises } from '../testHelpers.js';
|
||||
import BackButton from './BackButton.js';
|
||||
import { AccountNamePasswordCreation, Input, InputWithLabel, NextStepButton } from './index.js';
|
||||
|
||||
// For this file, there are a lot of them
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-argument */
|
||||
|
||||
const { configure, mount } = enzyme;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment,@typescript-eslint/no-unsafe-call
|
||||
configure({ adapter: new Adapter() });
|
||||
|
||||
const account = {
|
||||
name: 'My Polkadot Account',
|
||||
password: 'somepassword'
|
||||
};
|
||||
|
||||
const buttonLabel = 'create account';
|
||||
|
||||
let wrapper: ReactWrapper;
|
||||
const onBackClick = jest.fn();
|
||||
const onCreate = jest.fn();
|
||||
const onNameChange = jest.fn();
|
||||
|
||||
const type = async (input: ReactWrapper, value: string): Promise<void> => {
|
||||
input.simulate('change', { target: { value } });
|
||||
await act(flushAllPromises);
|
||||
wrapper.update();
|
||||
};
|
||||
|
||||
const capsLockOn = async (input: ReactWrapper): Promise<void> => {
|
||||
input.simulate('keyPress', { getModifierState: () => true });
|
||||
await act(flushAllPromises);
|
||||
wrapper.update();
|
||||
};
|
||||
|
||||
const enterName = (name: string): Promise<void> => type(wrapper.find('input').first(), name);
|
||||
const password = (password: string) => (): Promise<void> => type(wrapper.find('input[type="password"]').first(), password);
|
||||
const repeat = (password: string) => (): Promise<void> => type(wrapper.find('input[type="password"]').last(), password);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
||||
const mountComponent = (isBusy = false): ReactWrapper => mount(
|
||||
<AccountNamePasswordCreation
|
||||
buttonLabel={buttonLabel}
|
||||
isBusy={isBusy}
|
||||
onBackClick={onBackClick}
|
||||
onCreate={onCreate}
|
||||
onNameChange={onNameChange}
|
||||
/>
|
||||
);
|
||||
|
||||
describe('AccountNamePasswordCreation', () => {
|
||||
beforeEach(async () => {
|
||||
wrapper = mountComponent();
|
||||
await act(flushAllPromises);
|
||||
wrapper.update();
|
||||
});
|
||||
|
||||
it('only account name input is visible at first', () => {
|
||||
expect(wrapper.find(InputWithLabel).find('[data-input-name]').find(Input)).toHaveLength(1);
|
||||
expect(wrapper.find(InputWithLabel).find('[data-input-password]').find(Input)).toHaveLength(1);
|
||||
expect(wrapper.find(InputWithLabel).find('[data-input-repeat-password]')).toHaveLength(0);
|
||||
expect(wrapper.find('[data-button-action="add new root"] button').prop('disabled')).toBe(true);
|
||||
});
|
||||
|
||||
it('next step button has the correct label', () => {
|
||||
expect(wrapper.find(NextStepButton).text()).toBe(buttonLabel);
|
||||
});
|
||||
|
||||
it('back button calls onBackClick', () => {
|
||||
wrapper.find(BackButton).simulate('click');
|
||||
expect(onBackClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('input should not be highlighted as error until first interaction', () => {
|
||||
expect(wrapper.find(InputWithLabel).find('[data-input-name]').find(Input).prop('withError')).toBe(false);
|
||||
});
|
||||
|
||||
it('after typing less than 3 characters into name input, password input is not visible', async () => {
|
||||
await enterName('ab');
|
||||
expect(wrapper.find(InputWithLabel).find('[data-input-name]').find(Input).prop('withError')).toBe(true);
|
||||
expect(wrapper.find('.warning-message').first().text()).toBe('Account name is too short');
|
||||
expect(wrapper.find(InputWithLabel).find('[data-input-password]').find(Input)).toHaveLength(1);
|
||||
expect(wrapper.find(InputWithLabel).find('[data-input-repeat-password]')).toHaveLength(0);
|
||||
expect(wrapper.find('[data-button-action="add new root"] button').prop('disabled')).toBe(true);
|
||||
});
|
||||
|
||||
it('input should keep showing error when something has been typed but then erased', async () => {
|
||||
await enterName(account.name);
|
||||
await enterName('');
|
||||
expect(wrapper.find(InputWithLabel).find('[data-input-name]').find(Input).prop('withError')).toBe(true);
|
||||
});
|
||||
|
||||
it('after typing 3 characters into name input, onNameChange is called', async () => {
|
||||
await enterName(account.name);
|
||||
expect(onNameChange).toHaveBeenLastCalledWith(account.name);
|
||||
});
|
||||
|
||||
it('after typing 3 characters into name input, first password input is visible', async () => {
|
||||
await enterName(account.name);
|
||||
expect(wrapper.find(InputWithLabel).find('[data-input-name]').find(Input).first().prop('withError')).toBe(false);
|
||||
expect(wrapper.find(InputWithLabel).find('[data-input-password]').find(Input)).toHaveLength(1);
|
||||
expect(wrapper.find(InputWithLabel).find('[data-input-repeat-password]')).toHaveLength(0);
|
||||
expect(wrapper.find('[data-button-action="add new root"] button').prop('disabled')).toBe(true);
|
||||
});
|
||||
|
||||
it('password with caps lock should show a warning', async () => {
|
||||
await enterName('abc').then(password('abcde'));
|
||||
await capsLockOn(wrapper.find(InputWithLabel).find('[data-input-password]').find(Input));
|
||||
|
||||
expect(wrapper.find('.warning-message').first().text()).toBe('Warning: Caps lock is on');
|
||||
});
|
||||
|
||||
it('password shorter than 6 characters should be not valid', async () => {
|
||||
await enterName('abc').then(password('abcde'));
|
||||
expect(wrapper.find(InputWithLabel).find('[data-input-password]').find(Input).prop('withError')).toBe(true);
|
||||
expect(wrapper.find('.warning-message').text()).toBe('Password is too short');
|
||||
expect(wrapper.find(InputWithLabel).find('[data-input-password]').find(Input)).toHaveLength(1);
|
||||
expect(wrapper.find(InputWithLabel).find('[data-input-repeat-password]')).toHaveLength(0);
|
||||
expect(wrapper.find('[data-button-action="add new root"] button').prop('disabled')).toBe(true);
|
||||
});
|
||||
|
||||
it('submit button is not enabled until both passwords are equal', async () => {
|
||||
await enterName('abc').then(password('abcdef')).then(repeat('abcdeg'));
|
||||
expect(wrapper.find('.warning-message').text()).toBe('Passwords do not match');
|
||||
expect(wrapper.find(InputWithLabel).find('[data-input-repeat-password]').find(Input).prop('withError')).toBe(true);
|
||||
expect(wrapper.find('[data-button-action="add new root"] button').prop('disabled')).toBe(true);
|
||||
});
|
||||
|
||||
it('submit button is enabled when both passwords are equal', async () => {
|
||||
await enterName('abc').then(password('abcdef')).then(repeat('abcdef'));
|
||||
expect(wrapper.find('.warning-message')).toHaveLength(0);
|
||||
expect(wrapper.find(InputWithLabel).find('[data-input-repeat-password]').find(Input).prop('withError')).toBe(false);
|
||||
expect(wrapper.find('[data-button-action="add new root"] button').prop('disabled')).toBe(false);
|
||||
});
|
||||
|
||||
it('calls onCreate with provided name and password', async () => {
|
||||
await enterName(account.name).then(password(account.password)).then(repeat(account.password));
|
||||
wrapper.find('[data-button-action="add new root"] button').simulate('click');
|
||||
await act(flushAllPromises);
|
||||
|
||||
expect(onCreate).toHaveBeenCalledWith(account.name, account.password);
|
||||
});
|
||||
|
||||
describe('All fields are filled correctly, but then', () => {
|
||||
beforeEach(async () => {
|
||||
await enterName(account.name).then(password(account.password)).then(repeat(account.password));
|
||||
});
|
||||
|
||||
it('first password input is cleared - second one disappears and button get disabled', async () => {
|
||||
await type(wrapper.find('input[type="password"]').first(), '');
|
||||
expect(wrapper.find(InputWithLabel).find('[data-input-repeat-password]')).toHaveLength(0);
|
||||
expect(wrapper.find('[data-button-action="add new root"] button').prop('disabled')).toBe(true);
|
||||
});
|
||||
|
||||
it('first password changes - button is disabled', async () => {
|
||||
await type(wrapper.find('input[type="password"]').first(), 'abcdef');
|
||||
expect(wrapper.find('.warning-message').text()).toBe('Passwords do not match');
|
||||
expect(wrapper.find('[data-button-action="add new root"] button').prop('disabled')).toBe(true);
|
||||
});
|
||||
|
||||
it('first password changes, then second changes too - button is enabled', async () => {
|
||||
await type(wrapper.find('input[type="password"]').first(), 'abcdef');
|
||||
await type(wrapper.find('input[type="password"]').last(), 'abcdef');
|
||||
expect(wrapper.find('[data-button-action="add new root"] button').prop('disabled')).toBe(false);
|
||||
});
|
||||
|
||||
it('second password changes, then first changes too - button is enabled', async () => {
|
||||
await type(wrapper.find('input[type="password"]').last(), 'abcdef');
|
||||
await type(wrapper.find('input[type="password"]').first(), 'abcdef');
|
||||
expect(wrapper.find('[data-button-action="add new root"] button').prop('disabled')).toBe(false);
|
||||
});
|
||||
|
||||
it('name is removed - button is disabled', async () => {
|
||||
await enterName('');
|
||||
expect(wrapper.find('[data-button-action="add new root"] button').prop('disabled')).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('AccountNamePasswordCreation busy button', () => {
|
||||
beforeAll(async () => {
|
||||
wrapper = mountComponent(true);
|
||||
await act(flushAllPromises);
|
||||
wrapper.update();
|
||||
});
|
||||
|
||||
it('button is busy', () => {
|
||||
expect(wrapper.find(NextStepButton).prop('isBusy')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import React, { useCallback, useState } from 'react';
|
||||
|
||||
import { Name, Password } from '../partials/index.js';
|
||||
import { BackButton, ButtonArea, NextStepButton, VerticalSpace } from './index.js';
|
||||
|
||||
interface Props {
|
||||
buttonLabel?: string;
|
||||
isBusy: boolean;
|
||||
onBackClick?: () => void;
|
||||
onCreate: (name: string, password: string) => void | Promise<void | boolean>;
|
||||
onNameChange: (name: string) => void;
|
||||
onPasswordChange?: (password: string) => void;
|
||||
}
|
||||
|
||||
function AccountNamePasswordCreation ({ buttonLabel, isBusy, onBackClick, onCreate, onNameChange, onPasswordChange }: Props): React.ReactElement<Props> {
|
||||
const [name, setName] = useState<string | null>(null);
|
||||
const [password, setPassword] = useState<string | null>(null);
|
||||
|
||||
const _onCreate = useCallback(
|
||||
(): void => {
|
||||
if (name && password) {
|
||||
Promise
|
||||
.resolve(onCreate(name, password))
|
||||
.catch(console.error);
|
||||
}
|
||||
},
|
||||
[name, password, onCreate]
|
||||
);
|
||||
|
||||
const _onNameChange = useCallback(
|
||||
(name: string | null) => {
|
||||
onNameChange(name || '');
|
||||
setName(name);
|
||||
},
|
||||
[onNameChange]
|
||||
);
|
||||
|
||||
const _onPasswordChange = useCallback(
|
||||
(password: string | null) => {
|
||||
onPasswordChange && onPasswordChange(password || '');
|
||||
setPassword(password);
|
||||
},
|
||||
[onPasswordChange]
|
||||
);
|
||||
|
||||
const _onBackClick = useCallback(
|
||||
() => {
|
||||
_onNameChange(null);
|
||||
setPassword(null);
|
||||
onBackClick && onBackClick();
|
||||
},
|
||||
[_onNameChange, onBackClick]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Name
|
||||
isFocused
|
||||
onChange={_onNameChange}
|
||||
/>
|
||||
<Password onChange={_onPasswordChange} />
|
||||
<VerticalSpace />
|
||||
{onBackClick && buttonLabel && (
|
||||
<ButtonArea>
|
||||
<BackButton onClick={_onBackClick} />
|
||||
<NextStepButton
|
||||
data-button-action='add new root'
|
||||
isBusy={isBusy}
|
||||
isDisabled={!password || !name}
|
||||
onClick={_onCreate}
|
||||
>
|
||||
{buttonLabel}
|
||||
</NextStepButton>
|
||||
</ButtonArea>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(AccountNamePasswordCreation);
|
||||
@@ -0,0 +1,35 @@
|
||||
// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { styled } from '../styled.js';
|
||||
|
||||
interface Props {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function ActionBar ({ children, className }: Props): React.ReactElement<Props> {
|
||||
return (
|
||||
<div className={className}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default styled(ActionBar)<Props>`
|
||||
align-content: flex-end;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 0.25rem;
|
||||
text-align: right;
|
||||
|
||||
a {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
a+a {
|
||||
margin-left: 0.75rem;
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,48 @@
|
||||
// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { IconDefinition } from '@fortawesome/fontawesome-svg-core';
|
||||
import type { MouseEventHandler } from 'react';
|
||||
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import React from 'react';
|
||||
|
||||
import { styled } from '../styled.js';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
icon?: IconDefinition;
|
||||
onClick: MouseEventHandler<HTMLDivElement>;
|
||||
text: string;
|
||||
}
|
||||
|
||||
function ActionText ({ className, icon, onClick, text }: Props): React.ReactElement<Props> {
|
||||
return (
|
||||
<div
|
||||
className={className}
|
||||
onClick={onClick}
|
||||
>
|
||||
{icon && <FontAwesomeIcon icon={icon} />}
|
||||
<span>{text}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default styled(ActionText)<Props>`
|
||||
cursor: pointer;
|
||||
|
||||
span {
|
||||
color: var(--labelColor);
|
||||
font-size: var(--labelFontSize);
|
||||
line-height: var(--labelLineHeight);
|
||||
text-decoration-line: underline;
|
||||
}
|
||||
|
||||
.svg-inline--fa {
|
||||
color: var(--iconNeutralColor);
|
||||
display: inline-block;
|
||||
margin-right: 0.3rem;
|
||||
position: relative;
|
||||
top: 2px;
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,351 @@
|
||||
// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import '@pezkuwi/extension-mocks/chrome';
|
||||
|
||||
import type { ReactWrapper } from 'enzyme';
|
||||
import type * as _ from '@pezkuwi/dev-test/globals.d.ts';
|
||||
import type { AccountJson } from '@pezkuwi/extension-base/background/types';
|
||||
import type { IconTheme } from '@pezkuwi/react-identicon/types';
|
||||
import type { HexString } from '@pezkuwi/util/types';
|
||||
import type { Props as AddressComponentProps } from './Address.js';
|
||||
|
||||
import Adapter from '@wojtekmaj/enzyme-adapter-react-17';
|
||||
import enzyme from 'enzyme';
|
||||
import React from 'react';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
|
||||
import * as messaging from '../messaging.js';
|
||||
import * as MetadataCache from '../MetadataCache.js';
|
||||
import { westendMetadata } from '../Popup/Signing/metadataMock.js';
|
||||
import { flushAllPromises } from '../testHelpers.js';
|
||||
import { buildHierarchy } from '../util/buildHierarchy.js';
|
||||
import { DEFAULT_TYPE } from '../util/defaultType.js';
|
||||
import getParentNameSuri from '../util/getParentNameSuri.js';
|
||||
import { AccountContext, Address } from './index.js';
|
||||
|
||||
const { configure, mount } = enzyme;
|
||||
|
||||
// NOTE Required for spyOn when using @swc/jest
|
||||
// https://github.com/swc-project/swc/issues/3843
|
||||
// jest.mock('../messaging', (): Record<string, unknown> => ({
|
||||
// __esModule: true,
|
||||
// ...jest.requireActual('../messaging')
|
||||
// }));
|
||||
|
||||
// jest.mock('../MetadataCache', (): Record<string, unknown> => ({
|
||||
// __esModule: true,
|
||||
// ...jest.requireActual('../MetadataCache')
|
||||
// }));
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment,@typescript-eslint/no-unsafe-call
|
||||
configure({ adapter: new Adapter() });
|
||||
|
||||
interface AccountTestJson extends AccountJson {
|
||||
expectedIconTheme: IconTheme
|
||||
}
|
||||
|
||||
interface AccountTestGenesisJson extends AccountTestJson {
|
||||
expectedEncodedAddress: string;
|
||||
expectedNetworkLabel: string;
|
||||
genesisHash: HexString;
|
||||
}
|
||||
|
||||
const externalAccount = { address: '5EeaoDj4VDk8V6yQngKBaCD5MpJUCHrhYjVhBjgMHXoYon1s', expectedIconTheme: 'polkadot', isExternal: true, name: 'External Account', type: 'sr25519' } as AccountJson;
|
||||
const hardwareAccount = {
|
||||
address: 'HDE6uFdw53SwUyfKSsjwZNmS2sziWMPuY6uJhGHcFzLYRaJ',
|
||||
expectedIconTheme: 'polkadot',
|
||||
// Kusama genesis hash
|
||||
genesisHash: '0xb0a8d493285c2df73290dfb7e61f870f17b41801197a149ca93654499ea3dafe',
|
||||
isExternal: true,
|
||||
isHardware: true,
|
||||
name: 'Hardware Account',
|
||||
type: 'sr25519'
|
||||
} as AccountJson;
|
||||
|
||||
const accounts = [
|
||||
{ address: '5HSDXAC3qEMkSzZK377sTD1zJhjaPiX5tNWppHx2RQMYkjaJ', expectedIconTheme: 'polkadot', name: 'ECDSA Account', type: 'ecdsa' },
|
||||
{ address: '5FjgD3Ns2UpnHJPVeRViMhCttuemaRXEqaD8V5z4vxcsUByA', expectedIconTheme: 'polkadot', name: 'Ed Account', type: 'ed25519' },
|
||||
{ address: '5Ggap6soAPaP5UeNaiJsgqQwdVhhNnm6ez7Ba1w9jJ62LM2Q', expectedIconTheme: 'polkadot', name: 'Parent Sr Account', type: 'sr25519' },
|
||||
{ address: '0xd5D81CD4236a43F48A983fc5B895975c511f634D', expectedIconTheme: 'ethereum', name: 'Ethereum', type: 'ethereum' },
|
||||
{ ...externalAccount },
|
||||
{ ...hardwareAccount }
|
||||
] as AccountTestJson[];
|
||||
|
||||
// With Westend genesis Hash
|
||||
// This account isn't part of the generic test because Westend isn't a built in network
|
||||
// The network would only be displayed if the corresponding metadata are known
|
||||
const westEndAccount = {
|
||||
address: 'Cs2LLqQ6DSRx8UPdVp6jny4DvwNqziBSowSu5Nb1u3R6Z7X',
|
||||
expectedEncodedAddress: '5CMQg2VXTrRWCUewro13qqc45Lf93KtzzS6hWR6dY6pvMZNF',
|
||||
expectedIconTheme: 'polkadot',
|
||||
expectedNetworkLabel: 'Westend',
|
||||
genesisHash: '0xe143f23803ac50e8f6f8e62695d1ce9e4e1d68aa36c1cd2cfd15340213f3423e',
|
||||
name: 'acc',
|
||||
type: 'ed25519'
|
||||
} as AccountTestGenesisJson;
|
||||
|
||||
const accountsWithGenesisHash = [
|
||||
// with Polkadot genesis Hash
|
||||
{
|
||||
address: '5Ggap6soAPaP5UeNaiJsgqQwdVhhNnm6ez7Ba1w9jJ62LM2Q',
|
||||
expectedEncodedAddress: '15csxS8s2AqrX1etYMMspzF6V7hM56KEjUqfjJvWHP7YWkoF',
|
||||
expectedIconTheme: 'polkadot',
|
||||
expectedNetworkLabel: 'Polkadot',
|
||||
genesisHash: '0x91b171bb158e2d3848fa23a9f1c25182fb8e20313b2c1eb49219da7a70ce90c3',
|
||||
type: 'sr25519'
|
||||
},
|
||||
// with Kusama genesis Hash
|
||||
{
|
||||
address: '5DoYawpxt6aBy1pKAt1beLMrakqtbWMtG3NF6jwRR8uKJGqD',
|
||||
expectedEncodedAddress: 'EKAFGAqWTb7ifdkwapeYHirjM88QBB4iRCzVQDNtw7p3bgF',
|
||||
expectedIconTheme: 'polkadot',
|
||||
expectedNetworkLabel: 'Kusama',
|
||||
genesisHash: '0xb0a8d493285c2df73290dfb7e61f870f17b41801197a149ca93654499ea3dafe',
|
||||
type: 'sr25519'
|
||||
},
|
||||
// with Edgeware genesis Hash
|
||||
{
|
||||
address: '5GYQRJj3NUznYDzCduENRcocMsyxmb6tjb5xW87ZMErBe9R7',
|
||||
expectedEncodedAddress: 'mzKNamvvJPM5ApxwGSYD5VjjtyfrB4g8fhMyCc29K37nuop',
|
||||
expectedIconTheme: 'substrate',
|
||||
expectedNetworkLabel: 'Edgeware',
|
||||
genesisHash: '0x742a2ca70c2fda6cee4f8df98d64c4c670a052d9568058982dad9d5a7a135c5b',
|
||||
type: 'sr25519'
|
||||
}
|
||||
] as AccountTestGenesisJson[];
|
||||
|
||||
const mountComponent = async (addressComponentProps: AddressComponentProps, contextAccounts: AccountJson[]): Promise<{
|
||||
wrapper: ReactWrapper;
|
||||
}> => {
|
||||
const actionStub = jest.fn();
|
||||
const { actions = actionStub } = addressComponentProps;
|
||||
|
||||
const wrapper = mount(
|
||||
<AccountContext.Provider
|
||||
value={{
|
||||
accounts: contextAccounts,
|
||||
hierarchy: buildHierarchy(contextAccounts)
|
||||
}}
|
||||
>
|
||||
<Address
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-expect-error
|
||||
actions={actions}
|
||||
{...addressComponentProps}
|
||||
/>
|
||||
</AccountContext.Provider>
|
||||
);
|
||||
|
||||
await act(flushAllPromises);
|
||||
wrapper.update();
|
||||
|
||||
return { wrapper };
|
||||
};
|
||||
|
||||
const getWrapper = async (account: AccountJson, contextAccounts: AccountJson[], withAccountsInContext: boolean) => {
|
||||
// the address component can query info about the account from the account context
|
||||
// in this case, the account's address (any encoding) should suffice
|
||||
// In case the account is not in the context, then more info are needed as props
|
||||
// to display accurately
|
||||
const mountedComponent = withAccountsInContext
|
||||
// only the address is passed as props, the full acount info are loaded in the context
|
||||
? await mountComponent({ address: account.address }, contextAccounts)
|
||||
// the context is empty, all account's info are passed as props to the Address component
|
||||
: await mountComponent(account, []);
|
||||
|
||||
return mountedComponent.wrapper;
|
||||
};
|
||||
|
||||
const genericTestSuite = (account: AccountTestJson, withAccountsInContext = true) => {
|
||||
let wrapper: ReactWrapper;
|
||||
const { address, expectedIconTheme, name = '', type = DEFAULT_TYPE } = account;
|
||||
|
||||
describe(`Account ${withAccountsInContext ? 'in context from address' : 'from props'} (${name}) - ${type}`, () => {
|
||||
beforeAll(async () => {
|
||||
wrapper = await getWrapper(account, accounts, withAccountsInContext);
|
||||
});
|
||||
|
||||
it('shows the account address and name', () => {
|
||||
expect(wrapper.find('[data-field="address"]').text()).toEqual(address);
|
||||
expect(wrapper.find('Name span').text()).toEqual(name);
|
||||
});
|
||||
|
||||
it(`shows a ${expectedIconTheme} identicon`, () => {
|
||||
expect(wrapper.find('Identicon').first().prop('iconTheme')).toEqual(expectedIconTheme);
|
||||
});
|
||||
|
||||
it('can copy its address', () => {
|
||||
// the first CopyToClipboard is from the identicon, the second from the copy button
|
||||
expect(wrapper.find('CopyToClipboard').at(0).prop('text')).toEqual(address);
|
||||
expect(wrapper.find('CopyToClipboard').at(1).prop('text')).toEqual(address);
|
||||
});
|
||||
|
||||
it('has the account visiblity icon', () => {
|
||||
expect(wrapper.find('FontAwesomeIcon.visibleIcon')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('can hide the account', () => {
|
||||
jest.spyOn(messaging, 'showAccount').mockImplementation(() => Promise.resolve(false));
|
||||
|
||||
const visibleIcon = wrapper.find('FontAwesomeIcon.visibleIcon');
|
||||
const hiddenIcon = wrapper.find('FontAwesomeIcon.hiddenIcon');
|
||||
|
||||
expect(visibleIcon.exists()).toBe(true);
|
||||
expect(hiddenIcon.exists()).toBe(false);
|
||||
|
||||
visibleIcon.simulate('click');
|
||||
expect(messaging.showAccount).toHaveBeenCalledWith(address, false);
|
||||
});
|
||||
|
||||
it('can show the account if hidden', async () => {
|
||||
const additionalProps = { isHidden: true };
|
||||
|
||||
const mountedHiddenComponent = withAccountsInContext
|
||||
? await mountComponent({ address, ...additionalProps }, accounts)
|
||||
: await mountComponent({ ...account, ...additionalProps }, []);
|
||||
|
||||
const wrapperHidden = mountedHiddenComponent.wrapper;
|
||||
|
||||
jest.spyOn(messaging, 'showAccount').mockImplementation(() => Promise.resolve(true));
|
||||
|
||||
const visibleIcon = wrapperHidden.find('FontAwesomeIcon.visibleIcon');
|
||||
const hiddenIcon = wrapperHidden.find('FontAwesomeIcon.hiddenIcon');
|
||||
|
||||
expect(visibleIcon.exists()).toBe(false);
|
||||
expect(hiddenIcon.exists()).toBe(true);
|
||||
|
||||
hiddenIcon.simulate('click');
|
||||
expect(messaging.showAccount).toHaveBeenCalledWith(address, true);
|
||||
});
|
||||
|
||||
it('has settings button', () => {
|
||||
expect(wrapper.find('.settings')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('has no account hidding and settings button if no action is provided', async () => {
|
||||
const additionalProps = { actions: null };
|
||||
|
||||
const mountedComponentWithoutAction = withAccountsInContext
|
||||
? await mountComponent({ address, ...additionalProps }, accounts)
|
||||
: await mountComponent({ ...account, ...additionalProps }, []);
|
||||
|
||||
wrapper = mountedComponentWithoutAction.wrapper;
|
||||
|
||||
expect(wrapper.find('.settings')).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const genesisHashTestSuite = (account: AccountTestGenesisJson, withAccountsInContext = true) => {
|
||||
const { expectedEncodedAddress, expectedIconTheme, expectedNetworkLabel } = account;
|
||||
|
||||
describe(`Account ${withAccountsInContext ? 'in context from address' : 'from props'} with ${expectedNetworkLabel} genesiHash`, () => {
|
||||
let wrapper: ReactWrapper;
|
||||
|
||||
beforeAll(async () => {
|
||||
wrapper = await getWrapper(account, accountsWithGenesisHash, withAccountsInContext);
|
||||
});
|
||||
|
||||
it('shows the account address correctly encoded', () => {
|
||||
expect(wrapper.find('[data-field="address"]').text()).toEqual(expectedEncodedAddress);
|
||||
});
|
||||
|
||||
it(`shows a ${expectedIconTheme} identicon`, () => {
|
||||
expect(wrapper.find('Identicon').first().prop('iconTheme')).toEqual(expectedIconTheme);
|
||||
});
|
||||
|
||||
it('Copy buttons contain the encoded address', () => {
|
||||
// the first CopyToClipboard is from the identicon, the second from the copy button
|
||||
expect(wrapper.find('CopyToClipboard').at(0).prop('text')).toEqual(expectedEncodedAddress);
|
||||
expect(wrapper.find('CopyToClipboard').at(1).prop('text')).toEqual(expectedEncodedAddress);
|
||||
});
|
||||
|
||||
it('Network label shows the correct network', () => {
|
||||
expect(wrapper.find('[data-field="chain"]').text()).toEqual(expectedNetworkLabel);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
describe('Address', () => {
|
||||
accounts.forEach((account) => {
|
||||
genericTestSuite(account);
|
||||
genericTestSuite(account, false);
|
||||
});
|
||||
|
||||
accountsWithGenesisHash.forEach((account) => {
|
||||
genesisHashTestSuite(account);
|
||||
genesisHashTestSuite(account, false);
|
||||
});
|
||||
|
||||
describe('External account', () => {
|
||||
let wrapper: ReactWrapper;
|
||||
|
||||
beforeAll(async () => {
|
||||
wrapper = await getWrapper(externalAccount, [], false);
|
||||
});
|
||||
|
||||
it('has an icon in front of its name', () => {
|
||||
expect(wrapper.find('Name').find('FontAwesomeIcon [data-icon="qrcode"]').exists()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Hardware wallet account', () => {
|
||||
let wrapper: ReactWrapper;
|
||||
|
||||
beforeAll(async () => {
|
||||
wrapper = await getWrapper(hardwareAccount, [], false);
|
||||
});
|
||||
|
||||
it('has a usb icon in front of its name', () => {
|
||||
expect(wrapper.find('Name').find('FontAwesomeIcon [data-icon="usb"]').exists()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Encoding and label based on Metadata', () => {
|
||||
let wrapper: ReactWrapper;
|
||||
|
||||
beforeAll(async () => {
|
||||
jest.spyOn(MetadataCache, 'getSavedMeta').mockImplementation(() => Promise.resolve(westendMetadata));
|
||||
|
||||
wrapper = await getWrapper(westEndAccount, [], false);
|
||||
});
|
||||
|
||||
it('shows westend label with the correct color', () => {
|
||||
const bannerChain = wrapper.find('[data-field="chain"]');
|
||||
|
||||
expect(bannerChain.text()).toEqual(westendMetadata.chain);
|
||||
expect(bannerChain.prop('style')?.backgroundColor).toEqual(westendMetadata.color);
|
||||
});
|
||||
|
||||
it('shows the account correctly reencoded', () => {
|
||||
expect(wrapper.find('[data-field="address"]').text()).toEqual(westEndAccount.expectedEncodedAddress);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Derived accounts', () => {
|
||||
let wrapper: ReactWrapper;
|
||||
const childAccount = {
|
||||
address: '5Ggap6soAPaP5UeNaiJsgqQwdVhhNnm6ez7Ba1w9jJ62LM2Q',
|
||||
name: 'Luke',
|
||||
parentName: 'Dark Vador',
|
||||
suri: '//42',
|
||||
type: 'sr25519'
|
||||
} as AccountJson;
|
||||
|
||||
beforeAll(async () => {
|
||||
wrapper = await getWrapper(childAccount, [], false);
|
||||
});
|
||||
|
||||
it('shows the child\'s account address and name', () => {
|
||||
expect(wrapper.find('[data-field="address"]').text()).toEqual(childAccount.address);
|
||||
expect(wrapper.find('Name span').text()).toEqual(childAccount.name);
|
||||
});
|
||||
|
||||
it('shows the parent account and suri', () => {
|
||||
const expectedParentNameSuri = getParentNameSuri(childAccount.parentName, childAccount.suri);
|
||||
|
||||
expect(wrapper.find('.parentName').text()).toEqual(expectedParentNameSuri);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,481 @@
|
||||
// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { AccountJson, AccountWithChildren } from '@pezkuwi/extension-base/background/types';
|
||||
import type { Chain } from '@pezkuwi/extension-chains/types';
|
||||
import type { IconTheme } from '@pezkuwi/react-identicon/types';
|
||||
import type { SettingsStruct } from '@pezkuwi/ui-settings/types';
|
||||
import type { KeypairType } from '@pezkuwi/util-crypto/types';
|
||||
|
||||
import { faUsb } from '@fortawesome/free-brands-svg-icons';
|
||||
import { faCopy, faEye, faEyeSlash } from '@fortawesome/free-regular-svg-icons';
|
||||
import { faCodeBranch, faQrcode } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import React, { useCallback, useContext, useEffect, useRef, useState } from 'react';
|
||||
import CopyToClipboard from 'react-copy-to-clipboard';
|
||||
|
||||
import { hexToU8a, isHex } from '@pezkuwi/util';
|
||||
import { decodeAddress, encodeAddress } from '@pezkuwi/util-crypto';
|
||||
|
||||
import details from '../assets/details.svg';
|
||||
import { useMetadata, useOutsideClick, useToast, useTranslation } from '../hooks/index.js';
|
||||
import { showAccount } from '../messaging.js';
|
||||
import { styled } from '../styled.js';
|
||||
import { DEFAULT_TYPE } from '../util/defaultType.js';
|
||||
import getParentNameSuri from '../util/getParentNameSuri.js';
|
||||
import { AccountContext, SettingsContext } from './contexts.js';
|
||||
import Identicon from './Identicon.js';
|
||||
import Menu from './Menu.js';
|
||||
import Svg from './Svg.js';
|
||||
|
||||
export interface Props {
|
||||
actions?: React.ReactNode;
|
||||
address?: string | null;
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
genesisHash?: string | null;
|
||||
isExternal?: boolean | null;
|
||||
isHardware?: boolean | null;
|
||||
isHidden?: boolean;
|
||||
name?: string | null;
|
||||
parentName?: string | null;
|
||||
showVisibilityAction?: boolean
|
||||
suri?: string;
|
||||
toggleActions?: number;
|
||||
type?: KeypairType;
|
||||
}
|
||||
|
||||
interface Recoded {
|
||||
account: AccountJson | null;
|
||||
formatted: string | null;
|
||||
genesisHash?: string | null;
|
||||
prefix?: number;
|
||||
type: KeypairType;
|
||||
}
|
||||
|
||||
// find an account in our list
|
||||
function findSubstrateAccount (accounts: AccountJson[], publicKey: Uint8Array): AccountJson | null {
|
||||
const pkStr = publicKey.toString();
|
||||
|
||||
return accounts.find(({ address }): boolean =>
|
||||
decodeAddress(address).toString() === pkStr
|
||||
) || null;
|
||||
}
|
||||
|
||||
// find an account in our list
|
||||
function findAccountByAddress (accounts: AccountJson[], _address: string): AccountJson | null {
|
||||
return accounts.find(({ address }): boolean =>
|
||||
address === _address
|
||||
) || null;
|
||||
}
|
||||
|
||||
// recodes an supplied address using the prefix/genesisHash, include the actual saved account & chain
|
||||
function recodeAddress (address: string, accounts: AccountWithChildren[], chain: Chain | null, settings: SettingsStruct): Recoded {
|
||||
// decode and create a shortcut for the encoded address
|
||||
const publicKey = isHex(address) ? hexToU8a(address) : decodeAddress(address);
|
||||
// find our account using the actual publicKey, and then find the associated chain
|
||||
const account = findSubstrateAccount(accounts, publicKey);
|
||||
const prefix = chain ? chain.ss58Format : (settings.prefix === -1 ? 42 : settings.prefix);
|
||||
|
||||
// always allow the actual settings to override the display
|
||||
return {
|
||||
account,
|
||||
formatted: account?.type === 'ethereum'
|
||||
? address
|
||||
: encodeAddress(publicKey, prefix),
|
||||
genesisHash: account?.genesisHash,
|
||||
prefix,
|
||||
type: account?.type || DEFAULT_TYPE
|
||||
};
|
||||
}
|
||||
|
||||
const ACCOUNTS_SCREEN_HEIGHT = 550;
|
||||
const defaultRecoded = { account: null, formatted: null, prefix: 42, type: DEFAULT_TYPE };
|
||||
|
||||
function Address ({ actions, address, children, className, genesisHash, isExternal, isHardware, isHidden, name, parentName, showVisibilityAction = false, suri, toggleActions, type: givenType }: Props): React.ReactElement<Props> {
|
||||
const { t } = useTranslation();
|
||||
const { accounts } = useContext(AccountContext);
|
||||
const settings = useContext(SettingsContext);
|
||||
const [{ account, formatted, genesisHash: recodedGenesis, prefix, type }, setRecoded] = useState<Recoded>(defaultRecoded);
|
||||
const chain = useMetadata(genesisHash || recodedGenesis, true);
|
||||
|
||||
const [showActionsMenu, setShowActionsMenu] = useState(false);
|
||||
const [moveMenuUp, setIsMovedMenu] = useState(false);
|
||||
const actIconRef = useRef<HTMLDivElement>(null);
|
||||
const actMenuRef = useRef<HTMLDivElement>(null);
|
||||
const { show } = useToast();
|
||||
|
||||
useOutsideClick([actIconRef, actMenuRef], () => (showActionsMenu && setShowActionsMenu(!showActionsMenu)));
|
||||
|
||||
useEffect((): void => {
|
||||
if (!address) {
|
||||
return setRecoded(defaultRecoded);
|
||||
}
|
||||
|
||||
const account = findAccountByAddress(accounts, address);
|
||||
|
||||
setRecoded(
|
||||
(
|
||||
chain?.definition.chainType === 'ethereum' ||
|
||||
account?.type === 'ethereum' ||
|
||||
(!account && givenType === 'ethereum')
|
||||
)
|
||||
? { account, formatted: address, type: 'ethereum' }
|
||||
: recodeAddress(address, accounts, chain, settings)
|
||||
);
|
||||
}, [accounts, address, chain, givenType, settings]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showActionsMenu) {
|
||||
setIsMovedMenu(false);
|
||||
} else if (actMenuRef.current) {
|
||||
const { bottom } = actMenuRef.current.getBoundingClientRect();
|
||||
|
||||
if (bottom > ACCOUNTS_SCREEN_HEIGHT) {
|
||||
setIsMovedMenu(true);
|
||||
}
|
||||
}
|
||||
}, [showActionsMenu]);
|
||||
|
||||
useEffect((): void => {
|
||||
setShowActionsMenu(false);
|
||||
}, [toggleActions]);
|
||||
|
||||
const theme = (
|
||||
type === 'ethereum'
|
||||
? 'ethereum'
|
||||
: (chain?.icon || 'polkadot')
|
||||
) as IconTheme;
|
||||
|
||||
const _onClick = useCallback(
|
||||
() => setShowActionsMenu(!showActionsMenu),
|
||||
[showActionsMenu]
|
||||
);
|
||||
|
||||
const _onCopy = useCallback(
|
||||
() => show(t('Copied')),
|
||||
[show, t]
|
||||
);
|
||||
|
||||
const _toggleVisibility = useCallback(
|
||||
(): void => {
|
||||
address && showAccount(address, isHidden || false).catch(console.error);
|
||||
},
|
||||
[address, isHidden]
|
||||
);
|
||||
|
||||
const Name = () => {
|
||||
const accountName = name || account?.name;
|
||||
const displayName = accountName || t('<unknown>');
|
||||
|
||||
return (
|
||||
<>
|
||||
{!!accountName && (account?.isExternal || isExternal) && (
|
||||
(account?.isHardware || isHardware)
|
||||
? (
|
||||
<FontAwesomeIcon
|
||||
className='hardwareIcon'
|
||||
icon={faUsb}
|
||||
rotation={270}
|
||||
title={t('hardware wallet account')}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<FontAwesomeIcon
|
||||
className='externalIcon'
|
||||
icon={faQrcode}
|
||||
title={t('external account')}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
<span title={displayName}>{displayName}</span>
|
||||
</>);
|
||||
};
|
||||
|
||||
const parentNameSuri = getParentNameSuri(parentName, suri);
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className='infoRow'>
|
||||
<Identicon
|
||||
className='identityIcon'
|
||||
iconTheme={theme}
|
||||
isExternal={isExternal}
|
||||
onCopy={_onCopy}
|
||||
prefix={prefix}
|
||||
value={formatted || address}
|
||||
/>
|
||||
<div className='info'>
|
||||
{parentName
|
||||
? (
|
||||
<>
|
||||
<div className='banner'>
|
||||
<FontAwesomeIcon
|
||||
className='deriveIcon'
|
||||
icon={faCodeBranch}
|
||||
/>
|
||||
<div
|
||||
className='parentName'
|
||||
data-field='parent'
|
||||
title = {parentNameSuri}
|
||||
>
|
||||
{parentNameSuri}
|
||||
</div>
|
||||
</div>
|
||||
<div className='name displaced'>
|
||||
<Name />
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
: (
|
||||
<div
|
||||
className='name'
|
||||
data-field='name'
|
||||
>
|
||||
<Name />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{chain?.genesisHash && chain?.name && (
|
||||
<div
|
||||
className='banner chain'
|
||||
data-field='chain'
|
||||
style={
|
||||
chain.definition.color
|
||||
? { backgroundColor: chain.definition.color }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{chain.name.replace(' Relay Chain', '')}
|
||||
</div>
|
||||
)}
|
||||
<div className='addressDisplay'>
|
||||
<div
|
||||
className='fullAddress'
|
||||
data-field='address'
|
||||
>
|
||||
{formatted || address || t('<unknown>')}
|
||||
</div>
|
||||
<CopyToClipboard text={(formatted && formatted) || ''}>
|
||||
<FontAwesomeIcon
|
||||
className='copyIcon'
|
||||
icon={faCopy}
|
||||
onClick={_onCopy}
|
||||
size='sm'
|
||||
title={t('copy address')}
|
||||
/>
|
||||
</CopyToClipboard>
|
||||
{(actions || showVisibilityAction) && (
|
||||
<FontAwesomeIcon
|
||||
className={isHidden ? 'hiddenIcon' : 'visibleIcon'}
|
||||
icon={isHidden ? faEyeSlash : faEye}
|
||||
onClick={_toggleVisibility}
|
||||
size='sm'
|
||||
title={t('account visibility')}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{actions && (
|
||||
<>
|
||||
<div
|
||||
className='settings'
|
||||
onClick={_onClick}
|
||||
ref={actIconRef}
|
||||
>
|
||||
<Svg
|
||||
className={`detailsIcon ${showActionsMenu ? 'active' : ''}`}
|
||||
src={details}
|
||||
/>
|
||||
</div>
|
||||
{showActionsMenu && (
|
||||
<Menu
|
||||
className={`movableMenu ${moveMenuUp ? 'isMoved' : ''}`}
|
||||
ref={actMenuRef}
|
||||
>
|
||||
{actions}
|
||||
</Menu>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default styled(Address)<Props>`
|
||||
background: var(--boxBackground);
|
||||
border: 1px solid var(--boxBorderColor);
|
||||
box-sizing: border-box;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 8px;
|
||||
position: relative;
|
||||
|
||||
.banner {
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
|
||||
&.chain {
|
||||
background: var(--primaryColor);
|
||||
border-radius: 0 0 0 10px;
|
||||
color: white;
|
||||
padding: 0.1rem 0.5rem 0.1rem 0.75rem;
|
||||
right: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.addressDisplay {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
position: relative;
|
||||
|
||||
.svg-inline--fa {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
margin-right: 10px;
|
||||
color: var(--accountDotsIconColor);
|
||||
&:hover {
|
||||
color: var(--labelColor);
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.hiddenIcon, .visibleIcon {
|
||||
position: absolute;
|
||||
right: 2px;
|
||||
top: -18px;
|
||||
}
|
||||
|
||||
.hiddenIcon {
|
||||
color: var(--errorColor);
|
||||
&:hover {
|
||||
color: var(--accountDotsIconColor);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.externalIcon, .hardwareIcon {
|
||||
margin-right: 0.3rem;
|
||||
color: var(--labelColor);
|
||||
width: 0.875em;
|
||||
}
|
||||
|
||||
.identityIcon {
|
||||
margin-left: 15px;
|
||||
margin-right: 10px;
|
||||
|
||||
& svg {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
}
|
||||
}
|
||||
|
||||
.info {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.infoRow {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
height: 72px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
img {
|
||||
max-width: 50px;
|
||||
max-height: 50px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.name {
|
||||
font-size: 16px;
|
||||
line-height: 22px;
|
||||
margin: 2px 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
width: 300px;
|
||||
white-space: nowrap;
|
||||
|
||||
&.displaced {
|
||||
padding-top: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.parentName {
|
||||
color: var(--labelColor);
|
||||
font-size: var(--inputLabelFontSize);
|
||||
line-height: 14px;
|
||||
overflow: hidden;
|
||||
padding: 0.25rem 0 0 0.8rem;
|
||||
text-overflow: ellipsis;
|
||||
width: 270px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.fullAddress {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
color: var(--labelColor);
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
}
|
||||
|
||||
.detailsIcon {
|
||||
background: var(--accountDotsIconColor);
|
||||
width: 3px;
|
||||
height: 19px;
|
||||
|
||||
&.active {
|
||||
background: var(--primaryColor);
|
||||
}
|
||||
}
|
||||
|
||||
.deriveIcon {
|
||||
color: var(--labelColor);
|
||||
position: absolute;
|
||||
top: 5px;
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
}
|
||||
|
||||
.movableMenu {
|
||||
margin-top: -20px;
|
||||
right: 28px;
|
||||
top: 0;
|
||||
|
||||
&.isMoved {
|
||||
top: auto;
|
||||
bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.settings {
|
||||
position: relative;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
width: 40px;
|
||||
|
||||
&:before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 25%;
|
||||
bottom: 25%;
|
||||
width: 1px;
|
||||
background: var(--boxBorderColor);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
cursor: pointer;
|
||||
background: var(--readonlyInputBackground);
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,45 @@
|
||||
// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import { faArrowLeft } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import React from 'react';
|
||||
|
||||
import { styled } from '../styled.js';
|
||||
import Button from './Button.js';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
function BackButton ({ className, onClick }: Props): React.ReactElement<Props> {
|
||||
return (
|
||||
<Button
|
||||
className={className}
|
||||
onClick={onClick}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
className='arrowLeft'
|
||||
icon={faArrowLeft}
|
||||
size='sm'
|
||||
/>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
export default styled(BackButton)<Props>`
|
||||
background: var(--backButtonBackground);
|
||||
margin-right: 11px;
|
||||
width: 42px;
|
||||
|
||||
.arrowLeft {
|
||||
color: var(--backButtonTextColor);
|
||||
display: block;
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
&:not(:disabled):hover {
|
||||
background: var(--backButtonBackgroundHover);
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,44 @@
|
||||
// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { styled } from '../styled.js';
|
||||
|
||||
interface Props {
|
||||
banner?: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function Box ({ banner, children, className }: Props): React.ReactElement<Props> {
|
||||
return (
|
||||
<article className={className}>
|
||||
{children}
|
||||
{banner && <div className='banner'>{banner}</div>}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
export default styled(Box)<Props>`
|
||||
background: var(--readonlyInputBackground);
|
||||
border: 1px solid var(--inputBorderColor);
|
||||
border-radius: var(--borderRadius);
|
||||
color: var(--subTextColor);
|
||||
font-family: var(--fontFamily);
|
||||
font-size: var(--fontSize);
|
||||
margin: 0.75rem 24px;
|
||||
padding: var(--boxPadding);
|
||||
position: relative;
|
||||
|
||||
.banner {
|
||||
background: darkorange;
|
||||
border-radius: 0 var(--borderRadius) 0 var(--borderRadius);
|
||||
color: white;
|
||||
font-size: 0.75rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,42 @@
|
||||
// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import React from 'react';
|
||||
import styled from 'styled-components';
|
||||
|
||||
import Label from './Label.js';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
label: string;
|
||||
value?: string;
|
||||
}
|
||||
|
||||
function BoxWithLabel ({ className, label, value }: Props): React.ReactElement<Props> {
|
||||
return (
|
||||
<Label
|
||||
className={className}
|
||||
label={label}
|
||||
>
|
||||
<div className='seedBox'>
|
||||
<span>{value}</span>
|
||||
</div>
|
||||
</Label>
|
||||
);
|
||||
}
|
||||
|
||||
export default styled(BoxWithLabel)<Props>`
|
||||
.seedBox {
|
||||
background: var(--readonlyInputBackground);
|
||||
box-shadow: none;
|
||||
border-radius: var(--borderRadius);
|
||||
border: 1px solid var(--inputBorderColor);
|
||||
border-color: var(--inputBorderColor);
|
||||
box-sizing: border-box;
|
||||
display: block;
|
||||
font-family: var(--fontFamily);
|
||||
outline: none;
|
||||
resize: none;
|
||||
width: 100%;
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,106 @@
|
||||
// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import React, { useCallback } from 'react';
|
||||
|
||||
import { styled } from '../styled.js';
|
||||
import Spinner from './Spinner.js';
|
||||
|
||||
export interface ButtonProps {
|
||||
className?: string;
|
||||
children?: React.ReactNode;
|
||||
isBusy?: boolean;
|
||||
isDanger?: boolean;
|
||||
isDisabled?: boolean;
|
||||
onClick?: () => void | Promise<void | boolean> | null;
|
||||
to?: string;
|
||||
}
|
||||
|
||||
function Button ({ children, className = '', isBusy, isDisabled, onClick, to }: ButtonProps): React.ReactElement<ButtonProps> {
|
||||
const _onClick = useCallback(
|
||||
(): void => {
|
||||
if (isBusy || isDisabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
onClick && Promise.resolve(onClick()).catch(console.error);
|
||||
|
||||
if (to) {
|
||||
window.location.hash = to;
|
||||
}
|
||||
},
|
||||
[isBusy, isDisabled, onClick, to]
|
||||
);
|
||||
|
||||
return (
|
||||
<button
|
||||
className={`${className}${(isDisabled || isBusy) ? ' isDisabled' : ''}${isBusy ? ' isBusy' : ''}`}
|
||||
disabled={isDisabled || isBusy}
|
||||
onClick={_onClick}
|
||||
>
|
||||
<div className='children'>{children}</div>
|
||||
<div className='disabledOverlay' />
|
||||
<Spinner className='busyOverlay' />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export default styled(Button)<ButtonProps>(({ isDanger }) => `
|
||||
background: var(${isDanger ? '--buttonBackgroundDanger' : '--buttonBackground'});
|
||||
cursor: pointer;
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: ${isDanger ? '40px' : '48px'};
|
||||
box-sizing: border-box;
|
||||
border: none;
|
||||
border-radius: var(--borderRadius);
|
||||
color: var(--buttonTextColor);
|
||||
font-size: 15px;
|
||||
line-height: 20px;
|
||||
padding: 0 1rem;
|
||||
position: relative;
|
||||
text-align: center;
|
||||
|
||||
&:disabled {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
&:not(:disabled):hover {
|
||||
background: var(${isDanger ? '--buttonBackgroundDangerHover' : '--buttonBackgroundHover'});
|
||||
}
|
||||
|
||||
.busyOverlay,
|
||||
.disabledOverlay {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.disabledOverlay {
|
||||
background: rgba(96,96,96,0.75);
|
||||
border-radius: var(--borderRadius);
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
svg {
|
||||
margin-right: 0.3rem;
|
||||
}
|
||||
|
||||
&.isBusy {
|
||||
background: rgba(96,96,96,0.15);
|
||||
|
||||
.children {
|
||||
opacity: 0.25;
|
||||
}
|
||||
|
||||
.busyOverlay {
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
|
||||
&.isDisabled .disabledOverlay {
|
||||
visibility: visible;
|
||||
}
|
||||
`);
|
||||
@@ -0,0 +1,33 @@
|
||||
// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { styled } from '../styled.js';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
function ButtonArea ({ children, className }: Props): React.ReactElement<Props> {
|
||||
return (
|
||||
<div className={className}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default styled(ButtonArea)<Props>`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
background: var(--highlightedAreaBackground);
|
||||
border-top: 1px solid var(--inputBorderColor);
|
||||
padding: 12px 24px;
|
||||
margin-left: 0;
|
||||
margin-right: 0;
|
||||
|
||||
& > button:not(:last-of-type) {
|
||||
margin-right: 8px;
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,43 @@
|
||||
// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { styled } from '../styled.js';
|
||||
import { Button } from './index.js';
|
||||
|
||||
interface ButtonWithSubtitleProps {
|
||||
title: string;
|
||||
subTitle: string;
|
||||
children?: string;
|
||||
to: string;
|
||||
}
|
||||
|
||||
export default function ButtonWithSubtitle ({ children, subTitle, title, to }: ButtonWithSubtitleProps): React.ReactElement<ButtonWithSubtitleProps> {
|
||||
return (
|
||||
<StyledButton to={to}>
|
||||
<p>{title}</p>
|
||||
<span>{subTitle}</span>
|
||||
{children}
|
||||
</StyledButton>
|
||||
);
|
||||
}
|
||||
|
||||
const StyledButton = styled(Button)`
|
||||
button {
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
span {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,113 @@
|
||||
// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import React, { useCallback, useEffect } from 'react';
|
||||
|
||||
import Checkmark from '../assets/checkmark.svg';
|
||||
import { styled } from '../styled.js';
|
||||
|
||||
interface Props {
|
||||
checked: boolean;
|
||||
indeterminate?: boolean;
|
||||
className?: string;
|
||||
label: string;
|
||||
onChange?: (checked: boolean) => void;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
function Checkbox ({ checked, className, indeterminate, label, onChange, onClick }: Props): React.ReactElement<Props> {
|
||||
const checkboxRef = React.useRef<HTMLInputElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (checkboxRef.current) {
|
||||
checkboxRef.current.indeterminate = !!indeterminate;
|
||||
}
|
||||
}, [indeterminate]);
|
||||
|
||||
const _onChange = useCallback(
|
||||
(event: React.ChangeEvent<HTMLInputElement>) => onChange && onChange(event.target.checked),
|
||||
[onChange]
|
||||
);
|
||||
|
||||
const _onClick = useCallback(
|
||||
() => onClick && onClick(),
|
||||
[onClick]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<label>
|
||||
{label}
|
||||
<input
|
||||
checked={checked && !indeterminate}
|
||||
onChange={_onChange}
|
||||
onClick={_onClick}
|
||||
ref={checkboxRef}
|
||||
type='checkbox'
|
||||
/>
|
||||
<span />
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default styled(Checkbox)<Props>`
|
||||
margin: var(--boxMargin);
|
||||
|
||||
label {
|
||||
display: block;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
padding-left: 24px;
|
||||
padding-top: 1px;
|
||||
color: var(--subTextColor);
|
||||
font-size: var(--fontSize);
|
||||
line-height: var(--lineHeight);
|
||||
|
||||
& input {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
height: 0;
|
||||
width: 0;
|
||||
}
|
||||
|
||||
& span {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
left: 0;
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
border-radius: var(--borderRadius);
|
||||
background-color: var(--readonlyInputBackground);
|
||||
border: 1px solid var(--inputBorderColor);
|
||||
border: 1px solid var(--inputBorderColor);
|
||||
|
||||
&:after {
|
||||
content: '';
|
||||
display: none;
|
||||
width: 13px;
|
||||
height: 10px;
|
||||
position: absolute;
|
||||
left: 1px;
|
||||
top: 2px;
|
||||
mask: url(${Checkmark});
|
||||
mask-size: cover;
|
||||
background: var(--primaryColor);
|
||||
}
|
||||
}
|
||||
|
||||
&:hover input ~ span {
|
||||
background-color: var(--inputBackground);
|
||||
}
|
||||
|
||||
input:checked ~ span:after {
|
||||
display: block;
|
||||
}
|
||||
|
||||
input:indeterminate ~ span {
|
||||
background: var(--primaryColor)
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,102 @@
|
||||
// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import React, { useCallback } from 'react';
|
||||
|
||||
import arrow from '../assets/arrow-down.svg';
|
||||
import { styled } from '../styled.js';
|
||||
import Label from './Label.js';
|
||||
|
||||
interface DropdownOption {
|
||||
text: string | React.ReactNode;
|
||||
value?: string | number | null;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
defaultValue?: string | null;
|
||||
isDisabled?: boolean
|
||||
isError?: boolean;
|
||||
isFocussed?: boolean;
|
||||
label: string;
|
||||
onBlur?: () => void;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
onChange?: (value: any) => void;
|
||||
options: DropdownOption[];
|
||||
value?: number | string | null;
|
||||
}
|
||||
|
||||
function Dropdown ({ className, defaultValue, isDisabled, isFocussed, label, onBlur, onChange, options, value }: Props): React.ReactElement<Props> {
|
||||
const _onChange = useCallback(
|
||||
({ target: { value } }: React.ChangeEvent<HTMLSelectElement>) =>
|
||||
onChange && onChange(value.trim()),
|
||||
[onChange]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Label
|
||||
className={className}
|
||||
label={label}
|
||||
>
|
||||
<select
|
||||
autoFocus={isFocussed}
|
||||
defaultValue={defaultValue || undefined}
|
||||
disabled={isDisabled}
|
||||
onBlur={onBlur}
|
||||
onChange={_onChange}
|
||||
value={value ?? undefined}
|
||||
>
|
||||
{options.map(({ text, value }): React.ReactNode => (
|
||||
<option
|
||||
key={value}
|
||||
value={value ?? undefined}
|
||||
>
|
||||
{text}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Label>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(styled(Dropdown)<Props>(({ isError, label }) => `
|
||||
position: relative;
|
||||
|
||||
select {
|
||||
-webkit-appearance: none;
|
||||
-moz-appearance: none;
|
||||
appearance: none;
|
||||
background: var(--readonlyInputBackground);
|
||||
border-color: var(${isError ? '--errorBorderColor' : '--inputBorderColor'});
|
||||
border-radius: var(--borderRadius);
|
||||
border-style: solid;
|
||||
border-width: 1px;
|
||||
box-sizing: border-box;
|
||||
color: var(${isError ? '--errorBorderColor' : '--textColor'});
|
||||
display: block;
|
||||
font-family: var(--fontFamily);
|
||||
font-size: var(--fontSize);
|
||||
padding: 0.5rem 0.75rem;
|
||||
width: 100%;
|
||||
cursor: pointer;
|
||||
|
||||
&:read-only {
|
||||
box-shadow: none;
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
label::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: ${label ? 'calc(50% + 14px)' : '50%'};
|
||||
transform: translateY(-50%);
|
||||
right: 12px;
|
||||
width: 8px;
|
||||
height: 6px;
|
||||
background: url(${arrow}) center no-repeat;
|
||||
pointer-events: none;
|
||||
}
|
||||
`));
|
||||
@@ -0,0 +1,62 @@
|
||||
// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributor
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import Header from '../partials/Header';
|
||||
import Button from './Button';
|
||||
import ButtonArea from './ButtonArea';
|
||||
import VerticalSpace from './VerticalSpace';
|
||||
|
||||
interface ErrorBoundaryProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
error?: Error | null;
|
||||
trigger?: string;
|
||||
}
|
||||
|
||||
const ErrorBoundary: React.FC<ErrorBoundaryProps> = ({ children, error: propError, trigger }) => {
|
||||
const { t } = useTranslation();
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (error !== null && trigger) {
|
||||
setError(null);
|
||||
}
|
||||
}, [error, trigger]);
|
||||
|
||||
useEffect(() => {
|
||||
if (propError) {
|
||||
setError(propError);
|
||||
}
|
||||
}, [propError]);
|
||||
|
||||
const goHome = useCallback(() => {
|
||||
setError(null);
|
||||
window.location.hash = '/';
|
||||
}, [setError]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<>
|
||||
<Header text={t('An error occurred')} />
|
||||
<div>
|
||||
{t('Something went wrong with the query and rendering of this component. {{message}}', {
|
||||
replace: { message: error.message }
|
||||
})}
|
||||
</div>
|
||||
<VerticalSpace />
|
||||
<ButtonArea>
|
||||
<Button onClick={goHome}>
|
||||
{t('Back to home')}
|
||||
</Button>
|
||||
</ButtonArea>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
export default ErrorBoundary;
|
||||
@@ -0,0 +1,31 @@
|
||||
// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { styled } from '../styled.js';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
icon: string;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
function Icon ({ className = '', icon, onClick }: Props): React.ReactElement<Props> {
|
||||
return (
|
||||
<div
|
||||
className={`${className} icon`}
|
||||
onClick={onClick}
|
||||
>
|
||||
{icon}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default styled(Icon)<Props>(({ onClick }) => `
|
||||
background: white;
|
||||
border-radius: 50%;
|
||||
box-sizing: border-box;
|
||||
cursor: ${onClick ? 'pointer' : 'inherit'};
|
||||
text-align: center;
|
||||
`);
|
||||
@@ -0,0 +1,52 @@
|
||||
// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { IconTheme } from '@pezkuwi/react-identicon/types';
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { Identicon as Icon } from '@pezkuwi/react-identicon';
|
||||
|
||||
import { styled } from '../styled.js';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
iconTheme?: IconTheme;
|
||||
isExternal?: boolean | null;
|
||||
onCopy?: () => void;
|
||||
prefix?: number;
|
||||
value?: string | null;
|
||||
}
|
||||
|
||||
function Identicon ({ className, iconTheme, onCopy, prefix, value }: Props): React.ReactElement<Props> {
|
||||
return (
|
||||
<div className={className}>
|
||||
<Icon
|
||||
className='icon'
|
||||
onCopy={onCopy}
|
||||
prefix={prefix}
|
||||
size={64}
|
||||
theme={iconTheme}
|
||||
value={value}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default styled(Identicon)<Props>`
|
||||
background: rgba(192, 192, 292, 0.25);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
||||
.container:before {
|
||||
box-shadow: none;
|
||||
background: var(--identiconBackground);
|
||||
}
|
||||
|
||||
svg {
|
||||
circle:first-of-type {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,146 @@
|
||||
// Copyright 2017-2025 @pezkuwi/extension-ui authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { DropzoneRef } from 'react-dropzone';
|
||||
|
||||
import React, { createRef, useCallback, useState } from 'react';
|
||||
import Dropzone from 'react-dropzone';
|
||||
|
||||
import { formatNumber, hexToU8a, isHex, u8aToString } from '@pezkuwi/util';
|
||||
|
||||
import { useTranslation } from '../hooks/index.js';
|
||||
import { styled } from '../styled.js';
|
||||
import Label from './Label.js';
|
||||
|
||||
function classes (...classNames: (boolean | null | string | undefined)[]): string {
|
||||
return classNames
|
||||
.filter((className): boolean => !!className)
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
export interface InputFileProps {
|
||||
// Reference Example Usage: https://github.com/react-dropzone/react-dropzone/tree/master/examples/Accept
|
||||
// i.e. MIME types: 'application/json, text/plain', or '.json, .txt'
|
||||
className?: string;
|
||||
accept?: string;
|
||||
clearContent?: boolean;
|
||||
convertHex?: boolean;
|
||||
help?: React.ReactNode;
|
||||
isDisabled?: boolean;
|
||||
isError?: boolean;
|
||||
label: string;
|
||||
onChange?: (contents: Uint8Array, name: string) => void;
|
||||
placeholder?: React.ReactNode | null;
|
||||
withEllipsis?: boolean;
|
||||
withLabel?: boolean;
|
||||
}
|
||||
|
||||
interface FileState {
|
||||
name: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
const BYTE_STR_0 = '0'.charCodeAt(0);
|
||||
const BYTE_STR_X = 'x'.charCodeAt(0);
|
||||
const NOOP = (): void => undefined;
|
||||
|
||||
function convertResult (result: ArrayBuffer, convertHex?: boolean): Uint8Array {
|
||||
const data = new Uint8Array(result);
|
||||
|
||||
// this converts the input (if detected as hex), vai the hex conversion route
|
||||
if (convertHex && data[0] === BYTE_STR_0 && data[1] === BYTE_STR_X) {
|
||||
const hex = u8aToString(data);
|
||||
|
||||
if (isHex(hex)) {
|
||||
return hexToU8a(hex);
|
||||
}
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
function InputFile ({ accept, className = '', clearContent, convertHex, isDisabled, isError = false, label, onChange, placeholder }: InputFileProps): React.ReactElement<InputFileProps> {
|
||||
const { t } = useTranslation();
|
||||
const dropRef = createRef<DropzoneRef>();
|
||||
const [file, setFile] = useState<FileState | undefined>();
|
||||
|
||||
const _onDrop = useCallback(
|
||||
(files: File[]): void => {
|
||||
files.forEach((file): void => {
|
||||
const reader = new FileReader();
|
||||
|
||||
reader.onabort = NOOP;
|
||||
reader.onerror = NOOP;
|
||||
|
||||
reader.onload = ({ target }: ProgressEvent<FileReader>): void => {
|
||||
if (target?.result) {
|
||||
const name = file.name;
|
||||
const data = convertResult(target.result as ArrayBuffer, convertHex);
|
||||
|
||||
onChange && onChange(data, name);
|
||||
dropRef && setFile({
|
||||
name,
|
||||
size: data.length
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
reader.readAsArrayBuffer(file);
|
||||
});
|
||||
},
|
||||
[convertHex, dropRef, onChange]
|
||||
);
|
||||
|
||||
const dropZone = (
|
||||
<Dropzone
|
||||
accept={accept}
|
||||
disabled={isDisabled}
|
||||
multiple={false}
|
||||
onDrop={_onDrop}
|
||||
ref={dropRef}
|
||||
>
|
||||
{({ getInputProps, getRootProps }): React.ReactElement => (
|
||||
<div {...getRootProps({ className: classes('ui--InputFile', isError ? 'error' : '', className) })}>
|
||||
<input {...getInputProps()} />
|
||||
<em className='label'>
|
||||
{
|
||||
!file || clearContent
|
||||
? placeholder || t('click to select or drag and drop the file here')
|
||||
: placeholder || t('{{name}} ({{size}} bytes)', {
|
||||
replace: {
|
||||
name: file.name,
|
||||
size: formatNumber(file.size)
|
||||
}
|
||||
})
|
||||
}
|
||||
</em>
|
||||
</div>
|
||||
)}
|
||||
</Dropzone>
|
||||
);
|
||||
|
||||
return label
|
||||
? (
|
||||
<Label
|
||||
label={label}
|
||||
>
|
||||
{dropZone}
|
||||
</Label>
|
||||
)
|
||||
: dropZone;
|
||||
}
|
||||
|
||||
export default React.memo(styled(InputFile)<InputFileProps>(({ isError }) => `
|
||||
border: 1px solid var(${isError ? '--errorBorderColor' : '--inputBorderColor'});
|
||||
background: var(--inputBackground);
|
||||
border-radius: var(--borderRadius);
|
||||
color: var(${isError ? '--errorBorderColor' : '--textColor'});
|
||||
font-size: 1rem;
|
||||
margin: 0.25rem 0;
|
||||
overflow-wrap: anywhere;
|
||||
padding: 0.5rem 0.75rem;
|
||||
|
||||
&:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
`));
|
||||
@@ -0,0 +1,67 @@
|
||||
// Copyright 2017-2025 @pezkuwi/extension-ui authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import { faTimes } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import React, { useCallback, useRef } from 'react';
|
||||
|
||||
import { styled } from '../styled.js';
|
||||
import { Input } from './TextInputs.js';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
onChange: (filter: string) => void;
|
||||
placeholder: string;
|
||||
value: string;
|
||||
withReset?: boolean;
|
||||
}
|
||||
|
||||
function InputFilter ({ className, onChange, placeholder, value, withReset = false }: Props) {
|
||||
const inputRef: React.RefObject<HTMLInputElement | null> = useRef(null);
|
||||
|
||||
const onChangeFilter = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
onChange(event.target.value);
|
||||
}, [onChange]);
|
||||
|
||||
const onResetFilter = useCallback(() => {
|
||||
onChange('');
|
||||
inputRef?.current && inputRef.current.select();
|
||||
}, [onChange]);
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<Input
|
||||
autoCapitalize='off'
|
||||
autoCorrect='off'
|
||||
autoFocus
|
||||
onChange={onChangeFilter}
|
||||
placeholder={placeholder}
|
||||
ref={inputRef}
|
||||
spellCheck={false}
|
||||
type='text'
|
||||
value={value}
|
||||
/>
|
||||
{withReset && !!value && (
|
||||
<FontAwesomeIcon
|
||||
className='resetIcon'
|
||||
icon={faTimes}
|
||||
onClick={onResetFilter}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default styled(InputFilter)<Props>`
|
||||
padding-left: 1rem !important;
|
||||
padding-right: 1rem !important;
|
||||
position: relative;
|
||||
|
||||
.resetIcon {
|
||||
position: absolute;
|
||||
right: 28px;
|
||||
top: 12px;
|
||||
color: var(--iconNeutralColor);
|
||||
cursor: pointer;
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,95 @@
|
||||
// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import React, { useCallback, useState } from 'react';
|
||||
|
||||
import { useTranslation } from '../hooks/index.js';
|
||||
import { styled } from '../styled.js';
|
||||
import Label from './Label.js';
|
||||
import { Input } from './TextInputs.js';
|
||||
import Warning from './Warning.js';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
defaultValue?: string | null;
|
||||
disabled?: boolean;
|
||||
isError?: boolean;
|
||||
isFocused?: boolean;
|
||||
isReadOnly?: boolean;
|
||||
label: string;
|
||||
onBlur?: () => void;
|
||||
onFocus?: () => void;
|
||||
onChange?: (value: string) => void;
|
||||
onEnter?: () => void;
|
||||
placeholder?: string;
|
||||
type?: 'text' | 'password';
|
||||
value?: string;
|
||||
withoutMargin?: boolean;
|
||||
}
|
||||
|
||||
function InputWithLabel ({ className, defaultValue, disabled, isError, isFocused, isReadOnly, label = '', onBlur, onChange, onEnter, onFocus, placeholder, type = 'text', value, withoutMargin }: Props): React.ReactElement<Props> {
|
||||
const [isCapsLock, setIsCapsLock] = useState(false);
|
||||
const { t } = useTranslation();
|
||||
|
||||
const _checkKey = useCallback(
|
||||
(event: React.KeyboardEvent<HTMLInputElement>): void => {
|
||||
onEnter && event.key === 'Enter' && onEnter();
|
||||
|
||||
if (type === 'password') {
|
||||
if (event.getModifierState('CapsLock')) {
|
||||
setIsCapsLock(true);
|
||||
} else {
|
||||
setIsCapsLock(false);
|
||||
}
|
||||
}
|
||||
},
|
||||
[onEnter, type]
|
||||
);
|
||||
|
||||
const _onChange = useCallback(
|
||||
({ target: { value } }: React.ChangeEvent<HTMLInputElement>): void => {
|
||||
onChange && onChange(value);
|
||||
},
|
||||
[onChange]
|
||||
);
|
||||
|
||||
return (
|
||||
<Label
|
||||
className={`${className || ''} ${withoutMargin ? 'withoutMargin' : ''}`}
|
||||
label={label}
|
||||
>
|
||||
<Input
|
||||
autoCapitalize='off'
|
||||
autoCorrect='off'
|
||||
autoFocus={isFocused}
|
||||
defaultValue={defaultValue || undefined}
|
||||
disabled={disabled}
|
||||
onBlur={onBlur}
|
||||
onChange={_onChange}
|
||||
onFocus={onFocus}
|
||||
onKeyPress={_checkKey}
|
||||
placeholder={placeholder}
|
||||
readOnly={isReadOnly}
|
||||
spellCheck={false}
|
||||
type={type}
|
||||
value={value}
|
||||
withError={isError}
|
||||
/>
|
||||
{ isCapsLock && (
|
||||
<Warning isBelowInput>{t('Warning: Caps lock is on')}</Warning>
|
||||
)}
|
||||
</Label>
|
||||
);
|
||||
}
|
||||
|
||||
export default styled(InputWithLabel)<Props>`
|
||||
margin-bottom: 16px;
|
||||
|
||||
&.withoutMargin {
|
||||
margin-bottom: 0px;
|
||||
|
||||
+ .danger {
|
||||
margin-top: 6px;
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,34 @@
|
||||
// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { styled } from '../styled.js';
|
||||
|
||||
interface Props {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
function Label ({ children, className, label }: Props): React.ReactElement<Props> {
|
||||
return (
|
||||
<div className={className}>
|
||||
<label>{label}</label>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default styled(Label)<Props>`
|
||||
color: var(--textColor);
|
||||
|
||||
label {
|
||||
font-size: var(--inputLabelFontSize);
|
||||
line-height: 14px;
|
||||
letter-spacing: 0.04em;
|
||||
opacity: 0.65;
|
||||
margin-bottom: 12px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,74 @@
|
||||
// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import React from 'react';
|
||||
import { Link as RouterLink } from 'react-router-dom';
|
||||
|
||||
import { styled } from '../styled.js';
|
||||
|
||||
interface Props {
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
isDanger?: boolean;
|
||||
isDisabled?: boolean;
|
||||
onClick?: () => void;
|
||||
title?: string;
|
||||
to?: string;
|
||||
}
|
||||
|
||||
function Link ({ children, className = '', isDisabled, onClick, title, to }: Props): React.ReactElement<Props> {
|
||||
if (isDisabled) {
|
||||
return (
|
||||
<div
|
||||
className={`${className} isDisabled`}
|
||||
title={title}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return to
|
||||
? (
|
||||
<RouterLink
|
||||
className={className}
|
||||
onClick={onClick}
|
||||
title={title}
|
||||
to={to}
|
||||
>
|
||||
{children}
|
||||
</RouterLink>
|
||||
)
|
||||
: (
|
||||
<a
|
||||
className={className}
|
||||
href='#'
|
||||
onClick={onClick}
|
||||
title={title}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
export default styled(Link)<Props>(({ isDanger }) => `
|
||||
align-items: center;
|
||||
color: var(${isDanger ? '--textColorDanger' : '--textColor'});
|
||||
display: flex;
|
||||
opacity: 0.85;
|
||||
text-decoration: none;
|
||||
vertical-align: middle;
|
||||
|
||||
&:hover {
|
||||
color: var(${isDanger ? '--textColorDanger' : '--textColor'});
|
||||
opacity: 1.0;
|
||||
}
|
||||
|
||||
&:visited {
|
||||
color: var(${isDanger ? '--textColorDanger' : '--textColor'});
|
||||
}
|
||||
|
||||
&.isDisabled {
|
||||
opacity: 0.4;
|
||||
}
|
||||
`);
|
||||
@@ -0,0 +1,38 @@
|
||||
// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { styled } from '../styled.js';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const List = ({ children, className }: Props) => (
|
||||
<ul className={className}>
|
||||
{children}
|
||||
</ul>
|
||||
);
|
||||
|
||||
export default styled(List)<Props>`
|
||||
list-style: none;
|
||||
padding-inline-start: 10px;
|
||||
padding-inline-end: 10px;
|
||||
text-indent: -22px;
|
||||
margin-left: 21px;
|
||||
|
||||
li {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
li::before {
|
||||
content: '\\2022';
|
||||
color: var(--primaryColor);
|
||||
font-size: 30px;
|
||||
font-weight: bold;
|
||||
margin-right: 10px;
|
||||
vertical-align: -20%;
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,24 @@
|
||||
// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { useTranslation } from '../hooks/index.js';
|
||||
|
||||
interface Props {
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export default function Loading ({ children }: Props): React.ReactElement<Props> {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!children) {
|
||||
return (
|
||||
<div>{t('... loading ...')}</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>{children}</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { styled } from '../styled.js';
|
||||
|
||||
interface Props {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function Main ({ children, className }: Props): React.ReactElement<Props> {
|
||||
return (
|
||||
<main className={className}>
|
||||
{children}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export default styled(Main)<Props>`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: calc(100vh - 2px);
|
||||
background: var(--background);
|
||||
color: var(--textColor);
|
||||
font-size: var(--fontSize);
|
||||
line-height: var(--lineHeight);
|
||||
border: 1px solid var(--inputBorderColor);
|
||||
|
||||
* {
|
||||
font-family: var(--fontFamily);
|
||||
}
|
||||
|
||||
> * {
|
||||
padding-left: 24px;
|
||||
padding-right: 24px;
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,37 @@
|
||||
// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import React, { forwardRef } from 'react';
|
||||
|
||||
import { styled } from '../styled.js';
|
||||
|
||||
interface Props {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const Menu = forwardRef<HTMLDivElement, Props>(({ children, className }, ref) => {
|
||||
return (
|
||||
<div
|
||||
className={className}
|
||||
ref={ref}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
Menu.displayName = 'Menu';
|
||||
|
||||
export default styled(Menu)<Props>`
|
||||
background: var(--popupBackground);
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--boxBorderColor);
|
||||
box-sizing: border-box;
|
||||
box-shadow: 0 0 10px var(--boxShadow);
|
||||
margin-top: 60px;
|
||||
padding: 16px 0;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
z-index: 2;
|
||||
`;
|
||||
@@ -0,0 +1,22 @@
|
||||
// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { styled } from '../styled.js';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function MenuDivider ({ className }: Props): React.ReactElement<Props> {
|
||||
return (
|
||||
<div className={className} />
|
||||
);
|
||||
}
|
||||
|
||||
export default styled(MenuDivider)<Props>`
|
||||
padding-top: 16px;
|
||||
margin-bottom: 16px;
|
||||
border-bottom: 1px solid var(--inputBorderColor);
|
||||
`;
|
||||
@@ -0,0 +1,45 @@
|
||||
// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { styled } from '../styled.js';
|
||||
|
||||
interface Props {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
noBorder?: boolean;
|
||||
title?: React.ReactNode;
|
||||
}
|
||||
|
||||
function MenuItem ({ children, className = '', title }: Props): React.ReactElement<Props> {
|
||||
return (
|
||||
<div className={`${className}${title ? ' isTitled' : ''}`}>
|
||||
{title && (
|
||||
<div className='itemTitle'>{title}</div>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default styled(MenuItem)<Props>`
|
||||
min-width: 13rem;
|
||||
padding: 0 16px;
|
||||
max-width: 100%;
|
||||
|
||||
> .itemTitle {
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
font-size: var(--inputLabelFontSize);
|
||||
line-height: 14px;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: var(--textColor);
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
&+&.isTitled {
|
||||
margin-top: 16px;
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,69 @@
|
||||
// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { MouseEventHandler } from 'react';
|
||||
|
||||
import { faCopy } from '@fortawesome/free-regular-svg-icons';
|
||||
import React from 'react';
|
||||
import CopyToClipboard from 'react-copy-to-clipboard';
|
||||
|
||||
import { useTranslation } from '../hooks/index.js';
|
||||
import { styled } from '../styled.js';
|
||||
import ActionText from './ActionText.js';
|
||||
import BoxWithLabel from './BoxWithLabel.js';
|
||||
|
||||
interface Props {
|
||||
seed: string;
|
||||
onCopy: MouseEventHandler<HTMLDivElement>;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function MnemonicSeed ({ className, onCopy, seed }: Props): React.ReactElement<Props> {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<BoxWithLabel
|
||||
className='mnemonicDisplay'
|
||||
label={t('Generated 12-word mnemonic seed:')}
|
||||
value={seed}
|
||||
/>
|
||||
<div className='buttonsRow'>
|
||||
<CopyToClipboard text={seed}>
|
||||
<ActionText
|
||||
className='copyBtn'
|
||||
data-seed-action='copy'
|
||||
icon={faCopy}
|
||||
onClick={onCopy}
|
||||
text={t('Copy to clipboard')}
|
||||
/>
|
||||
</CopyToClipboard>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default styled(MnemonicSeed)<Props>`
|
||||
margin-bottom: 21px;
|
||||
|
||||
.buttonsRow {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
.copyBtn {
|
||||
margin-right: 32px;
|
||||
}
|
||||
}
|
||||
|
||||
.mnemonicDisplay {
|
||||
.seedBox {
|
||||
color: var(--primaryColor);
|
||||
font-size: var(--fontSize);
|
||||
height: unset;
|
||||
letter-spacing: -0.01em;
|
||||
line-height: var(--lineHeight);
|
||||
margin-bottom: 10px;
|
||||
padding: 14px;
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,33 @@
|
||||
// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { ButtonProps } from './Button.js';
|
||||
|
||||
import { faArrowRight } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import React from 'react';
|
||||
|
||||
import { styled } from '../styled.js';
|
||||
import Button from './Button.js';
|
||||
|
||||
function NextStepButton ({ children, ...props }: ButtonProps): React.ReactElement<ButtonProps> {
|
||||
return (
|
||||
<Button {...props}>
|
||||
{children}
|
||||
<FontAwesomeIcon
|
||||
className='arrowRight'
|
||||
icon={faArrowRight}
|
||||
size='sm'
|
||||
/>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
export default styled(NextStepButton)<ButtonProps>`
|
||||
.arrowRight{
|
||||
float: right;
|
||||
margin-top: 4px;
|
||||
margin-right: 1px;
|
||||
color: var(--buttonTextColor);
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,74 @@
|
||||
// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { PasswordStrength } from '../util/passwordValidation.js';
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { styled } from '../styled.js';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
passwordStrength: PasswordStrength;
|
||||
}
|
||||
|
||||
function PasswordStrengthIndicator ({ className, passwordStrength }: Props): React.ReactElement<Props> {
|
||||
const { feedback, score } = passwordStrength;
|
||||
|
||||
const strengthColors = [
|
||||
'#ff4444', // Very Weak - Red
|
||||
'#ffa700', // Weak - Orange
|
||||
'#ffeb3b', // Medium - Yellow
|
||||
'#00C853', // Strong - Light Green
|
||||
'#00695C' // Very Strong - Dark Green
|
||||
];
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className='strength-meter'>
|
||||
{[0, 1, 2, 3, 4].map((index) => (
|
||||
<div
|
||||
className={`strength-segment ${index <= score ? 'active' : ''}`}
|
||||
key={index}
|
||||
style={{
|
||||
backgroundColor: index <= score ? strengthColors[score] : '#e0e0e0'
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{feedback.suggestions.length > 0 && (
|
||||
<ul className='suggestions'>
|
||||
{feedback.suggestions.map((suggestion, index) => (
|
||||
<li key={index}>{suggestion}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default styled(PasswordStrengthIndicator)`
|
||||
.strength-meter {
|
||||
margin-top: 0.5rem;
|
||||
display: flex;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.strength-segment {
|
||||
flex: 1;
|
||||
height: 4px;
|
||||
margin: 0 2px;
|
||||
}
|
||||
|
||||
.strength-label {
|
||||
font-size: 0.85rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.suggestions {
|
||||
margin: 0.5rem 0;
|
||||
padding-left: 1.5rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--warning-color);
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,34 @@
|
||||
// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import { faTrash } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import React from 'react';
|
||||
|
||||
import { styled } from '../styled.js';
|
||||
|
||||
interface Props {
|
||||
className?: string
|
||||
onRemove: () => void
|
||||
}
|
||||
|
||||
function RemoveAuth ({ className, onRemove }: Props): React.ReactElement {
|
||||
return (
|
||||
<FontAwesomeIcon
|
||||
className={className}
|
||||
icon={faTrash}
|
||||
onClick={onRemove}
|
||||
size='lg'
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default styled(RemoveAuth)<Props>`
|
||||
cursor: pointer;
|
||||
color: var(--labelColor);
|
||||
margin-right: 1rem;
|
||||
|
||||
&.selected {
|
||||
color: var(--primaryColor);
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,31 @@
|
||||
// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import spinnerSrc from '../assets/spinner.png';
|
||||
import { styled } from '../styled.js';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
size?: 'normal';
|
||||
}
|
||||
|
||||
function Spinner ({ className = '', size = 'normal' }: Props): React.ReactElement<Props> {
|
||||
return (
|
||||
<img
|
||||
className={`${className} ${size}Size`}
|
||||
src={spinnerSrc}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(styled(Spinner)<Props>`
|
||||
bottom: 0rem;
|
||||
height: 3rem;
|
||||
left: 50%;
|
||||
margin-left: -1.5rem;
|
||||
position: absolute;
|
||||
width: 3rem;
|
||||
z-index:
|
||||
`);
|
||||
@@ -0,0 +1,20 @@
|
||||
// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { styled } from '../styled.js';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
src: string;
|
||||
}
|
||||
|
||||
const Svg = ({ className }: Props) => <span className={`Comp--Svg ${className}`} />;
|
||||
|
||||
export default styled(Svg)<Props>(({ src }) => `
|
||||
background: var(--textColor);
|
||||
display: inline-block;
|
||||
mask: url(${src});
|
||||
mask-size: cover;
|
||||
`);
|
||||
@@ -0,0 +1,82 @@
|
||||
// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import React, { useCallback } from 'react';
|
||||
|
||||
import { styled } from '../styled.js';
|
||||
|
||||
interface Props {
|
||||
checked: boolean;
|
||||
onChange: (checked: boolean) => void;
|
||||
uncheckedLabel: string;
|
||||
checkedLabel: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function Switch ({ checked, checkedLabel, className, onChange, uncheckedLabel }: Props): React.ReactElement<Props> {
|
||||
const _onChange = useCallback(
|
||||
(event: React.ChangeEvent<HTMLInputElement>) => onChange(event.target.checked),
|
||||
[onChange]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<span>{uncheckedLabel}</span>
|
||||
<label>
|
||||
<input
|
||||
checked={checked}
|
||||
className='checkbox'
|
||||
onChange={_onChange}
|
||||
type='checkbox'
|
||||
/>
|
||||
<span className='slider' />
|
||||
</label>
|
||||
<span>{checkedLabel}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default styled(Switch)<Props>`
|
||||
label {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 48px;
|
||||
height: 24px;
|
||||
margin: 8px;
|
||||
}
|
||||
|
||||
.checkbox {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
|
||||
&:checked + .slider:before {
|
||||
transform: translateX(24px);
|
||||
}
|
||||
}
|
||||
|
||||
.slider {
|
||||
position: absolute;
|
||||
cursor: pointer;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: var(--readonlyInputBackground);
|
||||
transition: 0.2s;
|
||||
border-radius: 100px;
|
||||
border: 1px solid var(--inputBorderColor);
|
||||
|
||||
&:before {
|
||||
position: absolute;
|
||||
content: '';
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
left: 4px;
|
||||
bottom: 3px;
|
||||
background-color: var(--primaryColor);
|
||||
transition: 0.4s;
|
||||
border-radius: 50%;
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,74 @@
|
||||
// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { styled } from '../styled.js';
|
||||
|
||||
interface Props {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
isFull?: boolean;
|
||||
}
|
||||
|
||||
function Table ({ children, className = '', isFull }: Props): React.ReactElement<Props> {
|
||||
return (
|
||||
<table className={`${className} ${isFull ? 'isFull' : ''}`}>
|
||||
<tbody>
|
||||
{children}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(styled(Table)<Props>`
|
||||
border: 0;
|
||||
display: block;
|
||||
font-size: var(--labelFontSize);
|
||||
line-height: var(--labelLineHeight);
|
||||
margin-bottom: 1rem;
|
||||
|
||||
&.isFull {
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
td.data {
|
||||
max-width: 0;
|
||||
overflow: hidden;
|
||||
text-align: left;
|
||||
text-overflow: ellipsis;
|
||||
vertical-align: middle;
|
||||
width: 100%;
|
||||
|
||||
pre {
|
||||
font-family: inherit;
|
||||
font-size: 0.75rem;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
td.label {
|
||||
opacity: 0.5;
|
||||
padding: 0 0.5rem;
|
||||
text-align: right;
|
||||
vertical-align: top;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
details {
|
||||
cursor: pointer;
|
||||
max-width: 24rem;
|
||||
|
||||
summary {
|
||||
text-overflow: ellipsis;
|
||||
outline: 0;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&[open] summary {
|
||||
white-space: normal;
|
||||
}
|
||||
}
|
||||
`);
|
||||
@@ -0,0 +1,47 @@
|
||||
// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import React, { useCallback } from 'react';
|
||||
|
||||
import Label from './Label.js';
|
||||
import { TextArea } from './TextInputs.js';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
isError?: boolean;
|
||||
isFocused?: boolean;
|
||||
isReadOnly?: boolean;
|
||||
rowsCount?: number;
|
||||
label: string;
|
||||
onChange?: (value: string) => void;
|
||||
value?: string;
|
||||
}
|
||||
|
||||
export default function TextAreaWithLabel ({ className, isError, isFocused, isReadOnly, label, onChange, rowsCount, value }: Props): React.ReactElement<Props> {
|
||||
const _onChange = useCallback(
|
||||
({ target: { value } }: React.ChangeEvent<HTMLTextAreaElement>): void => {
|
||||
onChange && onChange(value);
|
||||
},
|
||||
[onChange]
|
||||
);
|
||||
|
||||
return (
|
||||
<Label
|
||||
className={className}
|
||||
label={label}
|
||||
>
|
||||
<TextArea
|
||||
autoCapitalize='off'
|
||||
autoComplete='off'
|
||||
autoCorrect='off'
|
||||
autoFocus={isFocused}
|
||||
onChange={_onChange}
|
||||
readOnly={isReadOnly}
|
||||
rows={rowsCount || 2}
|
||||
spellCheck={false}
|
||||
value={value}
|
||||
withError={isError}
|
||||
/>
|
||||
</Label>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import { css } from 'styled-components';
|
||||
|
||||
import { styled } from '../styled.js';
|
||||
|
||||
interface Props {
|
||||
withError?: boolean;
|
||||
}
|
||||
|
||||
const TextInput = css<Props>(({ withError }) => `
|
||||
background: var(--inputBackground);
|
||||
border-radius: var(--borderRadius);
|
||||
border: 1px solid var(--inputBorderColor);
|
||||
border-color: var(${withError ? '--errorBorderColor' : '--inputBorderColor'});
|
||||
box-sizing: border-box;
|
||||
color: var(${withError ? '--errorColor' : '--textColor'});
|
||||
display: block;
|
||||
font-family: var(--fontFamily);
|
||||
font-size: var(--fontSize);
|
||||
height: 40px;
|
||||
outline: none;
|
||||
padding: 0.5rem 0.75rem;
|
||||
resize: none;
|
||||
width: 100%;
|
||||
|
||||
&:read-only {
|
||||
background: var(--readonlyInputBackground);
|
||||
box-shadow: none;
|
||||
outline: none;
|
||||
}
|
||||
`);
|
||||
|
||||
export const TextArea = styled.textarea<Props>`${TextInput}`;
|
||||
export const Input = styled.input<Props>`${TextInput}`;
|
||||
@@ -0,0 +1,36 @@
|
||||
// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { styled } from '../../styled.js';
|
||||
|
||||
interface Props {
|
||||
content: React.ReactNode;
|
||||
className?: string;
|
||||
visible?: boolean;
|
||||
}
|
||||
|
||||
function Toast ({ className, content }: Props): React.ReactElement<Props> {
|
||||
return (
|
||||
<div className={className}>
|
||||
<p className='snackbar-content'>{content}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default styled(Toast)<Props>`
|
||||
position: fixed;
|
||||
display: ${({ visible }): string => visible ? 'block' : 'none'};
|
||||
height: 40px;
|
||||
text-align: center;
|
||||
vertical-align: middle;
|
||||
line-height: 7px;
|
||||
top: 460px;
|
||||
left: calc(50% - 50px);
|
||||
&& {
|
||||
margin: auto;
|
||||
border-radius: 25px;
|
||||
background: var(--highlightedAreaBackground);
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,41 @@
|
||||
// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import React, { useCallback, useState } from 'react';
|
||||
|
||||
import { ToastContext } from '../index.js';
|
||||
import Toast from './Toast.js';
|
||||
|
||||
interface ToastProviderProps {
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
const TOAST_TIMEOUT = 1500;
|
||||
|
||||
const ToastProvider = ({ children }: ToastProviderProps): React.ReactElement<ToastProviderProps> => {
|
||||
const [content, setContent] = useState('');
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
const show = useCallback((message: string): () => void => {
|
||||
const timerId = setTimeout(() => setVisible(false), TOAST_TIMEOUT);
|
||||
|
||||
setContent(message);
|
||||
setVisible(true);
|
||||
|
||||
return (): void => clearTimeout(timerId);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={{ show }}>
|
||||
{children}
|
||||
<Toast
|
||||
content={content}
|
||||
visible={visible}
|
||||
/>
|
||||
</ToastContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export default ToastProvider;
|
||||
|
||||
ToastProvider.displayName = 'Toast';
|
||||
@@ -0,0 +1,75 @@
|
||||
// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { ResultType, Validator } from '../util/validators.js';
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
import { useIsMounted } from '../hooks/index.js';
|
||||
import { Result } from '../util/validators.js';
|
||||
import Warning from './Warning.js';
|
||||
|
||||
interface BasicProps {
|
||||
isError?: boolean;
|
||||
value?: string | null;
|
||||
onChange?: (value: string) => void;
|
||||
onFocus?: () => void;
|
||||
onBlur?: () => void;
|
||||
}
|
||||
|
||||
type Props<T extends BasicProps> = T & {
|
||||
className?: string;
|
||||
component: React.ComponentType<T>;
|
||||
defaultValue?: string;
|
||||
onValidatedChange: (value: string | null) => void;
|
||||
validator: Validator<string>;
|
||||
}
|
||||
|
||||
function ValidatedInput<T extends Record<string, unknown>> ({ className, component: Input, defaultValue, onValidatedChange, validator, ...props }: Props<T>): React.ReactElement<Props<T>> {
|
||||
const [value, setValue] = useState(defaultValue || '');
|
||||
const [validationResult, setValidationResult] = useState<ResultType<string>>(Result.ok(''));
|
||||
const isMounted = useIsMounted();
|
||||
|
||||
useEffect(() => {
|
||||
if (defaultValue) {
|
||||
setValue(defaultValue);
|
||||
}
|
||||
}, [defaultValue]);
|
||||
|
||||
useEffect(() => {
|
||||
// Do not show any error on first mount
|
||||
if (!isMounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
(async (): Promise<void> => {
|
||||
const result = await validator(value);
|
||||
|
||||
setValidationResult(result);
|
||||
onValidatedChange(Result.isOk(result) ? value : null);
|
||||
})();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [value, validator, onValidatedChange]);
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<Input
|
||||
{...props as unknown as T}
|
||||
isError={Result.isError(validationResult)}
|
||||
onChange={setValue}
|
||||
value={value}
|
||||
/>
|
||||
{Result.isError(validationResult) && (
|
||||
<Warning
|
||||
isBelowInput
|
||||
isDanger
|
||||
>
|
||||
{validationResult.error.errorDescription}
|
||||
</Warning>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ValidatedInput;
|
||||
@@ -0,0 +1,18 @@
|
||||
// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { styled } from '../styled.js';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function VerticalSpace ({ className }: Props): React.ReactElement<Props> {
|
||||
return <div className={className} />;
|
||||
}
|
||||
|
||||
export default styled(VerticalSpace)<Props>`
|
||||
height: 100%;
|
||||
`;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user