Update domain references to pezkuwichain.app and rebrand from polkadot

This commit is contained in:
2026-01-06 12:21:11 +03:00
commit c2913a65d9
401 changed files with 23179 additions and 0 deletions
@@ -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&apos;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);
});
});
+135
View File
@@ -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>')} -&gt; {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;
}
`;
+189
View File
@@ -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
View File
@@ -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
View File
@@ -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;
}
+1
View File
@@ -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