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:
Arjun Porwal 2025-07-16 10:50:00 +05:30 committed by GitHub
parent 2b38b0effd
commit fd8c43b060
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 167 additions and 26 deletions

View File

@ -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);

View File

@ -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);