diff --git a/public/locales/en.json b/public/locales/en.json index 13c2ff1..753be54 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -1080,7 +1080,7 @@ "legacyMigration": { "modalTitle": "KristWeb v1 migration", - "description": "Welcome to KristWeb v2! It looks like you have used KristWeb v1 on this domain before. Please enter your master password to migrate your wallets to the new format. You will only have to do this once.", + "description": "Welcome to KristWeb v2! It looks like you have used KristWeb v1 on this domain before.

Please enter your master password to migrate your wallets to the new format. You will only have to do this once.", "walletCount": "Detected <1>{{count, number}} wallets", "contactCount": "Detected <1>{{count, number}} contacts", @@ -1091,6 +1091,7 @@ "errorPasswordRequired": "Password is required.", "errorPasswordLength": "Must be at least 1 character.", "errorPasswordIncorrect": "Incorrect password.", + "errorUnknown": "An unknown error occurred. See console for details.", "buttonSubmit": "Begin migration" } diff --git a/src/global/AppServices.tsx b/src/global/AppServices.tsx index fb5da45..fec7e9c 100644 --- a/src/global/AppServices.tsx +++ b/src/global/AppServices.tsx @@ -2,7 +2,7 @@ // This file is part of KristWeb 2 under AGPL-3.0. // Full details: https://github.com/tmpim/KristWeb2/blob/master/LICENSE.txt import { StorageBroadcast } from "./StorageBroadcast"; -import { LegacyMigration } from "./LegacyMigration"; +import { LegacyMigration } from "./legacy/LegacyMigration"; import { SyncWallets } from "@comp/wallets/SyncWallets"; import { ForcedAuth } from "./ForcedAuth"; import { WebsocketService } from "./ws/WebsocketService"; diff --git a/src/global/LegacyMigration.tsx b/src/global/LegacyMigration.tsx deleted file mode 100644 index 553d2aa..0000000 --- a/src/global/LegacyMigration.tsx +++ /dev/null @@ -1,59 +0,0 @@ -// Copyright (c) 2020-2021 Drew Lemmy -// This file is part of KristWeb 2 under AGPL-3.0. -// Full details: https://github.com/tmpim/KristWeb2/blob/master/LICENSE.txt -import { useEffect } from "react"; - -import { BackupFormatType } from "@pages/backup/backupFormats"; - -import Debug from "debug"; -const debug = Debug("kristweb:legacy-migration"); - -export function LegacyMigration(): JSX.Element | null { - useEffect(() => { - debug("checking legacy migration status"); - - // Check if legacy migration has already been handled - const legacyMigrated = localStorage.getItem("migrated"); - if (legacyMigrated === "2") { - debug("migration already at 2, done"); - return; - } - - // Check if there is a v1 master password in local storage - const legacySalt = localStorage.getItem("salt") || undefined; - const legacyTester = localStorage.getItem("tester") || undefined; - const hasLegacyMasterPassword = !!legacySalt && !!legacyTester; - if (!hasLegacyMasterPassword) { - debug("no legacy master password, done"); - return; - } - - // Check if there are any v1 wallets or contacts in local storage - const walletIndex = localStorage.getItem("Wallet") || undefined; - const contactIndex = localStorage.getItem("Friend") || undefined; - if (!walletIndex && !contactIndex) { - debug("no wallets or contacts, done"); - return; - } - - // Fetch all the wallets and contacts, skipping over any that are missing - const wallets: Record = Object.fromEntries((walletIndex || "").split(",") - .map(id => [`Wallet-${id}`, localStorage.getItem(`Wallet-${id}`)]) - .filter(([_, v]) => !!v)); - const contacts: Record = Object.fromEntries((contactIndex || "").split(",") - .map(id => [`Friend-${id}`, localStorage.getItem(`Friend-${id}`)]) - .filter(([_, v]) => !!v)); - debug("found %d wallets and %d contacts", Object.keys(wallets).length, Object.keys(contacts).length); - - // Construct the backup object prior to showing the modal - const backup = { - type: BackupFormatType.KRISTWEB_V1, - wallets, - friends: contacts - }; - - debug(backup); - }, []); - - return null; -} diff --git a/src/global/legacy/LegacyMigration.tsx b/src/global/legacy/LegacyMigration.tsx new file mode 100644 index 0000000..240066e --- /dev/null +++ b/src/global/legacy/LegacyMigration.tsx @@ -0,0 +1,121 @@ +// Copyright (c) 2020-2021 Drew Lemmy +// This file is part of KristWeb 2 under AGPL-3.0. +// Full details: https://github.com/tmpim/KristWeb2/blob/master/LICENSE.txt +import { useState, useEffect } from "react"; + +import { BackupFormatType, BackupKristWebV1 } from "@pages/backup/backupFormats"; +import { LegacyMigrationModal } from "./LegacyMigrationModal"; + +import Debug from "debug"; +const debug = Debug("kristweb:legacy-migration"); + +const MIGRATION_CLEANUP_THRESHOLD = 7 * 24 * 60 * 60 * 1000; +const LEGACY_KEY_RE = /^(?:(?:Wallet|Friend)(?:-.+)?|salt|tester)$/; + +// 7 days after a legacy migration, the old data can most likely now be removed +// from local storage. +function removeOldData() { + debug("checking to remove old data"); + + const migratedTime = localStorage.getItem("legacyMigratedTime"); + if (!migratedTime) { + debug("no migrated time, done"); + return; + } + + const t = new Date(migratedTime); + const now = new Date(); + const diff = now.getTime() - t.getTime(); + debug("%d ms since migration", diff); + + if (diff <= MIGRATION_CLEANUP_THRESHOLD) { + debug("migration threshold not enough time, done"); + return; + } + + // Remove all the old data from local storage, including the old salt and + // tester, the wallets, and the friends. + const keysToRemove = []; + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + if (key && LEGACY_KEY_RE.test(key)) { + // Push the keys to a queue to remove after looping, because otherwise + // localStorage.length would change during iteration. + debug("removing key %s", key); + keysToRemove.push(key); + } + } + + // Now actually remove the keys + keysToRemove.forEach(k => localStorage.removeItem(k)); + + // Clean up legacyMigratedTime so this doesn't happen again + localStorage.removeItem("legacyMigratedTime"); + + debug("legacy migration cleanup done"); +} + +export function LegacyMigration(): JSX.Element | null { + const [backup, setBackup] = useState(); + + // Check if a legacy migration needs to be performed + useEffect(() => { + debug("checking legacy migration status"); + + // Check if legacy migration has already been handled + const legacyMigrated = localStorage.getItem("migrated"); + if (legacyMigrated === "2") { + debug("migration already at 2, done"); + removeOldData(); + return; + } + + // Check if there is a v1 master password in local storage + const salt = localStorage.getItem("salt") || undefined; + const tester = localStorage.getItem("tester") || undefined; + if (!salt || !tester) { + debug("no legacy master password, done"); + return; + } + + // Check if there are any v1 wallets or contacts in local storage + const walletIndex = localStorage.getItem("Wallet") || undefined; + const contactIndex = localStorage.getItem("Friend") || undefined; + if (!walletIndex && !contactIndex) { + debug("no wallets or contacts, done"); + return; + } + + // Fetch all the wallets and contacts, skipping over any that are missing + const wallets = Object.fromEntries((walletIndex || "") + .split(",") + .map(id => [`Wallet-${id}`, localStorage.getItem(`Wallet-${id}`)]) + .filter(([_, v]) => !!v)); + const contacts = Object.fromEntries((contactIndex || "") + .split(",") + .map(id => [`Friend-${id}`, localStorage.getItem(`Friend-${id}`)]) + .filter(([_, v]) => !!v)); + debug("found %d wallets and %d contacts", + Object.keys(wallets).length, Object.keys(contacts).length); + + // Construct the backup object prior to showing the modal + const backup: BackupKristWebV1 = { + type: BackupFormatType.KRISTWEB_V1, + + salt, + tester, + + wallets, + friends: contacts + }; + + setBackup(backup); + }, []); + + return backup + ? setBackup(undefined)} + /> + : null; +} diff --git a/src/global/legacy/LegacyMigrationForm.tsx b/src/global/legacy/LegacyMigrationForm.tsx new file mode 100644 index 0000000..6063696 --- /dev/null +++ b/src/global/legacy/LegacyMigrationForm.tsx @@ -0,0 +1,129 @@ +// Copyright (c) 2020-2021 Drew Lemmy +// This file is part of KristWeb 2 under AGPL-3.0. +// Full details: https://github.com/tmpim/KristWeb2/blob/master/LICENSE.txt +import { useState, Dispatch, SetStateAction} from "react"; +import { Form, notification } from "antd"; + +import { Trans } from "react-i18next"; +import { useTFns, translateError } from "@utils/i18n"; + +import { store } from "@app"; + +import { getMasterPasswordInput } from "@comp/auth/MasterPasswordInput"; +import { setMasterPassword } from "@wallets"; + +import { BackupKristWebV1 } from "@pages/backup/backupFormats"; +import { IncrProgressFn, InitProgressFn } from "@pages/backup/ImportProgress"; +import { backupVerifyPassword, backupImport } from "@pages/backup/backupImport"; +import { BackupResults } from "@pages/backup/backupResults"; + +import Debug from "debug"; +const debug = Debug("kristweb:legacy-migration-form"); + +interface LegacyMigrationFormHookRes { + form: JSX.Element; + + triggerSubmit: () => Promise; +} + +export function useLegacyMigrationForm( + setLoading: Dispatch>, + setResults: Dispatch>, + + onProgress: IncrProgressFn, + initProgress: InitProgressFn, + + backup: BackupKristWebV1, +): LegacyMigrationFormHookRes { + const { t, tStr, tKey } = useTFns("legacyMigration."); + + const [form] = Form.useForm(); + + const [masterPasswordError, setMasterPasswordError] = useState(); + + async function onFinish() { + const values = await form.validateFields(); + const { masterPassword } = values; + if (!masterPassword) return; + + setLoading(true); + + try { + // Attempt to verify the master password + await backupVerifyPassword(backup, masterPassword); + setMasterPasswordError(undefined); + + // Initialise the app's master password to this one, if it's not already + // set up + const hasMP = store.getState().masterPassword.hasMasterPassword; + if (!hasMP) { + debug("no existing master password, initialising with provided one"); + await setMasterPassword(values.masterPassword); + } + + // Perform the import + const results = await backupImport( + backup, masterPassword, false, + onProgress, initProgress + ); + + // Mark the legacy migration as performed, so the modal doesn't appear + // again. Also store the date, so the old data can be removed after an + // appropriate amount of time has passed. + localStorage.setItem("migrated", "2"); + localStorage.setItem("legacyMigratedTime", new Date().toISOString()); + + setResults(results); + } catch (err) { + if (err.message === "import.masterPasswordRequired" + || err.message === "import.masterPasswordIncorrect") { + // Master password incorrect error + setMasterPasswordError(translateError(t, err)); + } else { + // Any other import error + console.error(err); + notification.error({ message: tStr("errorUnknown") }); + } + } finally { + setLoading(false); + } + } + + const formEl =
+ {/* Description */} +

+ + {/* Password input */} + + {getMasterPasswordInput({ + placeholder: tStr("masterPasswordPlaceholder"), + autoFocus: true + })} + +
; + + return { + form: formEl, + triggerSubmit: onFinish + }; +} diff --git a/src/global/legacy/LegacyMigrationModal.tsx b/src/global/legacy/LegacyMigrationModal.tsx new file mode 100644 index 0000000..da53b63 --- /dev/null +++ b/src/global/legacy/LegacyMigrationModal.tsx @@ -0,0 +1,71 @@ +// Copyright (c) 2020-2021 Drew Lemmy +// This file is part of KristWeb 2 under AGPL-3.0. +// Full details: https://github.com/tmpim/KristWeb2/blob/master/LICENSE.txt +import { useState } from "react"; +import { Modal, Button } from "antd"; + +import { useTFns } from "@utils/i18n"; + +import { useLegacyMigrationForm } from "./LegacyMigrationForm"; + +import { BackupKristWebV1 } from "@pages/backup/backupFormats"; +import { useImportProgress } from "@pages/backup/ImportProgress"; +import { BackupResults } from "@pages/backup/backupResults"; +import { BackupResultsSummary } from "@pages/backup/BackupResultsSummary"; +import { BackupResultsTree } from "@pages/backup/BackupResultsTree"; + +interface Props { + backup: BackupKristWebV1; + setVisible: (visible: boolean) => void; +} + +export function LegacyMigrationModal({ + backup, + setVisible +}: Props): JSX.Element { + const { t, tStr } = useTFns("legacyMigration."); + + const [loading, setLoading] = useState(false); + const [results, setResults] = useState(); + + const { progressBar, onProgress, initProgress } = useImportProgress(); + + const { form, triggerSubmit } = useLegacyMigrationForm( + setLoading, setResults, onProgress, initProgress, backup + ); + + function closeModal() { + setVisible(false); + } + + return + {results ? t("dialog.close") : tStr("buttonSubmit")} + + )} + > + {/* Show the results screen, progress bar, or backup form */} + {results + ? <> + + + + : (loading + ? progressBar + : form)} + ; +} diff --git a/src/pages/backup/ImportBackupModal.tsx b/src/pages/backup/ImportBackupModal.tsx index be64d9c..e12b891 100644 --- a/src/pages/backup/ImportBackupModal.tsx +++ b/src/pages/backup/ImportBackupModal.tsx @@ -22,14 +22,13 @@ } export function ImportBackupModal({ visible, setVisible }: Props): JSX.Element { - const tFns = useTFns("import."); - const { t, tStr } = tFns; + const { t, tStr } = useTFns("import."); const [loading, setLoading] = useState(false); const [results, setResults] = useState(); const { progressBar, onProgress, initProgress, resetProgress } - = useImportProgress(tFns); + = useImportProgress(); const { form, resetForm, triggerSubmit, setCode } = useImportBackupForm(setLoading, setResults, onProgress, initProgress); diff --git a/src/pages/backup/ImportProgress.tsx b/src/pages/backup/ImportProgress.tsx index 0c97d64..778131f 100644 --- a/src/pages/backup/ImportProgress.tsx +++ b/src/pages/backup/ImportProgress.tsx @@ -5,7 +5,7 @@ import { Progress } from "antd"; import { Trans } from "react-i18next"; -import { TFns } from "@utils/i18n"; +import { useTFns } from "@utils/i18n"; export type IncrProgressFn = () => void; export type InitProgressFn = (total: number) => void; @@ -17,9 +17,9 @@ resetProgress: () => void; } -export function useImportProgress( - { t, tKey }: TFns -): ImportProgressHookResponse { +export function useImportProgress(): ImportProgressHookResponse { + const { t, tKey } = useTFns("import."); + const [progress, setProgress] = useState(0); const [total, setTotal] = useState(1); diff --git a/src/pages/backup/backupImportV2.ts b/src/pages/backup/backupImportV2.ts index 8747bf1..d53c329 100644 --- a/src/pages/backup/backupImportV2.ts +++ b/src/pages/backup/backupImportV2.ts @@ -83,7 +83,7 @@ try { await importV2Contact( existingContacts, addressPrefix, nameSuffix, - backup, noOverwrite, + noOverwrite, uuid, rawContact, results ); @@ -182,7 +182,6 @@ nameSuffix: string, // Things related to the backup - backup: BackupKristWebV2, noOverwrite: boolean, uuid: string, @@ -191,7 +190,7 @@ results: BackupResults ): Promise { const shorthands = getShorthands(results, uuid, "v2", "contact"); - const { success, importWarn } = shorthands; + const { success } = shorthands; // Validate the type of the contact data if (!isPlainObject(contact)) { diff --git a/src/pages/credits/Supporters.tsx b/src/pages/credits/Supporters.tsx index 1e3776b..e7efa71 100644 --- a/src/pages/credits/Supporters.tsx +++ b/src/pages/credits/Supporters.tsx @@ -8,7 +8,7 @@ import packageJson from "../../../package.json"; -import { useMountEffect, localeSort } from "@utils"; +import { useMountEffect } from "@utils"; interface Supporter { name: string; diff --git a/src/pages/dashboard/TipsCard.tsx b/src/pages/dashboard/TipsCard.tsx index d55174a..b521639 100644 --- a/src/pages/dashboard/TipsCard.tsx +++ b/src/pages/dashboard/TipsCard.tsx @@ -1,7 +1,7 @@ // Copyright (c) 2020-2021 Drew Lemmy // This file is part of KristWeb 2 under AGPL-3.0. // Full details: https://github.com/tmpim/KristWeb2/blob/master/LICENSE.txt -import { Card, Button, Typography } from "antd"; +import { Card, Button } from "antd"; import { CaretLeftOutlined, CaretRightOutlined } from "@ant-design/icons"; import { useSelector, useDispatch } from "react-redux"; diff --git a/src/pages/names/mgmt/NameActions.tsx b/src/pages/names/mgmt/NameActions.tsx index 7d2765d..a861c86 100644 --- a/src/pages/names/mgmt/NameActions.tsx +++ b/src/pages/names/mgmt/NameActions.tsx @@ -13,7 +13,6 @@ import { AuthorisedAction } from "@comp/auth/AuthorisedAction"; import { OpenEditNameFn } from "./NameEditModalLink"; import { OpenSendTxFn } from "@comp/transactions/SendTransactionModalLink"; -import { NameEditModalLink } from "./NameEditModalLink"; interface Props { name: KristName;