Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: export all accounts as json #1786

Merged
merged 6 commits into from
Feb 6, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/extension/src/ui/api/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,8 @@ export const api: MessageTypes = {
messageService.sendMessage("pri(accounts.export)", { address, password, exportPw }),
accountExportPrivateKey: (address, password) =>
messageService.sendMessage("pri(accounts.export.pk)", { address, password }),
accountExportAll: (password, exportPw) =>
messageService.sendMessage("pri(accounts.export.all)", { password, exportPw }),
accountRename: (address, name) =>
messageService.sendMessage("pri(accounts.rename)", { address, name }),
accountExternalSetIsPortfolio: (address, isPortfolio) =>
Expand Down
7 changes: 6 additions & 1 deletion apps/extension/src/ui/api/types.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { KeyringPair$Json } from "@polkadot/keyring/types"
import type { KeyringPairs$Json } from "@polkadot/ui-keyring/types"
import type { KeypairType } from "@polkadot/util-crypto/types"
import type { HexString } from "@polkadot/util/types"
import { KeypairType } from "@polkadot/util-crypto/types"
import { BalanceJson } from "@talismn/balances"
import {
Chain,
Expand Down Expand Up @@ -170,6 +171,10 @@ export default interface MessageTypes {
password: string,
exportPw: string,
) => Promise<{ exportedJson: KeyringPair$Json }>
accountExportAll: (
password: string,
exportPw: string,
) => Promise<{ exportedJson: KeyringPairs$Json }>
accountExportPrivateKey: (address: string, password: string) => Promise<string>
accountRename: (address: string, name: string) => Promise<boolean>
validateDerivationPath: (derivationPath: string, type: AccountAddressType) => Promise<boolean>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ export const DashboardLayout: FC<{
</Suspense>
</div>
{/* Main area */}
<div className="flex grow flex-col items-center pb-20">
<div className="flex grow flex-col items-center overflow-hidden pb-20">
<div className="flex h-48 w-full shrink-0 items-center justify-center">
<HorizontalNav />
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ const Content = () => {

return (
<>
<HeaderBlock title={t("Accounts")} text={t("Organise and sort your accounts")} />
<HeaderBlock title={t("Manage Accounts")} text={t("Organise and sort your accounts")} />
<Spacer large />
<ManageAccountsProvider>
<ManageAccountsToolbar analytics={ANALYTICS_PAGE} />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,14 @@ const JsonAccount: FC<{ account: JsonImportAccount; onSelect: (select: boolean)
genesisHash={account.genesisHash}
/>
<div className="flex grow flex-col gap-2 overflow-hidden">
<div className="overflow-hidden text-ellipsis whitespace-nowrap text-base">
{account.name} <AccountTypeIcon origin={account.origin as AccountType} />
<div className="flex w-full items-center gap-1 overflow-hidden text-base">
<div className="truncate">{account.name}</div>
<div className="shrink-0">
<AccountTypeIcon
className="text-primary inline-block"
origin={account.origin as AccountType}
/>
</div>
</div>
<div className="text-body-secondary text-sm">{shortenAddress(account.address)}</div>
</div>
Expand Down
164 changes: 164 additions & 0 deletions apps/extension/src/ui/domains/Account/ExportAllAccountsModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
import { yupResolver } from "@hookform/resolvers/yup"
import { FC, useCallback, useEffect, useMemo } from "react"
import { useForm } from "react-hook-form"
import { Trans, useTranslation } from "react-i18next"
import { Button, FormFieldContainer, FormFieldInputText, Modal, ModalDialog } from "talisman-ui"
import * as yup from "yup"

import { CapsLockWarningMessage } from "@talisman/components/CapsLockWarningMessage"
import { PasswordStrength } from "@talisman/components/PasswordStrength"
import downloadJson from "@talisman/util/downloadJson"
import { api } from "@ui/api"

import { PasswordUnlock, usePasswordUnlock } from "./PasswordUnlock"

export const ExportAllAccountsModal: FC<{ isOpen: boolean; onClose: () => void }> = ({
isOpen,
onClose,
}) => {
const { t } = useTranslation()

return (
<Modal containerId="main" isOpen={isOpen} onDismiss={onClose}>
<ModalDialog
title={t("Export all accounts as JSON")}
className="w-[50.3rem] max-w-full overflow-hidden"
onClose={onClose}
>
<PasswordUnlock
title={
<div className="text-body-secondary mb-8">
{t("Please confirm your password to export your accounts.")}
</div>
}
>
<ExportAllAccountsForm onSuccess={onClose} />
</PasswordUnlock>
</ModalDialog>
</Modal>
)
}

type FormData = {
newPw: string
newPwConfirm: string
}

const ExportAllAccountsForm = ({ onSuccess }: { onSuccess?: () => void }) => {
const { t } = useTranslation()
const { password } = usePasswordUnlock()

const schema = useMemo(
() =>
yup
.object({
newPw: yup
.string()
.required(" ")
.min(6, t("Password must be at least 6 characters long")),
newPwConfirm: yup
.string()
.required(" ")
.oneOf([yup.ref("newPw")], t("Passwords must match!")),
})
.required(),
[t],
)

const {
register,
handleSubmit,
formState: { errors, isValid, isSubmitting },
watch,
setError,
setValue,
} = useForm<FormData>({
mode: "onChange",
resolver: yupResolver(schema),
})

const newPwWatch = watch("newPw")

const submit = useCallback(
async ({ newPw }: FormData) => {
if (!password) return
try {
const { exportedJson } = await api.accountExportAll(password, newPw)
downloadJson(exportedJson, "talisman-accounts")
onSuccess && onSuccess()
} catch (err) {
setError("newPwConfirm", {
message: (err as Error)?.message ?? "",
})
}
},
[setError, onSuccess, password],
)

useEffect(() => {
return () => {
setValue("newPw", "")
setValue("newPwConfirm", "")
}
}, [setValue])

if (!password) return null
return (
<div>
<form onSubmit={handleSubmit(submit)}>
<p className="text-body-secondary my-8 text-sm">
<Trans t={t}>
Set a password for your JSON export. We strongly suggest using a{" "}
<span className="text-white">different password</span> from your Talisman wallet
password. This avoids exposing your Talisman password to other wallets or applications.
</Trans>
</p>

<div className="mt-12">
<div className="mb-6 flex h-[1.2em] items-center justify-between text-sm">
<div className="text-body-disabled">
{t("Password strength:")} <PasswordStrength password={newPwWatch} />
</div>
<div>
<CapsLockWarningMessage />
</div>
</div>
<FormFieldContainer error={errors.newPw?.message}>
<FormFieldInputText
{...register("newPw")}
// eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus
placeholder={t("Enter New Password")}
spellCheck={false}
autoComplete="new-password"
data-lpignore
type="password"
tabIndex={0}
/>
</FormFieldContainer>
<FormFieldContainer error={errors.newPwConfirm?.message}>
<FormFieldInputText
{...register("newPwConfirm")}
placeholder={t("Confirm New Password")}
spellCheck={false}
autoComplete="off"
data-lpignore
type="password"
tabIndex={0}
/>
</FormFieldContainer>
</div>
<Button
className="mt-12"
type="submit"
primary
fullWidth
disabled={!isValid}
processing={isSubmitting}
>
{t("Export")}
</Button>
</form>
</div>
)
}
Original file line number Diff line number Diff line change
@@ -1,17 +1,28 @@
import { FolderPlusIcon, PlusIcon } from "@talismn/icons"
import { FolderPlusIcon, MoreHorizontalIcon, PlusIcon } from "@talismn/icons"
import { classNames } from "@talismn/util"
import { FC, ReactNode, useCallback } from "react"
import { useTranslation } from "react-i18next"
import { useNavigate } from "react-router-dom"
import { Tooltip, TooltipContent, TooltipTrigger } from "talisman-ui"
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuTrigger,
Tooltip,
TooltipContent,
TooltipTrigger,
useOpenClose,
} from "talisman-ui"

import { SearchInput } from "@talisman/components/SearchInput"
import { api } from "@ui/api"
import { AnalyticsPage } from "@ui/api/analytics"
import { useNewFolderModal } from "@ui/domains/Account/NewFolderModal"
import { PortfolioToolbarButton } from "@ui/domains/Portfolio/PortfolioToolbarButton"
import { useAccounts } from "@ui/state"
import { IS_POPUP } from "@ui/util/constants"

import { ExportAllAccountsModal } from "../ExportAllAccountsModal"
import { useManageAccounts } from "./ManageAccountsProvider"

export const ManageAccountsToolbar: FC<{
Expand Down Expand Up @@ -54,6 +65,7 @@ export const ManageAccountsToolbar: FC<{
</div>
<ToolbarButton icon={FolderPlusIcon} onClick={openNewFolderModal} label={t("Add Folder")} />
<ToolbarButton icon={PlusIcon} onClick={addNewAccountClick} label={t("Add Account")} />
<AccountsContextMenu />
</div>
)
}
Expand All @@ -79,3 +91,31 @@ const ToolbarButton: FC<{
{IS_POPUP && !label && <TooltipContent>{label}</TooltipContent>}
</Tooltip>
)

const AccountsContextMenu = () => {
const { t } = useTranslation()
const accounts = useAccounts()
const { isOpen: isOpenExportAll, open: openExportAll, close: closeExportAll } = useOpenClose()

if (!accounts.length) return null

return (
<>
<ContextMenu placement="bottom-end">
<ContextMenuTrigger
className={classNames(
"bg-grey-900 hover:bg-grey-800 text-body-secondary border-content flex items-center justify-center rounded-sm",
"focus-visible:border-grey-700 border border-transparent ring-transparent",
"@2xl:size-[4.4rem] size-[3.6rem]",
)}
>
<MoreHorizontalIcon />
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem onClick={openExportAll}>{t("Export all")}</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
<ExportAllAccountsModal isOpen={isOpenExportAll} onClose={closeExportAll} />
</>
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ export const PortfolioToolbarButton = forwardRef<
type="button"
{...props}
className={classNames(
"bg-grey-900 hover:bg-grey-800 text-body-secondary flex items-center justify-center rounded-sm",
"border-grey-700 size-16 ring-transparent focus-visible:border",
"bg-grey-900 hover:bg-grey-800 text-body-secondary border-content flex items-center justify-center rounded-sm",
"focus-visible:border-grey-700 size-16 border border-transparent ring-transparent",
props.className,
)}
/>
Expand Down
17 changes: 17 additions & 0 deletions packages/extension-core/src/domains/accounts/handler.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { ResponseAccountsExport } from "@polkadot/extension-base/background/types"
import { createPair, encodeAddress } from "@polkadot/keyring"
import { KeyringPair$Meta } from "@polkadot/keyring/types"
import keyring from "@polkadot/ui-keyring"
Expand All @@ -19,6 +20,7 @@ import type {
RequestAccountCreateSignet,
RequestAccountCreateWatched,
RequestAccountExport,
RequestAccountExportAll,
RequestAccountExportPrivateKey,
RequestAccountExternalSetIsPortfolio,
RequestAccountForget,
Expand Down Expand Up @@ -508,6 +510,19 @@ export default class AccountsHandler extends ExtensionHandler {
return val
}

private async accountExportAll({
password,
exportPw,
}: RequestAccountExportAll): Promise<ResponseAccountsExport> {
await this.stores.password.checkPassword(password)

const addresses = keyring.getPairs().map(({ address }) => address)

const exportedJson = await keyring.backupAccounts(addresses, exportPw)

return { exportedJson }
}

private async accountExportPrivateKey({
address,
password,
Expand Down Expand Up @@ -658,6 +673,8 @@ export default class AccountsHandler extends ExtensionHandler {
return this.accountForget(request as RequestAccountForget)
case "pri(accounts.export)":
return this.accountExport(request as RequestAccountExport)
case "pri(accounts.export.all)":
return this.accountExportAll(request as RequestAccountExportAll)
case "pri(accounts.export.pk)":
return this.accountExportPrivateKey(request as RequestAccountExportPrivateKey)
case "pri(accounts.rename)":
Expand Down
19 changes: 8 additions & 11 deletions packages/extension-core/src/domains/accounts/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type {
RequestAccountCreateHardware,
RequestAccountSubscribe,
ResponseAccountExport,
ResponseAccountsExport,
} from "@polkadot/extension-base/background/types"
import { KeyringPair$Json } from "@polkadot/keyring/types"
import { KeypairType } from "@polkadot/util-crypto/types"
Expand All @@ -15,17 +16,7 @@ import { Address } from "../../types/base"
export type { ResponseAccountExport, AccountJson }
export type { RequestAccountsCatalogAction } from "./helpers.catalog"

export type {
RequestAccountList,
RequestAccountBatchExport,
RequestAccountChangePassword,
RequestAccountCreateSuri,
RequestAccountEdit,
RequestAccountShow,
RequestAccountTie,
RequestAccountValidate,
ResponseJsonGetAccountInfo,
} from "@polkadot/extension-base/background/types"
export type { RequestAccountList } from "@polkadot/extension-base/background/types"

// account types ----------------------------------

Expand Down Expand Up @@ -209,6 +200,11 @@ export interface RequestAccountExport {
exportPw: string
}

export interface RequestAccountExportAll {
password: string
exportPw: string
}

export interface RequestAccountExportPrivateKey {
address: string
password: string
Expand Down Expand Up @@ -274,6 +270,7 @@ export interface AccountsMessages {
"pri(accounts.create.signet)": [RequestAccountCreateSignet, string]
"pri(accounts.forget)": [RequestAccountForget, boolean]
"pri(accounts.export)": [RequestAccountExport, ResponseAccountExport]
"pri(accounts.export.all)": [RequestAccountExportAll, ResponseAccountsExport]
"pri(accounts.export.pk)": [RequestAccountExportPrivateKey, string]
"pri(accounts.rename)": [RequestAccountRename, boolean]
"pri(accounts.external.setIsPortfolio)": [RequestAccountExternalSetIsPortfolio, boolean]
Expand Down
Loading
Loading