mirror of
https://github.com/pezkuwichain/pezkuwi-apps.git
synced 2026-07-21 02:45:45 +00:00
feat: initial Pezkuwi Apps rebrand from polkadot-apps
Rebranded terminology: - Polkadot → Pezkuwi - Kusama → Dicle - Westend → Zagros - Rococo → PezkuwiChain - Substrate → Bizinikiwi - parachain → teyrchain Custom logos with Kurdistan brand colors (#e6007a → #86e62a): - bizinikiwi-hexagon.svg - sora-bizinikiwi.svg - hezscanner.svg - heztreasury.svg - pezkuwiscan.svg - pezkuwistats.svg - pezkuwiassembly.svg - pezkuwiholic.svg
This commit is contained in:
@@ -0,0 +1 @@
|
||||
# @pezkuwi/app-accounts
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"bugs": "https://github.com/pezkuwichain/pezkuwi-apps/issues",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"homepage": "https://github.com/pezkuwichain/pezkuwi-apps/tree/master/packages/page-accounts#readme",
|
||||
"license": "Apache-2.0",
|
||||
"name": "@pezkuwi/app-accounts",
|
||||
"private": true,
|
||||
"repository": {
|
||||
"directory": "packages/page-accounts",
|
||||
"type": "git",
|
||||
"url": "https://github.com/pezkuwichain/pezkuwi-apps.git"
|
||||
},
|
||||
"sideEffects": false,
|
||||
"type": "module",
|
||||
"version": "0.168.2-4-x",
|
||||
"dependencies": {
|
||||
"@pezkuwi/hw-ledger": "^14.0.7",
|
||||
"@pezkuwi/phishing": "^0.25.22",
|
||||
"@pezkuwi/react-components": "^0.168.2-4-x",
|
||||
"@pezkuwi/react-hooks": "^0.168.2-4-x",
|
||||
"@pezkuwi/util": "^14.0.7",
|
||||
"@polkadot/vanitygen": "^0.63.18",
|
||||
"detect-browser": "^5.3.0",
|
||||
"file-saver": "^2.0.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@pezkuwi/test-support": "0.168.2-4-x"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "*",
|
||||
"react-dom": "*",
|
||||
"react-is": "*"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,843 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-accounts authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// This is for the use of `Ledger`
|
||||
//
|
||||
/* eslint-disable deprecation/deprecation */
|
||||
|
||||
import type { ApiPromise } from '@pezkuwi/api';
|
||||
import type { SubmittableExtrinsic } from '@pezkuwi/api/types';
|
||||
import type { DeriveDemocracyLock, DeriveStakingAccount } from '@pezkuwi/api-derive/types';
|
||||
import type { Ledger, LedgerGeneric } from '@pezkuwi/hw-ledger';
|
||||
import type { ActionStatus } from '@pezkuwi/react-components/Status/types';
|
||||
import type { Option } from '@pezkuwi/types';
|
||||
import type { BlockNumber, ProxyDefinition, RecoveryConfig } from '@pezkuwi/types/interfaces';
|
||||
import type { KeyringAddress, KeyringJson$Meta } from '@pezkuwi/ui-keyring/types';
|
||||
import type { AccountBalance, Delegation } from '../types.js';
|
||||
|
||||
import FileSaver from 'file-saver';
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import useAccountLocks from '@pezkuwi/app-referenda/useAccountLocks';
|
||||
import { AddressInfo, AddressSmall, Badge, Button, ChainLock, Columar, CryptoType, Forget, LinkExternal, Menu, Popup, styled, Table, Tags, TransferModal } from '@pezkuwi/react-components';
|
||||
import { useAccountInfo, useApi, useBalancesAll, useBestNumberRelay, useCall, useLedger, useQueue, useStakingAsyncApis, useStakingInfo, useToggle, useVesting } from '@pezkuwi/react-hooks';
|
||||
import { keyring } from '@pezkuwi/ui-keyring';
|
||||
import { settings } from '@pezkuwi/ui-settings';
|
||||
import { BN, BN_ZERO, formatBalance, formatNumber, isFunction } from '@pezkuwi/util';
|
||||
|
||||
import Backup from '../modals/Backup.js';
|
||||
import ChangePass from '../modals/ChangePass.js';
|
||||
import DelegateModal from '../modals/Delegate.js';
|
||||
import Derive from '../modals/Derive.js';
|
||||
import IdentityMain from '../modals/IdentityMain.js';
|
||||
import IdentitySub from '../modals/IdentitySub.js';
|
||||
import MultisigApprove from '../modals/MultisigApprove.js';
|
||||
import ProxyOverview from '../modals/ProxyOverview.js';
|
||||
import RecoverAccount from '../modals/RecoverAccount.js';
|
||||
import RecoverSetup from '../modals/RecoverSetup.js';
|
||||
import UndelegateModal from '../modals/Undelegate.js';
|
||||
import { useTranslation } from '../translate.js';
|
||||
import { createMenuGroup } from '../util.js';
|
||||
import useMultisigApprovals from './useMultisigApprovals.js';
|
||||
import useProxies from './useProxies.js';
|
||||
|
||||
interface Props {
|
||||
account: KeyringAddress;
|
||||
className?: string;
|
||||
delegation?: Delegation;
|
||||
filter: string;
|
||||
isFavorite: boolean;
|
||||
proxy?: [ProxyDefinition[], BN];
|
||||
setBalance: (address: string, value: AccountBalance) => void;
|
||||
toggleFavorite: (address: string) => void;
|
||||
onStatusChange: (status: ActionStatus) => void;
|
||||
}
|
||||
|
||||
interface DemocracyUnlockable {
|
||||
democracyUnlockTx: SubmittableExtrinsic<'promise'> | null;
|
||||
ids: BN[];
|
||||
}
|
||||
|
||||
interface ReferendaUnlockable {
|
||||
referendaUnlockTx: SubmittableExtrinsic<'promise'> | null;
|
||||
ids: [classId: BN, refId: BN][];
|
||||
}
|
||||
|
||||
const BAL_OPTS_DEFAULT = {
|
||||
available: false,
|
||||
bonded: false,
|
||||
locked: false,
|
||||
redeemable: false,
|
||||
reserved: false,
|
||||
total: true,
|
||||
unlocking: false,
|
||||
vested: false
|
||||
};
|
||||
|
||||
const BAL_OPTS_EXPANDED = {
|
||||
available: true,
|
||||
bonded: true,
|
||||
locked: true,
|
||||
nonce: true,
|
||||
redeemable: true,
|
||||
reserved: true,
|
||||
total: false,
|
||||
unlocking: true,
|
||||
vested: true
|
||||
};
|
||||
|
||||
function calcVisible (filter: string, name: string, tags: string[]): boolean {
|
||||
if (filter.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const _filter = filter.toLowerCase();
|
||||
|
||||
return tags.reduce((result: boolean, tag: string): boolean => {
|
||||
return result || tag.toLowerCase().includes(_filter);
|
||||
}, name.toLowerCase().includes(_filter));
|
||||
}
|
||||
|
||||
function calcUnbonding (stakingInfo?: DeriveStakingAccount) {
|
||||
if (!stakingInfo?.unlocking) {
|
||||
return BN_ZERO;
|
||||
}
|
||||
|
||||
const filtered = stakingInfo.unlocking
|
||||
.filter(({ remainingEras, value }) => value.gt(BN_ZERO) && remainingEras.gt(BN_ZERO))
|
||||
.map((unlock) => unlock.value);
|
||||
const total = filtered.reduce((total, value) => total.iadd(value), new BN(0));
|
||||
|
||||
return total;
|
||||
}
|
||||
|
||||
function createClearDemocracyTx (api: ApiPromise, address: string, ids: BN[]): SubmittableExtrinsic<'promise'> | null {
|
||||
return api.tx.utility && ids.length
|
||||
? api.tx.utility.batch(
|
||||
ids
|
||||
.map((id) => api.tx.democracy.removeVote(id))
|
||||
.concat(api.tx.democracy.unlock(address))
|
||||
)
|
||||
: null;
|
||||
}
|
||||
|
||||
function createClearReferendaTx (api: ApiPromise, address: string, ids: [BN, BN][], palletReferenda = 'convictionVoting'): SubmittableExtrinsic<'promise'> | null {
|
||||
if (!api.tx.utility || !ids.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const inner = ids.map(([classId, refId]) => api.tx[palletReferenda].removeVote(classId, refId));
|
||||
|
||||
ids
|
||||
.reduce((all: BN[], [classId]) => {
|
||||
if (!all.find((id) => id.eq(classId))) {
|
||||
all.push(classId);
|
||||
}
|
||||
|
||||
return all;
|
||||
}, [])
|
||||
.forEach((classId): void => {
|
||||
inner.push(api.tx[palletReferenda].unlock(classId, address));
|
||||
});
|
||||
|
||||
return api.tx.utility.batch(inner);
|
||||
}
|
||||
|
||||
async function showLedgerAddress (getLedger: () => LedgerGeneric | Ledger, meta: KeyringJson$Meta, ss58Prefix: number): Promise<void> {
|
||||
const currApp = settings.get().ledgerApp;
|
||||
const ledger = getLedger();
|
||||
|
||||
if (currApp === 'migration' || currApp === 'generic') {
|
||||
await (ledger as LedgerGeneric).getAddress(ss58Prefix, true, meta.accountOffset || 0, meta.addressOffset || 0);
|
||||
} else {
|
||||
// This will always be the `chainSpecific` setting if the above condition is not met
|
||||
await (ledger as Ledger).getAddress(true, meta.accountOffset || 0, meta.addressOffset || 0);
|
||||
}
|
||||
}
|
||||
|
||||
const transformRecovery = {
|
||||
transform: (opt: Option<RecoveryConfig>) => opt.unwrapOr(null)
|
||||
};
|
||||
|
||||
function Account ({ account: { address, meta }, className = '', delegation, filter, isFavorite, onStatusChange, proxy, setBalance, toggleFavorite }: Props): React.ReactElement<Props> | null {
|
||||
const { t } = useTranslation();
|
||||
const [isExpanded, toggleIsExpanded] = useToggle(false);
|
||||
const { queueExtrinsic } = useQueue();
|
||||
const { api, apiIdentity, enableIdentity, isDevelopment: isDevelopmentApiProps, isEthereum: isEthereumApiProps } = useApi();
|
||||
const { getLedger } = useLedger();
|
||||
const { ahApi, isRelayChain, rcApi } = useStakingAsyncApis();
|
||||
const bestNumber = useBestNumberRelay();
|
||||
const balancesAll = useBalancesAll(address);
|
||||
const vestingInfoRaw = useVesting(address);
|
||||
// Don't show vesting on relay chain - it's migrated to Asset Hub
|
||||
// Users should connect to Asset Hub to view vesting info
|
||||
const vestingInfo = isRelayChain ? undefined : vestingInfoRaw;
|
||||
const stakingInfo = useStakingInfo(address);
|
||||
|
||||
// Vesting schedules use relay chain blocks after Asset Hub migration.
|
||||
// When on Asset Hub, query relay chain block number for accurate vesting calculations.
|
||||
// For other chains, use normal block numbers (no cross-chain adjustment needed).
|
||||
const relayBestNumber = useCall<BlockNumber>(
|
||||
rcApi?.derive.chain.bestNumber
|
||||
);
|
||||
|
||||
// Use relay chain block for vesting ONLY when on Asset Hub with relay connection available.
|
||||
// This corrects the block number mismatch caused by Asset Hub migration.
|
||||
// For all other chains, use undefined to let normal block numbers apply.
|
||||
const vestingBestNumber = (vestingInfo && rcApi) ? relayBestNumber : undefined;
|
||||
const democracyLocks = useCall<DeriveDemocracyLock[]>(api.derive.democracy?.locks, [address]);
|
||||
const recoveryInfo = useCall<RecoveryConfig | null>(api.query.recovery?.recoverable, [address], transformRecovery);
|
||||
const multiInfos = useMultisigApprovals(address);
|
||||
const proxyInfo = useProxies(address);
|
||||
const { flags: { isDevelopment, isEditable, isEthereum, isExternal, isHardware, isInjected, isMultisig, isProxied }, genesisHash, identity, name: accName, onSetGenesisHash, tags } = useAccountInfo(address);
|
||||
const convictionLocks = useAccountLocks('referenda', 'convictionVoting', address);
|
||||
const [{ democracyUnlockTx }, setDemocracyUnlock] = useState<DemocracyUnlockable>({ democracyUnlockTx: null, ids: [] });
|
||||
const [{ referendaUnlockTx }, setReferandaUnlock] = useState<ReferendaUnlockable>({ ids: [], referendaUnlockTx: null });
|
||||
const [vestingVestTx, setVestingTx] = useState<SubmittableExtrinsic<'promise'> | null>(null);
|
||||
const [isBackupOpen, toggleBackup] = useToggle();
|
||||
const [isDeriveOpen, toggleDerive] = useToggle();
|
||||
const [isForgetOpen, toggleForget] = useToggle();
|
||||
const [isIdentityMainOpen, toggleIdentityMain] = useToggle();
|
||||
const [isIdentitySubOpen, toggleIdentitySub] = useToggle();
|
||||
const [isMultisigOpen, toggleMultisig] = useToggle();
|
||||
const [isProxyOverviewOpen, toggleProxyOverview] = useToggle();
|
||||
const [isPasswordOpen, togglePassword] = useToggle();
|
||||
const [isRecoverAccountOpen, toggleRecoverAccount] = useToggle();
|
||||
const [isRecoverSetupOpen, toggleRecoverSetup] = useToggle();
|
||||
const [isTransferOpen, toggleTransfer] = useToggle();
|
||||
const [isDelegateOpen, toggleDelegate] = useToggle();
|
||||
const [isUndelegateOpen, toggleUndelegate] = useToggle();
|
||||
|
||||
useEffect((): void => {
|
||||
if (balancesAll) {
|
||||
setBalance(address, {
|
||||
// some chains don't have "active" in the Ledger
|
||||
bonded: stakingInfo?.stakingLedger.active?.unwrap() || BN_ZERO,
|
||||
locked: balancesAll.lockedBalance,
|
||||
redeemable: stakingInfo?.redeemable || BN_ZERO,
|
||||
total: balancesAll.freeBalance.add(balancesAll.reservedBalance),
|
||||
transferable: balancesAll.transferable || balancesAll.availableBalance,
|
||||
unbonding: calcUnbonding(stakingInfo)
|
||||
});
|
||||
}
|
||||
}, [address, balancesAll, setBalance, stakingInfo]);
|
||||
|
||||
useEffect((): void => {
|
||||
// Vesting transactions must be sent to Asset Hub (after migration)
|
||||
// Use ahApi when on relay chain, otherwise use the current api
|
||||
const vestingApi = isRelayChain && ahApi ? ahApi : api;
|
||||
|
||||
if (vestingInfo && vestingApi.tx.vesting?.vest) {
|
||||
setVestingTx(() =>
|
||||
vestingInfo.vestingLocked.isZero()
|
||||
? null
|
||||
: vestingApi.tx.vesting.vest()
|
||||
);
|
||||
} else {
|
||||
setVestingTx(null);
|
||||
}
|
||||
}, [address, ahApi, api, isRelayChain, vestingInfo]);
|
||||
|
||||
useEffect((): void => {
|
||||
bestNumber && democracyLocks && setDemocracyUnlock(
|
||||
(prev): DemocracyUnlockable => {
|
||||
const ids = democracyLocks
|
||||
.filter(({ isFinished, unlockAt }) => isFinished && bestNumber.gt(unlockAt))
|
||||
.map(({ referendumId }) => referendumId);
|
||||
|
||||
if (JSON.stringify(prev.ids) === JSON.stringify(ids)) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
return {
|
||||
democracyUnlockTx: createClearDemocracyTx(api, address, ids),
|
||||
ids
|
||||
};
|
||||
}
|
||||
);
|
||||
}, [address, api, bestNumber, democracyLocks]);
|
||||
|
||||
useEffect((): void => {
|
||||
bestNumber && convictionLocks && setReferandaUnlock(
|
||||
(prev): ReferendaUnlockable => {
|
||||
const ids = convictionLocks
|
||||
.filter(({ endBlock }) => endBlock.gt(BN_ZERO) && bestNumber.gt(endBlock))
|
||||
.map(({ classId, refId }): [classId: BN, refId: BN] => [classId, refId]);
|
||||
|
||||
if (JSON.stringify(prev.ids) === JSON.stringify(ids)) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
return {
|
||||
ids,
|
||||
referendaUnlockTx: createClearReferendaTx(api, address, ids)
|
||||
};
|
||||
}
|
||||
);
|
||||
}, [address, api, bestNumber, convictionLocks]);
|
||||
|
||||
const isVisible = useMemo(
|
||||
() => calcVisible(filter, accName, tags),
|
||||
[accName, filter, tags]
|
||||
);
|
||||
|
||||
const _onForget = useCallback(
|
||||
(): void => {
|
||||
if (!address) {
|
||||
return;
|
||||
}
|
||||
|
||||
const status: Partial<ActionStatus> = {
|
||||
account: address,
|
||||
action: 'forget'
|
||||
};
|
||||
|
||||
try {
|
||||
keyring.forgetAccount(address);
|
||||
status.status = 'success';
|
||||
status.message = t('account forgotten');
|
||||
} catch (error) {
|
||||
status.status = 'error';
|
||||
status.message = (error as Error).message;
|
||||
}
|
||||
},
|
||||
[address, t]
|
||||
);
|
||||
|
||||
const _onExportMultisig = useCallback(() => {
|
||||
try {
|
||||
if (!isMultisig) {
|
||||
throw new Error('not a multisig account');
|
||||
}
|
||||
|
||||
if (!meta.who) {
|
||||
throw new Error('signatories not found');
|
||||
}
|
||||
|
||||
const signatories: string[] = meta.who;
|
||||
const blob = new Blob([JSON.stringify(signatories, null, 2)], { type: 'application/json; charset=utf-8' });
|
||||
|
||||
FileSaver.saveAs(blob, `${accName}_${address}_${new Date().getTime()}.json`);
|
||||
} catch (error) {
|
||||
const status: ActionStatus = {
|
||||
account: address,
|
||||
action: 'export',
|
||||
message: (error as Error).message,
|
||||
status: 'error'
|
||||
};
|
||||
|
||||
onStatusChange(status);
|
||||
}
|
||||
}, [accName, address, isMultisig, meta.who, onStatusChange]);
|
||||
|
||||
const _clearDemocracyLocks = useCallback(
|
||||
() => democracyUnlockTx && queueExtrinsic({
|
||||
accountId: address,
|
||||
extrinsic: democracyUnlockTx
|
||||
}),
|
||||
[address, democracyUnlockTx, queueExtrinsic]
|
||||
);
|
||||
|
||||
const _clearReferendaLocks = useCallback(
|
||||
() => referendaUnlockTx && queueExtrinsic({
|
||||
accountId: address,
|
||||
extrinsic: referendaUnlockTx
|
||||
}),
|
||||
[address, referendaUnlockTx, queueExtrinsic]
|
||||
);
|
||||
|
||||
const _vestingVest = useCallback(
|
||||
() => vestingVestTx && queueExtrinsic({
|
||||
accountId: address,
|
||||
extrinsic: vestingVestTx
|
||||
}),
|
||||
[address, queueExtrinsic, vestingVestTx]
|
||||
);
|
||||
|
||||
const _showOnHardware = useCallback(
|
||||
// TODO: we should check the hardwareType from metadata here as well,
|
||||
// for now we are always assuming hardwareType === 'ledger'
|
||||
(): void => {
|
||||
showLedgerAddress(getLedger, meta, api.consts.system.ss58Prefix.toNumber()).catch((error): void => {
|
||||
console.error(`ledger: ${(error as Error).message}`);
|
||||
});
|
||||
},
|
||||
[getLedger, meta, api.consts.system.ss58Prefix]
|
||||
);
|
||||
|
||||
const menuItems = useMemo(() => [
|
||||
createMenuGroup('identityGroup', [
|
||||
isFunction(apiIdentity.tx.identity?.setIdentity) && enableIdentity && !isHardware && (
|
||||
<Menu.Item
|
||||
icon='link'
|
||||
key='identityMain'
|
||||
label={t('Set on-chain identity')}
|
||||
onClick={toggleIdentityMain}
|
||||
/>
|
||||
),
|
||||
isFunction(apiIdentity.tx.identity?.setSubs) && enableIdentity && identity?.display && !isHardware && (
|
||||
<Menu.Item
|
||||
icon='vector-square'
|
||||
key='identitySub'
|
||||
label={t('Set on-chain sub-identities')}
|
||||
onClick={toggleIdentitySub}
|
||||
/>
|
||||
),
|
||||
isFunction(api.tx.democracy?.unlock) && democracyUnlockTx && (
|
||||
<Menu.Item
|
||||
icon='broom'
|
||||
key='clearDemocracy'
|
||||
label={t('Clear expired democracy locks')}
|
||||
onClick={_clearDemocracyLocks}
|
||||
/>
|
||||
),
|
||||
isFunction(api.tx.convictionVoting?.unlock) && referendaUnlockTx && (
|
||||
<Menu.Item
|
||||
icon='broom'
|
||||
key='clearReferenda'
|
||||
label={t('Clear expired referenda locks')}
|
||||
onClick={_clearReferendaLocks}
|
||||
/>
|
||||
),
|
||||
isFunction(api.tx.vesting?.vest) && vestingVestTx && (
|
||||
<Menu.Item
|
||||
icon='unlock'
|
||||
key='vestingVest'
|
||||
label={t('Unlock vested amount')}
|
||||
onClick={_vestingVest}
|
||||
/>
|
||||
)
|
||||
], t('Identity')),
|
||||
createMenuGroup('deriveGroup', [
|
||||
!(isEthereum || isExternal || isHardware || isInjected || isMultisig || isEthereumApiProps) && (
|
||||
<Menu.Item
|
||||
icon='download'
|
||||
key='deriveAccount'
|
||||
label={t('Derive account via derivation path')}
|
||||
onClick={toggleDerive}
|
||||
/>
|
||||
),
|
||||
isHardware && (
|
||||
<Menu.Item
|
||||
icon='eye'
|
||||
key='showHwAddress'
|
||||
label={t('Show address on hardware device')}
|
||||
onClick={_showOnHardware}
|
||||
/>
|
||||
)
|
||||
], t('Derive')),
|
||||
createMenuGroup('backupGroup', [
|
||||
!(isExternal || isHardware || isInjected || isMultisig || isDevelopment) && (
|
||||
<Menu.Item
|
||||
icon='database'
|
||||
key='backupJson'
|
||||
label={t('Create a backup file for this account')}
|
||||
onClick={toggleBackup}
|
||||
/>
|
||||
),
|
||||
!(isInjected || isDevelopment) && isMultisig && (
|
||||
<Menu.Item
|
||||
icon='database'
|
||||
key='backupJson'
|
||||
label={t('Export JSON file with signatories')}
|
||||
onClick={_onExportMultisig}
|
||||
/>
|
||||
),
|
||||
!(isExternal || isHardware || isInjected || isMultisig || isDevelopment) && (
|
||||
<Menu.Item
|
||||
icon='edit'
|
||||
key='changePassword'
|
||||
label={t("Change this account's password")}
|
||||
onClick={togglePassword}
|
||||
/>
|
||||
),
|
||||
!(isInjected || isDevelopment) && (
|
||||
<Menu.Item
|
||||
icon='trash-alt'
|
||||
key='forgetAccount'
|
||||
label={t('Forget this account')}
|
||||
onClick={toggleForget}
|
||||
/>
|
||||
)
|
||||
], t('Backup')),
|
||||
isFunction(api.tx.recovery?.createRecovery) && createMenuGroup('reoveryGroup', [
|
||||
!recoveryInfo && (
|
||||
<Menu.Item
|
||||
icon='redo'
|
||||
key='makeRecoverable'
|
||||
label={t('Make recoverable')}
|
||||
onClick={toggleRecoverSetup}
|
||||
/>
|
||||
),
|
||||
<Menu.Item
|
||||
icon='screwdriver'
|
||||
key='initRecovery'
|
||||
label={t('Initiate recovery for another')}
|
||||
onClick={toggleRecoverAccount}
|
||||
/>
|
||||
], t('Recovery')),
|
||||
isFunction(api.tx.multisig?.asMulti) && isMultisig && createMenuGroup('multisigGroup', [
|
||||
<Menu.Item
|
||||
icon='file-signature'
|
||||
isDisabled={!multiInfos?.length}
|
||||
key='multisigApprovals'
|
||||
label={t('Multisig approvals')}
|
||||
onClick={toggleMultisig}
|
||||
/>
|
||||
], t('Multisig')),
|
||||
isFunction(api.query.democracy?.votingOf) && delegation?.accountDelegated && createMenuGroup('undelegateGroup', [
|
||||
<Menu.Item
|
||||
icon='user-edit'
|
||||
key='changeDelegate'
|
||||
label={t('Change democracy delegation')}
|
||||
onClick={toggleDelegate}
|
||||
/>,
|
||||
<Menu.Item
|
||||
icon='user-minus'
|
||||
key='undelegate'
|
||||
label= {t('Undelegate')}
|
||||
onClick={toggleUndelegate}
|
||||
/>
|
||||
], t('Undelegate')),
|
||||
createMenuGroup('delegateGroup', [
|
||||
isFunction(api.query.democracy?.votingOf) && !delegation?.accountDelegated && (
|
||||
<Menu.Item
|
||||
icon='user-plus'
|
||||
key='delegate'
|
||||
label={t('Delegate democracy votes')}
|
||||
onClick={toggleDelegate}
|
||||
/>
|
||||
),
|
||||
isFunction(api.query.proxy?.proxies) && (
|
||||
<Menu.Item
|
||||
icon='sitemap'
|
||||
key='proxy-overview'
|
||||
label={proxy?.[0].length
|
||||
? t('Manage proxies')
|
||||
: t('Add proxy')
|
||||
}
|
||||
onClick={toggleProxyOverview}
|
||||
/>
|
||||
)
|
||||
], t('Delegate')),
|
||||
isEditable && !isDevelopmentApiProps && createMenuGroup('genesisGroup', [
|
||||
<ChainLock
|
||||
className='accounts--network-toggle'
|
||||
genesisHash={genesisHash}
|
||||
key='chainlock'
|
||||
onChange={onSetGenesisHash}
|
||||
/>
|
||||
])
|
||||
].filter((i) => i),
|
||||
[_clearDemocracyLocks, _clearReferendaLocks, _showOnHardware, _vestingVest, _onExportMultisig, api, apiIdentity.tx.identity, enableIdentity, delegation, democracyUnlockTx, genesisHash, identity, isDevelopment, isDevelopmentApiProps, isEthereumApiProps, isEditable, isEthereum, isExternal, isHardware, isInjected, isMultisig, multiInfos, onSetGenesisHash, proxy, referendaUnlockTx, recoveryInfo, t, toggleBackup, toggleDelegate, toggleDerive, toggleForget, toggleIdentityMain, toggleIdentitySub, toggleMultisig, togglePassword, toggleProxyOverview, toggleRecoverAccount, toggleRecoverSetup, toggleUndelegate, vestingVestTx]);
|
||||
|
||||
if (!isVisible) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<StyledTr className={`${className} isExpanded isFirst packedBottom`}>
|
||||
<Table.Column.Favorite
|
||||
address={address}
|
||||
isFavorite={isFavorite}
|
||||
toggle={toggleFavorite}
|
||||
/>
|
||||
<td className='address all relative'>
|
||||
<AddressSmall
|
||||
parentAddress={meta.parentAddress}
|
||||
value={address}
|
||||
withShortAddress
|
||||
/>
|
||||
{isBackupOpen && (
|
||||
<Backup
|
||||
address={address}
|
||||
key='modal-backup-account'
|
||||
onClose={toggleBackup}
|
||||
/>
|
||||
)}
|
||||
{isDelegateOpen && (
|
||||
<DelegateModal
|
||||
key='modal-delegate'
|
||||
onClose={toggleDelegate}
|
||||
previousAmount={delegation?.amount}
|
||||
previousConviction={delegation?.conviction}
|
||||
previousDelegatedAccount={delegation?.accountDelegated}
|
||||
previousDelegatingAccount={address}
|
||||
/>
|
||||
)}
|
||||
{isDeriveOpen && (
|
||||
<Derive
|
||||
from={address}
|
||||
key='modal-derive-account'
|
||||
onClose={toggleDerive}
|
||||
/>
|
||||
)}
|
||||
{isForgetOpen && (
|
||||
<Forget
|
||||
address={address}
|
||||
key='modal-forget-account'
|
||||
onClose={toggleForget}
|
||||
onForget={_onForget}
|
||||
/>
|
||||
)}
|
||||
{isIdentityMainOpen && (
|
||||
<IdentityMain
|
||||
address={address}
|
||||
key='modal-identity-main'
|
||||
onClose={toggleIdentityMain}
|
||||
/>
|
||||
)}
|
||||
{isIdentitySubOpen && (
|
||||
<IdentitySub
|
||||
address={address}
|
||||
key='modal-identity-sub'
|
||||
onClose={toggleIdentitySub}
|
||||
/>
|
||||
)}
|
||||
{isPasswordOpen && (
|
||||
<ChangePass
|
||||
address={address}
|
||||
key='modal-change-pass'
|
||||
onClose={togglePassword}
|
||||
/>
|
||||
)}
|
||||
{isTransferOpen && (
|
||||
<TransferModal
|
||||
key='modal-transfer'
|
||||
onClose={toggleTransfer}
|
||||
senderId={address}
|
||||
/>
|
||||
)}
|
||||
{isProxyOverviewOpen && (
|
||||
<ProxyOverview
|
||||
key='modal-proxy-overview'
|
||||
onClose={toggleProxyOverview}
|
||||
previousProxy={proxy}
|
||||
proxiedAccount={address}
|
||||
/>
|
||||
)}
|
||||
{isMultisig && isMultisigOpen && multiInfos && multiInfos.length !== 0 && (
|
||||
<MultisigApprove
|
||||
address={address}
|
||||
key='multisig-approve'
|
||||
onClose={toggleMultisig}
|
||||
ongoing={multiInfos}
|
||||
threshold={meta.threshold}
|
||||
who={meta.who}
|
||||
/>
|
||||
)}
|
||||
{isRecoverAccountOpen && (
|
||||
<RecoverAccount
|
||||
address={address}
|
||||
key='recover-account'
|
||||
onClose={toggleRecoverAccount}
|
||||
/>
|
||||
)}
|
||||
{isRecoverSetupOpen && (
|
||||
<RecoverSetup
|
||||
address={address}
|
||||
key='recover-setup'
|
||||
onClose={toggleRecoverSetup}
|
||||
/>
|
||||
)}
|
||||
{isUndelegateOpen && (
|
||||
<UndelegateModal
|
||||
accountDelegating={address}
|
||||
key='modal-delegate'
|
||||
onClose={toggleUndelegate}
|
||||
/>
|
||||
)}
|
||||
<div className='absolute'>
|
||||
{meta.genesisHash
|
||||
? <Badge color='transparent' />
|
||||
: isDevelopment
|
||||
? (
|
||||
<Badge
|
||||
className='warning'
|
||||
hover={t('This is a development account derived from the known development seed. Do not use for any funds on a non-development network.')}
|
||||
icon='wrench'
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<Badge
|
||||
className='warning'
|
||||
hover={
|
||||
<div>
|
||||
<p>{t('This account is available on all networks. It is recommended to link to a specific network via the account options ("only this network" option) to limit availability. For accounts from an extension, set the network on the extension.')}</p>
|
||||
<p>{t('This does not send any transaction, rather it only sets the genesis in the account JSON.')}</p>
|
||||
</div>
|
||||
}
|
||||
icon='exclamation-triangle'
|
||||
/>
|
||||
)
|
||||
}
|
||||
{recoveryInfo && (
|
||||
<Badge
|
||||
className='recovery'
|
||||
hover={
|
||||
<div>
|
||||
<p>{t('This account is recoverable, with the following friends:')}</p>
|
||||
<div>
|
||||
{recoveryInfo.friends.map((friend, index): React.ReactNode => (
|
||||
<AddressSmall
|
||||
key={index}
|
||||
value={friend}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>{t('threshold')}</td>
|
||||
<td>{formatNumber(recoveryInfo.threshold)}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{t('delay')}</td>
|
||||
<td>{formatNumber(recoveryInfo.delayPeriod)}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{t('deposit')}</td>
|
||||
<td>{formatBalance(recoveryInfo.deposit)}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
icon='redo'
|
||||
/>
|
||||
)}
|
||||
{isProxied && proxyInfo?.isEmpty && (
|
||||
<Badge
|
||||
className='important'
|
||||
hover={t('Proxied account has no owned proxies')}
|
||||
icon='sitemap'
|
||||
info='0'
|
||||
/>
|
||||
)}
|
||||
{isMultisig && multiInfos && multiInfos.length !== 0 && (
|
||||
<Badge
|
||||
className='important'
|
||||
color='purple'
|
||||
hover={t('Multisig approvals pending')}
|
||||
hoverAction={t('View pending approvals')}
|
||||
icon='file-signature'
|
||||
onClick={toggleMultisig}
|
||||
/>
|
||||
)}
|
||||
{delegation?.accountDelegated && (
|
||||
<Badge
|
||||
className='information'
|
||||
hover={t('This account has a governance delegation')}
|
||||
hoverAction={t('Manage delegation')}
|
||||
icon='calendar-check'
|
||||
onClick={toggleDelegate}
|
||||
/>
|
||||
)}
|
||||
{proxy && proxy[0].length !== 0 && api.tx.utility && (
|
||||
<Badge
|
||||
className='information'
|
||||
hover={
|
||||
proxy[0].length === 1
|
||||
? t('This account has a proxy set')
|
||||
: t('This account has {{proxyNumber}} proxies set', { replace: { proxyNumber: proxy[0].length } })
|
||||
}
|
||||
hoverAction={t('Manage proxies')}
|
||||
icon='sitemap'
|
||||
onClick={toggleProxyOverview}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className='actions button'>
|
||||
<Button.Group>
|
||||
{(isFunction(api.tx.balances?.transferAllowDeath) || isFunction(api.tx.balances?.transfer)) && (
|
||||
<Button
|
||||
className='send-button'
|
||||
icon='paper-plane'
|
||||
label={t('send')}
|
||||
onClick={toggleTransfer}
|
||||
/>
|
||||
)}
|
||||
<Popup
|
||||
isDisabled={!menuItems.length}
|
||||
value={
|
||||
<Menu>
|
||||
{menuItems}
|
||||
</Menu>
|
||||
}
|
||||
/>
|
||||
</Button.Group>
|
||||
</td>
|
||||
<Table.Column.Expand
|
||||
isExpanded={isExpanded}
|
||||
toggle={toggleIsExpanded}
|
||||
/>
|
||||
</StyledTr>
|
||||
<StyledTr className={`${className} isExpanded ${isExpanded ? '' : 'isLast'} packedTop`}>
|
||||
<td />
|
||||
<td
|
||||
className='balance all'
|
||||
colSpan={2}
|
||||
>
|
||||
<AddressInfo
|
||||
address={address}
|
||||
balancesAll={balancesAll}
|
||||
vestingBestNumber={vestingBestNumber}
|
||||
vestingInfo={vestingInfo}
|
||||
withBalance={BAL_OPTS_DEFAULT}
|
||||
/>
|
||||
</td>
|
||||
<td />
|
||||
</StyledTr>
|
||||
<StyledTr className={`${className} ${isExpanded ? 'isExpanded isLast' : 'isCollapsed'} packedTop`}>
|
||||
<td />
|
||||
<td
|
||||
className='balance columar'
|
||||
colSpan={2}
|
||||
>
|
||||
<AddressInfo
|
||||
address={address}
|
||||
balancesAll={balancesAll}
|
||||
convictionLocks={convictionLocks}
|
||||
vestingBestNumber={vestingBestNumber}
|
||||
vestingInfo={vestingInfo}
|
||||
withBalance={BAL_OPTS_EXPANDED}
|
||||
/>
|
||||
<Columar size='tiny'>
|
||||
<Columar.Column>
|
||||
<div data-testid='tags'>
|
||||
<Tags
|
||||
value={tags}
|
||||
withTitle
|
||||
/>
|
||||
</div>
|
||||
</Columar.Column>
|
||||
<Columar.Column>
|
||||
<h5>{t('account type')}</h5>
|
||||
<CryptoType accountId={address} />
|
||||
</Columar.Column>
|
||||
</Columar>
|
||||
<Columar is100>
|
||||
<Columar.Column>
|
||||
<LinkExternal
|
||||
data={address}
|
||||
type='address'
|
||||
withTitle
|
||||
/>
|
||||
</Columar.Column>
|
||||
</Columar>
|
||||
</td>
|
||||
<td />
|
||||
</StyledTr>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const StyledTr = styled.tr`
|
||||
.devBadge {
|
||||
opacity: var(--opacity-light);
|
||||
}
|
||||
`;
|
||||
|
||||
export default React.memo(Account);
|
||||
@@ -0,0 +1,30 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-accounts authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { styled } from '@pezkuwi/react-components';
|
||||
|
||||
interface Props {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
type: 'warning' | 'error';
|
||||
}
|
||||
|
||||
function Banner ({ children, className = '', type }: Props): React.ReactElement<Props> | null {
|
||||
return (
|
||||
<StyledArticle className={`${className} ${type} centered`}>
|
||||
<div className='box'>
|
||||
{children}
|
||||
</div>
|
||||
</StyledArticle>
|
||||
);
|
||||
}
|
||||
|
||||
const StyledArticle = styled.article`
|
||||
.box {
|
||||
padding: 0 0.5rem;
|
||||
}
|
||||
`;
|
||||
|
||||
export default React.memo(Banner);
|
||||
@@ -0,0 +1,53 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-accounts authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
|
||||
import { styled } from '@pezkuwi/react-components';
|
||||
import { useStakingAsyncApis } from '@pezkuwi/react-hooks';
|
||||
|
||||
import { useTranslation } from '../translate.js';
|
||||
import Banner from './Banner.js';
|
||||
|
||||
const BannerAssetHubMigration = () => {
|
||||
const { t } = useTranslation();
|
||||
const { ahEndPoints, isRelayChain } = useStakingAsyncApis();
|
||||
|
||||
const assetHubEndPoint = useMemo(() => {
|
||||
return ahEndPoints[Math.floor(Math.random() * ahEndPoints.length)];
|
||||
}, [ahEndPoints]);
|
||||
|
||||
if (!isRelayChain) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledBanner type='warning'>
|
||||
<p>
|
||||
{t('After Asset Hub migration, all funds have been moved to Asset Hub. Please switch to the ')}
|
||||
<a
|
||||
href={`${window.location.origin}${window.location.pathname}?rpc=${assetHubEndPoint}#/accounts`}
|
||||
rel='noopener noreferrer'
|
||||
target='_blank'
|
||||
>
|
||||
{t('Asset Hub chain')}
|
||||
</a>
|
||||
{t(' to view your balances and details.')}
|
||||
<br />
|
||||
{t('For more information about Asset Hub migration, check the ')}
|
||||
<a
|
||||
href='https://support.pezkuwi.network/support/solutions/articles/65000190561#What-would-happen-after-the-migration?'
|
||||
rel='noopener noreferrer'
|
||||
target='_blank'
|
||||
>details here</a>.
|
||||
</p>
|
||||
</StyledBanner>
|
||||
);
|
||||
};
|
||||
|
||||
const StyledBanner = styled(Banner)`
|
||||
border: 1px solid #ffc107;
|
||||
font-size: 1rem !important;
|
||||
`;
|
||||
|
||||
export default React.memo(BannerAssetHubMigration);
|
||||
@@ -0,0 +1,26 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-accounts authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import useClaimCounter from '@pezkuwi/app-claims/useCounter'; // exceptionally CRAP idea
|
||||
|
||||
import { useTranslation } from '../translate.js';
|
||||
import Banner from './Banner.js';
|
||||
|
||||
function BannerExtension (): React.ReactElement | null {
|
||||
const claimCount = useClaimCounter();
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!claimCount) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Banner type='error'>
|
||||
<p>{t('You have {{claimCount}} accounts that need attestations. Use the Claim Tokens app on the navigation bar to complete the process. Until you do, your balances for those accounts will not be reflected.', { replace: { claimCount } })} <a href='#/claims'>{t('Claim tokens...')}</a></p>
|
||||
</Banner>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(BannerExtension);
|
||||
@@ -0,0 +1,95 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-accounts authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import { detect } from 'detect-browser';
|
||||
import React, { useRef } from 'react';
|
||||
import { Trans } from 'react-i18next';
|
||||
|
||||
import useExtensionCounter from '@pezkuwi/app-settings/useCounter';
|
||||
import { availableExtensions } from '@pezkuwi/apps-config';
|
||||
import { isWeb3Injected } from '@pezkuwi/extension-dapp';
|
||||
import { onlyOnWeb } from '@pezkuwi/react-api/hoc';
|
||||
import { useApi } from '@pezkuwi/react-hooks';
|
||||
import { stringUpperFirst } from '@pezkuwi/util';
|
||||
|
||||
import { useTranslation } from '../translate.js';
|
||||
import Banner from './Banner.js';
|
||||
|
||||
// it would have been really good to import this from detect, however... not exported
|
||||
type Browser = 'chrome' | 'firefox';
|
||||
|
||||
const browserInfo = detect();
|
||||
const browserName: Browser | null = (browserInfo && (browserInfo.name as Browser)) || null;
|
||||
const isSupported = browserName && Object.keys(availableExtensions).includes(browserName);
|
||||
|
||||
function BannerExtension (): React.ReactElement | null {
|
||||
const { t } = useTranslation();
|
||||
const { hasInjectedAccounts } = useApi();
|
||||
const upgradableCount = useExtensionCounter();
|
||||
const phishing = useRef<string>(t('Since some extensions, such as the pezkuwi-js extension, protects you against all community reported phishing sites, there are valid reasons to use them for additional protection, even if you are not storing accounts in it.'));
|
||||
|
||||
if (!isSupported || !browserName) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isWeb3Injected) {
|
||||
if (hasInjectedAccounts) {
|
||||
if (!upgradableCount) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Banner type='warning'>
|
||||
<p>
|
||||
{upgradableCount === 1
|
||||
? t('You have 1 extension that needs to be updated with the latest chain properties in order to display the correct information for the chain you are connected to and to use a Ledger device.')
|
||||
: t('You have {{upgradableCount}} extensions that need to be updated with the latest chain properties in order to display the correct information for the chain you are connected to and to use a Ledger device.', { replace: { upgradableCount } })
|
||||
}
|
||||
{t(' This update includes chain metadata and chain properties.')}
|
||||
</p>
|
||||
<p><Trans key='extensionUpgrade'>Visit your <a href='#/settings/metadata'>settings page</a> to apply the updates to the injected extensions.</Trans></p>
|
||||
</Banner>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Banner type='warning'>
|
||||
<p>{t('One or more extensions are detected in your browser, however no accounts has been injected.')}</p>
|
||||
<p>{t('Ensure that the extension has accounts, some accounts are visible globally and available for this chain and that you gave the application permission to access accounts from the extension to use them.')}</p>
|
||||
<p>{phishing.current}</p>
|
||||
</Banner>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Banner type='warning'>
|
||||
<p>{t('It is recommended that you create/store your accounts securely and externally from the app. On {{yourBrowser}} the following browser extensions are available for use -', {
|
||||
replace: {
|
||||
yourBrowser: stringUpperFirst(browserName)
|
||||
}
|
||||
})}</p>
|
||||
<ul>{availableExtensions[browserName].map(({ desc, link, name }): React.ReactNode => (
|
||||
<li key={name}>
|
||||
<a
|
||||
href={link}
|
||||
rel='noopener noreferrer'
|
||||
target='_blank'
|
||||
>
|
||||
{name}
|
||||
</a> ({t(desc)})
|
||||
</li>
|
||||
))
|
||||
}</ul>
|
||||
<p>{t('Accounts injected from any of these extensions will appear in this application and be available for use. The above list is updated as more extensions with external signing capability become available.')}
|
||||
<a
|
||||
href='https://github.com/pezkuwi-js/extension'
|
||||
rel='noopener noreferrer'
|
||||
target='_blank'
|
||||
>{t('Learn more...')}</a>
|
||||
</p>
|
||||
<p>{phishing.current}</p>
|
||||
</Banner>
|
||||
);
|
||||
}
|
||||
|
||||
export default onlyOnWeb(React.memo(BannerExtension));
|
||||
@@ -0,0 +1,69 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-accounts authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { AccountBalance } from '../types.js';
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { CardSummary, SummaryBox } from '@pezkuwi/react-components';
|
||||
import { FormatBalance } from '@pezkuwi/react-query';
|
||||
|
||||
import { useTranslation } from '../translate.js';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
balance?: AccountBalance;
|
||||
}
|
||||
|
||||
function Summary ({ balance, className }: Props) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<SummaryBox className={className}>
|
||||
<CardSummary label={t('total balance')}>
|
||||
<FormatBalance
|
||||
className={balance ? '' : '--tmp'}
|
||||
value={balance?.total || 1}
|
||||
/>
|
||||
</CardSummary>
|
||||
<CardSummary
|
||||
className='media--900'
|
||||
label={t('total transferable')}
|
||||
>
|
||||
<FormatBalance
|
||||
className={balance ? '' : '--tmp'}
|
||||
value={balance?.transferable || 1}
|
||||
/>
|
||||
</CardSummary>
|
||||
<CardSummary label={t('total locked')}>
|
||||
<FormatBalance
|
||||
className={balance ? '' : '--tmp'}
|
||||
value={balance?.locked || 1}
|
||||
/>
|
||||
</CardSummary>
|
||||
{balance?.bonded.gtn(0) &&
|
||||
<CardSummary
|
||||
className='media--1100'
|
||||
label={t('bonded')}
|
||||
>
|
||||
<FormatBalance value={balance.bonded} />
|
||||
</CardSummary>}
|
||||
{balance?.redeemable.gtn(0) &&
|
||||
<CardSummary
|
||||
className='media--1500'
|
||||
label={t('redeemable')}
|
||||
>
|
||||
<FormatBalance value={balance.redeemable} />
|
||||
</CardSummary>}
|
||||
{balance?.unbonding.gtn(0) &&
|
||||
<CardSummary
|
||||
className='media--1300'
|
||||
label={t('unbonding')}
|
||||
>
|
||||
<FormatBalance value={balance.unbonding} />
|
||||
</CardSummary>}
|
||||
</SummaryBox>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(Summary);
|
||||
@@ -0,0 +1,545 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-accounts authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
/// <reference types="@pezkuwi/dev-test/globals.d.ts" />
|
||||
|
||||
import type { AddressFlags } from '@pezkuwi/react-hooks/types';
|
||||
import type { Table } from '@pezkuwi/test-support/pagesElements';
|
||||
import type { u32 } from '@pezkuwi/types';
|
||||
import type { AccountId, Multisig, ProxyDefinition, Timepoint, Voting, VotingDelegating } from '@pezkuwi/types/interfaces';
|
||||
import type { AccountRow } from '../../test/pageElements/AccountRow.js';
|
||||
|
||||
import { fireEvent, screen, within } from '@testing-library/react';
|
||||
|
||||
import { PEZKUWI_GENESIS } from '@pezkuwi/apps-config';
|
||||
import i18next from '@pezkuwi/react-components/i18n';
|
||||
import { toShortAddress } from '@pezkuwi/react-components/util';
|
||||
import { anAccountWithBalance, anAccountWithBalanceAndMeta, anAccountWithInfo, anAccountWithInfoAndMeta, anAccountWithMeta, anAccountWithStaking } from '@pezkuwi/test-support/creation/account';
|
||||
import { makeStakingLedger as ledger } from '@pezkuwi/test-support/creation/staking';
|
||||
import { alice, bob, MemoryStore } from '@pezkuwi/test-support/keyring';
|
||||
import { balance, mockApiHooks, showBalance } from '@pezkuwi/test-support/utils';
|
||||
import { TypeRegistry } from '@pezkuwi/types/create';
|
||||
import { keyring } from '@pezkuwi/ui-keyring';
|
||||
import { BN } from '@pezkuwi/util';
|
||||
|
||||
import { AccountsPage } from '../../test/pages/accountsPage.js';
|
||||
|
||||
// FIXME isSplit Table
|
||||
// eslint-disable-next-line jest/no-disabled-tests
|
||||
describe.skip('Accounts page', () => {
|
||||
let accountsPage: AccountsPage;
|
||||
|
||||
beforeAll(async () => {
|
||||
await i18next.changeLanguage('en');
|
||||
|
||||
if (keyring.getAccounts().length === 0) {
|
||||
keyring.loadAll({ isDevelopment: true, store: new MemoryStore() });
|
||||
}
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
accountsPage = new AccountsPage();
|
||||
accountsPage.clearAccounts();
|
||||
});
|
||||
|
||||
describe('when no accounts', () => {
|
||||
beforeEach(() => {
|
||||
accountsPage.render([]);
|
||||
});
|
||||
|
||||
// eslint-disable-next-line jest/expect-expect
|
||||
it('shows sort-by controls', async () => {
|
||||
await accountsPage.reverseSortingOrder();
|
||||
});
|
||||
|
||||
it('shows a table', async () => {
|
||||
const accountsTable = await accountsPage.getTable();
|
||||
|
||||
expect(accountsTable).not.toBeNull();
|
||||
});
|
||||
|
||||
it('the accounts table contains no account rows', async () => {
|
||||
const accountRows = await accountsPage.getAccountRows();
|
||||
|
||||
expect(accountRows).toHaveLength(0);
|
||||
});
|
||||
|
||||
// eslint-disable-next-line jest/expect-expect
|
||||
it('the accounts table contains a message about no accounts available', async () => {
|
||||
const noAccountsMessage = 'You don\'t have any accounts. Some features are currently hidden and will only become available once you have accounts.';
|
||||
const accountsTable = await accountsPage.getTable();
|
||||
|
||||
await accountsTable.assertText(noAccountsMessage);
|
||||
});
|
||||
|
||||
it('no summary is displayed', () => {
|
||||
const summaries = screen.queryAllByTestId(/card-summary:total \w+/i);
|
||||
|
||||
expect(summaries).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when some accounts exist', () => {
|
||||
it('the accounts table contains some account rows', async () => {
|
||||
accountsPage.renderDefaultAccounts(2);
|
||||
const accountRows = await accountsPage.getAccountRows();
|
||||
|
||||
expect(accountRows).toHaveLength(2);
|
||||
});
|
||||
|
||||
// eslint-disable-next-line jest/expect-expect
|
||||
it('account rows display the total balance info', async () => {
|
||||
accountsPage.renderAccountsWithDefaultAddresses(
|
||||
anAccountWithBalance({ freeBalance: balance(500) }),
|
||||
anAccountWithBalance({ freeBalance: balance(200), reservedBalance: balance(150) })
|
||||
);
|
||||
|
||||
const rows = await accountsPage.getAccountRows();
|
||||
|
||||
await rows[0].assertBalancesTotal(balance(500));
|
||||
await rows[1].assertBalancesTotal(balance(350));
|
||||
});
|
||||
|
||||
// eslint-disable-next-line jest/expect-expect
|
||||
it('account rows display the details balance info', async () => {
|
||||
accountsPage.renderAccountsWithDefaultAddresses(
|
||||
anAccountWithBalance({ freeBalance: balance(500), lockedBalance: balance(30) }),
|
||||
anAccountWithBalance({ availableBalance: balance(50), freeBalance: balance(200), reservedBalance: balance(150) })
|
||||
);
|
||||
|
||||
const rows = await accountsPage.getAccountRows();
|
||||
|
||||
await rows[0].assertBalancesDetails([
|
||||
{ amount: balance(0), name: 'transferable' },
|
||||
{ amount: balance(30), name: 'locked' }]);
|
||||
await rows[1].assertBalancesDetails([
|
||||
{ amount: balance(50), name: 'transferable' },
|
||||
{ amount: balance(150), name: 'reserved' }]);
|
||||
});
|
||||
|
||||
// FIXME multiple tables
|
||||
// eslint-disable-next-line jest/no-disabled-tests
|
||||
it.skip('derived account displays parent account info', async () => {
|
||||
accountsPage.renderAccountsWithDefaultAddresses(
|
||||
anAccountWithMeta({ isInjected: true, name: 'ALICE', whenCreated: 200 }),
|
||||
anAccountWithMeta({ name: 'ALICE_CHILD', parentAddress: alice, whenCreated: 300 })
|
||||
);
|
||||
|
||||
const accountRows = await accountsPage.getAccountRows();
|
||||
|
||||
expect(accountRows).toHaveLength(2);
|
||||
await accountRows[1].assertParentAccountName('ALICE');
|
||||
});
|
||||
|
||||
// FIXME broken after column rework
|
||||
// eslint-disable-next-line jest/no-disabled-tests, jest/expect-expect
|
||||
it.skip('a separate column for parent account is not displayed', async () => {
|
||||
accountsPage.renderDefaultAccounts(1);
|
||||
const accountsTable = await accountsPage.getTable();
|
||||
|
||||
accountsTable.assertColumnNotExist('parent');
|
||||
accountsTable.assertColumnExists('type');
|
||||
});
|
||||
|
||||
it('account rows display the shorted address', async () => {
|
||||
accountsPage.renderAccountsForAddresses(
|
||||
alice
|
||||
);
|
||||
const accountRows = await accountsPage.getAccountRows();
|
||||
|
||||
expect(accountRows).toHaveLength(1);
|
||||
const aliceShortAddress = toShortAddress(alice);
|
||||
|
||||
await accountRows[0].assertShortAddress(aliceShortAddress);
|
||||
});
|
||||
|
||||
// eslint-disable-next-line jest/expect-expect
|
||||
it('when account is not tagged, account row details displays none info', async () => {
|
||||
accountsPage.renderDefaultAccounts(1);
|
||||
const rows = await accountsPage.getAccountRows();
|
||||
|
||||
await rows[0].assertTags('none');
|
||||
});
|
||||
|
||||
// eslint-disable-next-line jest/expect-expect
|
||||
it('when account is tagged, account row details displays tags', async () => {
|
||||
accountsPage.renderAccountsWithDefaultAddresses(
|
||||
anAccountWithInfo({ tags: ['my tag', 'Super Tag'] })
|
||||
);
|
||||
|
||||
const rows = await accountsPage.getAccountRows();
|
||||
|
||||
await rows[0].assertTags('my tagSuper Tag');
|
||||
});
|
||||
|
||||
it('account details rows toggled on icon toggle click', async () => {
|
||||
accountsPage.renderDefaultAccounts(1);
|
||||
const row = (await accountsPage.getAccountRows())[0];
|
||||
|
||||
expect(row.detailsRow).toHaveClass('isCollapsed');
|
||||
|
||||
await row.expand();
|
||||
|
||||
expect(row.detailsRow).toHaveClass('isExpanded');
|
||||
});
|
||||
|
||||
it('displays some summary', () => {
|
||||
accountsPage.renderAccountsWithDefaultAddresses(
|
||||
anAccountWithBalance({ freeBalance: balance(500) }),
|
||||
anAccountWithBalance({ freeBalance: balance(200), reservedBalance: balance(150) })
|
||||
);
|
||||
|
||||
const summaries = screen.queryAllByTestId(/card-summary:total \w+/i);
|
||||
|
||||
expect(summaries).not.toHaveLength(0);
|
||||
});
|
||||
|
||||
it('displays balance summary', async () => {
|
||||
accountsPage.renderAccountsWithDefaultAddresses(
|
||||
anAccountWithBalance({ freeBalance: balance(500) }),
|
||||
anAccountWithBalance({ freeBalance: balance(200), reservedBalance: balance(150) })
|
||||
);
|
||||
|
||||
const summary = await screen.findByTestId(/card-summary:(total )?balance/i);
|
||||
|
||||
expect(summary).toHaveTextContent(showBalance(500 + 200 + 150));
|
||||
});
|
||||
|
||||
it('displays transferable summary', async () => {
|
||||
accountsPage.renderAccountsWithDefaultAddresses(
|
||||
anAccountWithBalance({ availableBalance: balance(400) }),
|
||||
anAccountWithBalance({ availableBalance: balance(600) })
|
||||
);
|
||||
|
||||
const summary = await screen.findByTestId(/card-summary:(total )?transferable/i);
|
||||
|
||||
expect(summary).toHaveTextContent(showBalance(400 + 600));
|
||||
});
|
||||
|
||||
it('displays locked summary', async () => {
|
||||
accountsPage.renderAccountsWithDefaultAddresses(
|
||||
anAccountWithBalance({ lockedBalance: balance(400) }),
|
||||
anAccountWithBalance({ lockedBalance: balance(600) })
|
||||
);
|
||||
|
||||
const summary = await screen.findByTestId(/card-summary:(total )?locked/i);
|
||||
|
||||
expect(summary).toHaveTextContent(showBalance(400 + 600));
|
||||
});
|
||||
|
||||
it('displays bonded summary', async () => {
|
||||
accountsPage.renderAccountsWithDefaultAddresses(
|
||||
anAccountWithStaking({ stakingLedger: ledger(balance(70)) }),
|
||||
anAccountWithStaking({ stakingLedger: ledger(balance(20)) })
|
||||
);
|
||||
|
||||
const summary = await screen.findByTestId(/card-summary:(total )?bonded/i);
|
||||
|
||||
expect(summary).toHaveTextContent(showBalance(70 + 20));
|
||||
});
|
||||
|
||||
it('displays unbonding summary', async () => {
|
||||
accountsPage.renderAccountsWithDefaultAddresses(
|
||||
anAccountWithStaking({
|
||||
unlocking: [
|
||||
{
|
||||
remainingEras: new BN('1000000000'),
|
||||
value: balance(200)
|
||||
},
|
||||
{
|
||||
remainingEras: new BN('2000000000'),
|
||||
value: balance(300)
|
||||
},
|
||||
{
|
||||
remainingEras: new BN('3000000000'),
|
||||
value: balance(400)
|
||||
}
|
||||
]
|
||||
}),
|
||||
anAccountWithStaking({
|
||||
unlocking: [
|
||||
{
|
||||
remainingEras: new BN('1000000000'),
|
||||
value: balance(100)
|
||||
},
|
||||
{
|
||||
remainingEras: new BN('2000000000'),
|
||||
value: balance(200)
|
||||
},
|
||||
{
|
||||
remainingEras: new BN('3000000000'),
|
||||
value: balance(300)
|
||||
}
|
||||
]
|
||||
})
|
||||
);
|
||||
|
||||
const summary = await screen.findByTestId(/card-summary:(total )?unbonding/i);
|
||||
|
||||
expect(summary).toHaveTextContent(showBalance(200 + 300 + 400 + 100 + 200 + 300));
|
||||
});
|
||||
|
||||
it('displays redeemable summary', async () => {
|
||||
accountsPage.renderAccountsWithDefaultAddresses(
|
||||
anAccountWithStaking({ redeemable: balance(4000) }),
|
||||
anAccountWithStaking({ redeemable: balance(5000) })
|
||||
);
|
||||
|
||||
const summary = await screen.findByTestId(/card-summary:(total )?redeemable/i);
|
||||
|
||||
expect(summary).toHaveTextContent(showBalance(4000 + 5000));
|
||||
});
|
||||
|
||||
it('sorts accounts by date by default', async () => {
|
||||
accountsPage.renderAccountsWithDefaultAddresses(
|
||||
anAccountWithBalanceAndMeta({ freeBalance: balance(1) }, { whenCreated: 200 }),
|
||||
anAccountWithBalanceAndMeta({ freeBalance: balance(2) }, { whenCreated: 300 }),
|
||||
anAccountWithBalanceAndMeta({ freeBalance: balance(3) }, { whenCreated: 100 })
|
||||
);
|
||||
expect(await accountsPage.getCurrentSortCategory()).toHaveTextContent('date');
|
||||
|
||||
const accountsTable = await accountsPage.getTable();
|
||||
|
||||
await accountsTable.assertRowsOrder([3, 1, 2]);
|
||||
});
|
||||
|
||||
// FIXME multiple tables now
|
||||
// eslint-disable-next-line jest/no-disabled-tests
|
||||
describe.skip('when sorting is used', () => {
|
||||
let accountsTable: Table;
|
||||
|
||||
beforeEach(async () => {
|
||||
accountsPage.renderAccountsWithDefaultAddresses(
|
||||
anAccountWithBalanceAndMeta({ freeBalance: balance(1) }, { isInjected: true, name: 'bbb', whenCreated: 200 }),
|
||||
anAccountWithBalanceAndMeta({ freeBalance: balance(2) }, {
|
||||
hardwareType: 'ledger',
|
||||
isHardware: true,
|
||||
name: 'bb',
|
||||
parentAddress: alice,
|
||||
whenCreated: 300
|
||||
}),
|
||||
anAccountWithBalanceAndMeta({ freeBalance: balance(3) }, { isInjected: true, name: 'aaa', whenCreated: 100 })
|
||||
);
|
||||
|
||||
accountsTable = await accountsPage.getTable();
|
||||
});
|
||||
|
||||
it('changes default dropdown value', async () => {
|
||||
await accountsPage.sortBy('balances');
|
||||
expect(await accountsPage.getCurrentSortCategory())
|
||||
.toHaveTextContent('balances');
|
||||
});
|
||||
|
||||
// eslint-disable-next-line jest/expect-expect
|
||||
it('sorts by parent if asked', async () => {
|
||||
await accountsPage.sortBy('parent');
|
||||
await accountsTable.assertRowsOrder([3, 1, 2]);
|
||||
});
|
||||
|
||||
// eslint-disable-next-line jest/expect-expect
|
||||
it('sorts by name if asked', async () => {
|
||||
await accountsPage.sortBy('name');
|
||||
await accountsTable.assertRowsOrder([3, 2, 1]);
|
||||
});
|
||||
|
||||
// eslint-disable-next-line jest/expect-expect
|
||||
it('sorts by date if asked', async () => {
|
||||
await accountsPage.sortBy('date');
|
||||
await accountsTable.assertRowsOrder([3, 1, 2]);
|
||||
});
|
||||
|
||||
// eslint-disable-next-line jest/expect-expect
|
||||
it('sorts by balances if asked', async () => {
|
||||
await accountsPage.sortBy('balances');
|
||||
await accountsTable.assertRowsOrder([1, 2, 3]);
|
||||
});
|
||||
|
||||
// eslint-disable-next-line jest/expect-expect
|
||||
it('implements stable sort', async () => {
|
||||
await accountsPage.sortBy('name');
|
||||
await accountsTable.assertRowsOrder([3, 2, 1]);
|
||||
await accountsPage.sortBy('balances');
|
||||
await accountsTable.assertRowsOrder([1, 2, 3]);
|
||||
});
|
||||
|
||||
// eslint-disable-next-line jest/expect-expect
|
||||
it('respects reverse button', async () => {
|
||||
await accountsPage.sortBy('name');
|
||||
await accountsTable.assertRowsOrder([3, 2, 1]);
|
||||
await accountsPage.sortBy('balances');
|
||||
await accountsTable.assertRowsOrder([1, 2, 3]);
|
||||
await accountsPage.reverseSortingOrder();
|
||||
await accountsTable.assertRowsOrder([3, 2, 1]);
|
||||
await accountsPage.sortBy('name');
|
||||
await accountsTable.assertRowsOrder([1, 2, 3]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('badges', () => {
|
||||
let accountRows: AccountRow[];
|
||||
|
||||
beforeEach(() => {
|
||||
mockApiHooks.setMultisigApprovals([
|
||||
[new TypeRegistry().createType('Hash', PEZKUWI_GENESIS), {
|
||||
approvals: [bob as unknown as AccountId],
|
||||
deposit: balance(927000000000000),
|
||||
depositor: bob as unknown as AccountId,
|
||||
when: { height: new BN(1190) as u32, index: new BN(1) as u32 } as Timepoint
|
||||
} as Multisig
|
||||
]
|
||||
]);
|
||||
mockApiHooks.setDelegations([{ asDelegating: { target: bob as unknown as AccountId } as unknown as VotingDelegating, isDelegating: true } as Voting]);
|
||||
mockApiHooks.setProxies([[[{ delegate: alice as unknown as AccountId, proxyType: { isAny: true, isGovernance: true, isNonTransfer: true, isStaking: true, toNumber: () => 1 } } as unknown as ProxyDefinition], new BN(1)]]);
|
||||
});
|
||||
|
||||
describe('when genesis hash is not set', () => {
|
||||
beforeEach(async () => {
|
||||
accountsPage.renderAccountsWithDefaultAddresses(
|
||||
anAccountWithInfoAndMeta({ flags: { isDevelopment: true } as AddressFlags }, { name: 'alice' }),
|
||||
anAccountWithMeta({ name: 'bob' })
|
||||
);
|
||||
accountRows = await accountsPage.getAccountRows();
|
||||
});
|
||||
|
||||
describe('when isDevelopment flag', () => {
|
||||
let aliceRow: AccountRow;
|
||||
|
||||
beforeEach(async () => {
|
||||
aliceRow = accountRows[0];
|
||||
await aliceRow.assertAccountName('ALICE');
|
||||
});
|
||||
|
||||
// eslint-disable-next-line jest/expect-expect
|
||||
it('the development badge is displayed', async () => {
|
||||
await aliceRow.assertBadge('wrench-badge');
|
||||
});
|
||||
|
||||
// eslint-disable-next-line jest/expect-expect
|
||||
it('the all networks badge is not displayed', () => {
|
||||
aliceRow.assertNoBadge('exclamation-triangle-badge');
|
||||
});
|
||||
|
||||
// eslint-disable-next-line jest/expect-expect
|
||||
it('the regular badge is not displayed', () => {
|
||||
aliceRow.assertNoBadge('transparent-badge');
|
||||
});
|
||||
});
|
||||
|
||||
describe('when no isDevelopment flag', () => {
|
||||
let bobRow: AccountRow;
|
||||
|
||||
beforeEach(async () => {
|
||||
bobRow = accountRows[1];
|
||||
await bobRow.assertAccountName('BOB');
|
||||
});
|
||||
|
||||
// eslint-disable-next-line jest/expect-expect
|
||||
it('the development badge is not displayed', () => {
|
||||
bobRow.assertNoBadge('wrench-badge');
|
||||
});
|
||||
|
||||
// eslint-disable-next-line jest/expect-expect
|
||||
it('the all networks badge is displayed', async () => {
|
||||
await bobRow.assertBadge('exclamation-triangle-badge');
|
||||
});
|
||||
|
||||
// eslint-disable-next-line jest/expect-expect
|
||||
it('the regular badge is not displayed', () => {
|
||||
bobRow.assertNoBadge('transparent-badge');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when genesis hash set', () => {
|
||||
beforeEach(async () => {
|
||||
accountsPage.renderAccountsWithDefaultAddresses(
|
||||
anAccountWithInfoAndMeta({ flags: { isDevelopment: true } as AddressFlags }, { genesisHash: '0x1234', name: 'charlie' })
|
||||
);
|
||||
accountRows = await accountsPage.getAccountRows();
|
||||
});
|
||||
|
||||
// eslint-disable-next-line jest/expect-expect
|
||||
it('the development badge is not displayed', () => {
|
||||
accountRows[0].assertNoBadge('wrench-badge');
|
||||
});
|
||||
|
||||
// eslint-disable-next-line jest/expect-expect
|
||||
it('the all networks badge is not displayed', () => {
|
||||
accountRows[0].assertNoBadge('exclamation-triangle-badge');
|
||||
});
|
||||
|
||||
// eslint-disable-next-line jest/expect-expect
|
||||
it('the regular badge is displayed', async () => {
|
||||
await accountRows[0].assertBadge('badge');
|
||||
});
|
||||
});
|
||||
|
||||
describe('show popups', () => {
|
||||
beforeEach(async () => {
|
||||
accountsPage.renderAccountsWithDefaultAddresses(
|
||||
anAccountWithInfoAndMeta({ flags: { isDevelopment: true } as AddressFlags }, { name: 'alice', who: [] })
|
||||
);
|
||||
accountRows = await accountsPage.getAccountRows();
|
||||
});
|
||||
|
||||
// eslint-disable-next-line jest/expect-expect
|
||||
it('development', async () => {
|
||||
await accountRows[0].assertBadge('wrench-badge');
|
||||
const badgePopup = getPopupById(/wrench-badge-hover.*/);
|
||||
|
||||
await within(badgePopup).findByText('This is a development account derived from the known development seed. Do not use for any funds on a non-development network.');
|
||||
});
|
||||
|
||||
it('multisig approvals', async () => {
|
||||
await accountRows[0].assertBadge('file-signature-badge');
|
||||
const badgePopup = getPopupById(/file-signature-badge-hover.*/);
|
||||
const approvalsModalToggle = await within(badgePopup).findByText('View pending approvals');
|
||||
|
||||
fireEvent.click(approvalsModalToggle);
|
||||
const modal = await screen.findByTestId('modal');
|
||||
|
||||
within(modal).getByText('Pending call hashes');
|
||||
expect(approvalsModalToggle).toHaveClass('purpleColor');
|
||||
});
|
||||
|
||||
it('delegate democracy vote', async () => {
|
||||
await accountRows[0].assertBadge('calendar-check-badge');
|
||||
const badgePopup = getPopupById(/calendar-check-badge-hover.*/);
|
||||
const delegateModalToggle = await within(badgePopup).findByText('Manage delegation');
|
||||
|
||||
fireEvent.click(delegateModalToggle);
|
||||
const modal = await screen.findByTestId('modal');
|
||||
|
||||
within(modal).getByText('democracy vote delegation');
|
||||
expect(delegateModalToggle).toHaveClass('normalColor');
|
||||
});
|
||||
|
||||
it('proxy overview', async () => {
|
||||
await accountRows[0].assertBadge('sitemap-badge');
|
||||
const badgePopup = getPopupById(/sitemap-badge-hover.*/);
|
||||
const proxyOverviewToggle = await within(badgePopup).findByText('Manage proxies');
|
||||
|
||||
fireEvent.click(proxyOverviewToggle);
|
||||
const modal = await screen.findByTestId('modal');
|
||||
|
||||
within(modal).getByText('Proxy overview');
|
||||
expect(proxyOverviewToggle).toHaveClass('normalColor');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mockApiHooks.setMultisigApprovals([]);
|
||||
});
|
||||
});
|
||||
|
||||
function getPopupById (popupId: RegExp): HTMLElement {
|
||||
const badgePopup = accountsPage.getById(popupId);
|
||||
|
||||
if (!badgePopup) {
|
||||
throw new Error('badge popup should be found');
|
||||
}
|
||||
|
||||
return badgePopup;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,473 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-accounts authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { ActionStatus } from '@pezkuwi/react-components/Status/types';
|
||||
import type { KeyringAddress } from '@pezkuwi/ui-keyring/types';
|
||||
import type { BN } from '@pezkuwi/util';
|
||||
import type { AccountBalance, Delegation, SortedAccount } from '../types.js';
|
||||
import type { SortCategory } from '../util.js';
|
||||
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { Button, FilterInput, SortDropdown, styled, SummaryBox, Table } from '@pezkuwi/react-components';
|
||||
import { getAccountCryptoType } from '@pezkuwi/react-components/util';
|
||||
import { useAccounts, useApi, useDelegations, useFavorites, useIpfs, useLedger, useNextTick, useProxies, useToggle } from '@pezkuwi/react-hooks';
|
||||
import { keyring } from '@pezkuwi/ui-keyring';
|
||||
import { settings } from '@pezkuwi/ui-settings';
|
||||
import { BN_ZERO, isFunction } from '@pezkuwi/util';
|
||||
|
||||
import CreateModal from '../modals/Create.js';
|
||||
import ExportAll from '../modals/ExportAll.js';
|
||||
import ImportModal from '../modals/Import.js';
|
||||
import ImportAll from '../modals/ImportAll.js';
|
||||
import Ledger from '../modals/Ledger.js';
|
||||
import Local from '../modals/LocalAdd.js';
|
||||
import Multisig from '../modals/MultisigCreate.js';
|
||||
import Proxy from '../modals/ProxiedAdd.js';
|
||||
import Qr from '../modals/Qr.js';
|
||||
import { useTranslation } from '../translate.js';
|
||||
import { SORT_CATEGORY, sortAccounts } from '../util.js';
|
||||
import Account from './Account.js';
|
||||
import BannerAssetHubMigration from './BannerAssetHubMigration.js';
|
||||
import BannerClaims from './BannerClaims.js';
|
||||
import BannerExtension from './BannerExtension.js';
|
||||
import Summary from './Summary.js';
|
||||
|
||||
interface Balances {
|
||||
accounts: Record<string, AccountBalance>;
|
||||
summary?: AccountBalance;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
onStatusChange: (status: ActionStatus) => void;
|
||||
}
|
||||
|
||||
interface SortControls {
|
||||
sortBy: SortCategory;
|
||||
sortFromMax: boolean;
|
||||
}
|
||||
|
||||
type GroupName = 'accounts' | 'chopsticks' | 'hardware' | 'injected' | 'multisig' | 'proxied' | 'qr' | 'testing';
|
||||
|
||||
const DEFAULT_SORT_CONTROLS: SortControls = { sortBy: 'date', sortFromMax: true };
|
||||
|
||||
const STORE_FAVS = 'accounts:favorites';
|
||||
|
||||
const GROUP_ORDER: GroupName[] = ['accounts', 'injected', 'qr', 'hardware', 'proxied', 'multisig', 'testing', 'chopsticks'];
|
||||
|
||||
function groupAccounts (accounts: SortedAccount[]): Record<GroupName, string[]> {
|
||||
const ret: Record<GroupName, string[]> = {
|
||||
accounts: [],
|
||||
chopsticks: [],
|
||||
hardware: [],
|
||||
injected: [],
|
||||
multisig: [],
|
||||
proxied: [],
|
||||
qr: [],
|
||||
testing: []
|
||||
};
|
||||
|
||||
for (let i = 0, count = accounts.length; i < count; i++) {
|
||||
const { account, address } = accounts[i];
|
||||
const cryptoType = getAccountCryptoType(address);
|
||||
|
||||
if (account?.meta.isHardware) {
|
||||
ret.hardware.push(address);
|
||||
} else if (account?.meta.isTesting) {
|
||||
ret.testing.push(address);
|
||||
} else if (cryptoType === 'injected') {
|
||||
ret.injected.push(address);
|
||||
} else if (cryptoType === 'multisig') {
|
||||
ret.multisig.push(address);
|
||||
} else if (cryptoType === 'proxied') {
|
||||
ret.proxied.push(address);
|
||||
} else if (cryptoType === 'chopsticks') {
|
||||
ret.chopsticks.push(address);
|
||||
} else if (cryptoType === 'qr') {
|
||||
ret.qr.push(address);
|
||||
} else {
|
||||
ret.accounts.push(address);
|
||||
}
|
||||
}
|
||||
|
||||
return ret;
|
||||
}
|
||||
|
||||
function Overview ({ className = '', onStatusChange }: Props): React.ReactElement<Props> {
|
||||
const { t } = useTranslation();
|
||||
const { api, fork, isElectron } = useApi();
|
||||
const { allAccounts, hasAccounts } = useAccounts();
|
||||
const { isIpfs } = useIpfs();
|
||||
const { isLedgerEnabled } = useLedger();
|
||||
const [isCreateOpen, toggleCreate] = useToggle();
|
||||
const [isImportOpen, toggleImport] = useToggle();
|
||||
const [isLedgerOpen, toggleLedger] = useToggle();
|
||||
const [isMultisigOpen, toggleMultisig] = useToggle();
|
||||
const [isProxyOpen, toggleProxy] = useToggle();
|
||||
const [isLocalOpen, toggleLocal] = useToggle();
|
||||
const [isQrOpen, toggleQr] = useToggle();
|
||||
const [isExportAll, toggleExportAll] = useToggle();
|
||||
const [isImportAll, toggleImportAll] = useToggle();
|
||||
const [favorites, toggleFavorite] = useFavorites(STORE_FAVS);
|
||||
const [balances, setBalances] = useState<Balances>({ accounts: {} });
|
||||
const [filterOn, setFilter] = useState<string>('');
|
||||
const [sortedAccounts, setSorted] = useState<SortedAccount[]>([]);
|
||||
const [{ sortBy, sortFromMax }, setSortBy] = useState<SortControls>(DEFAULT_SORT_CONTROLS);
|
||||
const delegations = useDelegations();
|
||||
const proxies = useProxies();
|
||||
const isNextTick = useNextTick();
|
||||
|
||||
const onSortChange = useCallback(
|
||||
(sortBy: SortCategory) => setSortBy(({ sortFromMax }) => ({ sortBy, sortFromMax })),
|
||||
[]
|
||||
);
|
||||
|
||||
const onSortDirectionChange = useCallback(
|
||||
() => setSortBy(({ sortBy, sortFromMax }) => ({ sortBy, sortFromMax: !sortFromMax })),
|
||||
[]
|
||||
);
|
||||
|
||||
const sortOptions = useRef(SORT_CATEGORY.map((text) => ({ text, value: text })));
|
||||
|
||||
const setBalance = useCallback(
|
||||
(account: string, balance: AccountBalance) =>
|
||||
setBalances(({ accounts }: Balances): Balances => {
|
||||
accounts[account] = balance;
|
||||
|
||||
const aggregate = (key: keyof AccountBalance) =>
|
||||
Object.values(accounts).reduce((total: BN, value: AccountBalance) => total.add(value[key]), BN_ZERO);
|
||||
|
||||
return {
|
||||
accounts,
|
||||
summary: {
|
||||
bonded: aggregate('bonded'),
|
||||
locked: aggregate('locked'),
|
||||
redeemable: aggregate('redeemable'),
|
||||
total: aggregate('total'),
|
||||
transferable: aggregate('transferable'),
|
||||
unbonding: aggregate('unbonding')
|
||||
}
|
||||
};
|
||||
}),
|
||||
[]
|
||||
);
|
||||
|
||||
const canStoreAccounts = useMemo(
|
||||
() => isElectron || (!isIpfs && settings.get().storage === 'on'),
|
||||
[isElectron, isIpfs]
|
||||
);
|
||||
|
||||
// We use favorites only to check if it includes some element,
|
||||
// so Object is better than array for that because hashmap access is O(1).
|
||||
const favoritesMap = useMemo(
|
||||
() => Object.fromEntries(favorites.map((x) => [x, true])),
|
||||
[favorites]
|
||||
);
|
||||
|
||||
// detect multisigs
|
||||
const hasPalletMultisig = useMemo(
|
||||
() => isFunction((api.tx.multisig || api.tx.utility)?.approveAsMulti),
|
||||
[api]
|
||||
);
|
||||
|
||||
// proxy support
|
||||
const hasPalletProxy = useMemo(
|
||||
() => isFunction(api.tx.proxy?.addProxy),
|
||||
[api]
|
||||
);
|
||||
|
||||
const accountsMap = useMemo(
|
||||
() => allAccounts
|
||||
.map((address, index): Omit<SortedAccount, 'account'> & { account: KeyringAddress | undefined } => {
|
||||
const deleg = delegations && delegations[index]?.isDelegating && delegations[index]?.asDelegating;
|
||||
const delegation: Delegation | undefined = (deleg && {
|
||||
accountDelegated: deleg.target.toString(),
|
||||
amount: deleg.balance,
|
||||
conviction: deleg.conviction
|
||||
}) || undefined;
|
||||
|
||||
return {
|
||||
account: keyring.getAccount(address),
|
||||
address,
|
||||
delegation,
|
||||
isFavorite: favoritesMap[address ?? ''] ?? false
|
||||
};
|
||||
})
|
||||
.filter((a): a is SortedAccount => !!a.account)
|
||||
.reduce((ret: Record<string, SortedAccount>, x) => {
|
||||
ret[x.address] = x;
|
||||
|
||||
return ret;
|
||||
}, {}),
|
||||
[allAccounts, favoritesMap, delegations]
|
||||
);
|
||||
|
||||
const header = useMemo(
|
||||
(): Record<GroupName, [React.ReactNode?, string?, number?, (() => void)?][]> => {
|
||||
const ret: Record<GroupName, [React.ReactNode?, string?, number?, (() => void)?][]> = {
|
||||
accounts: [[<>{t('accounts')}<div className='sub'>{t('all locally stored accounts')}</div></>]],
|
||||
chopsticks: [[<>{t('chopsticks')}<div className='sub'>{t('local accounts added via chopsticks fork')}</div></>]],
|
||||
hardware: [[<>{t('hardware')}<div className='sub'>{t('accounts managed via hardware devices')}</div></>]],
|
||||
injected: [[<>{t('extension')}<div className='sub'>{t('accounts available via browser extensions')}</div></>]],
|
||||
multisig: [[<>{t('multisig')}<div className='sub'>{t('on-chain multisig accounts')}</div></>]],
|
||||
proxied: [[<>{t('proxied')}<div className='sub'>{t('on-chain proxied accounts')}</div></>]],
|
||||
qr: [[<>{t('via qr')}<div className='sub'>{t('accounts available via mobile devices')}</div></>]],
|
||||
testing: [[<>{t('development')}<div className='sub'>{t('accounts derived via development seeds')}</div></>]]
|
||||
};
|
||||
|
||||
Object.values(ret).forEach((a): void => {
|
||||
a[0][1] = 'start';
|
||||
a[0][2] = 4;
|
||||
});
|
||||
|
||||
return ret;
|
||||
},
|
||||
[t]
|
||||
);
|
||||
|
||||
const grouped = useMemo(
|
||||
() => groupAccounts(sortedAccounts),
|
||||
[sortedAccounts]
|
||||
);
|
||||
|
||||
const accounts = useMemo(
|
||||
() => Object.values(accountsMap).reduce<Record<string, React.ReactNode>>((all, { account, address, delegation, isFavorite }, index) => {
|
||||
all[address] = (
|
||||
<Account
|
||||
account={account}
|
||||
delegation={delegation}
|
||||
filter={filterOn}
|
||||
isFavorite={isFavorite}
|
||||
key={address}
|
||||
onStatusChange={onStatusChange}
|
||||
proxy={proxies?.[index]}
|
||||
setBalance={setBalance}
|
||||
toggleFavorite={toggleFavorite}
|
||||
/>
|
||||
);
|
||||
|
||||
return all;
|
||||
}, {}),
|
||||
[accountsMap, filterOn, proxies, setBalance, toggleFavorite, onStatusChange]
|
||||
);
|
||||
|
||||
const groups = useMemo(
|
||||
() => GROUP_ORDER.reduce<Record<string, React.ReactNode[]>>((groups, group) => {
|
||||
const items = grouped[group];
|
||||
|
||||
if (items.length) {
|
||||
groups[group] = items.map((account) => accounts[account]);
|
||||
}
|
||||
|
||||
return groups;
|
||||
}, {}),
|
||||
[grouped, accounts]
|
||||
);
|
||||
|
||||
useEffect((): void => {
|
||||
setSorted((prev) => [
|
||||
...prev
|
||||
.map((x) => accountsMap[x.address])
|
||||
.filter((x): x is SortedAccount => !!x),
|
||||
...Object
|
||||
.keys(accountsMap)
|
||||
.filter((a) => !prev.find((y) => a === y.address))
|
||||
.map((a) => accountsMap[a])
|
||||
]);
|
||||
}, [accountsMap]);
|
||||
|
||||
useEffect((): void => {
|
||||
setSorted((sortedAccounts) =>
|
||||
sortAccounts(sortedAccounts, accountsMap, balances.accounts, sortBy, sortFromMax));
|
||||
}, [accountsMap, balances, sortBy, sortFromMax]);
|
||||
|
||||
return (
|
||||
<StyledDiv className={className}>
|
||||
{isCreateOpen && (
|
||||
<CreateModal
|
||||
onClose={toggleCreate}
|
||||
onStatusChange={onStatusChange}
|
||||
/>
|
||||
)}
|
||||
{isImportOpen && (
|
||||
<ImportModal
|
||||
onClose={toggleImport}
|
||||
onStatusChange={onStatusChange}
|
||||
/>
|
||||
)}
|
||||
{isLedgerOpen && (
|
||||
<Ledger onClose={toggleLedger} />
|
||||
)}
|
||||
{isLocalOpen && (
|
||||
<Local
|
||||
onClose={toggleLocal}
|
||||
onStatusChange={onStatusChange}
|
||||
/>
|
||||
)}
|
||||
{isMultisigOpen && (
|
||||
<Multisig
|
||||
onClose={toggleMultisig}
|
||||
onStatusChange={onStatusChange}
|
||||
/>
|
||||
)}
|
||||
{isProxyOpen && (
|
||||
<Proxy
|
||||
onClose={toggleProxy}
|
||||
onStatusChange={onStatusChange}
|
||||
/>
|
||||
)}
|
||||
{isQrOpen && (
|
||||
<Qr
|
||||
onClose={toggleQr}
|
||||
onStatusChange={onStatusChange}
|
||||
/>
|
||||
)}
|
||||
{isExportAll && (
|
||||
<ExportAll
|
||||
accountsByGroup={grouped}
|
||||
onClose={toggleExportAll}
|
||||
onStatusChange={onStatusChange}
|
||||
/>
|
||||
)}
|
||||
{isImportAll && (
|
||||
<ImportAll
|
||||
onClose={toggleImportAll}
|
||||
onStatusChange={onStatusChange}
|
||||
/>
|
||||
)}
|
||||
<BannerAssetHubMigration />
|
||||
<BannerExtension />
|
||||
<BannerClaims />
|
||||
<Summary balance={balances.summary} />
|
||||
<SummaryBox className='header-box'>
|
||||
<section
|
||||
className='dropdown-section media--1300'
|
||||
data-testid='sort-by-section'
|
||||
>
|
||||
<SortDropdown
|
||||
className='media--1500'
|
||||
defaultValue={sortBy}
|
||||
label={t('sort by')}
|
||||
onChange={onSortChange}
|
||||
onClick={onSortDirectionChange}
|
||||
options={sortOptions.current}
|
||||
sortDirection={
|
||||
sortFromMax
|
||||
? 'ascending'
|
||||
: 'descending'
|
||||
}
|
||||
/>
|
||||
<FilterInput
|
||||
filterOn={filterOn}
|
||||
label={t('filter by name or tags')}
|
||||
setFilter={setFilter}
|
||||
/>
|
||||
</section>
|
||||
<Button.Group>
|
||||
{canStoreAccounts && (
|
||||
<>
|
||||
<Button
|
||||
icon='plus'
|
||||
label={t('Account')}
|
||||
onClick={toggleCreate}
|
||||
/>
|
||||
<Button
|
||||
icon='sync'
|
||||
label={t('From JSON')}
|
||||
onClick={toggleImport}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
icon='file-import'
|
||||
label={t('Import')}
|
||||
onClick={toggleImportAll}
|
||||
/>
|
||||
<Button
|
||||
icon='file-export'
|
||||
label={t('Export')}
|
||||
onClick={toggleExportAll}
|
||||
/>
|
||||
<Button
|
||||
icon='qrcode'
|
||||
label={t('From Qr')}
|
||||
onClick={toggleQr}
|
||||
/>
|
||||
{isLedgerEnabled && (
|
||||
<Button
|
||||
icon='project-diagram'
|
||||
label={t('From Ledger')}
|
||||
onClick={toggleLedger}
|
||||
/>
|
||||
)}
|
||||
{hasAccounts && (
|
||||
<>
|
||||
{hasPalletMultisig && (
|
||||
<Button
|
||||
icon='plus'
|
||||
label={t('Multisig')}
|
||||
onClick={toggleMultisig}
|
||||
/>
|
||||
)}
|
||||
{hasPalletProxy && (
|
||||
<Button
|
||||
icon='plus'
|
||||
label={t('Proxied')}
|
||||
onClick={toggleProxy}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{fork && (
|
||||
<Button
|
||||
icon='plus'
|
||||
label={t('Local')}
|
||||
onClick={toggleLocal}
|
||||
/>
|
||||
)}
|
||||
</Button.Group>
|
||||
</SummaryBox>
|
||||
{!isNextTick || !sortedAccounts.length
|
||||
? (
|
||||
<Table
|
||||
empty={isNextTick && sortedAccounts && t("You don't have any accounts. Some features are currently hidden and will only become available once you have accounts.")}
|
||||
header={header.accounts}
|
||||
/>
|
||||
)
|
||||
: GROUP_ORDER.map((group) =>
|
||||
groups[group] && (
|
||||
<Table
|
||||
empty={t('No accounts')}
|
||||
header={header[group]}
|
||||
isSplit
|
||||
key={group}
|
||||
>
|
||||
{groups[group]}
|
||||
</Table>
|
||||
)
|
||||
)
|
||||
}
|
||||
</StyledDiv>
|
||||
);
|
||||
}
|
||||
|
||||
const StyledDiv = styled.div`
|
||||
.ui--Dropdown {
|
||||
width: 15rem;
|
||||
}
|
||||
|
||||
.header-box {
|
||||
.dropdown-section {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.ui--Button-Group {
|
||||
margin-left: auto;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default React.memo(Overview);
|
||||
@@ -0,0 +1,30 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-accounts authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { WithTranslation } from 'react-i18next';
|
||||
import type { ActionStatus } from '@pezkuwi/react-components/Status/types';
|
||||
import type { KeyringAddress } from '@pezkuwi/ui-keyring/types';
|
||||
|
||||
export type { AppProps as ComponentProps } from '@pezkuwi/react-components/types';
|
||||
|
||||
export interface BareProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export interface I18nProps extends BareProps, WithTranslation {}
|
||||
|
||||
export interface ModalProps {
|
||||
onClose: () => void;
|
||||
onStatusChange: (status: ActionStatus) => void;
|
||||
}
|
||||
|
||||
export interface SortedAccount {
|
||||
account: KeyringAddress;
|
||||
children: SortedAccount[];
|
||||
isFavorite: boolean;
|
||||
}
|
||||
|
||||
export interface AmountValidateState {
|
||||
error: string | null;
|
||||
warning: string | null;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-accounts authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { createNamedHook, useKeyring } from '@pezkuwi/react-hooks';
|
||||
|
||||
function merge (result: string[], input: string[], exclude?: string): string[] {
|
||||
return input.reduce<string[]>((result, a) => {
|
||||
if (a !== exclude && !result.includes(a)) {
|
||||
result.push(a);
|
||||
}
|
||||
|
||||
return result;
|
||||
}, result);
|
||||
}
|
||||
|
||||
function useKnownAddressesImpl (exclude?: string): string[] {
|
||||
const { accounts: { allAccounts }, addresses: { allAddresses } } = useKeyring();
|
||||
|
||||
return useMemo(
|
||||
() => merge(merge([], allAccounts, exclude), allAddresses, exclude),
|
||||
[allAccounts, allAddresses, exclude]
|
||||
);
|
||||
}
|
||||
|
||||
export default createNamedHook('useKnownAddresses', useKnownAddressesImpl);
|
||||
@@ -0,0 +1,59 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-accounts authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { Option, StorageKey } from '@pezkuwi/types';
|
||||
import type { H256, Multisig } from '@pezkuwi/types/interfaces';
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { createNamedHook, useApi, useBlockEvents, useIncrement, useIsMountedRef } from '@pezkuwi/react-hooks';
|
||||
|
||||
function useMultisigApprovalsImpl (address: string): [H256, Multisig][] | undefined {
|
||||
const { events } = useBlockEvents();
|
||||
const { api } = useApi();
|
||||
const [multiInfos, setMultiInfos] = useState<[H256, Multisig][] | undefined>();
|
||||
const [trigger, incTrigger] = useIncrement(1);
|
||||
const mountedRef = useIsMountedRef();
|
||||
const prevEventsRef = useRef<string>('');
|
||||
|
||||
// increment the trigger by looking at all events
|
||||
// - filter the by multisig module (old utility is not supported)
|
||||
// - find anything data item where the type is AccountId
|
||||
// - increment the trigger when at least one matches our address and is different from previous multisig events
|
||||
useEffect((): void => {
|
||||
const multisigEvents = events.filter(({ record: { event: { data, section } } }) =>
|
||||
section === 'multisig' &&
|
||||
data.some((item) =>
|
||||
item.toRawType() === 'AccountId' &&
|
||||
item.eq(address)
|
||||
)
|
||||
);
|
||||
|
||||
if (multisigEvents.length) {
|
||||
const eventsHash = JSON.stringify(multisigEvents.map((e) => e.key));
|
||||
|
||||
if (eventsHash !== prevEventsRef.current) {
|
||||
prevEventsRef.current = eventsHash;
|
||||
incTrigger();
|
||||
}
|
||||
}
|
||||
}, [address, events, incTrigger]);
|
||||
|
||||
// query all the entries for the multisig, extracting approvals with their hash
|
||||
useEffect((): void => {
|
||||
trigger && api.query.multisig?.multisigs && api.query.multisig?.multisigs
|
||||
.entries(address)
|
||||
.then((infos: [StorageKey, Option<Multisig>][]): void => {
|
||||
mountedRef.current && setMultiInfos(
|
||||
infos
|
||||
.filter(([, opt]) => opt.isSome)
|
||||
.map(([key, opt]) => [key.args[1] as H256, opt.unwrap()])
|
||||
);
|
||||
})
|
||||
.catch(console.error);
|
||||
}, [address, api, mountedRef, trigger]);
|
||||
|
||||
return multiInfos;
|
||||
}
|
||||
|
||||
export default createNamedHook('useMultisigApprovals', useMultisigApprovalsImpl);
|
||||
@@ -0,0 +1,73 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-accounts authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { Vec } from '@pezkuwi/types';
|
||||
import type { AccountId, BalanceOf } from '@pezkuwi/types/interfaces';
|
||||
import type { KitchensinkRuntimeProxyType, PalletProxyProxyDefinition } from '@pezkuwi/types/lookup';
|
||||
import type { ITuple } from '@pezkuwi/types/types';
|
||||
import type { BN } from '@pezkuwi/util';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { createNamedHook, useAccounts, useApi, useIsMountedRef } from '@pezkuwi/react-hooks';
|
||||
import { BN_ZERO } from '@pezkuwi/util';
|
||||
|
||||
interface Proxy {
|
||||
address: string;
|
||||
delay: BN;
|
||||
isOwned: boolean;
|
||||
type: KitchensinkRuntimeProxyType;
|
||||
}
|
||||
|
||||
interface State {
|
||||
isEmpty: boolean;
|
||||
owned: Proxy[];
|
||||
proxies: Proxy[];
|
||||
}
|
||||
|
||||
function createProxy (allAccounts: string[], delegate: AccountId, type: KitchensinkRuntimeProxyType, delay = BN_ZERO): Proxy {
|
||||
const address = delegate.toString();
|
||||
|
||||
return {
|
||||
address,
|
||||
delay,
|
||||
isOwned: allAccounts.includes(address),
|
||||
type
|
||||
};
|
||||
}
|
||||
|
||||
function useProxiesImpl (address?: string | null): State | null {
|
||||
const { api } = useApi();
|
||||
const { allAccounts } = useAccounts();
|
||||
const mountedRef = useIsMountedRef();
|
||||
const [known, setState] = useState<State | null>(null);
|
||||
|
||||
useEffect((): void => {
|
||||
setState(null);
|
||||
|
||||
address &&
|
||||
api.query.proxy
|
||||
?.proxies<ITuple<[Vec<ITuple<[AccountId, KitchensinkRuntimeProxyType]> | PalletProxyProxyDefinition>, BalanceOf]>>(address)
|
||||
.then(([_proxies]): void => {
|
||||
const proxies = api.tx.proxy.addProxy.meta.args.length === 3
|
||||
? (_proxies as PalletProxyProxyDefinition[]).map(({ delay, delegate, proxyType }) =>
|
||||
createProxy(allAccounts, delegate, proxyType, delay)
|
||||
)
|
||||
: (_proxies as [AccountId, KitchensinkRuntimeProxyType][]).map(([delegate, proxyType]) =>
|
||||
createProxy(allAccounts, delegate, proxyType)
|
||||
);
|
||||
const owned = proxies.filter(({ isOwned }) => isOwned);
|
||||
|
||||
mountedRef.current && setState({
|
||||
isEmpty: owned.length === 0,
|
||||
owned,
|
||||
proxies
|
||||
});
|
||||
})
|
||||
.catch(console.error);
|
||||
}, [allAccounts, api, address, mountedRef]);
|
||||
|
||||
return known;
|
||||
}
|
||||
|
||||
export default createNamedHook('useProxies', useProxiesImpl);
|
||||
@@ -0,0 +1,111 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-accounts authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
/// <reference types="@pezkuwi/dev-test/globals.d.ts" />
|
||||
|
||||
import '@pezkuwi/react-components/i18n';
|
||||
|
||||
import { fireEvent, render, waitForElementToBeRemoved } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { ThemeProvider } from 'styled-components';
|
||||
|
||||
import AccountsApp from '@pezkuwi/app-accounts';
|
||||
import { ApiCtxRoot } from '@pezkuwi/react-api';
|
||||
import { lightTheme } from '@pezkuwi/react-components';
|
||||
import { MemoryStore } from '@pezkuwi/test-support/keyring';
|
||||
import { WaitForApi } from '@pezkuwi/test-support/react';
|
||||
import { BIZINIKIWI_PORT } from '@pezkuwi/test-support/bizinikiwi';
|
||||
|
||||
function noop (): void {
|
||||
// do nothing
|
||||
}
|
||||
|
||||
const renderAccounts = () => {
|
||||
const memoryStore = new MemoryStore();
|
||||
|
||||
return render(
|
||||
<MemoryRouter>
|
||||
<ThemeProvider theme={lightTheme}>
|
||||
<ApiCtxRoot
|
||||
apiUrl={`ws://127.0.0.1:${BIZINIKIWI_PORT}`}
|
||||
isElectron={false}
|
||||
store={memoryStore}
|
||||
>
|
||||
<WaitForApi>
|
||||
<div>
|
||||
<AccountsApp
|
||||
basePath='/accounts'
|
||||
onStatusChange={noop}
|
||||
/>
|
||||
</div>
|
||||
</WaitForApi>
|
||||
</ApiCtxRoot>
|
||||
</ThemeProvider>
|
||||
</MemoryRouter>
|
||||
);
|
||||
};
|
||||
|
||||
// eslint-disable-next-line jest/no-disabled-tests
|
||||
describe.skip('--SLOW--: Account Create', () => {
|
||||
it('created account is added to list', async () => {
|
||||
const { findByTestId, findByText, queryByText } = renderAccounts();
|
||||
|
||||
const addAccountButton = await findByText('Add account', {});
|
||||
|
||||
fireEvent.click(addAccountButton);
|
||||
|
||||
const isSeedSavedCheckbox = await findByText('I have saved my mnemonic seed safely');
|
||||
const hiddenCheckbox = isSeedSavedCheckbox as HTMLInputElement;
|
||||
|
||||
fireEvent.click(hiddenCheckbox);
|
||||
|
||||
const nextStepButton = await findByText('Next', {});
|
||||
|
||||
fireEvent.click(nextStepButton);
|
||||
|
||||
const accountNameInput = await findByTestId('name');
|
||||
|
||||
fireEvent.change(accountNameInput, { target: { value: 'my new account' } });
|
||||
|
||||
const passwordInput = await findByTestId('password');
|
||||
|
||||
fireEvent.change(passwordInput, { target: { value: 'password' } });
|
||||
|
||||
const passwordInput2 = await findByTestId('password (repeat)');
|
||||
|
||||
fireEvent.change(passwordInput2, { target: { value: 'password' } });
|
||||
|
||||
const toStep3Button = await findByText('Next', {});
|
||||
|
||||
fireEvent.click(toStep3Button);
|
||||
|
||||
const createAnAccountButton = await findByText('Save', {});
|
||||
|
||||
fireEvent.click(createAnAccountButton);
|
||||
|
||||
await waitForElementToBeRemoved(() => queryByText('Add an account via seed 3/3'));
|
||||
|
||||
expect(await findByText('MY NEW ACCOUNT')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('gives an error message when entering invalid derivation path', async () => {
|
||||
const { findByTestId, findByText } = renderAccounts();
|
||||
|
||||
const addAccountButton = await findByText('Add account', {});
|
||||
|
||||
fireEvent.click(addAccountButton);
|
||||
|
||||
const showAdvancedOptionsButton = await findByText('Advanced creation options', {});
|
||||
|
||||
fireEvent.click(showAdvancedOptionsButton);
|
||||
|
||||
const derivationPathInput = await findByTestId('secret derivation path', {});
|
||||
|
||||
fireEvent.change(derivationPathInput, { target: { value: '//abc//' } });
|
||||
|
||||
const errorMsg = await findByText('Unable to match provided value to a secret URI', {});
|
||||
|
||||
expect(errorMsg).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-accounts authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
|
||||
import { Button, IdentityIcon, styled } from '@pezkuwi/react-components';
|
||||
import { u8aToHex } from '@pezkuwi/util';
|
||||
|
||||
import { useTranslation } from '../translate.js';
|
||||
|
||||
interface Props {
|
||||
address: string;
|
||||
className?: string;
|
||||
count: number;
|
||||
offset: number;
|
||||
onCreateToggle: (seed: string) => void;
|
||||
onRemove: (address: string) => void;
|
||||
seed?: Uint8Array;
|
||||
}
|
||||
|
||||
function Match ({ address, className = '', count, offset, onCreateToggle, onRemove, seed }: Props): React.ReactElement<Props> {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const hexSeed = useMemo(
|
||||
() => u8aToHex(seed),
|
||||
[seed]
|
||||
);
|
||||
const _onCreate = useCallback(
|
||||
() => onCreateToggle(hexSeed),
|
||||
[hexSeed, onCreateToggle]
|
||||
);
|
||||
const _onRemove = useCallback(
|
||||
() => onRemove(address),
|
||||
[address, onRemove]
|
||||
);
|
||||
|
||||
return (
|
||||
<StyledTr className={className}>
|
||||
<td
|
||||
className='number'
|
||||
colSpan={2}
|
||||
>
|
||||
<IdentityIcon
|
||||
className='vanity--Match-icon'
|
||||
value={address}
|
||||
/>
|
||||
</td>
|
||||
<td className='address all'>
|
||||
<div className='vanity--Match-addr'>
|
||||
<span className='no'>{address.slice(0, offset)}</span><span className='yes'>{address.slice(offset, count + offset)}</span><span className='no'>{address.slice(count + offset)}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className='hash'>
|
||||
{hexSeed}
|
||||
</td>
|
||||
<td className='button'>
|
||||
<Button
|
||||
icon='plus'
|
||||
label={t('Save')}
|
||||
onClick={_onCreate}
|
||||
/>
|
||||
<Button
|
||||
icon='times'
|
||||
onClick={_onRemove}
|
||||
/>
|
||||
</td>
|
||||
</StyledTr>
|
||||
);
|
||||
}
|
||||
|
||||
const StyledTr = styled.tr`
|
||||
text-align: center;
|
||||
|
||||
&:hover {
|
||||
background: #f9f8f7;
|
||||
}
|
||||
|
||||
.vanity--Match-addr {
|
||||
font-size: 1.1rem;
|
||||
|
||||
.no {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.yes {
|
||||
color: red;
|
||||
}
|
||||
}
|
||||
|
||||
.vanity--Match-buttons,
|
||||
.vanity--Match-data,
|
||||
.vanity--Match-icon {
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.vanity--Match-item {
|
||||
display: inline-block;
|
||||
font: var(--font-mono);
|
||||
margin: 0 auto;
|
||||
padding: 0.5em;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.vanity--Match-seed {
|
||||
opacity: 0.45;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
`;
|
||||
|
||||
export default React.memo(Match);
|
||||
@@ -0,0 +1,25 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-accounts authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import { cryptoWaitReady, ed25519PairFromSeed, mnemonicGenerate, mnemonicToMiniSecret, sr25519PairFromSeed } from '@pezkuwi/util-crypto';
|
||||
|
||||
const ctx: Worker = self as unknown as Worker;
|
||||
|
||||
cryptoWaitReady().catch((): void => {
|
||||
// ignore
|
||||
});
|
||||
|
||||
ctx.onmessage = async ({ data: { pairType } }): Promise<void> => {
|
||||
await cryptoWaitReady();
|
||||
|
||||
const seed = mnemonicGenerate();
|
||||
const miniSecret = mnemonicToMiniSecret(seed);
|
||||
const { publicKey } = pairType === 'sr25519'
|
||||
? sr25519PairFromSeed(miniSecret)
|
||||
: ed25519PairFromSeed(miniSecret);
|
||||
|
||||
ctx.postMessage({
|
||||
publicKey,
|
||||
seed
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,279 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-accounts authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { ActionStatus } from '@pezkuwi/react-components/Status/types';
|
||||
import type { KeypairType } from '@pezkuwi/util-crypto/types';
|
||||
import type { GeneratorMatch, GeneratorMatches, GeneratorResult } from '@polkadot/vanitygen/types';
|
||||
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { Button, Dropdown, Input, styled, Table } from '@pezkuwi/react-components';
|
||||
import { useApi, useIsMountedRef } from '@pezkuwi/react-hooks';
|
||||
import { settings } from '@pezkuwi/ui-settings';
|
||||
import { nextTick } from '@pezkuwi/util';
|
||||
import generator from '@polkadot/vanitygen/generator';
|
||||
import matchRegex from '@polkadot/vanitygen/regex';
|
||||
import generatorSort from '@polkadot/vanitygen/sort';
|
||||
|
||||
import CreateModal from '../modals/Create.js';
|
||||
import { useTranslation } from '../translate.js';
|
||||
import Match from './Match.js';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
onStatusChange: (status: ActionStatus) => void;
|
||||
}
|
||||
|
||||
interface MatchState {
|
||||
isMatchValid: boolean;
|
||||
match: string;
|
||||
}
|
||||
|
||||
interface Results {
|
||||
elapsed: number;
|
||||
isRunning: boolean;
|
||||
keyCount: number;
|
||||
keyTime: number;
|
||||
matches: GeneratorMatches;
|
||||
startAt: number;
|
||||
}
|
||||
|
||||
const DEFAULT_MATCH = 'Some';
|
||||
const BOOL_OPTIONS = [
|
||||
{ text: 'No', value: false },
|
||||
{ text: 'Yes', value: true }
|
||||
];
|
||||
|
||||
function VanityApp ({ className = '', onStatusChange }: Props): React.ReactElement<Props> {
|
||||
const { t } = useTranslation();
|
||||
const { api, isEthereum } = useApi();
|
||||
const results = useRef<GeneratorResult[]>([]);
|
||||
const runningRef = useRef(false);
|
||||
const mountedRef = useIsMountedRef();
|
||||
const [createSeed, setCreateSeed] = useState<string | null>(null);
|
||||
const [{ elapsed, isRunning, keyCount, matches }, setResults] = useState<Results>({
|
||||
elapsed: 0,
|
||||
isRunning: false,
|
||||
keyCount: -1,
|
||||
keyTime: 0,
|
||||
matches: [],
|
||||
startAt: 0
|
||||
});
|
||||
const [{ isMatchValid, match }, setMatch] = useState<MatchState>({ isMatchValid: true, match: DEFAULT_MATCH });
|
||||
const [type, setType] = useState<KeypairType>('ed25519');
|
||||
const [withCase, setWithCase] = useState(true);
|
||||
|
||||
const _clearSeed = useCallback(
|
||||
() => setCreateSeed(null),
|
||||
[]
|
||||
);
|
||||
|
||||
const _checkMatches = useCallback(
|
||||
(): void => {
|
||||
const checks = results.current;
|
||||
|
||||
results.current = [];
|
||||
|
||||
if (checks.length === 0 || !mountedRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
setResults(
|
||||
({ isRunning, keyCount, keyTime, matches, startAt }: Results): Results => {
|
||||
let newKeyCount = keyCount;
|
||||
let newKeyTime = keyTime;
|
||||
const newMatches = checks.reduce((result, { elapsed, found }): GeneratorMatch[] => {
|
||||
newKeyCount += found.length;
|
||||
newKeyTime += elapsed;
|
||||
|
||||
return result.concat(found);
|
||||
}, matches);
|
||||
|
||||
return {
|
||||
elapsed: Date.now() - startAt,
|
||||
isRunning,
|
||||
keyCount: newKeyCount,
|
||||
keyTime: newKeyTime,
|
||||
matches: newMatches.sort(generatorSort).slice(0, 25),
|
||||
startAt
|
||||
};
|
||||
}
|
||||
);
|
||||
},
|
||||
[mountedRef]
|
||||
);
|
||||
|
||||
const _executeGeneration = useCallback(
|
||||
(): void => {
|
||||
if (!runningRef.current) {
|
||||
return _checkMatches();
|
||||
}
|
||||
|
||||
nextTick((): void => {
|
||||
if (mountedRef.current) {
|
||||
if (results.current.length === 25) {
|
||||
_checkMatches();
|
||||
}
|
||||
|
||||
results.current.push(
|
||||
generator({ match, runs: 10, ss58Format: api.registry.chainSS58 || 0, type, withCase, withHex: true })
|
||||
);
|
||||
|
||||
_executeGeneration();
|
||||
}
|
||||
});
|
||||
},
|
||||
[_checkMatches, api, match, mountedRef, runningRef, type, withCase]
|
||||
);
|
||||
|
||||
const _onChangeMatch = useCallback(
|
||||
(match: string): void => setMatch({
|
||||
isMatchValid:
|
||||
matchRegex.test(match) &&
|
||||
(match.length !== 0) &&
|
||||
(match.length < 31),
|
||||
match
|
||||
}),
|
||||
[]
|
||||
);
|
||||
|
||||
const _onRemove = useCallback(
|
||||
(address: string): void => setResults(
|
||||
(results: Results): Results => ({
|
||||
...results,
|
||||
matches: results.matches.filter((item) => item.address !== address)
|
||||
})
|
||||
),
|
||||
[]
|
||||
);
|
||||
|
||||
const _toggleStart = useCallback(
|
||||
(): void => setResults(
|
||||
({ elapsed, isRunning, keyCount, keyTime, matches, startAt }: Results): Results => ({
|
||||
elapsed,
|
||||
isRunning: !isRunning,
|
||||
keyCount: isRunning ? keyCount : 0,
|
||||
keyTime: isRunning ? keyTime : 0,
|
||||
matches,
|
||||
startAt: isRunning ? startAt : Date.now()
|
||||
})
|
||||
),
|
||||
[]
|
||||
);
|
||||
|
||||
useEffect((): void => {
|
||||
runningRef.current = isRunning;
|
||||
|
||||
if (isRunning) {
|
||||
_executeGeneration();
|
||||
}
|
||||
}, [_executeGeneration, isRunning]);
|
||||
|
||||
const header = useMemo<[React.ReactNode?, string?, number?][]>(
|
||||
() => [
|
||||
[t('matches'), 'start', 2],
|
||||
[t('Evaluated {{count}} keys in {{elapsed}}s ({{avg}} keys/s)', {
|
||||
replace: {
|
||||
avg: (keyCount / (elapsed / 1000)).toFixed(3),
|
||||
count: keyCount,
|
||||
elapsed: (elapsed / 1000).toFixed(2)
|
||||
}
|
||||
}), 'start --digits'],
|
||||
[t('secret'), 'start'],
|
||||
[]
|
||||
],
|
||||
[elapsed, keyCount, t]
|
||||
);
|
||||
|
||||
return (
|
||||
<StyledDiv className={className}>
|
||||
<div className='ui--row'>
|
||||
<Input
|
||||
autoFocus
|
||||
className='medium'
|
||||
isDisabled={isRunning}
|
||||
isError={!isMatchValid}
|
||||
label={t('Search for')}
|
||||
onChange={_onChangeMatch}
|
||||
onEnter={_toggleStart}
|
||||
value={match}
|
||||
/>
|
||||
<Dropdown
|
||||
className='medium'
|
||||
isDisabled={isRunning}
|
||||
label={t('case sensitive')}
|
||||
onChange={setWithCase}
|
||||
options={BOOL_OPTIONS}
|
||||
value={withCase}
|
||||
/>
|
||||
</div>
|
||||
<div className='ui--row'>
|
||||
<Dropdown
|
||||
className='medium'
|
||||
defaultValue={type}
|
||||
label={t('keypair crypto type')}
|
||||
onChange={setType}
|
||||
options={isEthereum ? settings.availableCryptosEth : settings.availableCryptos}
|
||||
/>
|
||||
</div>
|
||||
<Button.Group>
|
||||
<Button
|
||||
icon={
|
||||
isRunning
|
||||
? 'stop'
|
||||
: 'sign-in-alt'
|
||||
}
|
||||
isDisabled={!isMatchValid}
|
||||
label={
|
||||
isRunning
|
||||
? t('Stop generation')
|
||||
: t('Start generation')
|
||||
}
|
||||
onClick={_toggleStart}
|
||||
/>
|
||||
</Button.Group>
|
||||
{matches.length !== 0 && (
|
||||
<>
|
||||
<article className='warning centered'>{t('Ensure that you utilized the "Save" functionality before using a generated address to receive funds. Without saving the address and the associated seed any funds sent to it will be lost.')}</article>
|
||||
<Table
|
||||
className='vanity--App-matches'
|
||||
empty={t('No matches found')}
|
||||
header={header}
|
||||
>
|
||||
{matches.map((match): React.ReactNode => (
|
||||
<Match
|
||||
{...match}
|
||||
key={match.address}
|
||||
onCreateToggle={setCreateSeed}
|
||||
onRemove={_onRemove}
|
||||
/>
|
||||
))}
|
||||
</Table>
|
||||
</>
|
||||
)}
|
||||
{createSeed && (
|
||||
<CreateModal
|
||||
onClose={_clearSeed}
|
||||
onStatusChange={onStatusChange}
|
||||
seed={createSeed}
|
||||
type={type}
|
||||
/>
|
||||
)}
|
||||
</StyledDiv>
|
||||
);
|
||||
}
|
||||
|
||||
const StyledDiv = styled.div`
|
||||
.vanity--App-matches {
|
||||
overflow-x: auto;
|
||||
padding: 1em 0;
|
||||
}
|
||||
|
||||
.vanity--App-stats {
|
||||
padding: 1em 0 0 0;
|
||||
opacity: 0.45;
|
||||
text-align: center;
|
||||
}
|
||||
`;
|
||||
|
||||
export default React.memo(VanityApp);
|
||||
@@ -0,0 +1,65 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-accounts authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { AppProps as Props } from '@pezkuwi/react-components/types';
|
||||
|
||||
import React, { useRef } from 'react';
|
||||
import { Route, Routes } from 'react-router';
|
||||
|
||||
import { Tabs } from '@pezkuwi/react-components';
|
||||
import { useAccounts, useIpfs } from '@pezkuwi/react-hooks';
|
||||
|
||||
import Accounts from './Accounts/index.js';
|
||||
import Vanity from './Vanity/index.js';
|
||||
import { useTranslation } from './translate.js';
|
||||
import useCounter from './useCounter.js';
|
||||
|
||||
export { useCounter };
|
||||
|
||||
const HIDDEN_ACC = ['vanity'];
|
||||
|
||||
function AccountsApp ({ basePath, onStatusChange }: Props): React.ReactElement<Props> {
|
||||
const { t } = useTranslation();
|
||||
const { hasAccounts } = useAccounts();
|
||||
const { isIpfs } = useIpfs();
|
||||
|
||||
const tabsRef = useRef([
|
||||
{
|
||||
isRoot: true,
|
||||
name: 'overview',
|
||||
text: t('My accounts')
|
||||
},
|
||||
{
|
||||
name: 'vanity',
|
||||
text: t('Vanity generator')
|
||||
}
|
||||
]);
|
||||
|
||||
return (
|
||||
<main className='accounts--App'>
|
||||
<Tabs
|
||||
basePath={basePath}
|
||||
hidden={(hasAccounts && !isIpfs) ? undefined : HIDDEN_ACC}
|
||||
items={tabsRef.current}
|
||||
/>
|
||||
<Routes>
|
||||
<Route path={basePath}>
|
||||
<Route
|
||||
element={
|
||||
<Vanity onStatusChange={onStatusChange} />
|
||||
}
|
||||
path='vanity'
|
||||
/>
|
||||
<Route
|
||||
element={
|
||||
<Accounts onStatusChange={onStatusChange} />
|
||||
}
|
||||
index
|
||||
/>
|
||||
</Route>
|
||||
</Routes>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(AccountsApp);
|
||||
@@ -0,0 +1,98 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-accounts authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import FileSaver from 'file-saver';
|
||||
import React, { useCallback, useState } from 'react';
|
||||
|
||||
import { AddressRow, Button, Modal, Password } from '@pezkuwi/react-components';
|
||||
import { keyring } from '@pezkuwi/ui-keyring';
|
||||
import { nextTick } from '@pezkuwi/util';
|
||||
|
||||
import { useTranslation } from '../translate.js';
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
address: string;
|
||||
}
|
||||
|
||||
function Backup ({ address, onClose }: Props): React.ReactElement<Props> {
|
||||
const { t } = useTranslation();
|
||||
const [isBusy, setIsBusy] = useState(false);
|
||||
const [{ isPassTouched, password }, setPassword] = useState({ isPassTouched: false, password: '' });
|
||||
const [backupFailed, setBackupFailed] = useState(false);
|
||||
const isPassValid = !backupFailed && keyring.isPassValid(password);
|
||||
|
||||
const _onChangePass = useCallback(
|
||||
(password: string): void => {
|
||||
setBackupFailed(false);
|
||||
setPassword({ isPassTouched: true, password });
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const _doBackup = useCallback(
|
||||
(): void => {
|
||||
setIsBusy(true);
|
||||
nextTick((): void => {
|
||||
try {
|
||||
const addressKeyring = address && keyring.getPair(address);
|
||||
const json = addressKeyring && keyring.backupAccount(addressKeyring, password);
|
||||
const blob = new Blob([JSON.stringify(json)], { type: 'application/json; charset=utf-8' });
|
||||
|
||||
// eslint-disable-next-line deprecation/deprecation
|
||||
FileSaver.saveAs(blob, `${address}.json`);
|
||||
} catch (error) {
|
||||
setBackupFailed(true);
|
||||
setIsBusy(false);
|
||||
console.error(error);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
setIsBusy(false);
|
||||
onClose();
|
||||
});
|
||||
},
|
||||
[address, onClose, password]
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
className='app--accounts-Modal'
|
||||
header={t('Backup account')}
|
||||
onClose={onClose}
|
||||
>
|
||||
<Modal.Content>
|
||||
<AddressRow
|
||||
isInline
|
||||
value={address}
|
||||
>
|
||||
<p>{t('An encrypted backup file will be created once you have pressed the "Download" button. This can be used to re-import your account on any other machine.')}</p>
|
||||
<p>{t('Save this backup file in a secure location. Additionally, the password associated with this account is needed together with this backup file in order to restore your account.')}</p>
|
||||
<div>
|
||||
<Password
|
||||
autoFocus
|
||||
isError={isPassTouched && !isPassValid}
|
||||
label={t('password')}
|
||||
onChange={_onChangePass}
|
||||
onEnter={_doBackup}
|
||||
tabIndex={0}
|
||||
value={password}
|
||||
/>
|
||||
</div>
|
||||
</AddressRow>
|
||||
</Modal.Content>
|
||||
<Modal.Actions>
|
||||
<Button
|
||||
icon='download'
|
||||
isBusy={isBusy}
|
||||
isDisabled={!isPassValid}
|
||||
label={t('Download')}
|
||||
onClick={_doBackup}
|
||||
/>
|
||||
</Modal.Actions>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(Backup);
|
||||
@@ -0,0 +1,146 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-accounts authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import React, { useCallback, useState } from 'react';
|
||||
|
||||
import { AddressRow, Button, Modal, Password, PasswordStrength } from '@pezkuwi/react-components';
|
||||
import { keyring } from '@pezkuwi/ui-keyring';
|
||||
import { nextTick } from '@pezkuwi/util';
|
||||
|
||||
import { useTranslation } from '../translate.js';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
address: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
interface NewPass {
|
||||
isValid: boolean;
|
||||
password: string;
|
||||
}
|
||||
|
||||
interface OldPass {
|
||||
isOldValid: boolean;
|
||||
oldPass: string;
|
||||
}
|
||||
|
||||
function ChangePass ({ address, className = '', onClose }: Props): React.ReactElement<Props> {
|
||||
const { t } = useTranslation();
|
||||
const [isBusy, setIsBusy] = useState(false);
|
||||
const [newPass1, setNewPass1] = useState<NewPass>({ isValid: false, password: '' });
|
||||
const [newPass2, setNewPass2] = useState<NewPass>({ isValid: false, password: '' });
|
||||
const [{ isOldValid, oldPass }, setOldPass] = useState<OldPass>({ isOldValid: false, oldPass: '' });
|
||||
|
||||
const _onChangeNew1 = useCallback(
|
||||
(password: string) =>
|
||||
setNewPass1({ isValid: keyring.isPassValid(password), password }),
|
||||
[]
|
||||
);
|
||||
|
||||
const _onChangeNew2 = useCallback(
|
||||
(password: string) =>
|
||||
setNewPass2({ isValid: keyring.isPassValid(password) && (newPass1.password === password), password }),
|
||||
[newPass1]
|
||||
);
|
||||
|
||||
const _onChangeOld = useCallback(
|
||||
(oldPass: string) => setOldPass({ isOldValid: keyring.isPassValid(oldPass), oldPass }),
|
||||
[]
|
||||
);
|
||||
|
||||
const _doChange = useCallback(
|
||||
(): void => {
|
||||
const account = address && keyring.getPair(address);
|
||||
|
||||
if (!account) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsBusy(true);
|
||||
nextTick((): void => {
|
||||
try {
|
||||
if (!account.isLocked) {
|
||||
account.lock();
|
||||
}
|
||||
|
||||
account.decodePkcs8(oldPass);
|
||||
} catch {
|
||||
setOldPass((state: OldPass) => ({ ...state, isOldValid: false }));
|
||||
setIsBusy(false);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
keyring.encryptAccount(account, newPass1.password);
|
||||
} catch {
|
||||
setNewPass2((state: NewPass) => ({ ...state, isValid: false }));
|
||||
setIsBusy(false);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
setIsBusy(false);
|
||||
onClose();
|
||||
});
|
||||
},
|
||||
[address, newPass1, oldPass, onClose]
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
className={`${className} app--accounts-Modal`}
|
||||
header={t('Change account password')}
|
||||
onClose={onClose}
|
||||
size='large'
|
||||
>
|
||||
<Modal.Content>
|
||||
<AddressRow
|
||||
isInline
|
||||
value={address}
|
||||
/>
|
||||
<Modal.Columns hint={t('The existing account password as specified when this account was created or when it was last changed.')}>
|
||||
<Password
|
||||
autoFocus
|
||||
isError={!isOldValid}
|
||||
label={t('your current password')}
|
||||
onChange={_onChangeOld}
|
||||
tabIndex={1}
|
||||
value={oldPass}
|
||||
/>
|
||||
</Modal.Columns>
|
||||
<Modal.Columns hint={t('This will apply to any future use of this account as stored on this browser. Ensure that you securely store this new password and that it is strong and unique to the account.')}>
|
||||
<Password
|
||||
isError={!newPass1.isValid}
|
||||
label={t('your new password')}
|
||||
onChange={_onChangeNew1}
|
||||
onEnter={_doChange}
|
||||
tabIndex={2}
|
||||
value={newPass1.password}
|
||||
/>
|
||||
<Password
|
||||
isError={!newPass2.isValid}
|
||||
label={t('password (repeat)')}
|
||||
onChange={_onChangeNew2}
|
||||
onEnter={_doChange}
|
||||
tabIndex={2}
|
||||
value={newPass2.password}
|
||||
/>
|
||||
<PasswordStrength value={newPass1.password} />
|
||||
</Modal.Columns>
|
||||
</Modal.Content>
|
||||
<Modal.Actions>
|
||||
<Button
|
||||
icon='sign-in-alt'
|
||||
isBusy={isBusy}
|
||||
isDisabled={!newPass1.isValid || !newPass2.isValid || !isOldValid}
|
||||
label={t('Change')}
|
||||
onClick={_doChange}
|
||||
/>
|
||||
</Modal.Actions>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(ChangePass);
|
||||
@@ -0,0 +1,131 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-accounts authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
/// <reference types="@pezkuwi/dev-test/globals.d.ts" />
|
||||
|
||||
import { fireEvent, screen, waitForElementToBeRemoved } from '@testing-library/react';
|
||||
|
||||
import i18next from '@pezkuwi/react-components/i18n';
|
||||
import { MemoryStore } from '@pezkuwi/test-support/keyring';
|
||||
import { assertButtonDisabled, assertText, clickButton, fillInput } from '@pezkuwi/test-support/utils';
|
||||
import { keyring } from '@pezkuwi/ui-keyring';
|
||||
import { cryptoWaitReady } from '@pezkuwi/util-crypto';
|
||||
|
||||
import { AccountsPage } from '../../test/pages/accountsPage.js';
|
||||
|
||||
const spy = jest.spyOn(keyring, 'addUri');
|
||||
|
||||
const newAccountName = 'NEW ACCOUNT NAME';
|
||||
const newAccountPassword = 'mySecretPassword';
|
||||
|
||||
describe('Create an account modal', () => {
|
||||
let accountsPage: AccountsPage;
|
||||
|
||||
beforeAll(async () => {
|
||||
await cryptoWaitReady();
|
||||
await i18next.changeLanguage('en');
|
||||
|
||||
if (keyring.getAccounts().length === 0) {
|
||||
keyring.loadAll({ isDevelopment: true, store: new MemoryStore() });
|
||||
}
|
||||
});
|
||||
beforeEach(() => {
|
||||
accountsPage = new AccountsPage();
|
||||
});
|
||||
|
||||
// eslint-disable-next-line jest/expect-expect
|
||||
it('creates an account', async () => {
|
||||
await accountsPage.enterCreateAccountModal();
|
||||
|
||||
assertButtonDisabled('Next');
|
||||
await fillFirstStep();
|
||||
await clickButton('Next');
|
||||
|
||||
await expectSecondStep();
|
||||
assertButtonDisabled('Next');
|
||||
fillSecondStep();
|
||||
await clickButton('Next');
|
||||
|
||||
await expectThirdStep();
|
||||
await clickButton('Save');
|
||||
|
||||
await waitForElementToBeRemoved(() => screen.queryByText('Add an account via seed 3/3'));
|
||||
expectCreateAnAccountCall();
|
||||
});
|
||||
|
||||
// eslint-disable-next-line jest/expect-expect
|
||||
it('navigates through the modal flow with enter key', async () => {
|
||||
await accountsPage.enterCreateAccountModal();
|
||||
|
||||
assertButtonDisabled('Next');
|
||||
pressEnterKey();
|
||||
await expectFirstStep();
|
||||
await fillFirstStep();
|
||||
pressEnterKey();
|
||||
|
||||
await expectSecondStep();
|
||||
fillSecondStep();
|
||||
pressEnterKey();
|
||||
|
||||
await expectThirdStep();
|
||||
pressEnterKey();
|
||||
|
||||
await waitForElementToBeRemoved(() => screen.queryByText('Add an account via seed 3/3'));
|
||||
expectCreateAnAccountCall();
|
||||
});
|
||||
|
||||
// eslint-disable-next-line jest/expect-expect
|
||||
it('gives an error message when entering invalid derivation path', async () => {
|
||||
await accountsPage.enterCreateAccountModal();
|
||||
|
||||
const showAdvancedOptionsButton = await screen.findByText('Advanced creation options');
|
||||
|
||||
fireEvent.click(showAdvancedOptionsButton);
|
||||
|
||||
fillInput('secret derivation path', '//abc//');
|
||||
|
||||
await assertText('Unable to match provided value to a secret URI');
|
||||
});
|
||||
});
|
||||
|
||||
function fillSecondStep () {
|
||||
fillInput('name', newAccountName);
|
||||
fillInput('password', newAccountPassword);
|
||||
fillInput('password (repeat)', newAccountPassword);
|
||||
}
|
||||
|
||||
async function fillFirstStep () {
|
||||
const checkbox = await screen.findByText('I have saved my mnemonic seed safely');
|
||||
|
||||
fireEvent.click(checkbox);
|
||||
}
|
||||
|
||||
function pressEnterKey () {
|
||||
fireEvent.keyDown(window, {
|
||||
code: 'Enter',
|
||||
key: 'Enter'
|
||||
});
|
||||
}
|
||||
|
||||
function expectCreateAnAccountCall () {
|
||||
expect(spy).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
newAccountPassword,
|
||||
expect.objectContaining({
|
||||
name: newAccountName
|
||||
}),
|
||||
'sr25519'
|
||||
);
|
||||
}
|
||||
|
||||
async function expectFirstStep () {
|
||||
await screen.findByText('Add an account via seed 1/3');
|
||||
}
|
||||
|
||||
async function expectSecondStep () {
|
||||
await screen.findByText('Add an account via seed 2/3');
|
||||
}
|
||||
|
||||
async function expectThirdStep () {
|
||||
await screen.findByText('Add an account via seed 3/3');
|
||||
}
|
||||
@@ -0,0 +1,475 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-accounts authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { ActionStatus } from '@pezkuwi/react-components/Status/types';
|
||||
import type { AddressState, CreateOptions, CreateProps, DeriveValidationOutput, PairType, SeedType } from '../types.js';
|
||||
|
||||
import React, { useCallback, useRef, useState } from 'react';
|
||||
|
||||
import { DEV_PHRASE } from '@pezkuwi/keyring/defaults';
|
||||
import { AddressRow, Button, Checkbox, CopyButton, Dropdown, Expander, Input, MarkError, MarkWarning, Modal, styled, TextArea } from '@pezkuwi/react-components';
|
||||
import { useApi, useLedger, useStepper } from '@pezkuwi/react-hooks';
|
||||
import { keyring } from '@pezkuwi/ui-keyring';
|
||||
import { settings } from '@pezkuwi/ui-settings';
|
||||
import { isHex, nextTick, u8aToHex } from '@pezkuwi/util';
|
||||
import { hdLedger, hdValidatePath, keyExtractSuri, mnemonicGenerate, mnemonicValidate, randomAsU8a } from '@pezkuwi/util-crypto';
|
||||
|
||||
import { useTranslation } from '../translate.js';
|
||||
import { tryCreateAccount } from '../util.js';
|
||||
import CreateAccountInputs from './CreateAccountInputs.js';
|
||||
import CreateConfirmation from './CreateConfirmation.js';
|
||||
import CreateEthDerivationPath, { ETH_DEFAULT_PATH } from './CreateEthDerivationPath.js';
|
||||
import CreateSuriLedger from './CreateSuriLedger.js';
|
||||
import ExternalWarning from './ExternalWarning.js';
|
||||
|
||||
const DEFAULT_PAIR_TYPE = 'sr25519';
|
||||
const STEPS_COUNT = 3;
|
||||
|
||||
function getSuri (seed: string, derivePath: string, pairType: PairType): string {
|
||||
return pairType === 'ed25519-ledger'
|
||||
? u8aToHex(hdLedger(seed, derivePath).secretKey.slice(0, 32))
|
||||
: pairType === 'ethereum'
|
||||
? `${seed}/${derivePath}`
|
||||
: `${seed}${derivePath}`;
|
||||
}
|
||||
|
||||
function deriveValidate (seed: string, seedType: SeedType, derivePath: string, pairType: PairType): DeriveValidationOutput {
|
||||
try {
|
||||
const { password, path } = keyExtractSuri(pairType === 'ethereum' ? `${seed}/${derivePath}` : `${seed}${derivePath}`);
|
||||
let result: DeriveValidationOutput = {};
|
||||
|
||||
// show a warning in case the password contains an unintended / character
|
||||
if (password?.includes('/')) {
|
||||
result = { warning: 'WARNING_SLASH_PASSWORD' };
|
||||
}
|
||||
|
||||
// we don't allow soft for ed25519
|
||||
if (pairType === 'ed25519' && path.some(({ isSoft }): boolean => isSoft)) {
|
||||
return { ...result, error: 'SOFT_NOT_ALLOWED' };
|
||||
}
|
||||
|
||||
// we don't allow password for hex seed
|
||||
if (seedType === 'raw' && password) {
|
||||
return { ...result, error: 'PASSWORD_IGNORED' };
|
||||
}
|
||||
|
||||
if (pairType === 'ethereum' && !hdValidatePath(derivePath)) {
|
||||
return { ...result, error: 'INVALID_DERIVATION_PATH' };
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
return { error: (error as Error).message };
|
||||
}
|
||||
}
|
||||
|
||||
function isHexSeed (seed: string): boolean {
|
||||
return isHex(seed) && seed.length === 66;
|
||||
}
|
||||
|
||||
function rawValidate (seed: string): boolean {
|
||||
return ((seed.length > 0) && (seed.length <= 32)) || isHexSeed(seed);
|
||||
}
|
||||
|
||||
function addressFromSeed (seed: string, derivePath: string, pairType: PairType): string {
|
||||
return keyring
|
||||
.createFromUri(getSuri(seed, derivePath, pairType), {}, pairType === 'ed25519-ledger' ? 'ed25519' : pairType)
|
||||
.address;
|
||||
}
|
||||
|
||||
function newSeed (seed: string | undefined | null, seedType: SeedType): string {
|
||||
switch (seedType) {
|
||||
case 'bip':
|
||||
return mnemonicGenerate();
|
||||
case 'dev':
|
||||
return DEV_PHRASE;
|
||||
default:
|
||||
return seed || u8aToHex(randomAsU8a());
|
||||
}
|
||||
}
|
||||
|
||||
function generateSeed (_seed: string | undefined | null, derivePath: string, seedType: SeedType, pairType: PairType = DEFAULT_PAIR_TYPE): AddressState {
|
||||
const seed = newSeed(_seed, seedType);
|
||||
const address = addressFromSeed(seed, derivePath, pairType);
|
||||
|
||||
return {
|
||||
address,
|
||||
derivePath,
|
||||
deriveValidation: undefined,
|
||||
isSeedValid: true,
|
||||
pairType,
|
||||
seed,
|
||||
seedType
|
||||
};
|
||||
}
|
||||
|
||||
function updateAddress (seed: string, derivePath: string, seedType: SeedType, pairType: PairType): AddressState {
|
||||
let address: string | null = null;
|
||||
let deriveValidation: DeriveValidationOutput = deriveValidate(seed, seedType, derivePath, pairType);
|
||||
let isSeedValid = false;
|
||||
|
||||
if (seedType === 'raw') {
|
||||
isSeedValid = rawValidate(seed);
|
||||
} else {
|
||||
const words = seed.split(' ');
|
||||
|
||||
if (pairType === 'ed25519-ledger' && words.length === 25) {
|
||||
words.pop();
|
||||
|
||||
isSeedValid = mnemonicValidate(words.join(' '));
|
||||
} else {
|
||||
isSeedValid = mnemonicValidate(seed);
|
||||
}
|
||||
}
|
||||
|
||||
if (!deriveValidation?.error && isSeedValid) {
|
||||
try {
|
||||
address = addressFromSeed(seed, derivePath, pairType);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
deriveValidation = { error: (error as Error).message ? (error as Error).message : (error as Error).toString() };
|
||||
isSeedValid = false;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
address,
|
||||
derivePath,
|
||||
deriveValidation,
|
||||
isSeedValid,
|
||||
pairType,
|
||||
seed,
|
||||
seedType
|
||||
};
|
||||
}
|
||||
|
||||
function createAccount (seed: string, derivePath: string, pairType: PairType, { genesisHash, name, tags = [] }: CreateOptions, password: string, success: string): ActionStatus {
|
||||
const commitAccount = () =>
|
||||
keyring.addUri(getSuri(seed, derivePath, pairType), password, { genesisHash, isHardware: false, name, tags }, pairType === 'ed25519-ledger' ? 'ed25519' : pairType);
|
||||
|
||||
return tryCreateAccount(commitAccount, success);
|
||||
}
|
||||
|
||||
function Create ({ className = '', onClose, onStatusChange, seed: propsSeed, type: propsType }: CreateProps): React.ReactElement<CreateProps> {
|
||||
const { t } = useTranslation();
|
||||
const { api, isDevelopment, isEthereum } = useApi();
|
||||
const { isLedgerEnabled } = useLedger();
|
||||
const [{ address, derivePath, deriveValidation, isSeedValid, pairType, seed, seedType }, setAddress] = useState<AddressState>(() => generateSeed(
|
||||
propsSeed,
|
||||
isEthereum ? ETH_DEFAULT_PATH : '',
|
||||
propsSeed ? 'raw' : 'bip',
|
||||
isEthereum ? 'ethereum' : propsType
|
||||
));
|
||||
const [isMnemonicSaved, setIsMnemonicSaved] = useState<boolean>(false);
|
||||
const [step, nextStep, prevStep] = useStepper();
|
||||
const [isBusy, setIsBusy] = useState(false);
|
||||
const [{ isNameValid, name }, setName] = useState(() => ({ isNameValid: false, name: '' }));
|
||||
const [{ isPasswordValid, password }, setPassword] = useState(() => ({ isPasswordValid: false, password: '' }));
|
||||
const isFirstStepValid = !!address && isMnemonicSaved && !deriveValidation?.error && isSeedValid;
|
||||
const isSecondStepValid = isNameValid && isPasswordValid;
|
||||
const isValid = isFirstStepValid && isSecondStepValid;
|
||||
|
||||
const errorIndex = useRef<Record<string, string>>({
|
||||
INVALID_DERIVATION_PATH: t('This is an invalid derivation path.'),
|
||||
PASSWORD_IGNORED: t('Password are ignored for hex seed'),
|
||||
SOFT_NOT_ALLOWED: t('Soft derivation paths are not allowed on ed25519'),
|
||||
WARNING_SLASH_PASSWORD: t('Your password contains at least one "/" character. Disregard this warning if it is intended.')
|
||||
});
|
||||
|
||||
const seedOpt = useRef((
|
||||
isDevelopment
|
||||
? [{ text: t('Development'), value: 'dev' }]
|
||||
: []
|
||||
).concat(
|
||||
{ text: t('Mnemonic'), value: 'bip' },
|
||||
isEthereum
|
||||
? { text: t('Private Key'), value: 'raw' }
|
||||
: { text: t('Raw seed'), value: 'raw' }
|
||||
));
|
||||
|
||||
const _onChangePath = useCallback(
|
||||
(newDerivePath: string) => setAddress(
|
||||
updateAddress(seed, newDerivePath, seedType, pairType)
|
||||
),
|
||||
[pairType, seed, seedType]
|
||||
);
|
||||
|
||||
const _onChangeSeed = useCallback(
|
||||
(newSeed: string) => setAddress(
|
||||
updateAddress(newSeed, derivePath, seedType, pairType)
|
||||
),
|
||||
[derivePath, pairType, seedType]
|
||||
);
|
||||
|
||||
const _onChangePairType = useCallback(
|
||||
(newPairType: PairType) => setAddress(
|
||||
updateAddress(seed, isEthereum ? ETH_DEFAULT_PATH : '', seedType, newPairType)
|
||||
),
|
||||
[seed, seedType, isEthereum]
|
||||
);
|
||||
|
||||
const _selectSeedType = useCallback(
|
||||
(newSeedType: SeedType): void => {
|
||||
if (newSeedType !== seedType) {
|
||||
setAddress(generateSeed(null, derivePath, newSeedType, pairType));
|
||||
}
|
||||
},
|
||||
[derivePath, pairType, seedType]
|
||||
);
|
||||
|
||||
const _toggleMnemonicSaved = useCallback(
|
||||
() => setIsMnemonicSaved(!isMnemonicSaved),
|
||||
[isMnemonicSaved]
|
||||
);
|
||||
|
||||
const _onCommit = useCallback(
|
||||
(): void => {
|
||||
if (!isValid) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsBusy(true);
|
||||
nextTick((): void => {
|
||||
const options = { genesisHash: isDevelopment ? undefined : api.genesisHash.toHex(), isHardware: false, name: name.trim() };
|
||||
const status = createAccount(seed, derivePath, pairType, options, password, t('created account'));
|
||||
|
||||
onStatusChange(status);
|
||||
setIsBusy(false);
|
||||
onClose();
|
||||
});
|
||||
},
|
||||
[api, derivePath, isDevelopment, isValid, name, onClose, onStatusChange, pairType, password, seed, t]
|
||||
);
|
||||
|
||||
return (
|
||||
<StyledModal
|
||||
className={className}
|
||||
header={t('Add an account via seed {{step}}/{{STEPS_COUNT}}', { replace: { STEPS_COUNT, step } })}
|
||||
onClose={onClose}
|
||||
size='large'
|
||||
>
|
||||
<Modal.Content>
|
||||
<Modal.Columns>
|
||||
<AddressRow
|
||||
defaultName={name}
|
||||
fullLength
|
||||
isEditableName={false}
|
||||
noDefaultNameOpacity
|
||||
value={(isSeedValid && address) || null}
|
||||
/>
|
||||
</Modal.Columns>
|
||||
{step === 1 && <>
|
||||
<Modal.Columns hint={t('The secret seed value for this account. Ensure that you keep this in a safe place, with access to the seed you can re-create the account.')}>
|
||||
<TextArea
|
||||
isError={!isSeedValid}
|
||||
isReadOnly={seedType === 'dev'}
|
||||
label={
|
||||
seedType === 'bip'
|
||||
? t('mnemonic seed')
|
||||
: seedType === 'dev'
|
||||
? t('development seed')
|
||||
: isEthereum
|
||||
? t('ethereum private key')
|
||||
: t('seed (hex or string)')
|
||||
}
|
||||
onChange={_onChangeSeed}
|
||||
seed={seed}
|
||||
withLabel
|
||||
>
|
||||
<CopyButton
|
||||
className='copyMoved'
|
||||
type={seedType === 'bip' ? t('mnemonic') : seedType === 'raw' ? isEthereum ? t('private key') : 'seed' : t('raw seed')}
|
||||
value={seed}
|
||||
/>
|
||||
<Dropdown
|
||||
defaultValue={seedType}
|
||||
isButton
|
||||
onChange={_selectSeedType}
|
||||
options={seedOpt.current}
|
||||
/>
|
||||
</TextArea>
|
||||
</Modal.Columns>
|
||||
<Expander
|
||||
className='accounts--Creator-advanced'
|
||||
isPadded
|
||||
summary={t('Advanced creation options')}
|
||||
>
|
||||
{pairType !== 'ethereum' && (
|
||||
<Modal.Columns hint={t('If you are moving accounts between applications, ensure that you use the correct type.')}>
|
||||
<Dropdown
|
||||
defaultValue={pairType}
|
||||
label={t('keypair crypto type')}
|
||||
onChange={_onChangePairType}
|
||||
options={
|
||||
isEthereum
|
||||
? settings.availableCryptosEth
|
||||
: isLedgerEnabled
|
||||
? settings.availableCryptosLedger
|
||||
: settings.availableCryptos
|
||||
}
|
||||
tabIndex={-1}
|
||||
/>
|
||||
</Modal.Columns>
|
||||
)}
|
||||
{pairType === 'ed25519-ledger'
|
||||
? (
|
||||
<CreateSuriLedger
|
||||
onChange={_onChangePath}
|
||||
seedType={seedType}
|
||||
/>
|
||||
)
|
||||
: pairType === 'ethereum'
|
||||
? (
|
||||
<CreateEthDerivationPath
|
||||
derivePath={derivePath}
|
||||
deriveValidation={deriveValidation}
|
||||
onChange={_onChangePath}
|
||||
seed={seed}
|
||||
seedType={seedType}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<Modal.Columns hint={t('The derivation path allows you to create different accounts from the same base mnemonic.')}>
|
||||
<Input
|
||||
isDisabled={seedType === 'raw'}
|
||||
isError={!!deriveValidation?.error}
|
||||
label={t('secret derivation path')}
|
||||
onChange={_onChangePath}
|
||||
placeholder={
|
||||
seedType === 'raw'
|
||||
? pairType === 'sr25519'
|
||||
? t('//hard/soft')
|
||||
: t('//hard')
|
||||
: pairType === 'sr25519'
|
||||
? t('//hard/soft///password')
|
||||
: t('//hard///password')
|
||||
}
|
||||
tabIndex={-1}
|
||||
value={derivePath}
|
||||
/>
|
||||
{deriveValidation?.error && (
|
||||
<MarkError content={errorIndex.current[deriveValidation.error] || deriveValidation.error} />
|
||||
)}
|
||||
{deriveValidation?.warning && (
|
||||
<MarkWarning content={errorIndex.current[deriveValidation.warning]} />
|
||||
)}
|
||||
</Modal.Columns>
|
||||
)}
|
||||
</Expander>
|
||||
<Modal.Columns>
|
||||
<ExternalWarning />
|
||||
<div className='saveToggle'>
|
||||
<Checkbox
|
||||
label={<>{t('I have saved my mnemonic seed safely')}</>}
|
||||
onChange={_toggleMnemonicSaved}
|
||||
value={isMnemonicSaved}
|
||||
/>
|
||||
</div>
|
||||
</Modal.Columns>
|
||||
</>}
|
||||
{step === 2 && <>
|
||||
<CreateAccountInputs
|
||||
name={{ isNameValid, name }}
|
||||
onCommit={_onCommit}
|
||||
setName={setName}
|
||||
setPassword={setPassword}
|
||||
/>;
|
||||
<Modal.Columns>
|
||||
<ExternalWarning />
|
||||
</Modal.Columns>
|
||||
</>}
|
||||
{step === 3 && address && (
|
||||
<CreateConfirmation
|
||||
derivePath={derivePath}
|
||||
isBusy={isBusy}
|
||||
pairType={
|
||||
pairType === 'ed25519-ledger'
|
||||
? 'ed25519'
|
||||
: pairType
|
||||
}
|
||||
seed={seed}
|
||||
/>
|
||||
)}
|
||||
</Modal.Content>
|
||||
<Modal.Actions>
|
||||
{step === 1 &&
|
||||
<Button
|
||||
activeOnEnter
|
||||
icon='step-forward'
|
||||
isDisabled={!isFirstStepValid}
|
||||
label={t('Next')}
|
||||
onClick={nextStep}
|
||||
/>
|
||||
}
|
||||
{step === 2 && (
|
||||
<>
|
||||
<Button
|
||||
icon='step-backward'
|
||||
label={t('Prev')}
|
||||
onClick={prevStep}
|
||||
/>
|
||||
<Button
|
||||
activeOnEnter
|
||||
icon='step-forward'
|
||||
isDisabled={!isSecondStepValid}
|
||||
label={t('Next')}
|
||||
onClick={nextStep}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{step === 3 && (
|
||||
<>
|
||||
<Button
|
||||
icon='step-backward'
|
||||
label={t('Prev')}
|
||||
onClick={prevStep}
|
||||
/>
|
||||
<Button
|
||||
activeOnEnter
|
||||
icon='plus'
|
||||
isBusy={isBusy}
|
||||
label={t('Save')}
|
||||
onClick={_onCommit}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Modal.Actions>
|
||||
</StyledModal>
|
||||
);
|
||||
}
|
||||
|
||||
const StyledModal = styled(Modal)`
|
||||
.accounts--Creator-advanced {
|
||||
margin-top: 1rem;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.ui--CopyButton.copyMoved {
|
||||
position: absolute;
|
||||
right: 9.25rem;
|
||||
top: 1.15rem;
|
||||
}
|
||||
|
||||
&& .TextAreaWithDropdown {
|
||||
textarea {
|
||||
width: 80%;
|
||||
}
|
||||
.ui.buttons {
|
||||
width: 20%;
|
||||
}
|
||||
}
|
||||
|
||||
.saveToggle {
|
||||
text-align: right;
|
||||
|
||||
.ui--Checkbox {
|
||||
margin: 0.8rem 0;
|
||||
|
||||
> label {
|
||||
font-weight: var(--font-weight-normal);
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default React.memo(Create);
|
||||
@@ -0,0 +1,62 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-accounts authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import React, { useCallback } from 'react';
|
||||
|
||||
import { Input, Modal } from '@pezkuwi/react-components';
|
||||
|
||||
import { useTranslation } from '../translate.js';
|
||||
import PasswordInput from './PasswordInput.js';
|
||||
|
||||
interface AccountName {
|
||||
name: string;
|
||||
isNameValid: boolean;
|
||||
}
|
||||
|
||||
interface AccountPassword {
|
||||
password: string;
|
||||
isPasswordValid: boolean;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
name: AccountName;
|
||||
onCommit: () => void;
|
||||
setName: (value: AccountName) => void;
|
||||
setPassword: (value: AccountPassword) => void;
|
||||
}
|
||||
|
||||
const CreateAccountInputs = ({ name: { isNameValid, name }, onCommit, setName, setPassword }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const _onChangeName = useCallback(
|
||||
(name: string) => setName({ isNameValid: !!name.trim(), name }),
|
||||
[setName]
|
||||
);
|
||||
|
||||
const _onChangePass = useCallback(
|
||||
(password: string, isValid: boolean) => setPassword({ isPasswordValid: isValid, password }),
|
||||
[setPassword]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal.Columns hint={t('The name for this account and how it will appear under your addresses. With an on-chain identity, it can be made available to others.')}>
|
||||
<Input
|
||||
className='full'
|
||||
isError={!isNameValid}
|
||||
label={t('name')}
|
||||
onChange={_onChangeName}
|
||||
onEnter={onCommit}
|
||||
placeholder={t('new account')}
|
||||
value={name}
|
||||
/>
|
||||
</Modal.Columns>
|
||||
<PasswordInput
|
||||
onChange={_onChangePass}
|
||||
onEnter={onCommit}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(CreateAccountInputs);
|
||||
@@ -0,0 +1,67 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-accounts authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { KeypairType } from '@pezkuwi/util-crypto/types';
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { AddressRow, Modal, Static } from '@pezkuwi/react-components';
|
||||
import { isHex } from '@pezkuwi/util';
|
||||
|
||||
import { useTranslation } from '../translate.js';
|
||||
|
||||
interface Props {
|
||||
address?: string;
|
||||
derivePath: string;
|
||||
isBusy: boolean;
|
||||
name?: string;
|
||||
pairType: KeypairType;
|
||||
seed?: string;
|
||||
}
|
||||
|
||||
function CreateConfirmation ({ address, derivePath, name, pairType, seed }: Props): React.ReactElement<Props> | null {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const splitSeed = seed?.split(' ');
|
||||
const shortSeed = isHex(seed)
|
||||
? `${seed.slice(10)} … ${seed.slice(-8)}`
|
||||
: splitSeed?.map((value, index) => (index % 3) ? '…' : value).join(' ');
|
||||
|
||||
return (
|
||||
<Modal.Content>
|
||||
<Modal.Columns
|
||||
hint={
|
||||
<>
|
||||
<p>{t('We will provide you with a generated backup file after your account is created. As long as you have access to your account you can always download this file later by clicking on "Backup" button from the Accounts section.')}</p>
|
||||
<p>{t('Please make sure to save this file in a secure location as it is required, together with your password, to restore your account.')}</p>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{address && name && (
|
||||
<AddressRow
|
||||
defaultName={name}
|
||||
isInline
|
||||
noDefaultNameOpacity
|
||||
value={address}
|
||||
/>
|
||||
)}
|
||||
{shortSeed && (
|
||||
<Static
|
||||
label={t('partial seed')}
|
||||
value={shortSeed}
|
||||
/>
|
||||
)}
|
||||
<Static
|
||||
label={t('keypair type')}
|
||||
value={pairType}
|
||||
/>
|
||||
<Static
|
||||
label={t('derivation path')}
|
||||
value={derivePath || t('<none provided>')}
|
||||
/>
|
||||
</Modal.Columns>
|
||||
</Modal.Content>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(CreateConfirmation);
|
||||
@@ -0,0 +1,124 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-accounts authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { ReactNode } from 'react';
|
||||
import type { BN } from '@pezkuwi/util';
|
||||
import type { DeriveValidationOutput } from '../types.js';
|
||||
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { Checkbox, Dropdown, Input, InputNumber, MarkError, MarkWarning, Modal } from '@pezkuwi/react-components';
|
||||
import { useToggle } from '@pezkuwi/react-hooks';
|
||||
import { BN_ZERO } from '@pezkuwi/util';
|
||||
|
||||
import { useTranslation } from '../translate.js';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
onChange: (string: string) => void;
|
||||
seedType: string;
|
||||
derivePath: string;
|
||||
deriveValidation: DeriveValidationOutput | undefined;
|
||||
seed: string;
|
||||
}
|
||||
|
||||
export const ETH_DEFAULT_PATH = "m/44'/60'/0'/0/0";
|
||||
|
||||
function CreateEthDerivationPath ({ className, derivePath, deriveValidation, onChange, seedType }: Props): React.ReactElement<Props> {
|
||||
const { t } = useTranslation();
|
||||
const [addIndex, setAddIndex] = useState(0);
|
||||
const [customIndex, setCustomIndex] = useState<BN | undefined>(BN_ZERO);
|
||||
const [addressList] = useState<{ key: number; text: ReactNode; value: number; }[]>(
|
||||
() => new Array(10)
|
||||
.fill(0)
|
||||
.map((_, i) => ({
|
||||
key: i,
|
||||
text: t('Address index {{index}}', {
|
||||
replace: { index: i }
|
||||
}),
|
||||
value: i
|
||||
}))
|
||||
);
|
||||
const [useCustomPath, toggleCustomPath] = useToggle();
|
||||
const [useCustomIndex, toggleCustomIndex] = useToggle();
|
||||
|
||||
const errorIndex = useRef<Record<string, string>>({
|
||||
INVALID_DERIVATION_PATH: t('This is an invalid derivation path.'),
|
||||
PASSWORD_IGNORED: t('Password are ignored for hex seed'),
|
||||
SOFT_NOT_ALLOWED: t('Soft derivation paths are not allowed on ed25519'),
|
||||
WARNING_SLASH_PASSWORD: t('Your password contains at least one "/" character. Disregard this warning if it is intended.')
|
||||
});
|
||||
|
||||
useEffect((): void => {
|
||||
onChange(`m/44'/60'/0'/0/${useCustomIndex ? Number(customIndex) : addIndex}`);
|
||||
}, [customIndex, onChange, useCustomIndex, addIndex]);
|
||||
|
||||
return (
|
||||
<Modal.Columns
|
||||
className={className}
|
||||
hint={
|
||||
seedType === 'raw'
|
||||
? t('The derivation path is only relevant when deriving keys from a mnemonic.')
|
||||
: t('The derivation path allows you to create different accounts from the same base mnemonic.')
|
||||
}
|
||||
>
|
||||
{seedType === 'bip'
|
||||
? (
|
||||
<>
|
||||
<div className='saveToggle'>
|
||||
<Checkbox
|
||||
label={<>{t('Use custom address index')}</>}
|
||||
onChange={toggleCustomIndex}
|
||||
value={useCustomIndex}
|
||||
/>
|
||||
</div>
|
||||
{useCustomIndex
|
||||
? (
|
||||
<InputNumber
|
||||
isDecimal={false}
|
||||
label={t('Custom index')}
|
||||
onChange={setCustomIndex}
|
||||
value={customIndex}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<Dropdown
|
||||
label={t('address index')}
|
||||
onChange={setAddIndex}
|
||||
options={addressList}
|
||||
value={addIndex}
|
||||
/>
|
||||
)}
|
||||
<div className='saveToggle'>
|
||||
<Checkbox
|
||||
label={<>{t('Use custom derivation path')}</>}
|
||||
onChange={toggleCustomPath}
|
||||
value={useCustomPath}
|
||||
/>
|
||||
</div>
|
||||
{useCustomPath
|
||||
? (
|
||||
<Input
|
||||
isError={!!deriveValidation?.error}
|
||||
label={t('secret derivation path')}
|
||||
onChange={onChange}
|
||||
placeholder={ETH_DEFAULT_PATH}
|
||||
tabIndex={-1}
|
||||
value={derivePath}
|
||||
/>
|
||||
)
|
||||
: null}
|
||||
</>
|
||||
)
|
||||
: (
|
||||
<MarkWarning content={t('The derivation path is only relevant when deriving keys from a mnemonic.')} />
|
||||
)}
|
||||
{deriveValidation?.error && (
|
||||
<MarkError content={errorIndex.current[deriveValidation.error] || deriveValidation.error} />
|
||||
)}
|
||||
{deriveValidation?.warning && <MarkWarning content={errorIndex.current[deriveValidation.warning]} />}
|
||||
</Modal.Columns>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(CreateEthDerivationPath);
|
||||
@@ -0,0 +1,81 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-accounts authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { selectableNetworks } from '@pezkuwi/networks';
|
||||
import { Dropdown, MarkError, Modal } from '@pezkuwi/react-components';
|
||||
|
||||
import { useTranslation } from '../translate.js';
|
||||
import { AVAIL_INDEXES } from './Ledger.js';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
onChange: (string: string) => void;
|
||||
seedType: string;
|
||||
}
|
||||
|
||||
const ledgerNets = selectableNetworks.filter(({ hasLedgerSupport }) => hasLedgerSupport);
|
||||
|
||||
function CreateSuriLedger ({ className, onChange, seedType }: Props): React.ReactElement<Props> {
|
||||
const { t } = useTranslation();
|
||||
const [accIndex, setAccIndex] = useState(0);
|
||||
const [addIndex, setAddIndex] = useState(0);
|
||||
const [chainType, setChainType] = useState('pezkuwi');
|
||||
|
||||
const netOpts = useRef(ledgerNets.map(({ displayName, network }) => ({
|
||||
text: displayName,
|
||||
value: network
|
||||
})));
|
||||
|
||||
const accOps = useRef(AVAIL_INDEXES.map((value) => ({
|
||||
text: t('Account type {{index}}', { replace: { index: value } }),
|
||||
value
|
||||
})));
|
||||
|
||||
const addOps = useRef(AVAIL_INDEXES.map((value) => ({
|
||||
text: t('Address index {{index}}', { replace: { index: value } }),
|
||||
value
|
||||
})));
|
||||
|
||||
useEffect((): void => {
|
||||
const network = ledgerNets.find(({ network }) => network === chainType);
|
||||
|
||||
onChange(`m/44'/${network?.slip44}'/${accIndex}'/0'/${addIndex}'`);
|
||||
}, [accIndex, addIndex, chainType, onChange]);
|
||||
|
||||
return (
|
||||
<Modal.Columns
|
||||
className={className}
|
||||
hint={t('The derivation will be constructed from the values you specify.')}
|
||||
>
|
||||
{seedType === 'bip'
|
||||
? (
|
||||
<>
|
||||
<Dropdown
|
||||
label={t('Ledger app type (originated from)')}
|
||||
onChange={setChainType}
|
||||
options={netOpts.current}
|
||||
value={chainType}
|
||||
/>
|
||||
<Dropdown
|
||||
label={t('account type')}
|
||||
onChange={setAccIndex}
|
||||
options={accOps.current}
|
||||
value={accIndex}
|
||||
/>
|
||||
<Dropdown
|
||||
label={t('address index')}
|
||||
onChange={setAddIndex}
|
||||
options={addOps.current}
|
||||
value={addIndex}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
: <MarkError content={t('Derivation for Ledger-type accounts are only available on mnemonic seeds.')} />
|
||||
}
|
||||
</Modal.Columns>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(CreateSuriLedger);
|
||||
@@ -0,0 +1,135 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-staking authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { Conviction } from '@pezkuwi/types/interfaces';
|
||||
import type { BN } from '@pezkuwi/util';
|
||||
import type { AmountValidateState } from '../Accounts/types.js';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import { ConvictionDropdown, InputAddress, InputBalance, Modal, TxButton } from '@pezkuwi/react-components';
|
||||
import { useApi } from '@pezkuwi/react-hooks';
|
||||
import { BalanceFree } from '@pezkuwi/react-query';
|
||||
import { BN_ZERO } from '@pezkuwi/util';
|
||||
|
||||
import { useTranslation } from '../translate.js';
|
||||
import ValidateAmount from './InputValidateAmount.js';
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
previousAmount?: BN;
|
||||
previousConviction?: Conviction;
|
||||
previousDelegatedAccount?: string;
|
||||
previousDelegatingAccount?: string;
|
||||
}
|
||||
|
||||
function Delegate ({ onClose, previousAmount, previousConviction, previousDelegatedAccount, previousDelegatingAccount }: Props): React.ReactElement<Props> {
|
||||
const { t } = useTranslation();
|
||||
const { api } = useApi();
|
||||
const [amountError, setAmountError] = useState<AmountValidateState | null>(null);
|
||||
const [maxBalance] = useState<BN | undefined>();
|
||||
const [amount, setAmount] = useState<BN | undefined>(previousAmount);
|
||||
const [delegatingAccount, setDelegatingAccount] = useState<string | null>(previousDelegatingAccount || null);
|
||||
const [delegatedAccount, setDelegatedAccount] = useState<string | null>(previousDelegatedAccount || null);
|
||||
const defaultConviction = previousConviction === undefined
|
||||
? 0
|
||||
: previousConviction.toNumber();
|
||||
const [conviction, setConviction] = useState(defaultConviction);
|
||||
|
||||
const isDirty = amount?.toString() !== previousAmount?.toString() ||
|
||||
delegatedAccount !== previousDelegatedAccount ||
|
||||
delegatingAccount !== previousDelegatingAccount ||
|
||||
conviction !== previousConviction?.toNumber();
|
||||
|
||||
return (
|
||||
<Modal
|
||||
className='staking--Delegate'
|
||||
header={previousDelegatedAccount
|
||||
? t('democracy vote delegation')
|
||||
: t('delegate democracy vote')
|
||||
}
|
||||
onClose={onClose}
|
||||
size='large'
|
||||
>
|
||||
<Modal.Content>
|
||||
<Modal.Columns
|
||||
hint={
|
||||
<>
|
||||
<p>{t('Any democracy vote performed by the delegated account will result in an additional vote from the delegating account')}</p>
|
||||
<p>{t('If the delegated account is currently voting in a referendum, the delegating vote and conviction will be added.')}</p>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<InputAddress
|
||||
label={t('delegating account')}
|
||||
onChange={setDelegatingAccount}
|
||||
type='account'
|
||||
value={delegatingAccount}
|
||||
/>
|
||||
<InputAddress
|
||||
label={t('delegated account')}
|
||||
onChange={setDelegatedAccount}
|
||||
type='allPlus'
|
||||
value={delegatedAccount}
|
||||
/>
|
||||
</Modal.Columns>
|
||||
<Modal.Columns hint={t('The amount to allocate and the conviction that will be applied to all votes made on a referendum.')}>
|
||||
<InputBalance
|
||||
autoFocus
|
||||
isError={!!amountError?.error}
|
||||
isZeroable={false}
|
||||
label={t('delegating amount')}
|
||||
labelExtra={
|
||||
<BalanceFree
|
||||
label={<span className='label'>{t('balance')}</span>}
|
||||
params={delegatingAccount}
|
||||
/>
|
||||
}
|
||||
maxValue={maxBalance}
|
||||
onChange={setAmount}
|
||||
value={amount}
|
||||
/>
|
||||
<ValidateAmount
|
||||
amount={amount}
|
||||
delegatingAccount={delegatingAccount}
|
||||
onError={setAmountError}
|
||||
/>
|
||||
<ConvictionDropdown
|
||||
label={t('conviction')}
|
||||
onChange={setConviction}
|
||||
value={conviction}
|
||||
voteLockingPeriod={
|
||||
api.consts.democracy.voteLockingPeriod ||
|
||||
api.consts.democracy.enactmentPeriod
|
||||
}
|
||||
/>
|
||||
</Modal.Columns>
|
||||
</Modal.Content>
|
||||
<Modal.Actions>
|
||||
{previousDelegatedAccount && (
|
||||
<TxButton
|
||||
accountId={delegatingAccount}
|
||||
icon='trash-alt'
|
||||
label={t('Undelegate')}
|
||||
onStart={onClose}
|
||||
tx={api.tx.democracy.undelegate}
|
||||
/>
|
||||
)}
|
||||
<TxButton
|
||||
accountId={delegatingAccount}
|
||||
icon='sign-in-alt'
|
||||
isDisabled={!amount?.gt(BN_ZERO) || !!amountError?.error || !isDirty}
|
||||
label={previousDelegatedAccount
|
||||
? t('Save delegation')
|
||||
: t('Delegate')
|
||||
}
|
||||
onStart={onClose}
|
||||
params={[delegatedAccount, conviction, amount]}
|
||||
tx={api.tx.democracy.delegate}
|
||||
/>
|
||||
</Modal.Actions>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(Delegate);
|
||||
@@ -0,0 +1,257 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-accounts authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { KeyringPair } from '@pezkuwi/keyring/types';
|
||||
import type { ActionStatus } from '@pezkuwi/react-components/Status/types';
|
||||
import type { HexString } from '@pezkuwi/util/types';
|
||||
import type { KeypairType } from '@pezkuwi/util-crypto/types';
|
||||
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { AddressRow, Button, Input, InputAddress, MarkError, Modal, Password } from '@pezkuwi/react-components';
|
||||
import { useApi, useDebounce, useQueue, useToggle } from '@pezkuwi/react-hooks';
|
||||
import { keyring } from '@pezkuwi/ui-keyring';
|
||||
import { nextTick } from '@pezkuwi/util';
|
||||
import { keyExtractPath } from '@pezkuwi/util-crypto';
|
||||
|
||||
import { useTranslation } from '../translate.js';
|
||||
import { tryCreateAccount } from '../util.js';
|
||||
import CreateAccountInputs from './CreateAccountInputs.js';
|
||||
import CreateConfirmation from './CreateConfirmation.js';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
from: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
interface DeriveAddress {
|
||||
address: string | null;
|
||||
deriveError: string | null;
|
||||
}
|
||||
|
||||
interface LockState {
|
||||
isLocked: boolean;
|
||||
lockedError: string | null;
|
||||
}
|
||||
|
||||
function deriveValidate (suri: string, pairType: KeypairType): string | null {
|
||||
if (suri.includes('///')) {
|
||||
return 'Password paths are not supported on keys derived from others';
|
||||
}
|
||||
|
||||
try {
|
||||
const { path } = keyExtractPath(suri);
|
||||
|
||||
// we don't allow soft for ed25519
|
||||
if (pairType === 'ed25519' && path.some(({ isSoft }): boolean => isSoft)) {
|
||||
return 'Soft derivation paths are not allowed on ed25519';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
|
||||
return (error as Error).message;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function createAccount (source: KeyringPair, suri: string, name: string, password: string, success: string, genesisHash?: HexString): ActionStatus {
|
||||
const commitAccount = () => {
|
||||
const derived = source.derive(suri);
|
||||
|
||||
derived.setMeta({ ...derived.meta, genesisHash, name, parentAddress: source.address, tags: [] });
|
||||
|
||||
return keyring.addPair(derived, password || '');
|
||||
};
|
||||
|
||||
return tryCreateAccount(commitAccount, success);
|
||||
}
|
||||
|
||||
function Derive ({ className = '', from, onClose }: Props): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const { api, isDevelopment } = useApi();
|
||||
const { queueAction } = useQueue();
|
||||
const [source] = useState(() => keyring.getPair(from));
|
||||
const [isBusy, setIsBusy] = useState(false);
|
||||
const [{ isNameValid, name }, setName] = useState({ isNameValid: false, name: '' });
|
||||
const [{ isPasswordValid, password }, setPassword] = useState({ isPasswordValid: false, password: '' });
|
||||
const [{ address, deriveError }, setDerive] = useState<DeriveAddress>({ address: null, deriveError: null });
|
||||
const [isConfirmationOpen, toggleConfirmation] = useToggle();
|
||||
const [{ isLocked, lockedError }, setIsLocked] = useState<LockState>({ isLocked: source.isLocked, lockedError: null });
|
||||
const [{ isRootValid, rootPass }, setRootPass] = useState({ isRootValid: false, rootPass: '' });
|
||||
const [suri, setSuri] = useState('');
|
||||
const debouncedSuri = useDebounce(suri);
|
||||
const isValid = !!address && !deriveError && isNameValid && isPasswordValid;
|
||||
|
||||
useEffect((): void => {
|
||||
setIsLocked({ isLocked: source.isLocked, lockedError: null });
|
||||
}, [source]);
|
||||
|
||||
useEffect((): void => {
|
||||
!isLocked && setDerive((): DeriveAddress => {
|
||||
let address: string | null = null;
|
||||
const deriveError = deriveValidate(debouncedSuri, source.type);
|
||||
|
||||
if (!deriveError) {
|
||||
const result = source.derive(debouncedSuri);
|
||||
|
||||
address = result.address;
|
||||
}
|
||||
|
||||
return { address, deriveError };
|
||||
});
|
||||
}, [debouncedSuri, isLocked, source]);
|
||||
|
||||
const _onChangeRootPass = useCallback(
|
||||
(rootPass: string): void => {
|
||||
setRootPass({ isRootValid: !!rootPass, rootPass });
|
||||
setIsLocked(({ isLocked }) => ({ isLocked, lockedError: null }));
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const _onUnlock = useCallback(
|
||||
(): void => {
|
||||
setIsBusy(true);
|
||||
nextTick((): void => {
|
||||
try {
|
||||
source.decodePkcs8(rootPass);
|
||||
setIsLocked({ isLocked: source.isLocked, lockedError: null });
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
setIsLocked({ isLocked: true, lockedError: (error as Error).message });
|
||||
}
|
||||
|
||||
setIsBusy(false);
|
||||
});
|
||||
},
|
||||
[rootPass, source]
|
||||
);
|
||||
|
||||
const _onCommit = useCallback(
|
||||
(): void => {
|
||||
if (!isValid) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsBusy(true);
|
||||
nextTick((): void => {
|
||||
const status = createAccount(source, suri, name, password, t('created account'), isDevelopment ? undefined : api.genesisHash.toHex());
|
||||
|
||||
queueAction(status);
|
||||
setIsBusy(false);
|
||||
onClose();
|
||||
});
|
||||
},
|
||||
[api, isDevelopment, isValid, name, onClose, password, queueAction, source, suri, t]
|
||||
);
|
||||
|
||||
const sourceStatic = (
|
||||
<InputAddress
|
||||
isDisabled
|
||||
label={t('derive root account')}
|
||||
value={from}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
className={className}
|
||||
header={t('Derive account from pair')}
|
||||
onClose={onClose}
|
||||
>
|
||||
{address && isConfirmationOpen
|
||||
? (
|
||||
<CreateConfirmation
|
||||
address={address}
|
||||
derivePath={suri}
|
||||
isBusy={isBusy}
|
||||
name={name}
|
||||
pairType={source.type}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<Modal.Content>
|
||||
{isLocked && (
|
||||
<>
|
||||
{sourceStatic}
|
||||
<Password
|
||||
autoFocus
|
||||
isError={!!lockedError}
|
||||
label={t('password')}
|
||||
onChange={_onChangeRootPass}
|
||||
value={rootPass}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{!isLocked && (
|
||||
<AddressRow
|
||||
defaultName={name}
|
||||
noDefaultNameOpacity
|
||||
value={deriveError ? '' : address}
|
||||
>
|
||||
{sourceStatic}
|
||||
<Input
|
||||
autoFocus
|
||||
label={t('derivation path')}
|
||||
onChange={setSuri}
|
||||
placeholder={t('//hard/soft')}
|
||||
/>
|
||||
{deriveError && (
|
||||
<MarkError content={deriveError} />
|
||||
)}
|
||||
<CreateAccountInputs
|
||||
name={{ isNameValid, name }}
|
||||
onCommit={_onCommit}
|
||||
setName={setName}
|
||||
setPassword={setPassword}
|
||||
/>;
|
||||
</AddressRow>
|
||||
)}
|
||||
</Modal.Content>
|
||||
)
|
||||
}
|
||||
<Modal.Actions>
|
||||
{isLocked
|
||||
? (
|
||||
<Button
|
||||
icon='lock'
|
||||
isBusy={isBusy}
|
||||
isDisabled={!isRootValid}
|
||||
label={t('Unlock')}
|
||||
onClick={_onUnlock}
|
||||
/>
|
||||
)
|
||||
: (isConfirmationOpen
|
||||
? (
|
||||
<>
|
||||
<Button
|
||||
icon='step-backward'
|
||||
label={t('Prev')}
|
||||
onClick={toggleConfirmation}
|
||||
/>
|
||||
<Button
|
||||
icon='plus'
|
||||
isBusy={isBusy}
|
||||
label={t('Save')}
|
||||
onClick={_onCommit}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
: (
|
||||
<Button
|
||||
icon='step-forward'
|
||||
isDisabled={!isValid}
|
||||
label={t('Next')}
|
||||
onClick={toggleConfirmation}
|
||||
/>
|
||||
)
|
||||
)
|
||||
}
|
||||
</Modal.Actions>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(Derive);
|
||||
@@ -0,0 +1,209 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-accounts authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { ActionStatus } from '@pezkuwi/react-components/Status/types';
|
||||
import type { ModalProps } from '../types.js';
|
||||
|
||||
import FileSaver from 'file-saver';
|
||||
import React, { useCallback, useState } from 'react';
|
||||
|
||||
import { AddressRow, Button, Checkbox, MarkWarning, Modal, Password, styled } from '@pezkuwi/react-components';
|
||||
import { keyring } from '@pezkuwi/ui-keyring';
|
||||
import { nextTick } from '@pezkuwi/util';
|
||||
|
||||
import { useTranslation } from '../translate.js';
|
||||
|
||||
interface Props extends ModalProps {
|
||||
className?: string;
|
||||
onClose: () => void;
|
||||
accountsByGroup: Record<string, string[]>;
|
||||
onStatusChange: (status: ActionStatus) => void;
|
||||
}
|
||||
|
||||
interface TPassword {
|
||||
account: string, isPassTouched: boolean, password: string
|
||||
}
|
||||
|
||||
const ALLOWED_CATEGORIES = ['accounts', 'multisig', 'qr'];
|
||||
|
||||
function ExportAll ({ accountsByGroup, className, onClose, onStatusChange }: Props): React.ReactElement | null {
|
||||
const { t } = useTranslation();
|
||||
const [isBusy, setIsBusy] = useState(false);
|
||||
const [selectedTypes, setSelectedTypes] = useState<string[]>([]);
|
||||
const [allBrowserAccountPassword, setAllBrowserAccountPassword] = useState<TPassword[]>([]);
|
||||
|
||||
// toggle checkboxes
|
||||
const onChangeCheckBox = useCallback((group: string) => {
|
||||
if (!selectedTypes.includes(group)) {
|
||||
setSelectedTypes([...selectedTypes, group]);
|
||||
} else {
|
||||
setSelectedTypes(selectedTypes.filter((prev) => prev !== group));
|
||||
}
|
||||
}, [selectedTypes]);
|
||||
|
||||
const _onExportButtonClick = useCallback(() => {
|
||||
setIsBusy(true);
|
||||
|
||||
nextTick((): void => {
|
||||
const status: Partial<ActionStatus> = { action: 'export' };
|
||||
|
||||
try {
|
||||
let allAccounts: string[] = [];
|
||||
|
||||
// get all accounts for selected groups
|
||||
selectedTypes.forEach((group) => {
|
||||
allAccounts = [...allAccounts, ...accountsByGroup[group]];
|
||||
});
|
||||
|
||||
const accounts = allAccounts.map((account) => {
|
||||
const keyringAccount = keyring.getPair(account);
|
||||
|
||||
// Handle In-Browser Accounts explicitly
|
||||
if (accountsByGroup.accounts.includes(account)) {
|
||||
return keyring.backupAccount(keyringAccount, allBrowserAccountPassword.find((a) => a.account === account)?.password || '');
|
||||
} else {
|
||||
return keyringAccount;
|
||||
}
|
||||
});
|
||||
|
||||
const blob = new Blob([JSON.stringify(accounts, null, 2)], { type: 'application/json; charset=utf-8' });
|
||||
|
||||
// eslint-disable-next-line deprecation/deprecation
|
||||
FileSaver.saveAs(blob, `batch_exported_accounts_${new Date().getTime()}.json`);
|
||||
|
||||
status.status = 'success';
|
||||
status.message = t('accounts exported');
|
||||
} catch (error) {
|
||||
status.status = 'error';
|
||||
status.message = (error as Error).message;
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
setIsBusy(false);
|
||||
onStatusChange(status as ActionStatus);
|
||||
|
||||
if (status.status !== 'error') {
|
||||
onClose();
|
||||
}
|
||||
});
|
||||
}, [accountsByGroup, allBrowserAccountPassword, onClose, onStatusChange, selectedTypes, t]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
className={className}
|
||||
header={t('export all accounts')}
|
||||
onClose={onClose}
|
||||
size='large'
|
||||
>
|
||||
<Modal.Content>
|
||||
<MarkWarning
|
||||
content={t('You can batch export accounts, but only for in-browser, QR, and multisig types.')}
|
||||
withIcon={false}
|
||||
/>
|
||||
<StyledCheckBoxGroup>
|
||||
{Object.keys(accountsByGroup)
|
||||
.filter((group) => ALLOWED_CATEGORIES.includes(group) && accountsByGroup[group].length > 0)
|
||||
.map((group) => {
|
||||
const isSelected = selectedTypes.includes(group);
|
||||
|
||||
return (
|
||||
<section key={group}>
|
||||
<Checkbox
|
||||
label={`${group === 'accounts' ? 'in-browser' : group} accounts (${accountsByGroup[group].length})`}
|
||||
// eslint-disable-next-line react/jsx-no-bind
|
||||
onChange={() => onChangeCheckBox(group)}
|
||||
value={isSelected}
|
||||
/>
|
||||
{group === 'accounts' && isSelected &&
|
||||
<BrowserAccounts
|
||||
accounts={accountsByGroup[group]}
|
||||
allPassword={allBrowserAccountPassword}
|
||||
setAllPassword={setAllBrowserAccountPassword}
|
||||
/>}
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</StyledCheckBoxGroup>
|
||||
</Modal.Content>
|
||||
<Modal.Actions>
|
||||
<Button
|
||||
icon='sync'
|
||||
isBusy={isBusy}
|
||||
isDisabled={!selectedTypes.length}
|
||||
label={t('Export')}
|
||||
onClick={_onExportButtonClick}
|
||||
/>
|
||||
</Modal.Actions>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
const BrowserAccounts = ({ accounts, allPassword, setAllPassword }: {accounts: string[], allPassword: TPassword[], setAllPassword: React.Dispatch<React.SetStateAction<TPassword[]>>}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const _onChangePass = useCallback(
|
||||
(account: string, password: string): void => {
|
||||
setAllPassword((prev) => {
|
||||
return [
|
||||
...prev.filter((e) => e.account !== account),
|
||||
{ account, isPassTouched: true, password }
|
||||
];
|
||||
});
|
||||
},
|
||||
[setAllPassword]
|
||||
);
|
||||
|
||||
return accounts.map((account) => {
|
||||
const { isPassTouched, password } = allPassword.find((a) => a.account === account) || { isPassTouched: false, password: '' };
|
||||
const isPassValid = keyring.isPassValid(password);
|
||||
|
||||
return <BrowserAccountsDiv key={account}>
|
||||
<AddressRow
|
||||
isInline
|
||||
value={account}
|
||||
/>
|
||||
<Password
|
||||
autoFocus
|
||||
isError={isPassTouched && !isPassValid}
|
||||
label={t('password')}
|
||||
// eslint-disable-next-line react/jsx-no-bind
|
||||
onChange={(password) => _onChangePass(account, password)}
|
||||
value={password}
|
||||
/>
|
||||
</BrowserAccountsDiv>;
|
||||
});
|
||||
};
|
||||
|
||||
const StyledCheckBoxGroup = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
margin-top: 1.5rem;
|
||||
padding-inline: 2rem;
|
||||
|
||||
label, .ui--Icon {
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
section > div > label {
|
||||
text-transform: capitalize;
|
||||
}
|
||||
`;
|
||||
|
||||
const BrowserAccountsDiv = styled.div`
|
||||
width: 100%;
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-top: 1.5rem;
|
||||
padding-inline: 2rem;
|
||||
|
||||
div:nth-child(2) {
|
||||
width: 100%;
|
||||
label {
|
||||
font-size: var(--font-size-label);
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default React.memo(ExportAll);
|
||||
@@ -0,0 +1,24 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-accounts authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { MarkWarning } from '@pezkuwi/react-components';
|
||||
|
||||
import { useTranslation } from '../translate.js';
|
||||
|
||||
const isElectron = navigator.userAgent.toLowerCase().indexOf(' electron/') > -1;
|
||||
|
||||
function ExternalWarning (): React.ReactElement | null {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (isElectron) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<MarkWarning content={<>{t('Consider storing your account in a signer such as a browser extension, hardware device, QR-capable phone wallet (non-connected) or desktop application for optimal account security.')} {t('Future versions of the web-only interface will drop support for non-external accounts, much like the IPFS version.')}</>} />
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(ExternalWarning);
|
||||
@@ -0,0 +1,396 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-accounts authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { ApiPromise } from '@pezkuwi/api';
|
||||
import type { Bytes, Data, Option, Struct } from '@pezkuwi/types';
|
||||
import type { H160, IdentityInfo, Registration } from '@pezkuwi/types/interfaces';
|
||||
import type { PalletIdentityRegistration } from '@pezkuwi/types/lookup';
|
||||
import type { ITuple } from '@pezkuwi/types-codec/types';
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
import { Input, InputBalance, Modal, Toggle, TxButton } from '@pezkuwi/react-components';
|
||||
import { getAddressMeta } from '@pezkuwi/react-components/util';
|
||||
import { useApi, useCall } from '@pezkuwi/react-hooks';
|
||||
import { AddressIdentityOtherDiscordKey } from '@pezkuwi/react-hooks/constants';
|
||||
import { u8aToString } from '@pezkuwi/util';
|
||||
|
||||
import { useTranslation } from '../translate.js';
|
||||
|
||||
interface Props {
|
||||
address: string;
|
||||
className?: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
interface ValueState {
|
||||
okAll: boolean;
|
||||
okDiscord?: boolean;
|
||||
okDisplay?: boolean;
|
||||
okEmail?: boolean;
|
||||
okGithub?: boolean;
|
||||
okLegal?: boolean;
|
||||
okMatrix?: boolean;
|
||||
okRiot?: boolean;
|
||||
okTwitter?: boolean;
|
||||
okWeb?: boolean;
|
||||
}
|
||||
|
||||
// With the Identity pallet being transferred to the people chain, the types are also changed.
|
||||
interface PeopleIdentityInfo extends Struct {
|
||||
display: Data;
|
||||
legal: Data;
|
||||
web: Data;
|
||||
matrix: Data;
|
||||
email: Data;
|
||||
pgpFingerprint: Option<H160>;
|
||||
image: Data;
|
||||
twitter: Data;
|
||||
github: Data;
|
||||
discord: Data;
|
||||
}
|
||||
|
||||
const WHITESPACE = [' ', '\t'];
|
||||
|
||||
function setData (data: Data, setActive: null | ((isActive: boolean) => void), setVal: (val: string) => void): void {
|
||||
if (data && data.isRaw) {
|
||||
setActive && setActive(true);
|
||||
setVal(u8aToString(data.asRaw.toU8a(true)));
|
||||
}
|
||||
}
|
||||
|
||||
function setAdditionalFieldData (api: ApiPromise, info: IdentityInfo, key: string, setActive: null | ((isActive: boolean) => void), setVal: (val: string) => void): Data {
|
||||
const dataKey = api.registry.createType('Data', api.registry.createType('Data', { Raw: key }));
|
||||
const dataNone = api.registry.createType('Data', '');
|
||||
const value = info.additional.find((v) => v[0].eq(dataKey))?.[1] || dataNone;
|
||||
|
||||
if (value && value.isRaw) {
|
||||
setData(value, setActive, setVal);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
function setDiscordFieldData (info: PeopleIdentityInfo, setActive: null | ((isActive: boolean) => void), setVal: (val: string) => void): Data {
|
||||
if (info.discord && !info.discord.isNone) {
|
||||
setActive && setActive(true);
|
||||
setVal(u8aToString(info.discord.asRaw.toU8a(true)));
|
||||
}
|
||||
|
||||
return info.discord;
|
||||
}
|
||||
|
||||
function checkValue (hasValue: boolean, value: string | null | undefined, minLength: number, includes: string[], excludes: string[], starting: string[], notStarting: string[] = WHITESPACE, notEnding: string[] = WHITESPACE): boolean {
|
||||
return !hasValue || (
|
||||
!!value &&
|
||||
(value.length >= minLength) &&
|
||||
includes.reduce((hasIncludes: boolean, check) => hasIncludes && value.includes(check), true) &&
|
||||
(!starting.length || starting.some((check) => value.startsWith(check))) &&
|
||||
!excludes.some((check) => value.includes(check)) &&
|
||||
!notStarting.some((check) => value.startsWith(check)) &&
|
||||
!notEnding.some((check) => value.endsWith(check))
|
||||
);
|
||||
}
|
||||
|
||||
function IdentityMain ({ address, className = '', onClose }: Props): React.ReactElement<Props> {
|
||||
const { t } = useTranslation();
|
||||
const { apiIdentity, enableIdentity, specName } = useApi();
|
||||
const identityOpt = useCall<Option<ITuple<[PalletIdentityRegistration, Option<Bytes>]>>>(apiIdentity.query.identity.identityOf, [address]);
|
||||
const [{ okAll, okDiscord, okDisplay, okEmail, okGithub, okLegal, okMatrix, okRiot, okTwitter, okWeb }, setOkInfo] = useState<ValueState>({ okAll: false });
|
||||
const [legacyInfo, setLegacyInfo] = useState<Record<string, unknown>>({});
|
||||
const [info, setInfo] = useState<Record<string, unknown>>({});
|
||||
const [hasEmail, setHasEmail] = useState(false);
|
||||
const [hasLegal, setHasLegal] = useState(false);
|
||||
const [hasRiot, setHasRiot] = useState(false);
|
||||
const [hasMatrix, setHasMatrix] = useState(false);
|
||||
const [hasGithub, setHasGithub] = useState(false);
|
||||
const [hasTwitter, setHasTwitter] = useState(false);
|
||||
const [hasDiscord, setHasDiscord] = useState(false);
|
||||
const [hasWeb, setHasWeb] = useState(false);
|
||||
const [valDisplay, setValDisplay] = useState(() => (getAddressMeta(address).name || '').replace(/\(.*\)/, '').trim());
|
||||
const [valEmail, setValEmail] = useState('');
|
||||
const [valLegal, setValLegal] = useState('');
|
||||
const [valRiot, setValRiot] = useState('');
|
||||
const [valMatrix, setValMatrix] = useState('');
|
||||
const [valGithub, setValGithub] = useState('');
|
||||
const [valTwitter, setValTwitter] = useState('');
|
||||
const [valDiscord, setValDiscord] = useState('');
|
||||
const [valWeb, setValWeb] = useState('');
|
||||
const [gotPreviousIdentity, setGotPreviousIdentity] = useState(false);
|
||||
// HACK This ensures we can deal with legacy indentity information for chains while
|
||||
// also giving support for the new identity logic inside of the Peoples chain.
|
||||
// The new logic is specific to people system teyrchains.
|
||||
const [isLegacyIdentity, setIsLegacyIdentity] = useState<boolean>(!specName.includes('people-'));
|
||||
const [isPalletChecked, setIsPalletChecked] = useState<boolean>(false);
|
||||
|
||||
useEffect((): void => {
|
||||
if (identityOpt && identityOpt.isSome) {
|
||||
const identity = identityOpt.unwrap();
|
||||
const foundInfo = Array.isArray(identity) ? identity[0].info : (identity as Registration).info;
|
||||
|
||||
// Ensure we set the state before ever jumping into the logic below.
|
||||
if (!isPalletChecked) {
|
||||
// riot was replaced with matrix, so if we see riot as part of the structure
|
||||
// we know its part of the legacy identity logic
|
||||
foundInfo.riot ? setIsLegacyIdentity(true) : setIsLegacyIdentity(false);
|
||||
setIsPalletChecked(true);
|
||||
} else {
|
||||
setData(foundInfo.display, null, setValDisplay);
|
||||
setData(foundInfo.email, setHasEmail, setValEmail);
|
||||
setData(foundInfo.legal, setHasLegal, setValLegal);
|
||||
setData(foundInfo.riot, setHasRiot, setValRiot);
|
||||
setData(foundInfo.twitter, setHasTwitter, setValTwitter);
|
||||
setData((foundInfo as unknown as PeopleIdentityInfo).matrix, setHasMatrix, setValMatrix);
|
||||
setData((foundInfo as unknown as PeopleIdentityInfo).github, setHasGithub, setValGithub);
|
||||
setData(foundInfo.web, setHasWeb, setValWeb);
|
||||
const infoDiscord = isLegacyIdentity ? setAdditionalFieldData(apiIdentity, foundInfo, AddressIdentityOtherDiscordKey, setHasDiscord, setValDiscord) : setDiscordFieldData((foundInfo as unknown as PeopleIdentityInfo), setHasDiscord, setValDiscord);
|
||||
|
||||
const previousKeys = isLegacyIdentity
|
||||
? [foundInfo.display, foundInfo.email, foundInfo.legal, foundInfo.riot, foundInfo.twitter, infoDiscord, foundInfo.web]
|
||||
: [foundInfo.display, foundInfo.email, (foundInfo as unknown as PeopleIdentityInfo).github, foundInfo.legal, (foundInfo as unknown as PeopleIdentityInfo).matrix, foundInfo.twitter, infoDiscord, foundInfo.web];
|
||||
|
||||
previousKeys.some((info: Data | null) => {
|
||||
if (info && info.isRaw) {
|
||||
setGotPreviousIdentity(true);
|
||||
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [identityOpt, apiIdentity, isLegacyIdentity, isPalletChecked]);
|
||||
|
||||
useEffect((): void => {
|
||||
const okDisplay = checkValue(true, valDisplay, 1, [], [], []);
|
||||
const okEmail = checkValue(hasEmail, valEmail, 3, ['@'], WHITESPACE, []);
|
||||
const okLegal = checkValue(hasLegal, valLegal, 1, [], [], []);
|
||||
const okMatrix = checkValue(hasMatrix, valMatrix, 6, [':'], WHITESPACE, ['@', '~']);
|
||||
const okRiot = checkValue(hasRiot, valRiot, 6, [':'], WHITESPACE, ['@', '~']);
|
||||
const okTwitter = checkValue(hasTwitter, valTwitter, 3, [], WHITESPACE, ['@']);
|
||||
const okDiscord = checkValue(hasDiscord, valDiscord, 3, [], WHITESPACE, []);
|
||||
const okGithub = checkValue(hasGithub, valGithub, 3, [], WHITESPACE, []);
|
||||
const okWeb = checkValue(hasWeb, valWeb, 8, ['.'], WHITESPACE, ['https://', 'http://']);
|
||||
|
||||
setOkInfo({
|
||||
okAll: okDisplay && okEmail && okLegal && okRiot && okTwitter && okDiscord && okWeb,
|
||||
okDiscord,
|
||||
okDisplay,
|
||||
okEmail,
|
||||
okGithub,
|
||||
okLegal,
|
||||
okMatrix,
|
||||
okRiot,
|
||||
okTwitter,
|
||||
okWeb
|
||||
});
|
||||
|
||||
isLegacyIdentity
|
||||
? setLegacyInfo({
|
||||
additional: [
|
||||
(okDiscord && hasDiscord ? [{ raw: AddressIdentityOtherDiscordKey }, { raw: valDiscord }] : null)
|
||||
].filter((item) => !!item),
|
||||
display: { [okDisplay ? 'raw' : 'none']: valDisplay || null },
|
||||
email: { [okEmail && hasEmail ? 'raw' : 'none']: okEmail && hasEmail ? valEmail : null },
|
||||
legal: { [okLegal && hasLegal ? 'raw' : 'none']: okLegal && hasLegal ? valLegal : null },
|
||||
riot: { [okRiot && hasRiot ? 'raw' : 'none']: okRiot && hasRiot ? valRiot : null },
|
||||
twitter: { [okTwitter && hasTwitter ? 'raw' : 'none']: okTwitter && hasTwitter ? valTwitter : null },
|
||||
web: { [okWeb && hasWeb ? 'raw' : 'none']: okWeb && hasWeb ? valWeb : null }
|
||||
})
|
||||
: setInfo({
|
||||
discord: { [okDiscord && hasDiscord ? 'raw' : 'none']: okDiscord && hasDiscord ? valDiscord : null },
|
||||
display: { [okDisplay ? 'raw' : 'none']: valDisplay || null },
|
||||
email: { [okEmail && hasEmail ? 'raw' : 'none']: okEmail && hasEmail ? valEmail : null },
|
||||
github: { [okGithub && hasGithub ? 'raw' : 'none']: okGithub && hasGithub ? valGithub : null },
|
||||
legal: { [okLegal && hasLegal ? 'raw' : 'none']: okLegal && hasLegal ? valLegal : null },
|
||||
matrix: { [okMatrix && hasMatrix ? 'raw' : 'none']: okMatrix && hasMatrix ? valMatrix : null },
|
||||
twitter: { [okTwitter && hasTwitter ? 'raw' : 'none']: okTwitter && hasTwitter ? valTwitter : null },
|
||||
web: { [okWeb && hasWeb ? 'raw' : 'none']: okWeb && hasWeb ? valWeb : null }
|
||||
});
|
||||
}, [hasEmail, hasLegal, hasRiot, hasTwitter, hasDiscord, hasWeb, hasMatrix, hasGithub, valDisplay, valEmail, valLegal, valRiot, valTwitter, valDiscord, valWeb, valMatrix, valGithub, isLegacyIdentity]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
className={className}
|
||||
header={t('Register identity')}
|
||||
onClose={onClose}
|
||||
>
|
||||
<Modal.Content>
|
||||
<Input
|
||||
autoFocus
|
||||
isError={!okDisplay}
|
||||
label={t('display name')}
|
||||
maxLength={32}
|
||||
onChange={setValDisplay}
|
||||
placeholder={t('My On-Chain Name')}
|
||||
value={valDisplay}
|
||||
/>
|
||||
<Input
|
||||
isDisabled={!hasLegal}
|
||||
isError={!okLegal}
|
||||
label={t('legal name')}
|
||||
labelExtra={
|
||||
<Toggle
|
||||
label={t('include field')}
|
||||
onChange={setHasLegal}
|
||||
value={hasLegal}
|
||||
/>
|
||||
}
|
||||
maxLength={32}
|
||||
onChange={setValLegal}
|
||||
placeholder={t('Full Legal Name')}
|
||||
value={hasLegal ? valLegal : '<none>'}
|
||||
/>
|
||||
<Input
|
||||
isDisabled={!hasEmail}
|
||||
isError={!okEmail}
|
||||
label={t('email')}
|
||||
labelExtra={
|
||||
<Toggle
|
||||
label={t('include field')}
|
||||
onChange={setHasEmail}
|
||||
value={hasEmail}
|
||||
/>
|
||||
}
|
||||
maxLength={32}
|
||||
onChange={setValEmail}
|
||||
placeholder={t('somebody@example.com')}
|
||||
value={hasEmail ? valEmail : '<none>'}
|
||||
/>
|
||||
<Input
|
||||
isDisabled={!hasWeb}
|
||||
isError={!okWeb}
|
||||
label={t('web')}
|
||||
labelExtra={
|
||||
<Toggle
|
||||
label={t('include field')}
|
||||
onChange={setHasWeb}
|
||||
value={hasWeb}
|
||||
/>
|
||||
}
|
||||
maxLength={32}
|
||||
onChange={setValWeb}
|
||||
placeholder={t('https://example.com')}
|
||||
value={hasWeb ? valWeb : '<none>'}
|
||||
/>
|
||||
<Input
|
||||
isDisabled={!hasTwitter}
|
||||
isError={!okTwitter}
|
||||
label={t('twitter')}
|
||||
labelExtra={
|
||||
<Toggle
|
||||
label={t('include field')}
|
||||
onChange={setHasTwitter}
|
||||
value={hasTwitter}
|
||||
/>
|
||||
}
|
||||
onChange={setValTwitter}
|
||||
placeholder={t('@YourTwitterName')}
|
||||
value={hasTwitter ? valTwitter : '<none>'}
|
||||
/>
|
||||
<Input
|
||||
isDisabled={!hasDiscord}
|
||||
isError={!okDiscord}
|
||||
label={t('discord')}
|
||||
labelExtra={
|
||||
<Toggle
|
||||
label={t('include field')}
|
||||
onChange={setHasDiscord}
|
||||
value={hasDiscord}
|
||||
/>
|
||||
}
|
||||
onChange={setValDiscord}
|
||||
placeholder={t('YourDiscordHandle')}
|
||||
value={hasDiscord ? valDiscord : '<none>'}
|
||||
/>
|
||||
{
|
||||
!isLegacyIdentity
|
||||
? (
|
||||
<Input
|
||||
isDisabled={!hasMatrix}
|
||||
isError={!okMatrix}
|
||||
label={t('matrix name')}
|
||||
labelExtra={
|
||||
<Toggle
|
||||
label={t('include field')}
|
||||
onChange={setHasMatrix}
|
||||
value={hasMatrix}
|
||||
/>
|
||||
}
|
||||
maxLength={32}
|
||||
onChange={setValMatrix}
|
||||
placeholder={t('@yourname:matrix.org')}
|
||||
value={hasMatrix ? valMatrix : '<none>'}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<Input
|
||||
isDisabled={!hasRiot}
|
||||
isError={!okRiot}
|
||||
label={t('riot name')}
|
||||
labelExtra={
|
||||
<Toggle
|
||||
label={t('include field')}
|
||||
onChange={setHasRiot}
|
||||
value={hasRiot}
|
||||
/>
|
||||
}
|
||||
maxLength={32}
|
||||
onChange={setValRiot}
|
||||
placeholder={t('@yourname:matrix.org')}
|
||||
value={hasRiot ? valRiot : '<none>'}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
!isLegacyIdentity
|
||||
? (
|
||||
<Input
|
||||
isDisabled={!hasGithub}
|
||||
isError={!okGithub}
|
||||
label={t('Github name')}
|
||||
labelExtra={
|
||||
<Toggle
|
||||
label={t('include field')}
|
||||
onChange={setHasGithub}
|
||||
value={hasGithub}
|
||||
/>
|
||||
}
|
||||
maxLength={32}
|
||||
onChange={setValGithub}
|
||||
placeholder={t('YourGithubHandle')}
|
||||
value={hasGithub ? valGithub : '<none>'}
|
||||
/>
|
||||
)
|
||||
: <div></div>
|
||||
}
|
||||
<InputBalance
|
||||
defaultValue={apiIdentity.consts.identity?.basicDeposit}
|
||||
isDisabled
|
||||
label={t('total deposit')}
|
||||
/>
|
||||
</Modal.Content>
|
||||
<Modal.Actions>
|
||||
<TxButton
|
||||
accountId={address}
|
||||
icon={'trash-alt'}
|
||||
isDisabled={!enableIdentity || !gotPreviousIdentity}
|
||||
label={t('Clear Identity')}
|
||||
onStart={onClose}
|
||||
tx={apiIdentity.tx.identity.clearIdentity}
|
||||
/>
|
||||
<TxButton
|
||||
accountId={address}
|
||||
isDisabled={!enableIdentity || !okAll}
|
||||
label={t('Set Identity')}
|
||||
onStart={onClose}
|
||||
params={[isLegacyIdentity ? legacyInfo : info]}
|
||||
tx={apiIdentity.tx.identity.setIdentity}
|
||||
/>
|
||||
</Modal.Actions>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(IdentityMain);
|
||||
@@ -0,0 +1,181 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-accounts authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { Data, Option } from '@pezkuwi/types';
|
||||
import type { AccountId } from '@pezkuwi/types/interfaces';
|
||||
import type { ITuple } from '@pezkuwi/types/types';
|
||||
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { Button, Columar, Input, InputAddress, Modal, Spinner, TxButton } from '@pezkuwi/react-components';
|
||||
import { useAccounts, useApi, useCall, useSubidentities } from '@pezkuwi/react-hooks';
|
||||
import { u8aToString } from '@pezkuwi/util';
|
||||
|
||||
import { useTranslation } from '../translate.js';
|
||||
|
||||
interface Props {
|
||||
address: string;
|
||||
className?: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
interface SubProps {
|
||||
address: string;
|
||||
index: number;
|
||||
name: string;
|
||||
setAddress: (index: number, value: string) => void;
|
||||
setName: (index: number, value: string) => void;
|
||||
t: (key: string, options?: { replace: Record<string, unknown> }) => string;
|
||||
}
|
||||
|
||||
function extractInfo ([[ids], opts]: [[string[]], Option<ITuple<[AccountId, Data]>>[]]): [string, string][] {
|
||||
return ids.reduce((result: [string, string][], id, index): [string, string][] => {
|
||||
const opt = opts[index];
|
||||
|
||||
if (opt.isSome) {
|
||||
const [, data] = opt.unwrap();
|
||||
|
||||
if (data.isRaw) {
|
||||
result.push([id, u8aToString(data.asRaw)]);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}, []);
|
||||
}
|
||||
|
||||
function IdentitySub ({ address, index, name, setAddress, setName, t }: SubProps): React.ReactElement<SubProps> {
|
||||
const _setAddress = useCallback(
|
||||
(value?: string | null) => setAddress(index, value || ''),
|
||||
[index, setAddress]
|
||||
);
|
||||
|
||||
const _setName = useCallback(
|
||||
(value: string) => setName(index, value || ''),
|
||||
[index, setName]
|
||||
);
|
||||
|
||||
return (
|
||||
<Columar>
|
||||
<Columar.Column>
|
||||
<InputAddress
|
||||
defaultValue={address}
|
||||
label={t('address {{index}}', { replace: { index: index + 1 } })}
|
||||
onChange={_setAddress}
|
||||
/>
|
||||
</Columar.Column>
|
||||
<Columar.Column>
|
||||
<Input
|
||||
defaultValue={name}
|
||||
isError={!name}
|
||||
isFull
|
||||
label={t('sub name')}
|
||||
onChange={_setName}
|
||||
/>
|
||||
</Columar.Column>
|
||||
</Columar>
|
||||
);
|
||||
}
|
||||
|
||||
const IdentitySubMemo = React.memo(IdentitySub);
|
||||
|
||||
const transformInfo = { withParams: true };
|
||||
|
||||
function IdentitySubModal ({ address, className, onClose }: Props): React.ReactElement<Props> {
|
||||
const { t } = useTranslation();
|
||||
const { apiIdentity, enableIdentity } = useApi();
|
||||
const { allAccounts } = useAccounts();
|
||||
const queryIds = useSubidentities(address);
|
||||
const queryInfos = useCall<[[string[]], Option<ITuple<[AccountId, Data]>>[]]>(queryIds && queryIds.length !== 0 && apiIdentity.query.identity.superOf.multi, [queryIds], transformInfo);
|
||||
const [infos, setInfos] = useState<[string, string][] | undefined>();
|
||||
|
||||
useEffect((): void => {
|
||||
if (queryInfos) {
|
||||
setInfos(extractInfo(queryInfos));
|
||||
} else if (queryIds && !queryIds.length) {
|
||||
setInfos([]);
|
||||
}
|
||||
}, [allAccounts, queryIds, queryInfos]);
|
||||
|
||||
const _rowAdd = useCallback(
|
||||
() => setInfos((infos) => infos?.concat([[allAccounts[0], '']])),
|
||||
[allAccounts]
|
||||
);
|
||||
|
||||
const _rowRemove = useCallback(
|
||||
() => setInfos((infos) => infos?.slice(0, infos.length - 1)),
|
||||
[]
|
||||
);
|
||||
|
||||
const _setAddress = useCallback(
|
||||
(index: number, address: string) => setInfos((infos) => (infos || []).map(([a, n], i) => [index === i ? address : a, n])),
|
||||
[]
|
||||
);
|
||||
|
||||
const _setName = useCallback(
|
||||
(index: number, name: string) => setInfos((infos) => (infos || []).map(([a, n], i) => [a, index === i ? name : n])),
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
className={className}
|
||||
header={t('Register sub-identities')}
|
||||
onClose={onClose}
|
||||
size='large'
|
||||
>
|
||||
<Modal.Content>
|
||||
{!infos
|
||||
? <Spinner label={t('Retrieving sub-identities')} />
|
||||
: (
|
||||
<div>
|
||||
{!infos.length
|
||||
? <article>{t('No sub identities set.')}</article>
|
||||
: infos.map(([address, name], index) =>
|
||||
<IdentitySubMemo
|
||||
address={address}
|
||||
index={index}
|
||||
key={index}
|
||||
name={name}
|
||||
setAddress={_setAddress}
|
||||
setName={_setName}
|
||||
t={t}
|
||||
/>
|
||||
)
|
||||
}
|
||||
<Button.Group>
|
||||
<Button
|
||||
icon='plus'
|
||||
label={t('Add sub')}
|
||||
onClick={_rowAdd}
|
||||
/>
|
||||
<Button
|
||||
icon='minus'
|
||||
isDisabled={infos.length === 0}
|
||||
label={t('Remove sub')}
|
||||
onClick={_rowRemove}
|
||||
/>
|
||||
</Button.Group>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</Modal.Content>
|
||||
<Modal.Actions>
|
||||
{infos && (
|
||||
<TxButton
|
||||
accountId={address}
|
||||
isDisabled={!enableIdentity || infos.some(([address, raw]) => !address || !raw)}
|
||||
label={t('Set Subs')}
|
||||
onStart={onClose}
|
||||
params={[
|
||||
infos.map(([address, raw]) => [address, { raw }])
|
||||
]}
|
||||
tx={apiIdentity.tx.identity.setSubs}
|
||||
/>
|
||||
)}
|
||||
</Modal.Actions>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(IdentitySubModal);
|
||||
@@ -0,0 +1,168 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-accounts authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { Dispatch, SetStateAction } from 'react';
|
||||
import type { KeyringPair, KeyringPair$Json } from '@pezkuwi/keyring/types';
|
||||
import type { ActionStatus } from '@pezkuwi/react-components/Status/types';
|
||||
import type { HexString } from '@pezkuwi/util/types';
|
||||
import type { ModalProps } from '../types.js';
|
||||
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
|
||||
import { AddressRow, Button, InputAddress, InputFile, MarkError, MarkWarning, Modal, Password } from '@pezkuwi/react-components';
|
||||
import { useApi } from '@pezkuwi/react-hooks';
|
||||
import { keyring } from '@pezkuwi/ui-keyring';
|
||||
import { assert, nextTick, u8aToString } from '@pezkuwi/util';
|
||||
|
||||
import { useTranslation } from '../translate.js';
|
||||
import ExternalWarning from './ExternalWarning.js';
|
||||
|
||||
interface Props extends ModalProps {
|
||||
className?: string;
|
||||
onClose: () => void;
|
||||
onStatusChange: (status: ActionStatus) => void;
|
||||
}
|
||||
|
||||
interface PassState {
|
||||
isPassValid: boolean;
|
||||
password: string;
|
||||
}
|
||||
|
||||
const acceptedFormats = ['application/json', 'text/plain'];
|
||||
|
||||
function parseFile (file: Uint8Array, setError: Dispatch<SetStateAction<string | null>>, isEthereum: boolean, genesisHash?: HexString | null): KeyringPair | null {
|
||||
try {
|
||||
const pair = keyring.createFromJson(JSON.parse(u8aToString(file)) as KeyringPair$Json, { genesisHash });
|
||||
|
||||
if (isEthereum) {
|
||||
assert(pair.type === 'ethereum', 'JSON File does not contain an ethereum type key pair');
|
||||
} else {
|
||||
assert(pair.type !== 'ethereum', 'JSON contains an ethereum keytype, this is not available on this network');
|
||||
}
|
||||
|
||||
return pair;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
setError((error as Error).message ? (error as Error).message : (error as Error).toString());
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function Import ({ className = '', onClose, onStatusChange }: Props): React.ReactElement<Props> {
|
||||
const { t } = useTranslation();
|
||||
const { api, isDevelopment, isEthereum } = useApi();
|
||||
const [isBusy, setIsBusy] = useState(false);
|
||||
const [pair, setPair] = useState<KeyringPair | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [{ isPassValid, password }, setPass] = useState<PassState>({ isPassValid: false, password: '' });
|
||||
const apiGenesisHash = useMemo(() => isDevelopment ? null : api.genesisHash.toHex(), [api, isDevelopment]);
|
||||
const differentGenesis = useMemo(() => !!pair?.meta.genesisHash && pair.meta.genesisHash !== apiGenesisHash, [apiGenesisHash, pair]);
|
||||
|
||||
const _onChangeFile = useCallback(
|
||||
(file: Uint8Array) => setPair(parseFile(file, setError, isEthereum, apiGenesisHash)),
|
||||
[apiGenesisHash, isEthereum]
|
||||
);
|
||||
|
||||
const _onChangePass = useCallback(
|
||||
(password: string) => setPass({ isPassValid: keyring.isPassValid(password), password }),
|
||||
[]
|
||||
);
|
||||
|
||||
const _onSave = useCallback(
|
||||
(): void => {
|
||||
if (!pair) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsBusy(true);
|
||||
nextTick((): void => {
|
||||
const status: Partial<ActionStatus> = { action: 'restore' };
|
||||
|
||||
try {
|
||||
keyring.addPair(pair, password);
|
||||
|
||||
status.status = 'success';
|
||||
status.account = pair.address;
|
||||
status.message = t('account restored');
|
||||
|
||||
InputAddress.setLastValue('account', pair.address);
|
||||
} catch (error) {
|
||||
setPass((state: PassState) => ({ ...state, isPassValid: false }));
|
||||
|
||||
status.status = 'error';
|
||||
status.message = (error as Error).message;
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
setIsBusy(false);
|
||||
onStatusChange(status as ActionStatus);
|
||||
|
||||
if (status.status !== 'error') {
|
||||
onClose();
|
||||
}
|
||||
});
|
||||
},
|
||||
[onClose, onStatusChange, pair, password, t]
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
className={className}
|
||||
header={t('Add via backup file')}
|
||||
onClose={onClose}
|
||||
size='large'
|
||||
>
|
||||
<Modal.Content>
|
||||
<Modal.Columns>
|
||||
<AddressRow
|
||||
defaultName={pair?.meta.name || null}
|
||||
noDefaultNameOpacity
|
||||
value={pair?.address || null}
|
||||
/>
|
||||
</Modal.Columns>
|
||||
<Modal.Columns hint={t('Supply a backed-up JSON file, encrypted with your account-specific password.')}>
|
||||
<InputFile
|
||||
accept={acceptedFormats}
|
||||
className='full'
|
||||
isError={!pair}
|
||||
label={t('backup file')}
|
||||
onChange={_onChangeFile}
|
||||
withLabel
|
||||
/>
|
||||
</Modal.Columns>
|
||||
<Modal.Columns hint={t('The password previously used to encrypt this account.')}>
|
||||
<Password
|
||||
autoFocus
|
||||
className='full'
|
||||
isError={!isPassValid}
|
||||
label={t('password')}
|
||||
onChange={_onChangePass}
|
||||
onEnter={_onSave}
|
||||
value={password}
|
||||
/>
|
||||
</Modal.Columns>
|
||||
<Modal.Columns>
|
||||
{error && (
|
||||
<MarkError content={error} />
|
||||
)}
|
||||
{differentGenesis && (
|
||||
<MarkWarning content={t('The network from which this account was originally generated is different than the network you are currently connected to. Once imported ensure you toggle the "allow on any network" option for the account to keep it visible on the current network.')} />
|
||||
)}
|
||||
<ExternalWarning />
|
||||
</Modal.Columns>
|
||||
</Modal.Content>
|
||||
<Modal.Actions>
|
||||
<Button
|
||||
icon='sync'
|
||||
isBusy={isBusy}
|
||||
isDisabled={!pair || !isPassValid}
|
||||
label={t('Restore')}
|
||||
onClick={_onSave}
|
||||
/>
|
||||
</Modal.Actions>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(Import);
|
||||
@@ -0,0 +1,212 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-accounts authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { Dispatch, SetStateAction } from 'react';
|
||||
import type { KeyringPair, KeyringPair$Json } from '@pezkuwi/keyring/types';
|
||||
import type { ActionStatus } from '@pezkuwi/react-components/Status/types';
|
||||
import type { ModalProps } from '../types.js';
|
||||
|
||||
import React, { useCallback, useState } from 'react';
|
||||
|
||||
import { AddressRow, Button, InputFile, MarkError, Modal, Password, styled } from '@pezkuwi/react-components';
|
||||
import keyring from '@pezkuwi/ui-keyring';
|
||||
import { nextTick, u8aToString } from '@pezkuwi/util';
|
||||
|
||||
import { useTranslation } from '../translate.js';
|
||||
|
||||
interface Props extends ModalProps {
|
||||
className?: string;
|
||||
onClose: () => void;
|
||||
onStatusChange: (status: ActionStatus) => void;
|
||||
}
|
||||
|
||||
interface TPassword {
|
||||
account: string, isPassTouched: boolean, password: string
|
||||
}
|
||||
|
||||
type FileAccount = (KeyringPair$Json | KeyringPair);
|
||||
|
||||
type File = FileAccount[]
|
||||
|
||||
const acceptedFormats = ['application/json'];
|
||||
|
||||
function isInBrowserAccount (pair: FileAccount): boolean {
|
||||
return !pair.meta.isInjected && !pair.meta.isHardware && !pair.meta.isMultisig && !!(pair as KeyringPair$Json).encoded && !!(pair as KeyringPair$Json).encoding;
|
||||
}
|
||||
|
||||
// Check if file is valid
|
||||
function isValidFile (fileContent: string, setError: Dispatch<SetStateAction<string | null>>): boolean {
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const content = JSON.parse(fileContent) as File;
|
||||
|
||||
if (!Array.isArray(content)) {
|
||||
throw new Error('Invalid format: File content should be an array');
|
||||
}
|
||||
|
||||
for (const item of content) {
|
||||
if (
|
||||
typeof item.address !== 'string' ||
|
||||
typeof item.meta !== 'object' ||
|
||||
item.meta === null
|
||||
) {
|
||||
throw new Error('Invalid format: Each item must have a string "address" and an object "meta"');
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
setError((error as Error).message ? (error as Error).message : (error as Error).toString());
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function ImportAll ({ className, onClose, onStatusChange }: Props): React.ReactElement | null {
|
||||
const { t } = useTranslation();
|
||||
const [isBusy, setIsBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [allBrowserAccountPassword, setAllBrowserAccountPassword] = useState<TPassword[]>([]);
|
||||
|
||||
const _onChangeFile = useCallback(
|
||||
(file: Uint8Array) => {
|
||||
const fileContent = u8aToString(file);
|
||||
|
||||
setFile(isValidFile(fileContent, setError) ? JSON.parse(fileContent) as File : null);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const _onImportButtonClick = useCallback(() => {
|
||||
setIsBusy(true);
|
||||
nextTick((): void => {
|
||||
const status: Partial<ActionStatus> = { action: 'import' };
|
||||
|
||||
try {
|
||||
file?.forEach((pair) => {
|
||||
if (isInBrowserAccount(pair)) {
|
||||
const keyringPair = keyring.createFromJson(pair as KeyringPair$Json);
|
||||
const password = allBrowserAccountPassword.find((e) => e.account === pair.address)?.password || '';
|
||||
|
||||
keyring.addPair(keyringPair, password);
|
||||
} else {
|
||||
keyring.addExternal(pair.address, pair.meta);
|
||||
}
|
||||
});
|
||||
|
||||
status.status = 'success';
|
||||
status.message = t('all accounts imported');
|
||||
} catch (error) {
|
||||
status.status = 'error';
|
||||
status.message = (error as Error).message;
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
setIsBusy(false);
|
||||
onStatusChange(status as ActionStatus);
|
||||
|
||||
if (status.status !== 'error') {
|
||||
onClose();
|
||||
}
|
||||
});
|
||||
}, [allBrowserAccountPassword, file, onClose, onStatusChange, t]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
className={className}
|
||||
header={t('import all accounts')}
|
||||
onClose={onClose}
|
||||
size='large'
|
||||
>
|
||||
<Modal.Content>
|
||||
<Modal.Columns hint={t('Supply a backed-up JSON file')}>
|
||||
<InputFile
|
||||
accept={acceptedFormats}
|
||||
className='full'
|
||||
isError={!file}
|
||||
label={t('backup file')}
|
||||
onChange={_onChangeFile}
|
||||
withLabel
|
||||
/>
|
||||
</Modal.Columns>
|
||||
{file && file.some(isInBrowserAccount) &&
|
||||
<Modal.Columns hint={t('Provide password for browser accounts')}>
|
||||
<BrowserAccounts
|
||||
allPassword={allBrowserAccountPassword}
|
||||
pairs={file}
|
||||
setAllPassword={setAllBrowserAccountPassword}
|
||||
/>
|
||||
</Modal.Columns>
|
||||
}
|
||||
<Modal.Columns>
|
||||
{error && (
|
||||
<MarkError content={error} />
|
||||
)}
|
||||
</Modal.Columns>
|
||||
</Modal.Content>
|
||||
<Modal.Actions>
|
||||
<Button
|
||||
icon='sync'
|
||||
isBusy={isBusy}
|
||||
isDisabled={!file || !!error}
|
||||
label={t('Import')}
|
||||
onClick={_onImportButtonClick}
|
||||
/>
|
||||
</Modal.Actions>
|
||||
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
const BrowserAccounts = ({ allPassword, pairs, setAllPassword }: { pairs: FileAccount[], allPassword: TPassword[], setAllPassword: React.Dispatch<React.SetStateAction<TPassword[]>> }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const _onChangePass = useCallback(
|
||||
(account: string, password: string): void => {
|
||||
setAllPassword((prev) => {
|
||||
return [
|
||||
...prev.filter((e) => e.account !== account),
|
||||
{ account, isPassTouched: true, password }
|
||||
];
|
||||
});
|
||||
},
|
||||
[setAllPassword]
|
||||
);
|
||||
|
||||
return pairs.map((pair) => {
|
||||
const { address: account } = pair;
|
||||
const { isPassTouched, password } = allPassword.find((a) => a.account === account) || { isPassTouched: false, password: '' };
|
||||
const isPassValid = keyring.isPassValid(password);
|
||||
|
||||
return isInBrowserAccount(pair) && <BrowserAccountsDiv key={account}>
|
||||
<AddressRow
|
||||
defaultName={pair?.meta.name || null}
|
||||
noDefaultNameOpacity
|
||||
value={pair?.address || null}
|
||||
/>
|
||||
<Password
|
||||
autoFocus
|
||||
isError={isPassTouched && !isPassValid}
|
||||
label={t('password')}
|
||||
// eslint-disable-next-line react/jsx-no-bind
|
||||
onChange={(password) => _onChangePass(account, password)}
|
||||
value={password}
|
||||
/>
|
||||
</BrowserAccountsDiv>;
|
||||
});
|
||||
};
|
||||
|
||||
const BrowserAccountsDiv = styled.div`
|
||||
width: 100%;
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-top: 1.5rem;
|
||||
padding-inline: 2rem;
|
||||
|
||||
div:nth-child(2) {width: 100%;}
|
||||
`;
|
||||
|
||||
export default React.memo(ImportAll);
|
||||
@@ -0,0 +1,56 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-staking authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { DeriveBalancesAll } from '@pezkuwi/api-derive/types';
|
||||
import type { BN } from '@pezkuwi/util';
|
||||
import type { AmountValidateState } from '../Accounts/types.js';
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
import { MarkError, MarkWarning } from '@pezkuwi/react-components';
|
||||
import { useApi, useCall } from '@pezkuwi/react-hooks';
|
||||
import { BN_ZERO } from '@pezkuwi/util';
|
||||
|
||||
import { useTranslation } from '../translate.js';
|
||||
|
||||
interface Props {
|
||||
amount?: BN | null;
|
||||
delegatingAccount: string | null;
|
||||
onError: (state: AmountValidateState | null) => void;
|
||||
}
|
||||
|
||||
function ValidateAmount ({ amount, delegatingAccount, onError }: Props): React.ReactElement<Props> | null {
|
||||
const { t } = useTranslation();
|
||||
const { api } = useApi();
|
||||
const delegatingAccountBalance = useCall<DeriveBalancesAll>(api.derive.balances?.all, [delegatingAccount]);
|
||||
const [{ error, warning }, setResult] = useState<AmountValidateState>({ error: null, warning: null });
|
||||
|
||||
useEffect((): void => {
|
||||
if (delegatingAccountBalance?.freeBalance && amount?.gt(BN_ZERO)) {
|
||||
let newError: string | null = null;
|
||||
|
||||
if (amount.gte(delegatingAccountBalance.freeBalance)) {
|
||||
newError = t('The maximum amount you can delegate is the amount of funds available on the delegating account.');
|
||||
}
|
||||
|
||||
setResult((state): AmountValidateState => {
|
||||
const error = state.error !== newError ? newError : state.error;
|
||||
const warning = state.warning;
|
||||
|
||||
onError((error || warning) ? { error, warning } : null);
|
||||
|
||||
return { error, warning };
|
||||
});
|
||||
}
|
||||
}, [api, onError, amount, t, delegatingAccountBalance]);
|
||||
|
||||
if (error) {
|
||||
return <MarkError content={error} />;
|
||||
} else if (warning) {
|
||||
return <MarkWarning content={warning} />;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export default React.memo(ValidateAmount);
|
||||
@@ -0,0 +1,155 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-accounts authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// This is for the use of `Ledger`
|
||||
//
|
||||
/* eslint-disable deprecation/deprecation */
|
||||
|
||||
import type { ApiPromise } from '@pezkuwi/api';
|
||||
import type { Ledger, LedgerGeneric } from '@pezkuwi/hw-ledger';
|
||||
|
||||
import React, { useCallback, useRef, useState } from 'react';
|
||||
|
||||
import { Button, Dropdown, Input, MarkError, Modal } from '@pezkuwi/react-components';
|
||||
import { useApi, useLedger } from '@pezkuwi/react-hooks';
|
||||
import { keyring } from '@pezkuwi/ui-keyring';
|
||||
import { settings } from '@pezkuwi/ui-settings';
|
||||
import { arrayRange } from '@pezkuwi/util';
|
||||
|
||||
import Banner from '../Accounts/Banner.js';
|
||||
import { useTranslation } from '../translate.js';
|
||||
|
||||
interface Option {
|
||||
text: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const AVAIL_INDEXES = arrayRange(20);
|
||||
|
||||
// query the ledger for the address, adding it to the keyring
|
||||
async function queryLedger (api: ApiPromise, getLedger: () => LedgerGeneric | Ledger, name: string, accountOffset: number, addressOffset: number, ss58Prefix: number): Promise<void> {
|
||||
let address: string;
|
||||
const currApp = settings.get().ledgerApp;
|
||||
|
||||
if (currApp === 'migration' || currApp === 'generic') {
|
||||
const addr = await (getLedger() as LedgerGeneric).getAddress(ss58Prefix, false, accountOffset, addressOffset);
|
||||
|
||||
address = addr.address;
|
||||
} else {
|
||||
// This will always be the `chainSpecific` setting if the above condition is not met
|
||||
const addr = await (getLedger() as Ledger).getAddress(false, accountOffset, addressOffset);
|
||||
|
||||
address = addr.address;
|
||||
}
|
||||
|
||||
keyring.addHardware(address, 'ledger', {
|
||||
accountOffset,
|
||||
addressOffset,
|
||||
genesisHash: api.genesisHash.toHex(),
|
||||
name: name || `ledger ${accountOffset}/${addressOffset}`
|
||||
});
|
||||
}
|
||||
|
||||
function LedgerModal ({ className, onClose }: Props): React.ReactElement<Props> {
|
||||
const { t } = useTranslation();
|
||||
const { api } = useApi();
|
||||
const { getLedger } = useLedger();
|
||||
const [accIndex, setAccIndex] = useState(0);
|
||||
const [addIndex, setAddIndex] = useState(0);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const [{ isNameValid, name }, setName] = useState({ isNameValid: false, name: '' });
|
||||
const [isBusy, setIsBusy] = useState(false);
|
||||
|
||||
const accOps = useRef(AVAIL_INDEXES.map((value): Option => ({
|
||||
text: t('Account type {{index}}', { replace: { index: value } }),
|
||||
value
|
||||
})));
|
||||
|
||||
const addOps = useRef(AVAIL_INDEXES.map((value): Option => ({
|
||||
text: t('Address index {{index}}', { replace: { index: value } }),
|
||||
value
|
||||
})));
|
||||
|
||||
const _onChangeName = useCallback(
|
||||
(name: string) => setName({ isNameValid: !!name.trim(), name }),
|
||||
[]
|
||||
);
|
||||
|
||||
const _onSave = useCallback(
|
||||
(): void => {
|
||||
setError(null);
|
||||
setIsBusy(true);
|
||||
|
||||
queryLedger(api, getLedger, name, accIndex, addIndex, api.consts.system.ss58Prefix.toNumber())
|
||||
.then(() => onClose())
|
||||
.catch((error: Error): void => {
|
||||
console.error(error);
|
||||
|
||||
setIsBusy(false);
|
||||
setError(error);
|
||||
});
|
||||
},
|
||||
[accIndex, addIndex, api, getLedger, name, onClose]
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
className={className}
|
||||
header={t('Add account via Ledger')}
|
||||
onClose={onClose}
|
||||
size='large'
|
||||
>
|
||||
<Modal.Content>
|
||||
<Modal.Columns hint={t('The name for this account as it will appear under your accounts.')}>
|
||||
<Input
|
||||
autoFocus
|
||||
className='full'
|
||||
isError={!isNameValid}
|
||||
label={t('name')}
|
||||
onChange={_onChangeName}
|
||||
placeholder={t('account name')}
|
||||
value={name}
|
||||
/>
|
||||
</Modal.Columns>
|
||||
<Modal.Columns hint={t('The account type that you wish to create. This is the top-level derivation.')}>
|
||||
<Dropdown
|
||||
label={t('account type')}
|
||||
onChange={setAccIndex}
|
||||
options={accOps.current}
|
||||
value={accIndex}
|
||||
/>
|
||||
</Modal.Columns>
|
||||
<Modal.Columns hint={t('The address index on the account that you wish to add. This is the second-level derivation.')}>
|
||||
<Dropdown
|
||||
label={t('address index')}
|
||||
onChange={setAddIndex}
|
||||
options={addOps.current}
|
||||
value={addIndex}
|
||||
/>
|
||||
{error && (
|
||||
<MarkError content={error.message} />
|
||||
)}
|
||||
</Modal.Columns>
|
||||
</Modal.Content>
|
||||
<Banner type={'warning'}>
|
||||
<p>{`You are using the Ledger ${settings.ledgerApp.toUpperCase()} App. If you would like to switch it, please go the "manage ledger app" in the settings.`}</p>
|
||||
</Banner>
|
||||
<Modal.Actions>
|
||||
<Button
|
||||
icon='plus'
|
||||
isBusy={isBusy}
|
||||
isDisabled={!isNameValid}
|
||||
label={t('Save')}
|
||||
onClick={_onSave}
|
||||
/>
|
||||
</Modal.Actions>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(LedgerModal);
|
||||
@@ -0,0 +1,107 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-accounts authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { ActionStatus } from '@pezkuwi/react-components/Status/types';
|
||||
import type { ModalProps } from '../types.js';
|
||||
|
||||
import React, { useCallback, useState } from 'react';
|
||||
|
||||
import { Button, Input, InputAddressSimple, Modal } from '@pezkuwi/react-components';
|
||||
import { keyring } from '@pezkuwi/ui-keyring';
|
||||
|
||||
import { useTranslation } from '../translate.js';
|
||||
|
||||
interface Props extends ModalProps {
|
||||
className?: string;
|
||||
onClose: () => void;
|
||||
onStatusChange: (status: ActionStatus) => void;
|
||||
}
|
||||
|
||||
interface CreateOptions {
|
||||
name: string;
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
export function createLocalAccount (address: string, { name }: CreateOptions): ActionStatus {
|
||||
const status = { action: 'create' } as ActionStatus;
|
||||
|
||||
try {
|
||||
keyring.addExternal(address, { isLocal: true, name, tags: ['local'] });
|
||||
|
||||
status.account = address;
|
||||
status.status = 'success';
|
||||
status.message = 'Local account created.';
|
||||
} catch (error) {
|
||||
status.status = 'error';
|
||||
status.message = (error as Error).message;
|
||||
}
|
||||
|
||||
return status;
|
||||
}
|
||||
|
||||
function LocalAdd ({ className = '', onClose, onStatusChange }: Props): React.ReactElement<Props> {
|
||||
const { t } = useTranslation();
|
||||
const [{ isNameValid, name }, setName] = useState({ isNameValid: false, name: '' });
|
||||
const [address, setAdress] = useState<string | null>(null);
|
||||
|
||||
const _createLocalAccount = useCallback(
|
||||
() => {
|
||||
if (!address) {
|
||||
return;
|
||||
}
|
||||
|
||||
const options = { name: name.trim() };
|
||||
const status = createLocalAccount(address, options);
|
||||
|
||||
onStatusChange(status);
|
||||
onClose();
|
||||
},
|
||||
[address, name, onClose, onStatusChange]
|
||||
);
|
||||
|
||||
const _onChangeName = useCallback(
|
||||
(name: string) => setName({ isNameValid: (name.trim().length >= 3), name }),
|
||||
[]
|
||||
);
|
||||
|
||||
const isValid = isNameValid && !!address;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
className={className}
|
||||
header={t('Add a mock account to chopsticks')}
|
||||
onClose={onClose}
|
||||
size='large'
|
||||
>
|
||||
<Modal.Content>
|
||||
<Modal.Columns hint={t('Any address, this can override other accounts')}>
|
||||
<InputAddressSimple
|
||||
autoFocus
|
||||
label={t('address')}
|
||||
onChange={setAdress}
|
||||
placeholder={t('e.g. 5Gg3...')}
|
||||
/>
|
||||
</Modal.Columns>
|
||||
<Modal.Columns hint={t('The name for this account')}>
|
||||
<Input
|
||||
className='full'
|
||||
isError={!isNameValid}
|
||||
label={t('name')}
|
||||
onChange={_onChangeName}
|
||||
placeholder={t('local account name')}
|
||||
/>
|
||||
</Modal.Columns>
|
||||
</Modal.Content>
|
||||
<Modal.Actions>
|
||||
<Button
|
||||
icon='plus'
|
||||
isDisabled={!isValid}
|
||||
label={t('Add')}
|
||||
onClick={_createLocalAccount}
|
||||
/>
|
||||
</Modal.Actions>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(LocalAdd);
|
||||
@@ -0,0 +1,302 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-accounts authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { SubmittableExtrinsic } from '@pezkuwi/api/types';
|
||||
import type { AccountId, Call, H256, Multisig } from '@pezkuwi/types/interfaces';
|
||||
import type { CallFunction } from '@pezkuwi/types/types';
|
||||
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { AddressMini, Dropdown, Expander, Input, InputAddress, MarkError, Modal, styled, Toggle, TxButton } from '@pezkuwi/react-components';
|
||||
import { useAccounts, useApi, useWeight } from '@pezkuwi/react-hooks';
|
||||
import { Call as CallDisplay } from '@pezkuwi/react-params';
|
||||
import { assert, isHex } from '@pezkuwi/util';
|
||||
|
||||
import { useTranslation } from '../translate.js';
|
||||
|
||||
interface Props {
|
||||
address: string;
|
||||
className?: string;
|
||||
onClose: () => void;
|
||||
ongoing: [H256, Multisig][];
|
||||
threshold?: number;
|
||||
who?: string[];
|
||||
}
|
||||
|
||||
interface MultiInfo {
|
||||
isMultiCall: boolean;
|
||||
multisig: Multisig | null;
|
||||
}
|
||||
|
||||
interface Option {
|
||||
text: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface CallData {
|
||||
callData: Call | null;
|
||||
callError: string | null;
|
||||
callInfo: CallFunction | null;
|
||||
}
|
||||
|
||||
const EMPTY_CALL: CallData = {
|
||||
callData: null,
|
||||
callError: null,
|
||||
callInfo: null
|
||||
};
|
||||
|
||||
function MultisigApprove ({ className = '', onClose, ongoing, threshold = 0, who = [] }: Props): React.ReactElement<Props> {
|
||||
const { t } = useTranslation();
|
||||
const { api } = useApi();
|
||||
const { allAccounts } = useAccounts();
|
||||
const [callHex, setCallHex] = useState<string>('');
|
||||
const [{ callData, callError, callInfo }, setCallData] = useState<CallData>(EMPTY_CALL);
|
||||
const { encodedCallLength, weight } = useWeight(callData);
|
||||
const [hash, setHash] = useState<string | null>(() => ongoing[0][0].toHex());
|
||||
const [{ isMultiCall, multisig }, setMultisig] = useState<MultiInfo>(() => ({ isMultiCall: false, multisig: null }));
|
||||
const [isCallOverride, setCallOverride] = useState(true);
|
||||
const [others, setOthers] = useState<AccountId[]>([]);
|
||||
const [signatory, setSignatory] = useState<string | null>(null);
|
||||
const [whoFilter, setWhoFilter] = useState<string[]>([]);
|
||||
const [type, setType] = useState<string | null>('aye');
|
||||
const [tx, setTx] = useState<SubmittableExtrinsic<'promise'> | null>(null);
|
||||
|
||||
const callOptRef = useRef<Option[]>([
|
||||
{ text: t('Approve this call hash'), value: 'aye' },
|
||||
{ text: t('Cancel this call hash'), value: 'nay' }
|
||||
]);
|
||||
|
||||
const hashes = useMemo<Option[]>(
|
||||
() => ongoing.map(([h]) => ({ text: h.toHex(), value: h.toHex() })),
|
||||
[ongoing]
|
||||
);
|
||||
|
||||
// filter the current multisig by supplied hash
|
||||
useEffect((): void => {
|
||||
const [, multisig] = ongoing.find(([h]) => h.eq(hash)) || [null, null];
|
||||
|
||||
setMultisig({
|
||||
isMultiCall: !!multisig && (multisig.approvals.length + 1) >= threshold,
|
||||
multisig
|
||||
});
|
||||
setCallHex('');
|
||||
setCallData(EMPTY_CALL);
|
||||
}, [hash, ongoing, threshold]);
|
||||
|
||||
// the others are all the who elements, without the current signatory (re-encoded)
|
||||
useEffect((): void => {
|
||||
setOthers(
|
||||
who
|
||||
.map((w) => api.createType('AccountId', w))
|
||||
.filter((w) => !signatory || !w.eq(signatory))
|
||||
);
|
||||
}, [api, signatory, who]);
|
||||
|
||||
// Filter the who by those not approved yet that is an actual account we own. In the case of
|
||||
// rejections, we defer to the first approver, since he is the only one to send the cancel
|
||||
// On reaching threshold, we include all possible signatories in the list
|
||||
useEffect((): void => {
|
||||
const hasThreshold = multisig && (multisig.approvals.length >= threshold);
|
||||
|
||||
setWhoFilter(
|
||||
who
|
||||
.map((w) => api.createType('AccountId', w).toString())
|
||||
.filter((w) => allAccounts.some((a) => a === w) && multisig && (
|
||||
type === 'nay'
|
||||
? multisig.depositor.eq(w)
|
||||
: hasThreshold || !multisig.approvals.some((a) => a.eq(w))
|
||||
))
|
||||
);
|
||||
}, [api, allAccounts, multisig, threshold, type, who]);
|
||||
|
||||
// when the hex changes, re-evaluate
|
||||
useEffect((): void => {
|
||||
if (callHex) {
|
||||
try {
|
||||
assert(isHex(callHex), 'Hex call data required');
|
||||
|
||||
const callData = api.createType('Call', callHex);
|
||||
|
||||
assert(callData.hash.eq(hash), 'Call data does not match the existing call hash');
|
||||
|
||||
const callInfo = api.registry.findMetaCall(callData.callIndex);
|
||||
|
||||
setCallData({ callData, callError: null, callInfo });
|
||||
} catch (error) {
|
||||
setCallData({ callData: null, callError: (error as Error).message, callInfo: null });
|
||||
}
|
||||
} else {
|
||||
setCallData(EMPTY_CALL);
|
||||
}
|
||||
}, [api, callHex, hash]);
|
||||
|
||||
// based on the type, multisig, others create the tx. This can be either an approval or final call
|
||||
useEffect((): void => {
|
||||
const multiMod = api.tx.multisig || api.tx.utility;
|
||||
|
||||
setTx(() =>
|
||||
hash && multisig
|
||||
? type === 'aye'
|
||||
? isMultiCall && isCallOverride
|
||||
? callData
|
||||
? multiMod.asMulti.meta.args.length === 5
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
||||
? multiMod.asMulti(threshold, others, multisig.when, callData.toHex(), weight as any)
|
||||
: multiMod.asMulti.meta.args.length === 6
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore (We are doing toHex here since we have a Vec<u8> input)
|
||||
? multiMod.asMulti(threshold, others, multisig.when, callData.toHex(), false, weight)
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore (We are doing toHex here since we have a Vec<u8> input)
|
||||
: multiMod.asMulti(threshold, others, multisig.when, callData)
|
||||
: null
|
||||
: multiMod.approveAsMulti.meta.args.length === 5
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
||||
? multiMod.approveAsMulti(threshold, others, multisig.when, hash, weight as any)
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
: multiMod.approveAsMulti(threshold, others, multisig.when, hash)
|
||||
: multiMod.cancelAsMulti(threshold, others, multisig.when, hash)
|
||||
: null
|
||||
);
|
||||
}, [api, callData, hash, isCallOverride, isMultiCall, others, multisig, threshold, type, weight]);
|
||||
|
||||
const isAye = type === 'aye';
|
||||
|
||||
return (
|
||||
<StyledModal
|
||||
className={className}
|
||||
header={t('Pending call hashes')}
|
||||
onClose={onClose}
|
||||
size='large'
|
||||
>
|
||||
<Modal.Content>
|
||||
<Modal.Columns hint={t('The call hash from the list of available and unapproved calls.')}>
|
||||
<Dropdown
|
||||
label={t('pending hashes {{count}}', {
|
||||
replace: { count: `(${hashes.length})` }
|
||||
})}
|
||||
onChange={setHash}
|
||||
options={hashes}
|
||||
value={hash}
|
||||
/>
|
||||
</Modal.Columns>
|
||||
{multisig && (
|
||||
<>
|
||||
<Modal.Columns hint={t('The creator for this multisig call')}>
|
||||
<InputAddress
|
||||
defaultValue={multisig.depositor}
|
||||
isDisabled
|
||||
label={t('depositor')}
|
||||
/>
|
||||
</Modal.Columns>
|
||||
<Modal.Columns hint={t('The current approvals applied to this multisig')}>
|
||||
<Expander
|
||||
isPadded
|
||||
summary={t('Existing approvals ({{approvals}}/{{threshold}})', {
|
||||
replace: {
|
||||
approvals: multisig.approvals.length,
|
||||
threshold
|
||||
}
|
||||
})}
|
||||
>
|
||||
{multisig.approvals.map((a) =>
|
||||
<AddressMini
|
||||
isPadded={false}
|
||||
key={assert.toString()}
|
||||
value={a}
|
||||
/>
|
||||
)}
|
||||
</Expander>
|
||||
</Modal.Columns>
|
||||
</>
|
||||
)}
|
||||
<Modal.Columns hint={t('The operation type to apply. For approvals both non-final and final approvals are supported.')}>
|
||||
<Dropdown
|
||||
label={t('approval type')}
|
||||
onChange={setType}
|
||||
options={callOptRef.current}
|
||||
value={type}
|
||||
/>
|
||||
</Modal.Columns>
|
||||
{whoFilter.length !== 0 && (
|
||||
<>
|
||||
<Modal.Columns hint={t('For approvals outstanding approvers will be shown, for hashes that should be cancelled the first approver is required.')}>
|
||||
<InputAddress
|
||||
filter={whoFilter}
|
||||
label={t('signatory')}
|
||||
onChange={setSignatory}
|
||||
/>
|
||||
</Modal.Columns>
|
||||
{type === 'aye' && isMultiCall && (
|
||||
<>
|
||||
{isCallOverride && (
|
||||
<Modal.Columns hint={t('The call data for this transaction matching the hash. Once sent, the multisig will be executed against this.')}>
|
||||
<Input
|
||||
autoFocus
|
||||
isError={!callHex || !!callError}
|
||||
label={t('call data for final approval')}
|
||||
onChange={setCallHex}
|
||||
value={callHex}
|
||||
/>
|
||||
{callData && callInfo &&
|
||||
(
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<Expander
|
||||
isPadded
|
||||
summary={`${callInfo.section}.${callInfo.method}`}
|
||||
summaryMeta={callInfo.meta}
|
||||
>
|
||||
<CallDisplay
|
||||
className='details'
|
||||
value={callData}
|
||||
/>
|
||||
</Expander>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{callError && (
|
||||
<MarkError content={callError} />
|
||||
)}
|
||||
</Modal.Columns>
|
||||
)}
|
||||
<Modal.Columns hint={t('Swap to a non-executing approval type, with subsequent calls providing the actual call data.')}>
|
||||
<Toggle
|
||||
className='tipToggle'
|
||||
label={
|
||||
isMultiCall
|
||||
? t('Multisig message with call (for final approval)')
|
||||
: t('Multisig approval with hash (non-final approval)')
|
||||
}
|
||||
onChange={setCallOverride}
|
||||
value={isCallOverride}
|
||||
/>
|
||||
</Modal.Columns>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Modal.Content>
|
||||
<Modal.Actions>
|
||||
<TxButton
|
||||
accountId={signatory}
|
||||
extrinsic={tx}
|
||||
icon={isAye ? 'check' : 'times'}
|
||||
isDisabled={!tx || (isAye && (!whoFilter.length || (!!callData && !encodedCallLength)))}
|
||||
label={isAye ? 'Approve' : 'Reject'}
|
||||
onStart={onClose}
|
||||
/>
|
||||
</Modal.Actions>
|
||||
</StyledModal>
|
||||
);
|
||||
}
|
||||
|
||||
const StyledModal = styled(Modal)`
|
||||
.tipToggle {
|
||||
width: fit-content;
|
||||
float: right;
|
||||
text-align: right;
|
||||
}
|
||||
`;
|
||||
|
||||
export default React.memo(MultisigApprove);
|
||||
@@ -0,0 +1,268 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-accounts authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { ActionStatus } from '@pezkuwi/react-components/Status/types';
|
||||
import type { HexString } from '@pezkuwi/util/types';
|
||||
import type { ModalProps } from '../types.js';
|
||||
|
||||
import React, { useCallback, useState } from 'react';
|
||||
|
||||
import { AddressMini, Button, IconLink, Input, InputAddressMulti, InputFile, InputNumber, Labelled, MarkError, Modal, styled, Toggle } from '@pezkuwi/react-components';
|
||||
import { useApi } from '@pezkuwi/react-hooks';
|
||||
import { keyring } from '@pezkuwi/ui-keyring';
|
||||
import { assert, BN, u8aToString } from '@pezkuwi/util';
|
||||
import { validateAddress } from '@pezkuwi/util-crypto';
|
||||
|
||||
import useKnownAddresses from '../Accounts/useKnownAddresses.js';
|
||||
import { useTranslation } from '../translate.js';
|
||||
|
||||
interface Props extends ModalProps {
|
||||
className?: string;
|
||||
onClose: () => void;
|
||||
onStatusChange: (status: ActionStatus) => void;
|
||||
}
|
||||
|
||||
interface CreateOptions {
|
||||
genesisHash?: HexString;
|
||||
name: string;
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
interface UploadedFileData {
|
||||
isUploadedFileValid: boolean;
|
||||
uploadedFileError: string;
|
||||
uploadedSignatories: string[];
|
||||
}
|
||||
|
||||
const MAX_SIGNATORIES = 100;
|
||||
const BN_TWO = new BN(2);
|
||||
|
||||
const acceptedFormats = ['application/json'];
|
||||
|
||||
function parseFile (file: Uint8Array): UploadedFileData {
|
||||
let uploadError = '';
|
||||
let items: string[];
|
||||
|
||||
try {
|
||||
items = JSON.parse(u8aToString(file)) as string[];
|
||||
assert(Array.isArray(items) && !!items.length, 'JSON file should contain an array of signatories');
|
||||
|
||||
items = items.filter((item) => validateAddress(item));
|
||||
items = [...new Set(items)]; // remove duplicates
|
||||
|
||||
assert(items.length <= MAX_SIGNATORIES, `Maximum you can have ${MAX_SIGNATORIES} signatories`);
|
||||
} catch (error) {
|
||||
items = [];
|
||||
uploadError = (error as Error).message ? (error as Error).message : (error as Error).toString();
|
||||
}
|
||||
|
||||
return {
|
||||
isUploadedFileValid: !uploadError,
|
||||
uploadedFileError: uploadError,
|
||||
uploadedSignatories: items
|
||||
};
|
||||
}
|
||||
|
||||
function createMultisig (signatories: string[], threshold: BN | number, { genesisHash, name, tags = [] }: CreateOptions, success: string): ActionStatus {
|
||||
// we will fill in all the details below
|
||||
const status = { action: 'create' } as ActionStatus;
|
||||
|
||||
try {
|
||||
const result = keyring.addMultisig(signatories, threshold, { genesisHash, name, tags });
|
||||
const { address } = result.pair;
|
||||
|
||||
status.account = address;
|
||||
status.status = 'success';
|
||||
status.message = success;
|
||||
} catch (error) {
|
||||
status.status = 'error';
|
||||
status.message = (error as Error).message;
|
||||
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
return status;
|
||||
}
|
||||
|
||||
function Multisig ({ className = '', onClose, onStatusChange }: Props): React.ReactElement<Props> {
|
||||
const { api, isDevelopment } = useApi();
|
||||
const { t } = useTranslation();
|
||||
const availableSignatories = useKnownAddresses();
|
||||
const [{ isNameValid, name }, setName] = useState({ isNameValid: false, name: '' });
|
||||
const [{ isUploadedFileValid, uploadedFileError, uploadedSignatories }, setUploadedFile] = useState<UploadedFileData>({
|
||||
isUploadedFileValid: true,
|
||||
uploadedFileError: '',
|
||||
uploadedSignatories: []
|
||||
});
|
||||
const [signatories, setSignatories] = useState<string[]>(['']);
|
||||
const [showSignaturesUpload, setShowSignaturesUpload] = useState(false);
|
||||
const [{ isThresholdValid, threshold }, setThreshold] = useState({ isThresholdValid: true, threshold: BN_TWO });
|
||||
|
||||
const _createMultisig = useCallback(
|
||||
(): void => {
|
||||
const options = { genesisHash: isDevelopment ? undefined : api.genesisHash.toHex(), name: name.trim() };
|
||||
const status = createMultisig(signatories, threshold, options, t('created multisig'));
|
||||
|
||||
onStatusChange(status);
|
||||
onClose();
|
||||
},
|
||||
[api.genesisHash, isDevelopment, name, onClose, onStatusChange, signatories, t, threshold]
|
||||
);
|
||||
|
||||
const _onChangeName = useCallback(
|
||||
(name: string) => setName({ isNameValid: (name.trim().length >= 3), name }),
|
||||
[]
|
||||
);
|
||||
|
||||
const _onChangeThreshold = useCallback(
|
||||
(threshold: BN | undefined) =>
|
||||
threshold && setThreshold({ isThresholdValid: threshold.gte(BN_TWO) && threshold.lten(signatories.length), threshold }),
|
||||
[signatories]
|
||||
);
|
||||
|
||||
const _onChangeFile = useCallback(
|
||||
(file: Uint8Array) => {
|
||||
const fileData = parseFile(file);
|
||||
|
||||
setUploadedFile(fileData);
|
||||
|
||||
if (fileData.isUploadedFileValid || uploadedSignatories.length) {
|
||||
setSignatories(fileData.uploadedSignatories.length ? fileData.uploadedSignatories : ['']);
|
||||
}
|
||||
},
|
||||
[uploadedSignatories]
|
||||
);
|
||||
|
||||
const resetFileUpload = useCallback(
|
||||
() => {
|
||||
setUploadedFile({
|
||||
isUploadedFileValid,
|
||||
uploadedFileError,
|
||||
uploadedSignatories: []
|
||||
});
|
||||
},
|
||||
[uploadedFileError, isUploadedFileValid]
|
||||
);
|
||||
|
||||
const _onChangeAddressMulti = useCallback(
|
||||
(items: string[]) => {
|
||||
resetFileUpload();
|
||||
setSignatories(items);
|
||||
},
|
||||
[resetFileUpload]
|
||||
);
|
||||
|
||||
const isValid = isNameValid && isThresholdValid;
|
||||
|
||||
return (
|
||||
<StyledModal
|
||||
className={className}
|
||||
header={t('Add multisig')}
|
||||
onClose={onClose}
|
||||
size='large'
|
||||
>
|
||||
<Modal.Content>
|
||||
<Modal.Columns>
|
||||
<Toggle
|
||||
className='signaturesFileToggle'
|
||||
label={t('Upload JSON file with signatories')}
|
||||
onChange={setShowSignaturesUpload}
|
||||
value={showSignaturesUpload}
|
||||
/>
|
||||
</Modal.Columns>
|
||||
{!showSignaturesUpload && (
|
||||
<Modal.Columns
|
||||
hint={
|
||||
<>
|
||||
<p>{t('The signatories has the ability to create transactions using the multisig and approve transactions sent by others.Once the threshold is reached with approvals, the multisig transaction is enacted on-chain.')}</p>
|
||||
<p>{t('Since the multisig function like any other account, once created it is available for selection anywhere accounts are used and needs to be funded before use.')}</p>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<InputAddressMulti
|
||||
available={availableSignatories}
|
||||
availableLabel={t('available signatories')}
|
||||
maxCount={MAX_SIGNATORIES}
|
||||
onChange={_onChangeAddressMulti}
|
||||
valueLabel={t('selected signatories')}
|
||||
/>
|
||||
</Modal.Columns>
|
||||
)}
|
||||
{showSignaturesUpload && (
|
||||
<Modal.Columns hint={t('Supply a JSON file with the list of signatories.')}>
|
||||
<InputFile
|
||||
accept={acceptedFormats}
|
||||
className='full'
|
||||
clearContent={!uploadedSignatories.length && isUploadedFileValid}
|
||||
isError={!isUploadedFileValid}
|
||||
label={t('upload signatories list')}
|
||||
onChange={_onChangeFile}
|
||||
withLabel
|
||||
/>
|
||||
{!!uploadedSignatories.length && (
|
||||
<Labelled
|
||||
label={t('found signatories')}
|
||||
labelExtra={(
|
||||
<IconLink
|
||||
icon='sync'
|
||||
label={t('Reset')}
|
||||
onClick={resetFileUpload}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<div className='ui--Static ui dropdown selection'>
|
||||
{uploadedSignatories.map((address): React.ReactNode => (
|
||||
<div key={address}>
|
||||
<AddressMini
|
||||
value={address}
|
||||
withSidebar={false}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Labelled>
|
||||
)}
|
||||
{uploadedFileError && (
|
||||
<MarkError content={uploadedFileError} />
|
||||
)}
|
||||
</Modal.Columns>
|
||||
)}
|
||||
<Modal.Columns hint={t('The threshold for approval should be less or equal to the number of signatories for this multisig.')}>
|
||||
<InputNumber
|
||||
isError={!isThresholdValid}
|
||||
label={t('threshold')}
|
||||
onChange={_onChangeThreshold}
|
||||
value={threshold}
|
||||
/>
|
||||
</Modal.Columns>
|
||||
<Modal.Columns hint={t('The name is for unique identification of the account in your owner lists.')}>
|
||||
<Input
|
||||
autoFocus
|
||||
className='full'
|
||||
isError={!isNameValid}
|
||||
label={t('name')}
|
||||
onChange={_onChangeName}
|
||||
placeholder={t('multisig name')}
|
||||
/>
|
||||
</Modal.Columns>
|
||||
</Modal.Content>
|
||||
<Modal.Actions>
|
||||
<Button
|
||||
icon='plus'
|
||||
isDisabled={!isValid}
|
||||
label={t('Create')}
|
||||
onClick={_createMultisig}
|
||||
/>
|
||||
</Modal.Actions>
|
||||
</StyledModal>
|
||||
);
|
||||
}
|
||||
|
||||
const StyledModal = styled(Modal)`
|
||||
.signaturesFileToggle {
|
||||
width: 100%;
|
||||
text-align: right;
|
||||
}
|
||||
`;
|
||||
|
||||
export default React.memo(Multisig);
|
||||
@@ -0,0 +1,76 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-accounts authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { Modal, Password, PasswordStrength } from '@pezkuwi/react-components';
|
||||
import { keyring } from '@pezkuwi/ui-keyring';
|
||||
|
||||
import { useTranslation } from '../translate.js';
|
||||
|
||||
interface Props {
|
||||
onChange: (password: string, isPasswordValid: boolean) => void;
|
||||
onEnter: () => void;
|
||||
}
|
||||
|
||||
export default function PasswordInput ({ onChange, onEnter }: Props): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const [{ isPass1Valid, password1 }, setPassword1] = useState({ isPass1Valid: false, password1: '' });
|
||||
const [{ isPass2Valid, password2 }, setPassword2] = useState({ isPass2Valid: false, password2: '' });
|
||||
|
||||
useEffect(
|
||||
() => onChange(password1, isPass1Valid && isPass2Valid),
|
||||
[password1, onChange, isPass1Valid, isPass2Valid]
|
||||
);
|
||||
|
||||
const _onPassword1Change = useCallback(
|
||||
(password1: string) => {
|
||||
setPassword1({
|
||||
isPass1Valid: keyring.isPassValid(password1),
|
||||
password1
|
||||
});
|
||||
setPassword2({
|
||||
isPass2Valid: keyring.isPassValid(password2) && (password2 === password1),
|
||||
password2
|
||||
});
|
||||
},
|
||||
[password2]
|
||||
);
|
||||
|
||||
const onPassword2Change = useCallback(
|
||||
(password2: string) => setPassword2({
|
||||
isPass2Valid: keyring.isPassValid(password2) && (password2 === password1),
|
||||
password2
|
||||
}),
|
||||
[password1]
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal.Columns
|
||||
hint={
|
||||
<>
|
||||
<p>{t('The password and password confirmation for this account. This is required to authenticate any transactions made and to encrypt the keypair.')}</p>
|
||||
<p>{t('Ensure you are using a strong password for proper account protection.')}</p>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Password
|
||||
className='full'
|
||||
isError={!isPass1Valid}
|
||||
label={t('password')}
|
||||
onChange={_onPassword1Change}
|
||||
onEnter={onEnter}
|
||||
value={password1}
|
||||
/>
|
||||
<Password
|
||||
className='full'
|
||||
isError={!isPass2Valid}
|
||||
label={t('password (repeat)')}
|
||||
onChange={onPassword2Change}
|
||||
onEnter={onEnter}
|
||||
value={password2}
|
||||
/>
|
||||
<PasswordStrength value={password1} />
|
||||
</Modal.Columns>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-accounts authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { ActionStatus } from '@pezkuwi/react-components/Status/types';
|
||||
import type { HexString } from '@pezkuwi/util/types';
|
||||
import type { ModalProps } from '../types.js';
|
||||
|
||||
import React, { useCallback, useState } from 'react';
|
||||
|
||||
import { Button, Input, InputAddressSimple, Modal } from '@pezkuwi/react-components';
|
||||
import { useApi } from '@pezkuwi/react-hooks';
|
||||
import { keyring } from '@pezkuwi/ui-keyring';
|
||||
|
||||
import useProxies from '../Accounts/useProxies.js';
|
||||
import { useTranslation } from '../translate.js';
|
||||
|
||||
interface Props extends ModalProps {
|
||||
className?: string;
|
||||
onClose: () => void;
|
||||
onStatusChange: (status: ActionStatus) => void;
|
||||
}
|
||||
|
||||
interface CreateOptions {
|
||||
genesisHash?: HexString;
|
||||
name: string;
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
function createProxy (address: string, { genesisHash, name, tags = [] }: CreateOptions, success: string): ActionStatus {
|
||||
// we will fill in all the details below
|
||||
const status = { action: 'create' } as ActionStatus;
|
||||
|
||||
try {
|
||||
keyring.addExternal(address, { genesisHash, isProxied: true, name, tags });
|
||||
|
||||
status.account = address;
|
||||
status.status = 'success';
|
||||
status.message = success;
|
||||
} catch (error) {
|
||||
status.status = 'error';
|
||||
status.message = (error as Error).message;
|
||||
}
|
||||
|
||||
return status;
|
||||
}
|
||||
|
||||
function ProxyAdd ({ className = '', onClose, onStatusChange }: Props): React.ReactElement<Props> {
|
||||
const { api, isDevelopment } = useApi();
|
||||
const { t } = useTranslation();
|
||||
const [{ isNameValid, name }, setName] = useState({ isNameValid: false, name: '' });
|
||||
const [stashAddress, setStashAddress] = useState<string | null>(null);
|
||||
const proxyInfo = useProxies(stashAddress);
|
||||
|
||||
const _createProxied = useCallback(
|
||||
(): void => {
|
||||
if (stashAddress) {
|
||||
const options = { genesisHash: isDevelopment ? undefined : api.genesisHash.toHex(), name: name.trim() };
|
||||
const status = createProxy(stashAddress, options, t('added proxy'));
|
||||
|
||||
onStatusChange(status);
|
||||
onClose();
|
||||
}
|
||||
},
|
||||
[api.genesisHash, isDevelopment, name, onClose, onStatusChange, stashAddress, t]
|
||||
);
|
||||
|
||||
const _onChangeName = useCallback(
|
||||
(name: string) => setName({ isNameValid: (name.trim().length >= 3), name }),
|
||||
[]
|
||||
);
|
||||
|
||||
const isValid = isNameValid && !!stashAddress && proxyInfo && !proxyInfo.isEmpty;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
className={className}
|
||||
header={t('Add proxied account')}
|
||||
onClose={onClose}
|
||||
size='large'
|
||||
>
|
||||
<Modal.Content>
|
||||
<Modal.Columns hint={t('The address that has previously setup a proxy to one of the accounts that you control.')}>
|
||||
<InputAddressSimple
|
||||
autoFocus
|
||||
isError={!proxyInfo || proxyInfo.isEmpty}
|
||||
label={t('proxied account')}
|
||||
onChange={setStashAddress}
|
||||
placeholder={t('address being proxied')}
|
||||
/>
|
||||
</Modal.Columns>
|
||||
<Modal.Columns hint={t('The name is for unique identification of the account in your owner lists.')}>
|
||||
<Input
|
||||
className='full'
|
||||
isError={!isNameValid}
|
||||
label={t('name')}
|
||||
onChange={_onChangeName}
|
||||
placeholder={t('proxied name')}
|
||||
/>
|
||||
</Modal.Columns>
|
||||
</Modal.Content>
|
||||
<Modal.Actions>
|
||||
<Button
|
||||
icon='plus'
|
||||
isDisabled={!isValid}
|
||||
label={t('Add')}
|
||||
onClick={_createProxied}
|
||||
/>
|
||||
</Modal.Actions>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(ProxyAdd);
|
||||
@@ -0,0 +1,347 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-staking authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { ApiPromise } from '@pezkuwi/api';
|
||||
import type { SubmittableExtrinsic } from '@pezkuwi/api/types';
|
||||
import type { BatchOptions } from '@pezkuwi/react-hooks/types';
|
||||
import type { AccountId } from '@pezkuwi/types/interfaces';
|
||||
import type { KitchensinkRuntimeProxyType, PalletProxyProxyDefinition } from '@pezkuwi/types/lookup';
|
||||
import type { BN } from '@pezkuwi/util';
|
||||
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { BatchWarning, Button, Dropdown, InputAddress, InputBalance, MarkError, Modal, styled, TxButton } from '@pezkuwi/react-components';
|
||||
import { useApi, useTxBatch } from '@pezkuwi/react-hooks';
|
||||
import { BN_ZERO } from '@pezkuwi/util';
|
||||
|
||||
import { useTranslation } from '../translate.js';
|
||||
|
||||
type PrevProxyProp = [AccountId | null, KitchensinkRuntimeProxyType];
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
onClose: () => void;
|
||||
previousProxy?: [PalletProxyProxyDefinition[], BN];
|
||||
proxiedAccount: string;
|
||||
}
|
||||
|
||||
interface ValueProps {
|
||||
index: number;
|
||||
typeOpts: { text: string; value: number }[];
|
||||
value: PrevProxyProp;
|
||||
}
|
||||
|
||||
interface NewProxyProps extends ValueProps {
|
||||
onChangeAccount: (index: number, value: string | null) => void;
|
||||
onChangeType: (index: number, value: number | undefined) => void;
|
||||
onRemove: (index: number) => void;
|
||||
proxiedAccount: string;
|
||||
}
|
||||
|
||||
interface PrevProxyProps extends ValueProps {
|
||||
onRemove: (accountId: AccountId, type: KitchensinkRuntimeProxyType, index: number) => void;
|
||||
}
|
||||
|
||||
const BATCH_OPTS: BatchOptions = { type: 'all' };
|
||||
const EMPTY_EXISTING: [PalletProxyProxyDefinition[], BN] = [[], BN_ZERO];
|
||||
|
||||
function createAddProxy (api: ApiPromise, account: AccountId, type: KitchensinkRuntimeProxyType, delay = 0): SubmittableExtrinsic<'promise'> {
|
||||
return api.tx.proxy.addProxy.meta.args.length === 2
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore old version
|
||||
? api.tx.proxy.addProxy(account, type)
|
||||
: api.tx.proxy.addProxy(account, type, delay);
|
||||
}
|
||||
|
||||
function createRmProxy (api: ApiPromise, account: AccountId, type: KitchensinkRuntimeProxyType, delay = 0): SubmittableExtrinsic<'promise'> {
|
||||
return api.tx.proxy.removeProxy.meta.args.length === 2
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore old version
|
||||
? api.tx.proxy.removeProxy(account, type)
|
||||
: api.tx.proxy.removeProxy(account, type, delay);
|
||||
}
|
||||
|
||||
function PrevProxy ({ index, onRemove, typeOpts, value: [accountId, type] }: PrevProxyProps): React.ReactElement<PrevProxyProps> {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const _onRemove = useCallback(
|
||||
(): void => {
|
||||
accountId && onRemove(accountId, type, index);
|
||||
},
|
||||
[accountId, index, onRemove, type]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className='proxy-container'>
|
||||
<div className='input-column'>
|
||||
<InputAddress
|
||||
defaultValue={accountId}
|
||||
isDisabled
|
||||
label={t('proxy account')}
|
||||
/>
|
||||
<Dropdown
|
||||
isDisabled
|
||||
label={'type'}
|
||||
options={typeOpts}
|
||||
value={type.toNumber()}
|
||||
/>
|
||||
</div>
|
||||
<div className='buttons-column'>
|
||||
<Button
|
||||
icon='times'
|
||||
onClick={_onRemove}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NewProxy ({ index, onChangeAccount, onChangeType, onRemove, proxiedAccount, typeOpts, value: [accountId, type] }: NewProxyProps): React.ReactElement<NewProxyProps> {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const _onChangeAccount = useCallback(
|
||||
(value: string | null) => onChangeAccount(index, value),
|
||||
[index, onChangeAccount]
|
||||
);
|
||||
|
||||
const _onChangeType = useCallback(
|
||||
(value?: number) => onChangeType(index, value),
|
||||
[index, onChangeType]
|
||||
);
|
||||
|
||||
const _onRemove = useCallback(
|
||||
() => onRemove(index),
|
||||
[index, onRemove]
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className='proxy-container'
|
||||
key={`addedProxy-${index}`}
|
||||
>
|
||||
<div className='input-column'>
|
||||
<InputAddress
|
||||
isError={!accountId}
|
||||
label={t('proxy account')}
|
||||
onChange={_onChangeAccount}
|
||||
type='allPlus'
|
||||
value={accountId}
|
||||
/>
|
||||
{accountId && accountId.eq(proxiedAccount) && (
|
||||
<MarkError content={t('You should not setup proxies to act as a self-proxy.')} />
|
||||
)}
|
||||
<Dropdown
|
||||
label={'type'}
|
||||
onChange={_onChangeType}
|
||||
options={typeOpts}
|
||||
value={type.toNumber()}
|
||||
/>
|
||||
</div>
|
||||
<div className='buttons-column'>
|
||||
<Button
|
||||
icon='times'
|
||||
onClick={_onRemove}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getProxyTypeInstance (api: ApiPromise, index = 0): KitchensinkRuntimeProxyType {
|
||||
// This is not perfect, but should work for `{Dicle, Node, Pezkuwi}RuntimeProxyType`
|
||||
const proxyNames = api.registry.lookup.names.filter((name) => name.endsWith('RuntimeProxyType'));
|
||||
|
||||
// fallback to previous version (may be Bizinikiwi default), when not found
|
||||
return api.createType<KitchensinkRuntimeProxyType>(proxyNames.length ? proxyNames[0] : 'ProxyType', index);
|
||||
}
|
||||
|
||||
function getProxyOptions (api: ApiPromise): { text: string; value: number; }[] {
|
||||
return getProxyTypeInstance(api).defKeys
|
||||
.map((text, value) => ({ text, value }))
|
||||
// Filter the empty entries added in v14
|
||||
.filter(({ text }) => !text.startsWith('__Unused'));
|
||||
}
|
||||
|
||||
function ProxyOverview ({ className, onClose, previousProxy: [existing] = EMPTY_EXISTING, proxiedAccount }: Props): React.ReactElement<Props> {
|
||||
const { t } = useTranslation();
|
||||
const { api } = useApi();
|
||||
const [batchPrevious, setBatchPrevious] = useState<SubmittableExtrinsic<'promise'>[]>([]);
|
||||
const [batchAdded, setBatchAdded] = useState<SubmittableExtrinsic<'promise'>[]>([]);
|
||||
const [txs, setTxs] = useState<SubmittableExtrinsic<'promise'>[] | null>(null);
|
||||
const [previous, setPrevious] = useState<PrevProxyProp[]>(() => existing.map(({ delegate, proxyType }) => [delegate, proxyType]));
|
||||
const [added, setAdded] = useState<PrevProxyProp[]>([]);
|
||||
const extrinsics = useTxBatch(txs, BATCH_OPTS);
|
||||
|
||||
const reservedAmount = useMemo(
|
||||
() => api.consts.proxy.proxyDepositFactor
|
||||
.muln(batchPrevious.length + batchAdded.length)
|
||||
.iadd(api.consts.proxy.proxyDepositBase),
|
||||
[api, batchPrevious, batchAdded]
|
||||
);
|
||||
|
||||
const typeOpts = useRef(getProxyOptions(api));
|
||||
|
||||
useEffect((): void => {
|
||||
setBatchAdded(
|
||||
added
|
||||
.filter((f): f is [AccountId, KitchensinkRuntimeProxyType] => !!f[0])
|
||||
.map(([newAccount, newType]) => createAddProxy(api, newAccount, newType))
|
||||
);
|
||||
}, [api, added]);
|
||||
|
||||
useEffect((): void => {
|
||||
setTxs(() => [...batchPrevious, ...batchAdded]);
|
||||
}, [batchPrevious, batchAdded]);
|
||||
|
||||
const _addProxy = useCallback(
|
||||
() => setAdded((added) =>
|
||||
[...added, [
|
||||
added.length
|
||||
? added[added.length - 1][0]
|
||||
: previous.length
|
||||
? previous[previous.length - 1][0]
|
||||
: api.createType('AccountId', proxiedAccount),
|
||||
getProxyTypeInstance(api)
|
||||
]]
|
||||
),
|
||||
[api, previous, proxiedAccount]
|
||||
);
|
||||
|
||||
const _delProxy = useCallback(
|
||||
(index: number) => setAdded((added) => added.filter((_, i) => i !== index)),
|
||||
[]
|
||||
);
|
||||
|
||||
const _delPrev = useCallback(
|
||||
(accountId: AccountId, type: KitchensinkRuntimeProxyType, index: number): void => {
|
||||
setPrevious((previous) => previous.filter((_, i) => i !== index));
|
||||
setBatchPrevious((previous) => [...previous, createRmProxy(api, accountId, type)]);
|
||||
},
|
||||
[api]
|
||||
);
|
||||
|
||||
const _changeProxyAccount = useCallback(
|
||||
(index: number, address: string | null) => setAdded((prevState) => {
|
||||
const newState = [...prevState];
|
||||
|
||||
newState[index][0] = address
|
||||
? api.createType('AccountId', address)
|
||||
: null;
|
||||
|
||||
return newState;
|
||||
}),
|
||||
[api]
|
||||
);
|
||||
|
||||
const _changeProxyType = useCallback(
|
||||
(index: number, newTypeNumber: number | undefined): void =>
|
||||
setAdded((added) => {
|
||||
const newState = [...added];
|
||||
|
||||
newState[index][1] = getProxyTypeInstance(api, newTypeNumber);
|
||||
|
||||
return newState;
|
||||
}),
|
||||
[api]
|
||||
);
|
||||
|
||||
const isSameAdd = added.some(([accountId]) => accountId && accountId.eq(proxiedAccount));
|
||||
|
||||
return (
|
||||
<StyledModal
|
||||
className={className}
|
||||
header={t('Proxy overview')}
|
||||
onClose={onClose}
|
||||
size='large'
|
||||
>
|
||||
<Modal.Content>
|
||||
<Modal.Columns hint={t('Any account set as proxy will be able to perform actions in place of the proxied account')}>
|
||||
<InputAddress
|
||||
isDisabled={true}
|
||||
label={t('proxied account')}
|
||||
type='account'
|
||||
value={proxiedAccount}
|
||||
/>
|
||||
</Modal.Columns>
|
||||
<Modal.Columns hint={t('If you add several proxy accounts for the same proxy type (e.g 2 accounts set as proxy for Governance), then any of those 2 accounts will be able to perform governance actions on behalf of the proxied account')}>
|
||||
{previous.map((value, index) => (
|
||||
<PrevProxy
|
||||
index={index}
|
||||
key={`${value.toString()}-${index}`}
|
||||
onRemove={_delPrev}
|
||||
typeOpts={typeOpts.current}
|
||||
value={value}
|
||||
/>
|
||||
))}
|
||||
{added.map((value, index) => (
|
||||
<NewProxy
|
||||
index={index}
|
||||
key={`${value.toString()}-${index}`}
|
||||
onChangeAccount={_changeProxyAccount}
|
||||
onChangeType={_changeProxyType}
|
||||
onRemove={_delProxy}
|
||||
proxiedAccount={proxiedAccount}
|
||||
typeOpts={typeOpts.current}
|
||||
value={value}
|
||||
/>
|
||||
))}
|
||||
<Button.Group>
|
||||
<Button
|
||||
icon='plus'
|
||||
label={t('Add proxy')}
|
||||
onClick={_addProxy}
|
||||
/>
|
||||
</Button.Group>
|
||||
</Modal.Columns>
|
||||
<Modal.Columns hint={t('A deposit paid by the proxied account that can not be used while the proxy is in existence. The deposit is returned when the proxy is destroyed. The amount reserved is based on the base deposit and number of proxies')}>
|
||||
<InputBalance
|
||||
defaultValue={reservedAmount}
|
||||
isDisabled
|
||||
label={t('reserved balance')}
|
||||
/>
|
||||
</Modal.Columns>
|
||||
<Modal.Columns>
|
||||
<BatchWarning />
|
||||
</Modal.Columns>
|
||||
</Modal.Content>
|
||||
<Modal.Actions>
|
||||
{existing.length !== 0 && (
|
||||
<TxButton
|
||||
accountId={proxiedAccount}
|
||||
icon='trash-alt'
|
||||
label={t('Clear all')}
|
||||
onStart={onClose}
|
||||
tx={api.tx.proxy.removeProxies}
|
||||
/>
|
||||
)}
|
||||
<TxButton
|
||||
accountId={proxiedAccount}
|
||||
extrinsic={extrinsics}
|
||||
icon='sign-in-alt'
|
||||
isDisabled={isSameAdd || (!batchPrevious.length && !batchAdded.length)}
|
||||
onStart={onClose}
|
||||
/>
|
||||
</Modal.Actions>
|
||||
</StyledModal>
|
||||
);
|
||||
}
|
||||
|
||||
const StyledModal = styled(Modal)`
|
||||
.proxy-container {
|
||||
display: grid;
|
||||
grid-column-gap: 0.5rem;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
margin-bottom: 1rem;
|
||||
|
||||
.input-column {
|
||||
grid-column: 1;
|
||||
}
|
||||
|
||||
.buttons-column {
|
||||
grid-column: 2;
|
||||
padding-top: 0.3rem;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default React.memo(ProxyOverview);
|
||||
@@ -0,0 +1,192 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-accounts authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { ActionStatus } from '@pezkuwi/react-components/Status/types';
|
||||
import type { HexString } from '@pezkuwi/util/types';
|
||||
import type { ModalProps } from '../types.js';
|
||||
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
|
||||
import { AddressRow, Button, Input, InputAddress, MarkWarning, Modal, QrScanAddress, styled } from '@pezkuwi/react-components';
|
||||
import { useApi, useIpfs } from '@pezkuwi/react-hooks';
|
||||
import { keyring } from '@pezkuwi/ui-keyring';
|
||||
|
||||
import { useTranslation } from '../translate.js';
|
||||
import PasswordInput from './PasswordInput.js';
|
||||
|
||||
interface Scanned {
|
||||
content: string;
|
||||
isAddress: boolean;
|
||||
genesisHash: HexString | null;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
interface Props extends ModalProps {
|
||||
className?: string;
|
||||
onClose: () => void;
|
||||
onStatusChange: (status: ActionStatus) => void;
|
||||
}
|
||||
|
||||
interface Address {
|
||||
address: string;
|
||||
isAddress: boolean;
|
||||
scanned: Scanned | null;
|
||||
warning?: string | null;
|
||||
}
|
||||
|
||||
function QrModal ({ className = '', onClose, onStatusChange }: Props): React.ReactElement<Props> {
|
||||
const { t } = useTranslation();
|
||||
const { api, isEthereum } = useApi();
|
||||
const { isIpfs } = useIpfs();
|
||||
const [{ isNameValid, name }, setName] = useState({ isNameValid: false, name: '' });
|
||||
const [{ address, isAddress, scanned, warning }, setAddress] = useState<Address>({ address: '', isAddress: false, scanned: null });
|
||||
const [{ isPasswordValid, password }, setPassword] = useState({ isPasswordValid: false, password: '' });
|
||||
|
||||
const isValid = !!address && isNameValid && (isAddress || isPasswordValid);
|
||||
|
||||
const scannedGenesisWarn = useMemo(
|
||||
() => !!scanned && !!scanned.genesisHash && !api.genesisHash.eq(scanned.genesisHash),
|
||||
[scanned, api]
|
||||
);
|
||||
|
||||
const _onNameChange = useCallback(
|
||||
(name: string) => setName({ isNameValid: !!name.trim(), name }),
|
||||
[]
|
||||
);
|
||||
|
||||
const _onPasswordChange = useCallback(
|
||||
(password: string, isPasswordValid: boolean) => setPassword({ isPasswordValid, password }),
|
||||
[]
|
||||
);
|
||||
|
||||
const _onScan = useCallback(
|
||||
(scanned: Scanned): void => {
|
||||
setAddress({
|
||||
address: scanned.isAddress
|
||||
? scanned.content
|
||||
: keyring.createFromUri(scanned.content, {}, 'sr25519').address,
|
||||
isAddress: scanned.isAddress,
|
||||
scanned
|
||||
});
|
||||
|
||||
if (scanned.name) {
|
||||
_onNameChange(scanned.name);
|
||||
}
|
||||
},
|
||||
[_onNameChange]
|
||||
);
|
||||
|
||||
const _onError = useCallback(
|
||||
(err: Error): void => {
|
||||
setAddress({
|
||||
address: '',
|
||||
isAddress: false,
|
||||
scanned: null,
|
||||
warning: err.message
|
||||
});
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const _onSave = useCallback(
|
||||
(): void => {
|
||||
if (!scanned || !isValid) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { content, isAddress } = scanned;
|
||||
const meta = {
|
||||
genesisHash: scanned.genesisHash || api.genesisHash.toHex(),
|
||||
name: name.trim()
|
||||
};
|
||||
const account = isAddress
|
||||
? isEthereum ? keyring.addExternal(content).pair.address : keyring.addExternal(content, meta).pair.address
|
||||
: keyring.addUri(content, password, meta, 'sr25519').pair.address;
|
||||
|
||||
InputAddress.setLastValue('account', account);
|
||||
|
||||
onStatusChange({
|
||||
account,
|
||||
action: 'create',
|
||||
message: t('created account'),
|
||||
status: 'success'
|
||||
});
|
||||
onClose();
|
||||
},
|
||||
[api, isValid, name, onClose, onStatusChange, password, scanned, isEthereum, t]
|
||||
);
|
||||
|
||||
return (
|
||||
<StyledModal
|
||||
className={className}
|
||||
header={t('Add account via Qr')}
|
||||
onClose={onClose}
|
||||
size='large'
|
||||
>
|
||||
<Modal.Content>
|
||||
{scanned
|
||||
? (
|
||||
<>
|
||||
<Modal.Columns>
|
||||
<AddressRow
|
||||
defaultName={name}
|
||||
noDefaultNameOpacity
|
||||
value={scanned.content}
|
||||
/>
|
||||
</Modal.Columns>
|
||||
<Modal.Columns hint={t('The local name for this account. Changing this does not affect your on-line identity, so this is only used to indicate the name of the account locally.')}>
|
||||
<Input
|
||||
autoFocus
|
||||
className='full'
|
||||
isError={!isNameValid}
|
||||
label={t('name')}
|
||||
onChange={_onNameChange}
|
||||
onEnter={_onSave}
|
||||
value={name}
|
||||
/>
|
||||
{scannedGenesisWarn && (
|
||||
<MarkWarning content={t('The genesisHash for the scanned account does not match the genesisHash of the connected chain. The account will not be usable on this chain.')} />
|
||||
)}
|
||||
</Modal.Columns>
|
||||
{!isAddress && (
|
||||
<PasswordInput
|
||||
onChange={_onPasswordChange}
|
||||
onEnter={_onSave}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
: (
|
||||
<Modal.Columns hint={t('Provide the account QR from the module/external application for scanning. Once detected as valid, you will be taken to the next step to add the account to your list.')}>
|
||||
<div className='qr-wrapper'>
|
||||
<QrScanAddress
|
||||
isEthereum={isEthereum}
|
||||
onError={_onError}
|
||||
onScan={_onScan}
|
||||
/>
|
||||
</div>
|
||||
{warning && <MarkWarning content={warning} />}
|
||||
</Modal.Columns>
|
||||
)
|
||||
}
|
||||
</Modal.Content>
|
||||
<Modal.Actions>
|
||||
<Button
|
||||
icon='plus'
|
||||
isDisabled={!scanned || !isValid || (!isAddress && isIpfs)}
|
||||
label={t('Save')}
|
||||
onClick={_onSave}
|
||||
/>
|
||||
</Modal.Actions>
|
||||
</StyledModal>
|
||||
);
|
||||
}
|
||||
|
||||
const StyledModal = styled(Modal)`
|
||||
.qr-wrapper {
|
||||
margin: 0 auto;
|
||||
max-width: 30rem;
|
||||
}
|
||||
`;
|
||||
|
||||
export default React.memo(QrModal);
|
||||
@@ -0,0 +1,55 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-accounts authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import { InputAddress, Modal, TxButton } from '@pezkuwi/react-components';
|
||||
import { useApi } from '@pezkuwi/react-hooks';
|
||||
|
||||
import { useTranslation } from '../translate.js';
|
||||
|
||||
interface Props {
|
||||
address: string;
|
||||
className?: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function RecoverAccount ({ address, className = '', onClose }: Props): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const { api } = useApi();
|
||||
const [recover, setRecover] = useState<string | null>(null);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
className={className}
|
||||
header={t('Initiate account recovery')}
|
||||
onClose={onClose}
|
||||
>
|
||||
<Modal.Content>
|
||||
<InputAddress
|
||||
isDisabled
|
||||
label={t('the account to recover to')}
|
||||
value={address}
|
||||
/>
|
||||
<InputAddress
|
||||
label={t('recover this account')}
|
||||
onChange={setRecover}
|
||||
type='allPlus'
|
||||
/>
|
||||
</Modal.Content>
|
||||
<Modal.Actions>
|
||||
<TxButton
|
||||
accountId={address}
|
||||
icon='recycle'
|
||||
isDisabled={!recover || recover === address}
|
||||
label={t('Start recovery')}
|
||||
onStart={onClose}
|
||||
params={[recover]}
|
||||
tx={api.tx.recovery.initiateRecovery}
|
||||
/>
|
||||
</Modal.Actions>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(RecoverAccount);
|
||||
@@ -0,0 +1,95 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-accounts authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { BN } from '@pezkuwi/util';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import { InputAddress, InputAddressMulti, InputNumber, Modal, TxButton } from '@pezkuwi/react-components';
|
||||
import { useApi } from '@pezkuwi/react-hooks';
|
||||
import { sortAddresses } from '@pezkuwi/util-crypto';
|
||||
|
||||
import useKnownAddresses from '../Accounts/useKnownAddresses.js';
|
||||
import { useTranslation } from '../translate.js';
|
||||
|
||||
interface Props {
|
||||
address: string;
|
||||
className?: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const MAX_HELPERS = 16;
|
||||
|
||||
function RecoverSetup ({ address, className = '', onClose }: Props): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const { api } = useApi();
|
||||
const availableHelpers = useKnownAddresses(address);
|
||||
const [delay, setDelay] = useState<BN | undefined>();
|
||||
const [helpers, setHelpers] = useState<string[]>([]);
|
||||
const [threshold, setThreshold] = useState<BN | undefined>();
|
||||
|
||||
const isErrorDelay = !delay;
|
||||
const isErrorHelpers = !helpers.length;
|
||||
const isErrorThreshold = !threshold || !threshold.gtn(0) || threshold.gtn(helpers.length);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
className={className}
|
||||
header={t('Setup account as recoverable')}
|
||||
onClose={onClose}
|
||||
size='large'
|
||||
>
|
||||
<Modal.Content>
|
||||
<Modal.Columns hint={t('The recoverable account is protected against the loss of seed/access by a social process.')}>
|
||||
<InputAddress
|
||||
isDisabled
|
||||
label={t('the account to make recoverable')}
|
||||
value={address}
|
||||
/>
|
||||
</Modal.Columns>
|
||||
<Modal.Columns
|
||||
hint={
|
||||
<>
|
||||
<p>{t('These are trusted individuals that can verify and approve any recovery actions. With recovery, once the threshold is reached, the funds associated with the account can be moved to a new destination.')}</p>
|
||||
<p>{t('The helpers should be able to verify, via an off-chain mechanism, that the account owner indeed wishes to recover access and as such provide any approvals. In the cases of malicious recovery procedures, they will have the power to stop it.')}</p>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<InputAddressMulti
|
||||
available={availableHelpers}
|
||||
availableLabel={t('available social recovery helpers')}
|
||||
maxCount={MAX_HELPERS}
|
||||
onChange={setHelpers}
|
||||
valueLabel={t('trusted social recovery helpers')}
|
||||
/>
|
||||
</Modal.Columns>
|
||||
<Modal.Columns hint={t('The threshold for approvals and the delay is the protection associated with the account. The delay should be such that any colluding recovery attempts does have a window to stop.')}>
|
||||
<InputNumber
|
||||
isError={isErrorThreshold}
|
||||
label={t('recovery threshold')}
|
||||
onChange={setThreshold}
|
||||
/>
|
||||
<InputNumber
|
||||
isError={isErrorDelay}
|
||||
isZeroable
|
||||
label={t('recovery block delay')}
|
||||
onChange={setDelay}
|
||||
/>
|
||||
</Modal.Columns>
|
||||
</Modal.Content>
|
||||
<Modal.Actions>
|
||||
<TxButton
|
||||
accountId={address}
|
||||
icon='share-alt'
|
||||
isDisabled={isErrorHelpers || isErrorThreshold || isErrorDelay}
|
||||
label={t('Make recoverable')}
|
||||
onStart={onClose}
|
||||
params={[sortAddresses(helpers), threshold, delay]}
|
||||
tx={api.tx.recovery.createRecovery}
|
||||
/>
|
||||
</Modal.Actions>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(RecoverSetup);
|
||||
@@ -0,0 +1,49 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-staking authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { InputAddress, Modal, TxButton } from '@pezkuwi/react-components';
|
||||
import { useApi } from '@pezkuwi/react-hooks';
|
||||
|
||||
import { useTranslation } from '../translate.js';
|
||||
|
||||
interface Props {
|
||||
accountDelegating: string | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function Undelegate ({ accountDelegating, onClose }: Props): React.ReactElement<Props> {
|
||||
const { t } = useTranslation();
|
||||
const { api } = useApi();
|
||||
|
||||
return (
|
||||
<Modal
|
||||
className='staking--Undelegate'
|
||||
header= {t('Undelegate')}
|
||||
onClose={onClose}
|
||||
size='large'
|
||||
>
|
||||
<Modal.Content>
|
||||
<Modal.Columns hint={t('You will remove any delegation made by this account')}>
|
||||
<InputAddress
|
||||
defaultValue={accountDelegating}
|
||||
isDisabled
|
||||
label={t('delegating account')}
|
||||
/>
|
||||
</Modal.Columns>
|
||||
</Modal.Content>
|
||||
<Modal.Actions>
|
||||
<TxButton
|
||||
accountId={accountDelegating}
|
||||
icon='sign-in-alt'
|
||||
label={t('Undelegate')}
|
||||
onStart={onClose}
|
||||
tx={api.tx.democracy.undelegate}
|
||||
/>
|
||||
</Modal.Actions>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(Undelegate);
|
||||
@@ -0,0 +1,8 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-accounts authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import { useTranslation as useTranslationBase } from 'react-i18next';
|
||||
|
||||
export function useTranslation (): { t: (key: string, options?: { replace: Record<string, unknown> }) => string } {
|
||||
return useTranslationBase('app-accounts');
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-accounts authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { WithTranslation } from 'react-i18next';
|
||||
import type { ActionStatus } from '@pezkuwi/react-components/Status/types';
|
||||
import type { Balance, Conviction } from '@pezkuwi/types/interfaces';
|
||||
import type { KeyringAddress } from '@pezkuwi/ui-keyring/types';
|
||||
import type { BN } from '@pezkuwi/util';
|
||||
import type { HexString } from '@pezkuwi/util/types';
|
||||
|
||||
export type { AppProps as ComponentProps } from '@pezkuwi/react-components/types';
|
||||
|
||||
export interface BareProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export interface I18nProps extends BareProps, WithTranslation {}
|
||||
|
||||
export interface ModalProps {
|
||||
onClose: () => void;
|
||||
onStatusChange: (status: ActionStatus) => void;
|
||||
}
|
||||
|
||||
export interface Delegation {
|
||||
accountDelegated: string
|
||||
amount: Balance
|
||||
conviction: Conviction
|
||||
}
|
||||
|
||||
export interface SortedAccount {
|
||||
account: KeyringAddress;
|
||||
address: string;
|
||||
delegation?: Delegation;
|
||||
isFavorite: boolean;
|
||||
}
|
||||
|
||||
export interface AccountBalance {
|
||||
total: BN;
|
||||
locked: BN;
|
||||
transferable: BN;
|
||||
bonded: BN;
|
||||
redeemable: BN;
|
||||
unbonding: BN;
|
||||
}
|
||||
|
||||
export type PairType = 'ecdsa' | 'ed25519' | 'ed25519-ledger' | 'ethereum' | 'sr25519';
|
||||
|
||||
export interface CreateProps extends ModalProps {
|
||||
className?: string;
|
||||
onClose: () => void;
|
||||
onStatusChange: (status: ActionStatus) => void;
|
||||
seed?: string;
|
||||
type?: PairType;
|
||||
}
|
||||
|
||||
export type SeedType = 'bip' | 'raw' | 'dev';
|
||||
|
||||
export interface AddressState {
|
||||
address: string | null;
|
||||
derivePath: string;
|
||||
deriveValidation?: DeriveValidationOutput
|
||||
isSeedValid: boolean;
|
||||
pairType: PairType;
|
||||
seed: string;
|
||||
seedType: SeedType;
|
||||
}
|
||||
|
||||
export interface CreateOptions {
|
||||
genesisHash?: HexString;
|
||||
name: string;
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
export interface DeriveValidationOutput {
|
||||
error?: string;
|
||||
warning?: string;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-accounts authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import { createNamedHook, useAccounts } from '@pezkuwi/react-hooks';
|
||||
|
||||
function useCounterImpl (): string | null {
|
||||
const { hasAccounts } = useAccounts();
|
||||
|
||||
return hasAccounts ? null : '!';
|
||||
}
|
||||
|
||||
export default createNamedHook('useCounter', useCounterImpl);
|
||||
@@ -0,0 +1,118 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-accounts authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { ActionStatus } from '@pezkuwi/react-components/Status/types';
|
||||
import type { AccountId, AccountIndex, Address } from '@pezkuwi/types/interfaces';
|
||||
import type { CreateResult } from '@pezkuwi/ui-keyring/types';
|
||||
import type { AccountBalance, SortedAccount } from './types.js';
|
||||
|
||||
import FileSaver from 'file-saver';
|
||||
import React from 'react';
|
||||
|
||||
import { getEnvironment } from '@pezkuwi/react-api/util';
|
||||
import { InputAddress, Menu } from '@pezkuwi/react-components';
|
||||
import { getAddressMeta } from '@pezkuwi/react-components/util';
|
||||
import { BN_ZERO } from '@pezkuwi/util';
|
||||
|
||||
export function createMenuGroup (key: string, items: (React.ReactNode | false | undefined | null)[], header?: string): React.ReactNode | null {
|
||||
const filtered = items.filter((e): e is React.ReactNode => !!e);
|
||||
|
||||
return filtered.length
|
||||
? (
|
||||
<React.Fragment key={key}>
|
||||
<Menu.Divider />
|
||||
{header && (
|
||||
<Menu.Header>{header}</Menu.Header>
|
||||
)}
|
||||
{filtered}
|
||||
</React.Fragment>
|
||||
)
|
||||
: null;
|
||||
}
|
||||
|
||||
export type AccountIdIsh = AccountId | AccountIndex | Address | string | Uint8Array | null;
|
||||
|
||||
export function downloadAccount ({ json, pair }: CreateResult): void {
|
||||
// eslint-disable-next-line deprecation/deprecation
|
||||
FileSaver.saveAs(
|
||||
new Blob([JSON.stringify(json)], { type: 'application/json; charset=utf-8' }),
|
||||
`${pair.address}.json`
|
||||
);
|
||||
}
|
||||
|
||||
export function tryCreateAccount (commitAccount: () => CreateResult, success: string): ActionStatus {
|
||||
const status: ActionStatus = {
|
||||
action: 'create',
|
||||
message: success,
|
||||
status: 'success'
|
||||
};
|
||||
|
||||
try {
|
||||
const result = commitAccount();
|
||||
const address = result.pair.address;
|
||||
|
||||
status.account = address;
|
||||
|
||||
if (getEnvironment() === 'web') {
|
||||
downloadAccount(result);
|
||||
}
|
||||
|
||||
downloadAccount(result);
|
||||
InputAddress.setLastValue('account', address);
|
||||
} catch (error) {
|
||||
status.status = 'error';
|
||||
status.message = (error as Error).message;
|
||||
}
|
||||
|
||||
return status;
|
||||
}
|
||||
|
||||
export const SORT_CATEGORY = ['parent', 'name', 'date', 'balances'] as const;
|
||||
|
||||
export type SortCategory = typeof SORT_CATEGORY[number];
|
||||
|
||||
function comparator (accountsMap: Record<string, SortedAccount>, balances: Record<string, AccountBalance | undefined>, category: SortCategory, fromMax: boolean): (a: SortedAccount, b: SortedAccount) => number {
|
||||
function accountQualifiedName (account: SortedAccount | undefined): string {
|
||||
if (account) {
|
||||
const parent = (account.account?.meta.parentAddress || '');
|
||||
|
||||
return accountQualifiedName(accountsMap[parent]) + account.address;
|
||||
} else {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function make <T> (getValue: (acc: SortedAccount) => T, compare: (a: T, b: T) => number) {
|
||||
const mult = fromMax ? 1 : -1;
|
||||
|
||||
return (a: SortedAccount, b: SortedAccount) =>
|
||||
mult * compare(getValue(a), getValue(b));
|
||||
}
|
||||
|
||||
switch (category) {
|
||||
case 'parent':
|
||||
// We need to ensure that parent will be adjacent to its children
|
||||
// so we use format '...<grand-parent-addr><parent-addr><account-addr>'
|
||||
return make(accountQualifiedName, (a, b) => a.localeCompare(b));
|
||||
|
||||
case 'name':
|
||||
return make((acc) => getAddressMeta(acc.address).name ?? '', (a, b) => a.localeCompare(b));
|
||||
|
||||
case 'date':
|
||||
return make((acc) => acc.account?.meta.whenCreated ?? 0, (a, b) => a - b);
|
||||
|
||||
case 'balances':
|
||||
return make((acc) => balances[acc.address]?.total ?? BN_ZERO, (a, b) => a.cmp(b));
|
||||
}
|
||||
}
|
||||
|
||||
export function sortAccounts (accountsList: SortedAccount[], accountsMap: Record<string, SortedAccount>, balances: Record<string, AccountBalance>, by: SortCategory, fromMax: boolean): SortedAccount[] {
|
||||
return [...accountsList]
|
||||
.sort(comparator(accountsMap, balances, by, fromMax))
|
||||
.sort((a, b) =>
|
||||
a.isFavorite === b.isFavorite
|
||||
? 0
|
||||
: b.isFavorite
|
||||
? 1
|
||||
: -1);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-accounts authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
/* global expect */
|
||||
|
||||
import { within } from '@testing-library/react';
|
||||
|
||||
import { Row } from '@pezkuwi/test-support/pagesElements';
|
||||
|
||||
export class AccountRow extends Row {
|
||||
async assertParentAccountName (expectedParentAccount: string): Promise<void> {
|
||||
const parentAccount = await within(this.primaryRow).findByTestId('parent');
|
||||
|
||||
expect(parentAccount).toHaveTextContent(expectedParentAccount);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
// Copyright 2017-2025 @pezkuwi/app-accounts authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { Sidebar } from '@pezkuwi/test-support/pagesElements';
|
||||
import type { AccountOverrides } from '@pezkuwi/test-support/types';
|
||||
|
||||
import { fireEvent, screen, within } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
|
||||
import { anAccount } from '@pezkuwi/test-support/creation/account';
|
||||
import { Page } from '@pezkuwi/test-support/pages/Page';
|
||||
import { assertText, clickButton } from '@pezkuwi/test-support/utils';
|
||||
import { settings } from '@pezkuwi/ui-settings';
|
||||
|
||||
import AccountOverview from '../../src/Accounts/index.js';
|
||||
import { AccountRow } from '../pageElements/AccountRow.js';
|
||||
|
||||
const NOOP_CHANGE = () => undefined;
|
||||
|
||||
// set the account creation for localStorage to on
|
||||
settings.set({ ...settings.get(), storage: 'on' });
|
||||
|
||||
export class AccountsPage extends Page {
|
||||
constructor () {
|
||||
super(
|
||||
<AccountOverview onStatusChange={NOOP_CHANGE} />,
|
||||
'Account-'
|
||||
);
|
||||
}
|
||||
|
||||
async getAccountRows (): Promise<AccountRow[]> {
|
||||
const table = await this.getTable();
|
||||
const rows = await table.getRows();
|
||||
|
||||
return rows.map((row) => new AccountRow(row.primaryRow, row.detailsRow));
|
||||
}
|
||||
|
||||
async reverseSortingOrder (): Promise<void> {
|
||||
const sortingComponent = await this.getSortByComponent();
|
||||
const reverseOrderButton = await within(sortingComponent).findByRole('button');
|
||||
|
||||
fireEvent.click(reverseOrderButton);
|
||||
}
|
||||
|
||||
async sortBy (category: string): Promise<void> {
|
||||
const currentCategory = await this.getCurrentSortCategory();
|
||||
|
||||
fireEvent.click(currentCategory);
|
||||
|
||||
const selectedCategory = await this.getSortCategory(category);
|
||||
|
||||
fireEvent.click(selectedCategory);
|
||||
}
|
||||
|
||||
async getCurrentSortCategory (): Promise<HTMLElement> {
|
||||
const sortByComponent = await this.getSortByComponent();
|
||||
|
||||
return within(sortByComponent).findByRole('alert');
|
||||
}
|
||||
|
||||
async enterCreateAccountModal (): Promise<void> {
|
||||
this.render([]);
|
||||
await clickButton('Account');
|
||||
|
||||
await assertText('Add an account via seed 1/3');
|
||||
}
|
||||
|
||||
renderAccountsWithDefaultAddresses (...overrides: AccountOverrides[]): void {
|
||||
const accounts = overrides.map((accountProperties, index) =>
|
||||
[this.defaultAddresses[index], accountProperties] as [string, AccountOverrides]);
|
||||
|
||||
this.render(accounts);
|
||||
}
|
||||
|
||||
renderAccountsForAddresses (...addresses: string[]): void {
|
||||
const accounts = addresses.map((address) => [address, anAccount()] as [string, AccountOverrides]);
|
||||
|
||||
this.render(accounts);
|
||||
}
|
||||
|
||||
renderDefaultAccounts (accountsNumber: number): void {
|
||||
const accounts = Array.from({ length: accountsNumber },
|
||||
(_, index) => [this.defaultAddresses[index], anAccount()] as [string, AccountOverrides]);
|
||||
|
||||
this.render(accounts);
|
||||
}
|
||||
|
||||
async openSidebarForRow (accountRowIndex: number): Promise<Sidebar> {
|
||||
const accountRows = await this.getAccountRows();
|
||||
|
||||
return accountRows[accountRowIndex].openSidebar();
|
||||
}
|
||||
|
||||
private async getSortCategory (categoryName: string): Promise<HTMLElement> {
|
||||
const sortByComponent = await this.getSortByComponent();
|
||||
const availableCategories = await within(sortByComponent).findAllByRole('option');
|
||||
const selectedCategory = availableCategories.find((category) => category.textContent === categoryName);
|
||||
|
||||
if (!selectedCategory) {
|
||||
throw new Error('No category found');
|
||||
}
|
||||
|
||||
return selectedCategory;
|
||||
}
|
||||
|
||||
private async getSortByComponent (): Promise<HTMLElement> {
|
||||
this.assertRendered();
|
||||
|
||||
return await screen.findByTestId('sort-by-section');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"baseUrl": "..",
|
||||
"outDir": "./build",
|
||||
"rootDir": "./src"
|
||||
},
|
||||
"exclude": [
|
||||
"**/*.spec.ts",
|
||||
"**/*.spec.tsx",
|
||||
"**/test/**/*"
|
||||
],
|
||||
"references": [
|
||||
{ "path": "../page-claims/tsconfig.build.json" },
|
||||
{ "path": "../page-referenda/tsconfig.build.json" },
|
||||
{ "path": "../page-settings/tsconfig.build.json" },
|
||||
{ "path": "../react-components/tsconfig.build.json" },
|
||||
{ "path": "../react-hooks/tsconfig.build.json" },
|
||||
{ "path": "../react-params/tsconfig.build.json" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"baseUrl": "..",
|
||||
"outDir": "./build",
|
||||
"rootDir": "./src",
|
||||
"emitDeclarationOnly": false,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": [
|
||||
"**/*.spec.ts",
|
||||
"**/*.spec.tsx"
|
||||
],
|
||||
"references": [
|
||||
{ "path": "../apps-config/tsconfig.build.json" },
|
||||
{ "path": "../page-accounts/tsconfig.build.json" },
|
||||
{ "path": "../page-accounts/tsconfig.test.json" },
|
||||
{ "path": "../test-support/tsconfig.build.json" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"baseUrl": "..",
|
||||
"outDir": "./build",
|
||||
"rootDir": ".",
|
||||
// "emitDeclarationOnly": false,
|
||||
// "noEmit": true
|
||||
},
|
||||
"include": [
|
||||
"**/test/**/*"
|
||||
],
|
||||
"references": [
|
||||
{ "path": "../apps-config/tsconfig.build.json" },
|
||||
{ "path": "../page-accounts/tsconfig.build.json" },
|
||||
{ "path": "../test-support/tsconfig.build.json" }
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user