Handle Import/Export of In-browser accounts gracefully (#11669)
* feat: enhance account export functionality with password handling for in-browser accounts * feat: add password handling for browser accounts during import * style: improve styling for BrowserAccountsDiv component
This commit is contained in:
parent
2b38b0effd
commit
fd8c43b060
@ -7,7 +7,7 @@ import type { ModalProps } from '../types.js';
|
||||
import FileSaver from 'file-saver';
|
||||
import React, { useCallback, useState } from 'react';
|
||||
|
||||
import { Button, Checkbox, MarkWarning, Modal, styled } from '@polkadot/react-components';
|
||||
import { AddressRow, Button, Checkbox, MarkWarning, Modal, Password, styled } from '@polkadot/react-components';
|
||||
import { keyring } from '@polkadot/ui-keyring';
|
||||
import { nextTick } from '@polkadot/util';
|
||||
|
||||
@ -20,10 +20,17 @@ interface Props extends ModalProps {
|
||||
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) => {
|
||||
@ -51,10 +58,12 @@ function ExportAll ({ accountsByGroup, className, onClose, onStatusChange }: Pro
|
||||
const accounts = allAccounts.map((account) => {
|
||||
const keyringAccount = keyring.getPair(account);
|
||||
|
||||
return {
|
||||
address: account,
|
||||
meta: keyringAccount.meta
|
||||
};
|
||||
// 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' });
|
||||
@ -77,7 +86,7 @@ function ExportAll ({ accountsByGroup, className, onClose, onStatusChange }: Pro
|
||||
onClose();
|
||||
}
|
||||
});
|
||||
}, [accountsByGroup, onClose, onStatusChange, selectedTypes, t]);
|
||||
}, [accountsByGroup, allBrowserAccountPassword, onClose, onStatusChange, selectedTypes, t]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
@ -88,23 +97,30 @@ function ExportAll ({ accountsByGroup, className, onClose, onStatusChange }: Pro
|
||||
>
|
||||
<Modal.Content>
|
||||
<MarkWarning
|
||||
content={t('You can export all your accounts metadata for the selected types')}
|
||||
content={t('You can batch export accounts, but only for in-browser, QR, and multisig types.')}
|
||||
withIcon={false}
|
||||
/>
|
||||
<StyledCheckBoxGroup>
|
||||
{Object.keys(accountsByGroup)
|
||||
.filter((group) => accountsByGroup[group].length > 0)
|
||||
.filter((group) => ALLOWED_CATEGORIES.includes(group) && accountsByGroup[group].length > 0)
|
||||
.map((group) => {
|
||||
const isSelected = selectedTypes.includes(group);
|
||||
|
||||
return (
|
||||
<Checkbox
|
||||
key={group}
|
||||
label={`${group} (${accountsByGroup[group].length})`}
|
||||
// eslint-disable-next-line react/jsx-no-bind
|
||||
onChange={() => onChangeCheckBox(group)}
|
||||
value={isSelected}
|
||||
/>
|
||||
<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>
|
||||
@ -122,10 +138,46 @@ function ExportAll ({ accountsByGroup, className, onClose, onStatusChange }: Pro
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
margin-top: 1.5rem;
|
||||
padding-inline: 2rem;
|
||||
|
||||
@ -133,6 +185,25 @@ const StyledCheckBoxGroup = styled.div`
|
||||
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);
|
||||
|
||||
@ -2,13 +2,13 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { Dispatch, SetStateAction } from 'react';
|
||||
import type { KeyringPair$Meta } from '@polkadot/keyring/types';
|
||||
import type { KeyringPair, KeyringPair$Json } from '@polkadot/keyring/types';
|
||||
import type { ActionStatus } from '@polkadot/react-components/Status/types';
|
||||
import type { ModalProps } from '../types.js';
|
||||
|
||||
import React, { useCallback, useState } from 'react';
|
||||
|
||||
import { Button, InputFile, MarkError, Modal } from '@polkadot/react-components';
|
||||
import { AddressRow, Button, InputFile, MarkError, Modal, Password, styled } from '@polkadot/react-components';
|
||||
import keyring from '@polkadot/ui-keyring';
|
||||
import { nextTick, u8aToString } from '@polkadot/util';
|
||||
|
||||
@ -20,15 +20,20 @@ interface Props extends ModalProps {
|
||||
onStatusChange: (status: ActionStatus) => void;
|
||||
}
|
||||
|
||||
interface FileAccount {
|
||||
address: string;
|
||||
meta: KeyringPair$Meta;
|
||||
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);
|
||||
@ -64,6 +69,7 @@ function ImportAll ({ className, onClose, onStatusChange }: Props): React.ReactE
|
||||
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) => {
|
||||
@ -80,9 +86,16 @@ function ImportAll ({ className, onClose, onStatusChange }: Props): React.ReactE
|
||||
const status: Partial<ActionStatus> = { action: 'import' };
|
||||
|
||||
try {
|
||||
file?.map((pair) =>
|
||||
keyring.addExternal(pair.address, pair.meta)
|
||||
);
|
||||
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');
|
||||
@ -99,7 +112,7 @@ function ImportAll ({ className, onClose, onStatusChange }: Props): React.ReactE
|
||||
onClose();
|
||||
}
|
||||
});
|
||||
}, [file, onClose, onStatusChange, t]);
|
||||
}, [allBrowserAccountPassword, file, onClose, onStatusChange, t]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
@ -119,6 +132,15 @@ function ImportAll ({ className, onClose, onStatusChange }: Props): React.ReactE
|
||||
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} />
|
||||
@ -139,4 +161,52 @@ function ImportAll ({ className, onClose, onStatusChange }: Props): React.ReactE
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user