diff --git a/public/locales/en.json b/public/locales/en.json index 0370774..34396f2 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -108,9 +108,15 @@ "nameCount_plural": "{{count}} names", "firstSeen": "First seen {{date}}", + "walletCount": "{{count}} wallet", + "walletCount_plural": "{{count}} wallets", + "actionsEditTooltip": "Edit wallet", "actionsDelete": "Delete wallet", - "actionsDeleteConfirm": "Are you sure you want to delete this wallet?" + "actionsDeleteConfirm": "Are you sure you want to delete this wallet?", + + "tagDontSave": "Temp", + "tagDontSaveTooltip": "Temporary wallet" }, "myTransactions": { @@ -125,8 +131,10 @@ "addWallet": { "dialogTitle": "Add wallet", "dialogTitleCreate": "Create wallet", + "dialogTitleEdit": "Edit wallet", "dialogOkAdd": "Add", "dialogOkCreate": "Create", + "dialogOkEdit": "Save", "walletLabel": "Wallet label", "walletLabelPlaceholder": "Wallet label (optional)", @@ -161,13 +169,19 @@ "messageSuccessAdd": "Added wallet successfully!", "messageSuccessCreate": "Created wallet successfully!", + "messageSuccessEdit": "Saved wallet successfully!", "errorPasswordRequired": "Password is required.", "errorPrivatekeyRequired": "Private key is required.", "errorUnexpectedTitle": "Unexpected error", "errorUnexpectedDescription": "There was an error while adding the wallet. See console for details.", + "errorUnexpectedEditDescription": "There was an error while editing the wallet. See console for details.", "errorDuplicateWalletTitle": "Wallet already exists", - "errorDuplicateWalletDescription": "You already have a wallet for that address." + "errorDuplicateWalletDescription": "You already have a wallet for that address.", + "errorMissingWalletTitle": "Wallet not found", + "errorMissingWalletDescription": "The wallet you are trying to edit no longer exists.", + "errorDecryptTitle": "Incorrect master password", + "errorDecryptDescription": "Failed to decrypt the wallet password. Is the master password correct?" }, "credits": { diff --git a/src/components/auth/AuthMasterPasswordPopover.tsx b/src/components/auth/AuthMasterPasswordPopover.tsx index 92bc290..540565b 100644 --- a/src/components/auth/AuthMasterPasswordPopover.tsx +++ b/src/components/auth/AuthMasterPasswordPopover.tsx @@ -1,9 +1,10 @@ import React, { useState, useRef, FunctionComponent } from "react"; import { Popover, Button, Input, Form } from "antd"; -import { useTranslation } from "react-i18next"; +import { TooltipPlacement } from "antd/lib/tooltip"; import { useDispatch, useSelector, shallowEqual } from "react-redux"; import { RootState } from "../../store"; +import { useTranslation } from "react-i18next"; import { FakeUsernameInput } from "./FakeUsernameInput"; import { getMasterPasswordInput } from "./MasterPasswordInput"; @@ -17,9 +18,10 @@ interface Props { encrypt?: boolean; onSubmit: () => void; + placement?: TooltipPlacement; } -export const AuthMasterPasswordPopover: FunctionComponent = ({ encrypt, onSubmit, children }) => { +export const AuthMasterPasswordPopover: FunctionComponent = ({ encrypt, onSubmit, placement, children }) => { const { salt, tester } = useSelector((s: RootState) => s.walletManager, shallowEqual); const dispatch = useDispatch(); @@ -45,6 +47,7 @@ trigger="click" overlayClassName="authorised-action-popover" title={t(encrypt ? "masterPassword.popoverTitleEncrypt" : "masterPassword.popoverTitle")} + placement={placement} onVisibleChange={visible => { if (visible) setTimeout(() => { if (inputRef.current) inputRef.current.focus(); }, 20); }} diff --git a/src/components/auth/AuthorisedAction.tsx b/src/components/auth/AuthorisedAction.tsx index 8f3e94f..cfd0b26 100644 --- a/src/components/auth/AuthorisedAction.tsx +++ b/src/components/auth/AuthorisedAction.tsx @@ -1,4 +1,5 @@ import React, { FunctionComponent, useState } from "react"; +import { TooltipPlacement } from "antd/lib/tooltip"; import { useSelector, shallowEqual } from "react-redux"; import { RootState } from "../../store"; @@ -11,9 +12,10 @@ interface Props { encrypt?: boolean; onAuthed?: () => void; + popoverPlacement?: TooltipPlacement; } -export const AuthorisedAction: FunctionComponent = ({ encrypt, onAuthed, children }) => { +export const AuthorisedAction: FunctionComponent = ({ encrypt, onAuthed, popoverPlacement, children }) => { const { isAuthed, hasMasterPassword } = useSelector((s: RootState) => s.walletManager, shallowEqual); @@ -53,6 +55,7 @@ return { if (onAuthed) onAuthed(); }} + placement={popoverPlacement} > {children} ; diff --git a/src/krist/wallets/Wallet.ts b/src/krist/wallets/Wallet.ts index 10d94a2..e41a581 100644 --- a/src/krist/wallets/Wallet.ts +++ b/src/krist/wallets/Wallet.ts @@ -3,7 +3,7 @@ import { applyWalletFormat, WalletFormatName } from "./formats/WalletFormat"; import { makeV2Address } from "../AddressAlgo"; -import { aesGcmEncrypt } from "../../utils/crypto"; +import { aesGcmDecrypt, aesGcmEncrypt } from "../../utils/crypto"; import { KristAddressWithNames, lookupAddresses } from "../api/lookup"; @@ -40,13 +40,21 @@ export type WalletNew = Pick; /** Properties of Wallet that are allowed to be updated. */ -export type WalletUpdatableKeys = "label" | "category" | "encPassword" | "encPrivatekey" | "username" | "format" | "address"; +export type WalletUpdatableKeys + = "label" | "category" | "encPassword" | "encPrivatekey" | "username" | "format" | "address"; +export const WALLET_UPDATABLE_KEYS: WalletUpdatableKeys[] + = ["label", "category", "encPassword", "encPrivatekey", "username", "format", "address"]; export type WalletUpdatable = Pick; /** Properties of Wallet that are allowed to be synced. */ -export type WalletSyncableKeys = "balance" | "names" | "firstSeen" | "lastSynced"; +export type WalletSyncableKeys + = "balance" | "names" | "firstSeen" | "lastSynced"; +export const WALLET_SYNCABLE_KEYS: WalletSyncableKeys[] + = ["balance", "names", "firstSeen", "lastSynced"]; export type WalletSyncable = Pick; +export interface DecryptedWallet { password: string; privatekey: string } + /** Get the local storage key for a given wallet. */ export function getWalletKey(wallet: Wallet): string { return `wallet2-${wallet.id}`; @@ -176,7 +184,7 @@ wallet: WalletNew, password: string, save: boolean -): Promise { +): Promise { // Calculate the privatekey for the given wallet format const privatekey = await applyWalletFormat(wallet.format || "kristwallet", password, wallet.username); const address = await makeV2Address(privatekey); @@ -208,8 +216,55 @@ dispatch(actions.addWallet(newWallet)); syncWallet(dispatch, newWallet); +} - return newWallet; +/** + * Edits a new wallet, encrypting its privatekey and password, saving it to + * local storage, and dispatching the changes to the Redux store. + * + * @param dispatch - The AppDispatch instance used to dispatch the new wallet to + * the Redux store. + * @param masterPassword - The master password used to encrypt the wallet + * password and privatekey. + * @param wallet - The old wallet information. + * @param updated - The new wallet information. + * @param password - The password of the updated wallet. + */ +export async function editWallet( + dispatch: AppDispatch, + masterPassword: string, + wallet: Wallet, + updated: WalletNew, + password: string +): Promise { + // Calculate the privatekey for the given wallet format + const privatekey = await applyWalletFormat(updated.format || "kristwallet", password, updated.username); + const address = await makeV2Address(privatekey); + + // Encrypt the password and privatekey. These will be decrypted on-demand. + const encPassword = await aesGcmEncrypt(password, masterPassword); + const encPrivatekey = await aesGcmEncrypt(privatekey, masterPassword); + + const finalWallet = { + ...wallet, + + label: updated.label?.trim() || undefined, // clean up empty strings + category: updated.category?.trim() || undefined, + + address, + username: updated.username, + encPassword, + encPrivatekey, + format: updated.format + }; + + // Save the updated wallet to local storage + saveWallet(finalWallet); + + // Dispatch the changes to the redux store + dispatch(actions.updateWallet(wallet.id, finalWallet)); + + syncWallet(dispatch, finalWallet); } /** Deletes a wallet, removing it from local storage and dispatching the change @@ -220,3 +275,19 @@ dispatch(actions.removeWallet(wallet.id)); } + +/** Decrypts a wallet's password and privatekey. */ +export async function decryptWallet(masterPassword: string, wallet: Wallet): Promise { + try { + const decPassword = await aesGcmDecrypt(wallet.encPassword, masterPassword); + const decPrivatekey = await aesGcmDecrypt(wallet.encPrivatekey, masterPassword); + + return { password: decPassword, privatekey: decPrivatekey }; + } catch (e) { + // OperationError usually means decryption failure + if (e.name === "OperationError") return null; + + console.error(e); + return null; + } +} diff --git a/src/layout/PageLayout.tsx b/src/layout/PageLayout.tsx index 93721ee..3914602 100644 --- a/src/layout/PageLayout.tsx +++ b/src/layout/PageLayout.tsx @@ -9,9 +9,9 @@ export type PageLayoutProps = React.HTMLProps & { siteTitle?: string; siteTitleKey?: string; - title?: string; + title?: React.ReactNode | string; titleKey?: string; - subTitle?: string; + subTitle?: React.ReactNode | string; subTitleKey?: string; extra?: React.ReactNode; diff --git a/src/pages/wallets/AddWalletModal.tsx b/src/pages/wallets/AddWalletModal.tsx index aceae3a..0f3857d 100644 --- a/src/pages/wallets/AddWalletModal.tsx +++ b/src/pages/wallets/AddWalletModal.tsx @@ -15,7 +15,7 @@ import { WalletFormatName, applyWalletFormat, formatNeedsUsername } from "../../krist/wallets/formats/WalletFormat"; import { getSelectWalletFormat } from "../../components/wallets/SelectWalletFormat"; import { makeV2Address } from "../../krist/AddressAlgo"; -import { addWallet } from "../../krist/wallets/Wallet"; +import { addWallet, decryptWallet, editWallet, Wallet } from "../../krist/wallets/Wallet"; const { Text } = Typography; @@ -32,12 +32,16 @@ interface Props { create?: boolean; + editing?: Wallet; visible: boolean; setVisible: React.Dispatch>; } -export function AddWalletModal({ create, visible, setVisible }: Props): JSX.Element { +export function AddWalletModal({ create, editing, visible, setVisible }: Props): JSX.Element { + if (editing && create) + throw new Error("AddWalletModal: 'editing' and 'create' simultaneously, uh oh!"); + const initialFormat = "kristwallet"; // TODO: change for edit modal // Required to encrypt new wallets @@ -54,30 +58,49 @@ const [formatState, setFormatState] = useState(initialFormat); async function onSubmit() { - if (!masterPassword) throw new Error(t("masterPassword.errorNoPassword")); + if (!masterPassword) return notification.error({ + message: t("addWallet.errorUnexpectedTitle"), + description: t("masterPassword.errorNoPassword") + }); const values = await form.validateFields(); if (!values.password) return; - // Check if the wallet already exists - if (Object.values(wallets).find(w => w.address === calculatedAddress)) { - return notification.error({ - message: t("addWallet.errorDuplicateWalletTitle"), - description: t("addWallet.errorDuplicateWalletDescription") - }); - } - try { - await addWallet(dispatch, masterPassword, values, values.password, values.save ?? true); - message.success(create ? t("addWallet.messageSuccessCreate") : t("addWallet.messageSuccessAdd")); + if (editing) { // Edit wallet + // Double check the wallet exists + if (!wallets[editing.id]) return notification.error({ + message: t("addWallet.errorMissingWalletTitle"), + description: t("addWallet.errorMissingWalletDescription") + }); - form.resetFields(); // Make sure to generate another password on re-open - setVisible(false); + await editWallet(dispatch, masterPassword, editing, values, values.password); + message.success(t("addWallet.messageSuccessEdit")); + + form.resetFields(); + setVisible(false); + } else { // Add/create wallet + // Check if the wallet already exists + if (Object.values(wallets).find(w => w.address === calculatedAddress)) { + return notification.error({ + message: t("addWallet.errorDuplicateWalletTitle"), + description: t("addWallet.errorDuplicateWalletDescription") + }); + } + + await addWallet(dispatch, masterPassword, values, values.password, values.save ?? true); + message.success(create ? t("addWallet.messageSuccessCreate") : t("addWallet.messageSuccessAdd")); + + form.resetFields(); // Make sure to generate another password on re-open + setVisible(false); + } } catch (err) { console.error(err); notification.error({ message: t("addWallet.errorUnexpectedTitle"), - description: t("addWallet.errorUnexpectedDescription") + description: editing + ? t("addWallet.errorUnexpectedEditDescription") + : t("addWallet.errorUnexpectedDescription") }); } } @@ -103,31 +126,59 @@ updateCalculatedAddress("kristwallet", password); } - // Generate a password when the modal is opened useEffect(() => { - if (create && visible && form && !form.getFieldValue("password")) { - generateNewPassword(); + if (visible && form && !form.getFieldValue("password")) { + // Generate a password when the 'Create wallet' modal is opened + if (create) generateNewPassword(); + + // Populate the password when the 'Edit wallet' modal is opened + if (editing && masterPassword) { + (async () => { + const dec = await decryptWallet(masterPassword, editing); + if (!dec) return notification.error({ + message: t("addWallet.errorDecryptTitle"), + description: t("addWallet.errorDecryptDescription") + }); + + const password = dec.password; + form.setFieldsValue({ password }); + updateCalculatedAddress(form.getFieldValue("format"), password); + })(); + } } - }, [create, visible, form]); + }, [visible, form, create, editing]); return { form.resetFields(); setVisible(false); }} onOk={onSubmit} + + destroyOnClose >
{/* Save in KristWeb checkbox */} - + {!editing && {t("addWallet.walletSave")} - + } }
diff --git a/src/pages/wallets/WalletsPage.tsx b/src/pages/wallets/WalletsPage.tsx index 790e366..71d2182 100644 --- a/src/pages/wallets/WalletsPage.tsx +++ b/src/pages/wallets/WalletsPage.tsx @@ -2,6 +2,8 @@ import { Button } from "antd"; import { DatabaseOutlined, PlusOutlined } from "@ant-design/icons"; +import { useSelector, shallowEqual } from "react-redux"; +import { RootState } from "../../store"; import { useTranslation } from "react-i18next"; import { PageLayout } from "../../layout/PageLayout"; @@ -11,6 +13,15 @@ import "./WalletsPage.less"; +/** Extract the subtitle into its own component to avoid re-rendering the + * entire page when a wallet is added. */ +function WalletsPageSubtitle(): JSX.Element { + const { t } = useTranslation(); + const { wallets } = useSelector((s: RootState) => s.wallets, shallowEqual); + + return <>{t("myWallets.walletCount", { count: Object.keys(wallets).length })}; +} + function WalletsPageExtraButtons(): JSX.Element { const { t } = useTranslation(); const [createWalletVisible, setCreateWalletVisible] = useState(false); @@ -34,10 +45,9 @@ } export function WalletsPage(): JSX.Element { - const { t } = useTranslation(); - return } extra={} > diff --git a/src/pages/wallets/WalletsTable.tsx b/src/pages/wallets/WalletsTable.tsx index ec47746..35c0903 100644 --- a/src/pages/wallets/WalletsTable.tsx +++ b/src/pages/wallets/WalletsTable.tsx @@ -1,5 +1,5 @@ -import React from "react"; -import { Table, Tooltip, Dropdown, Menu, Popconfirm } from "antd"; +import React, { useState } from "react"; +import { Table, Tooltip, Dropdown, Tag, Menu, Popconfirm } from "antd"; import { EditOutlined, DeleteOutlined } from "@ant-design/icons"; import { useDispatch, useSelector, shallowEqual } from "react-redux"; @@ -8,37 +8,63 @@ import { KristValue } from "../../components/KristValue"; import { DateTime } from "../../components/DateTime"; +import { AuthorisedAction } from "../../components/auth/AuthorisedAction"; +import { AddWalletModal } from "./AddWalletModal"; import { Wallet, deleteWallet } from "../../krist/wallets/Wallet"; import { keyedNullSort, localeSort } from "../../utils"; function WalletActions({ wallet }: { wallet: Wallet }): JSX.Element { - const { t } = useTranslation(); const dispatch = useDispatch(); + const { t } = useTranslation(); + const [editWalletVisible, setEditWalletVisible] = useState(false); + function onDeleteWallet() { deleteWallet(dispatch, wallet); } - return - {/* Delete button */} - - - {t("myWallets.actionsDelete")} - - - }> - {/* Edit button */} - + return <> + [ + + setEditWalletVisible(true)} + popoverPlacement="left" + > + {React.cloneElement(leftButton as React.ReactElement, { disabled: wallet.dontSave })} + + , + rightButton + ]} + + overlay={( + + {/* Delete button */} + + + {t("myWallets.actionsDelete")} + + + + )}> + + {/* Edit button */} - - ; + + + + ; } export function WalletsTable(): JSX.Element { @@ -53,14 +79,27 @@ localeSort(categories); return <> + {label} + {record.dontSave && + {t("myWallets.tagDontSave")} + } + , sorter: keyedNullSort("label", true) }, diff --git a/src/store/reducers/WalletsReducer.ts b/src/store/reducers/WalletsReducer.ts index 3e21880..8747f7d 100644 --- a/src/store/reducers/WalletsReducer.ts +++ b/src/store/reducers/WalletsReducer.ts @@ -1,7 +1,7 @@ import * as actions from "../actions/WalletsActions"; import { createReducer, ActionType } from "typesafe-actions"; -import { Wallet, loadWallets } from "../../krist/wallets/Wallet"; +import { Wallet, loadWallets, WALLET_UPDATABLE_KEYS, WALLET_SYNCABLE_KEYS } from "../../krist/wallets/Wallet"; export interface WalletMap { [key: string]: Wallet } export interface State { @@ -13,15 +13,20 @@ return { wallets }; } -function assignNewWalletProperties(state: State, id: string, partialWallet: Partial) { +function assignNewWalletProperties(state: State, id: string, partialWallet: Partial, allowedKeys?: (keyof Wallet)[]) { // Fetch the old wallet and assign the new properties const { [id]: wallet } = state.wallets; - const newWallet = { ...wallet, ...partialWallet }; + const newWallet = allowedKeys + ? allowedKeys.reduce((o, key) => partialWallet[key] !== undefined + ? { ...o, [key]: partialWallet[key] } + : o, {}) + : partialWallet; + return { ...state, wallets: { ...state.wallets, - [id]: newWallet + [id]: { ...wallet, ...newWallet } } }; } @@ -51,10 +56,10 @@ }) // Update wallet .handleAction(actions.updateWallet, (state: State, { payload }: ActionType) => - assignNewWalletProperties(state, payload.id, payload.wallet)) + assignNewWalletProperties(state, payload.id, payload.wallet, WALLET_UPDATABLE_KEYS)) // Sync wallet .handleAction(actions.syncWallet, (state: State, { payload }: ActionType) => - assignNewWalletProperties(state, payload.id, payload.wallet)) + assignNewWalletProperties(state, payload.id, payload.wallet, WALLET_SYNCABLE_KEYS)) // Sync wallets .handleAction(actions.syncWallets, (state: State, { payload }: ActionType) => { const updatedWallets = Object.entries(payload.wallets) diff --git a/src/style/theme.less b/src/style/theme.less index 08ad69c..559961c 100644 --- a/src/style/theme.less +++ b/src/style/theme.less @@ -92,6 +92,7 @@ .ant-table .ant-table-tbody > tr:last-child > td { border-bottom: 0; } +@table-header-icon-color: @text-color-secondary; // general theme // ---