diff --git a/.vscode/settings.json b/.vscode/settings.json index 07badc1..ca232f1 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -49,6 +49,7 @@ "serialised", "singleline", "submenu", + "summarising", "testid", "timeago", "totalin", diff --git a/public/locales/en.json b/public/locales/en.json index f6453c4..634b847 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -586,6 +586,8 @@ "masterPasswordRequired": "Master password is required.", "masterPasswordIncorrect": "Master password is incorrect.", + "appMasterPasswordRequired": "You must be authenticated to import wallets.", + "fromFileButton": "Import from file", "textareaPlaceholder": "Paste backup code here", "textareaRequired": "Backup code is required.", @@ -673,5 +675,7 @@ "treeWallet": "Wallet {{id}}", "treeFriend": "Friend {{id}}" } - } + }, + + "walletLimitMessage": "You have more wallets stored than KristWeb supports. This was either caused by a bug, or you bypassed it intentionally. Expect issues with syncing." } diff --git a/src/App.tsx b/src/App.tsx index 110811e..96414ad 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -7,9 +7,6 @@ import { Provider } from "react-redux"; import { initStore } from "./store/init"; -import { HotKeys } from "react-hotkeys"; -import { keyMap } from "./global/AppHotkeys"; - // Set up localisation import "./utils/i18n"; @@ -31,14 +28,12 @@ return }> - - - + + - {/* Services, etc. */} - - - + {/* Services, etc. */} + + ; } diff --git a/src/components/auth/AuthorisedAction.tsx b/src/components/auth/AuthorisedAction.tsx index 749fcde..7cb536e 100644 --- a/src/components/auth/AuthorisedAction.tsx +++ b/src/components/auth/AuthorisedAction.tsx @@ -12,6 +12,9 @@ import "./AuthorisedAction.less"; +import Debug from "debug"; +const debug = Debug("kristweb:authorised-action"); + interface Props { encrypt?: boolean; onAuthed?: () => void; @@ -31,6 +34,8 @@ // directly: return { e.preventDefault(); + debug("authorised action occurred: was already authed"); + if (onAuthed) onAuthed(); }}> {children} @@ -40,6 +45,8 @@ return <> { e.preventDefault(); + debug("authorised action postponed: no master password set"); + if (!clicked) setClicked(true); setModalVisible(true); }}> @@ -49,7 +56,12 @@ {clicked && setModalVisible(false)} - onSubmit={() => { setModalVisible(false); if (onAuthed) onAuthed(); }} + onSubmit={() => { + debug("authorised action occurred: master password now set, continuing with action"); + + setModalVisible(false); + if (onAuthed) onAuthed(); + }} />} ; } else { @@ -57,7 +69,10 @@ // enter it: return { if (onAuthed) onAuthed(); }} + onSubmit={() => { + debug("authorised action occurred: master password provided"); + if (onAuthed) onAuthed(); + }} placement={popoverPlacement} > {children} diff --git a/src/components/auth/ForcedAuth.tsx b/src/components/auth/ForcedAuth.tsx deleted file mode 100644 index 645f05f..0000000 --- a/src/components/auth/ForcedAuth.tsx +++ /dev/null @@ -1,40 +0,0 @@ -// Copyright (c) 2020-2021 Drew Lemmy -// This file is part of KristWeb 2 under GPL-3.0. -// Full details: https://github.com/tmpim/KristWeb2/blob/master/LICENSE.txt -import { message } from "antd"; -import { useTranslation, TFunction } from "react-i18next"; - -import { useSelector, shallowEqual } from "react-redux"; -import { RootState } from "@store"; - -import { authMasterPassword } from "@wallets"; - -import { useMountEffect } from "@utils"; - -async function forceAuth(t: TFunction, salt: string, tester: string): Promise { - try { - const password = localStorage.getItem("forcedAuth"); - if (!password) return; - - await authMasterPassword(salt, tester, password); - message.warning(t("masterPassword.forcedAuthWarning")); - } catch (e) { - console.error(e); - } -} - -/** For development purposes, check the presence of a local storage key - * containing the master password, and automatically authenticate with it. */ -export function ForcedAuth(): JSX.Element | null { - const { isAuthed, hasMasterPassword, salt, tester } - = useSelector((s: RootState) => s.masterPassword, shallowEqual); - - const { t } = useTranslation(); - - useMountEffect(() => { - if (isAuthed || !hasMasterPassword || !salt || !tester) return; - forceAuth(t, salt, tester); - }); - - return null; -} diff --git a/src/components/wallets/SyncWallets.tsx b/src/components/wallets/SyncWallets.tsx index 86ed69d..edf20bc 100644 --- a/src/components/wallets/SyncWallets.tsx +++ b/src/components/wallets/SyncWallets.tsx @@ -1,18 +1,35 @@ // Copyright (c) 2020-2021 Drew Lemmy // This file is part of KristWeb 2 under GPL-3.0. // Full details: https://github.com/tmpim/KristWeb2/blob/master/LICENSE.txt -import { syncWallets } from "@wallets"; +import { useEffect } from "react"; +import { message } from "antd"; + +import { useTranslation } from "react-i18next"; + +import { syncWallets, useWallets, ADDRESS_LIST_LIMIT } from "@wallets"; import { useMountEffect } from "@utils"; /** Sync the wallets with the Krist node on startup. */ export function SyncWallets(): JSX.Element | null { - // 'wallets' is not a dependency here, we don't want to re-fetch on every - // wallet update, just the initial startup. We'll leave fetching wallets when - // the syncNode changes to the WebsocketService. + const { t } = useTranslation(); + useMountEffect(() => { // TODO: show errors to the user? syncWallets().catch(console.error); }); + // This is an appropriate place to perform the wallet limit check too. Warn + // the user if they have more wallets than ADDRESS_LIST_LIMIT; bypassing this + // limit will generally result in issues with syncing/fetching. + const { addressList } = useWallets(); + useEffect(() => { + if (addressList.length > ADDRESS_LIST_LIMIT) { + message.warning({ + content: t("walletLimitMessage"), + style: { maxWidth: 512, marginLeft: "auto", marginRight: "auto" } + }); + } + }, [t, addressList.length]); + return null; } diff --git a/src/global/AppHotkeys.tsx b/src/global/AppHotkeys.tsx index 39547a5..96c5a37 100644 --- a/src/global/AppHotkeys.tsx +++ b/src/global/AppHotkeys.tsx @@ -1,4 +1,18 @@ // Copyright (c) 2020-2021 Drew Lemmy // This file is part of KristWeb 2 under GPL-3.0. // Full details: https://github.com/tmpim/KristWeb2/blob/master/LICENSE.txt -export const keyMap = {}; +import React from "react"; + +import { useHistory } from "react-router-dom"; +import { GlobalHotKeys } from "react-hotkeys"; + +export function AppHotkeys(): JSX.Element { + const history = useHistory(); + + return history.push("/dev") + }} + />; +} diff --git a/src/global/AppServices.tsx b/src/global/AppServices.tsx index 562e069..2645de0 100644 --- a/src/global/AppServices.tsx +++ b/src/global/AppServices.tsx @@ -4,10 +4,11 @@ import React from "react"; import { SyncWallets } from "@comp/wallets/SyncWallets"; -import { ForcedAuth } from "@comp/auth/ForcedAuth"; +import { ForcedAuth } from "./ForcedAuth"; import { WebsocketService } from "./ws/WebsocketService"; import { SyncWork } from "./ws/SyncWork"; import { SyncMOTD } from "./ws/SyncMOTD"; +import { AppHotkeys } from "./AppHotkeys"; export function AppServices(): JSX.Element { return <> @@ -16,5 +17,6 @@ + ; } diff --git a/src/global/ForcedAuth.tsx b/src/global/ForcedAuth.tsx new file mode 100644 index 0000000..645f05f --- /dev/null +++ b/src/global/ForcedAuth.tsx @@ -0,0 +1,40 @@ +// Copyright (c) 2020-2021 Drew Lemmy +// This file is part of KristWeb 2 under GPL-3.0. +// Full details: https://github.com/tmpim/KristWeb2/blob/master/LICENSE.txt +import { message } from "antd"; +import { useTranslation, TFunction } from "react-i18next"; + +import { useSelector, shallowEqual } from "react-redux"; +import { RootState } from "@store"; + +import { authMasterPassword } from "@wallets"; + +import { useMountEffect } from "@utils"; + +async function forceAuth(t: TFunction, salt: string, tester: string): Promise { + try { + const password = localStorage.getItem("forcedAuth"); + if (!password) return; + + await authMasterPassword(salt, tester, password); + message.warning(t("masterPassword.forcedAuthWarning")); + } catch (e) { + console.error(e); + } +} + +/** For development purposes, check the presence of a local storage key + * containing the master password, and automatically authenticate with it. */ +export function ForcedAuth(): JSX.Element | null { + const { isAuthed, hasMasterPassword, salt, tester } + = useSelector((s: RootState) => s.masterPassword, shallowEqual); + + const { t } = useTranslation(); + + useMountEffect(() => { + if (isAuthed || !hasMasterPassword || !salt || !tester) return; + forceAuth(t, salt, tester); + }); + + return null; +} diff --git a/src/krist/wallets/functions/editWallet.ts b/src/krist/wallets/functions/editWallet.ts index 3f62120..b559e23 100644 --- a/src/krist/wallets/functions/editWallet.ts +++ b/src/krist/wallets/functions/editWallet.ts @@ -54,3 +54,39 @@ syncWallet(finalWallet); } + +/** + * Edits just a wallet's label and category. They can be set to an empty + * string to be removed, or to `undefined` to use the existing value. + * + * @param wallet - The old wallet information. + * @param label - The new wallet label. + * @param category - The new wallet category. + */ +export async function editWalletLabel( + wallet: Wallet, + label: string | "" | undefined, + category?: string | "" | undefined +): Promise { + const updatedLabel = label?.trim() === "" + ? undefined + : (label?.trim() || wallet.label); + const updatedCategory = category?.trim() === "" + ? undefined + : (category?.trim() || wallet.category); + + const finalWallet = { + ...wallet, + label: updatedLabel, + category: updatedCategory + }; + + // Save the updated wallet to local storage + saveWallet(finalWallet); + + // Dispatch the changes to the redux store + store.dispatch(actions.updateWallet(wallet.id, finalWallet)); + + syncWallet(finalWallet); +} + diff --git a/src/krist/wallets/functions/syncWallets.ts b/src/krist/wallets/functions/syncWallets.ts index e55c218..7e78207 100644 --- a/src/krist/wallets/functions/syncWallets.ts +++ b/src/krist/wallets/functions/syncWallets.ts @@ -8,6 +8,9 @@ import { Wallet, saveWallet } from ".."; +import Debug from "debug"; +const debug = Debug("kristweb:sync-wallets"); + function syncWalletProperties( wallet: Wallet, address: KristAddressWithNames, @@ -29,6 +32,8 @@ const { address } = wallet; const lookupResults = await lookupAddresses([address], true); + debug("synced individual wallet %s (%s): %o", wallet.id, wallet.address, lookupResults); + const kristAddress = lookupResults[address]; if (!kristAddress) return; // Skip unsyncable wallet diff --git a/src/layout/nav/CymbalIndicator.tsx b/src/layout/nav/CymbalIndicator.tsx index f927889..681a3ad 100644 --- a/src/layout/nav/CymbalIndicator.tsx +++ b/src/layout/nav/CymbalIndicator.tsx @@ -2,12 +2,17 @@ // This file is part of KristWeb 2 under GPL-3.0. // Full details: https://github.com/tmpim/KristWeb2/blob/master/LICENSE.txt import React from "react"; +import { Typography } from "antd"; import Icon from "@ant-design/icons"; import { useSelector, shallowEqual } from "react-redux"; import { RootState } from "@store"; import { SettingsState } from "@utils/settings"; +import { useWallets, ADDRESS_LIST_LIMIT } from "@wallets"; + +const { Text } = Typography; + export const CymbalIconSvg = (): JSX.Element => ( @@ -18,9 +23,17 @@ export function CymbalIndicator(): JSX.Element | null { const allSettings: SettingsState = useSelector((s: RootState) => s.settings, shallowEqual); - const on = allSettings.walletFormats; + const { addressList } = useWallets(); + + const on = allSettings.walletFormats + || addressList.length > ADDRESS_LIST_LIMIT; return on ?
+ {addressList.length > ADDRESS_LIST_LIMIT && ( + + {addressList.length.toLocaleString()} + + )}
: null; } diff --git a/src/pages/CheckStatus.tsx b/src/pages/CheckStatus.tsx index 67bea1b..62620df 100644 --- a/src/pages/CheckStatus.tsx +++ b/src/pages/CheckStatus.tsx @@ -7,6 +7,6 @@ import { StatusPage } from "./StatusPage"; export function CheckStatus(): JSX.Element { - const ok = localStorage.getItem("wallet2-dad89f1a-3005-4cc1-afaa-4deba1e3c081") && localStorage.getItem("status") === "Ok"; + const ok = localStorage.getItem("status") === "Ok"; return ok ? : ; } diff --git a/src/pages/backup/BackupResultsSummary.less b/src/pages/backup/BackupResultsSummary.less new file mode 100644 index 0000000..9195671 --- /dev/null +++ b/src/pages/backup/BackupResultsSummary.less @@ -0,0 +1,21 @@ +// Copyright (c) 2020-2021 Drew Lemmy +// This file is part of KristWeb 2 under GPL-3.0. +// Full details: https://github.com/tmpim/KristWeb2/blob/master/LICENSE.txt +@import (reference) "../../App.less"; + +.backup-results-summary { + .summary-wallets-imported .positive { + color: @kw-green; + font-weight: bold; + } + + .summary-errors-warnings .errors { + color: @kw-red; + font-weight: bold; + } + + .summary-errors-warnings .warnings { + color: @kw-orange; + font-weight: bold; + } +} diff --git a/src/pages/backup/BackupResultsSummary.tsx b/src/pages/backup/BackupResultsSummary.tsx new file mode 100644 index 0000000..2740ccc --- /dev/null +++ b/src/pages/backup/BackupResultsSummary.tsx @@ -0,0 +1,81 @@ +// Copyright (c) 2020-2021 Drew Lemmy +// This file is part of KristWeb 2 under GPL-3.0. +// Full details: https://github.com/tmpim/KristWeb2/blob/master/LICENSE.txt +import React from "react"; +import { Typography } from "antd"; + +import { useTranslation, Trans } from "react-i18next"; + +import { BackupResults } from "./backupResults"; + +import "./BackupResultsSummary.less"; + +const { Paragraph } = Typography; + +/** Provides a paragraph summarising the results of the backup import (e.g. the + * amount of wallets imported, the amount of errors, etc.). */ +export function BackupResultsSummary({ results }: { results: BackupResults }): JSX.Element { + const { t } = useTranslation(); + + // TODO: do this for friends too + const { newWallets, skippedWallets } = results; + const warningCount = Object.values(results.messages.wallets) + .reduce((acc, msgs) => acc + msgs.filter(m => m.type === "warning").length, 0); + const errorCount = Object.values(results.messages.wallets) + .reduce((acc, msgs) => acc + msgs.filter(m => m.type === "error").length, 0); + + return + {/* New wallets imported count */} +
+ + 0 ? "positive" : ""}> + {{ count: newWallets }} new wallet + + was imported. + +
+ + {/* Skipped wallets count */} + {skippedWallets > 0 &&
+ + {{ count: skippedWallets }} wallet was skipped. + +
} + + {/* TODO: Show friend counts too (only if >0) */} + + {/* Errors/warnings */} +
+ {warningCount > 0 && errorCount > 0 + ? ( + // Show errors and warnings + + There were + {{ errors: errorCount }} error(s) + and + {{ warnings: warningCount }} warning(s) + while importing your backup. + + ) + : (warningCount > 0 + ? ( + // Show just warnings + + There was + {{ count: warningCount }} warning + while importing your backup. + + ) + : (errorCount > 0 + ? ( + // Show just errors + + There was + {{ count: errorCount }} error + while importing your backup. + + ) + : <>))} +
+
; +} diff --git a/src/pages/backup/BackupResultsTree.less b/src/pages/backup/BackupResultsTree.less index cbaa146..c9e8007 100644 --- a/src/pages/backup/BackupResultsTree.less +++ b/src/pages/backup/BackupResultsTree.less @@ -4,6 +4,9 @@ @import (reference) "../../App.less"; .backup-results-tree { + max-height: 480px; + overflow-y: auto; + .backup-result-icon { margin-right: @padding-xs; } @@ -17,4 +20,9 @@ .ant-tree-treenode:not(.backup-results-tree-message) .ant-tree-title { font-weight: 500; } + + // The leaf nodes have an invisible button which takes up space; remove that + .ant-tree-switcher-noop { + display: none; + } } diff --git a/src/pages/backup/BackupResultsTree.tsx b/src/pages/backup/BackupResultsTree.tsx index 338ac00..ddc905b 100644 --- a/src/pages/backup/BackupResultsTree.tsx +++ b/src/pages/backup/BackupResultsTree.tsx @@ -2,7 +2,7 @@ // This file is part of KristWeb 2 under GPL-3.0. // Full details: https://github.com/tmpim/KristWeb2/blob/master/LICENSE.txt import React, { useMemo } from "react"; -import { Tree, Typography } from "antd"; +import { Tree } from "antd"; import { DataNode } from "antd/lib/tree"; import { CheckCircleOutlined, WarningOutlined, ExclamationCircleOutlined } from "@ant-design/icons"; @@ -16,8 +16,6 @@ import "./BackupResultsTree.less"; -const { Paragraph } = Typography; - interface Props { results: BackupResults; } @@ -74,7 +72,7 @@ results: BackupResults ): DataNode[] { // Add the wallet messages data - const walletData: DataNode[] = []; + const out: DataNode[] = []; for (const id in results.messages.wallets) { // The IDs are the keys of the backup, which may begin with prefixes like @@ -97,7 +95,7 @@ }); } - walletData.push({ + out.push({ key: `wallets-${cleanID}`, title: t("import.results.treeWallet", { id: cleanID }), children: messageNodes @@ -106,11 +104,7 @@ // TODO: Add the friends data - return [{ - key: "wallets", - title: t("import.results.treeHeaderWallets"), - children: walletData - }]; + return out; } export function BackupResultsTree({ results }: Props): JSX.Element { @@ -119,19 +113,13 @@ const treeData = useMemo(() => getTreeData(t, i18n, results), [t, i18n, results]); - return <> - {/* Results summary */} - + return - ; + treeData={treeData} + />; } diff --git a/src/pages/backup/ImportBackupModal.tsx b/src/pages/backup/ImportBackupModal.tsx index efb8eec..336ccce 100644 --- a/src/pages/backup/ImportBackupModal.tsx +++ b/src/pages/backup/ImportBackupModal.tsx @@ -13,6 +13,7 @@ import { decodeBackup } from "./backupParser"; import { backupVerifyPassword, backupImport } from "./backupImport"; import { BackupResults } from "./backupResults"; +import { BackupResultsSummary } from "./BackupResultsSummary"; import { BackupResultsTree } from "./BackupResultsTree"; import Debug from "debug"; @@ -36,6 +37,7 @@ const { t } = useTranslation(); const [form] = Form.useForm(); + const [loading, setLoading] = useState(false); const [code, setCode] = useState(""); const [decodeError, setDecodeError] = useState(); const [masterPasswordError, setMasterPasswordError] = useState(); @@ -44,6 +46,7 @@ /** Resets all the state when the modal is closed. */ function resetState() { form.resetFields(); + setLoading(false); setCode(""); setDecodeError(""); setMasterPasswordError(""); @@ -106,6 +109,8 @@ const { masterPassword, code, overwrite } = values; if (!masterPassword || !code) return; + setLoading(true); + try { // Decode first const backup = decodeBackup(code); @@ -129,6 +134,8 @@ console.error(err); setDecodeError(translateError(t, err, "import.decodeErrors.unknown")); } + } finally { + setLoading(false); } } @@ -141,7 +148,7 @@ // Grow the modal when there are results. This not only helps make it look // better, but also prevents the user from accidentally double clicking // the 'Import' button and immediately closing the results. - width={results ? 768 : undefined} + width={results ? 640 : undefined} onCancel={closeModal} @@ -187,16 +194,23 @@ , // "Import" button for import screen - ]} > {results - ? ( - // Got results - show them + ? <> + {/* Got results - show them */} + - ) + : ( // No results - show the import form - + {/* Open import backup modal */} + setModalVisible(true)} + > + + + +

+ + {/* Delete all wallets with zero balance */} + ; }