const { $, showView, showFlash, goBack, clearViewStack, onViewLeave, } = require("./helpers"); const { state, saveState } = require("../../shared/state"); const { decryptWithPassword } = require("../../shared/vault"); const { removeWalletFromState, broadcastActiveChanged, } = require("../../shared/walletDelete"); let deleteWalletIndex = null; let lostPasswordIndex = null; let ctx = null; // The name shown for a wallet, and on the lost-password screen the string // the user has to type back. One function so the two cannot disagree: a // confirmation that asks for a name other than the one on screen is // unusable. function displayName(walletIdx) { const wallet = state.wallets[walletIdx]; return (wallet && wallet.name) || "Wallet " + (walletIdx + 1); } // What the typed confirmation and the wallet name are compared as. HTML // collapses runs of whitespace when it renders the name, so a wallet named // "My Wallet" with two spaces DISPLAYS as "My Wallet": the user cannot // see the second space and cannot type a string that matches the stored // name. Comparing collapsed on both sides is what keeps the confirmation // satisfiable, on the one screen whose whole purpose is unwedging a user // who is already stuck. Case and surrounding space go the same way. function confirmKey(name) { return name.trim().replace(/\s+/g, " ").toLowerCase(); } // Drop the password from the DOM and the wallet selection from the // closure. Registered as the view-leave handler as well as run on entry, // so the typed password does not sit in the hidden view after the user // navigates away by any route, including the Settings gear. function clear() { deleteWalletIndex = null; $("delete-wallet-password").value = ""; $("delete-wallet-flash").textContent = ""; $("delete-wallet-flash").style.visibility = "hidden"; } // The lost-password screen holds no secret — a wallet name is not one — // but it is wiped on leave for the neighbouring reason: a typed // confirmation left standing in a hidden view is one click away from // destroying a wallet the user has since navigated off. The button is // re-enabled here too, so a screen left mid-delete is usable on re-entry. function clearLostPassword() { lostPasswordIndex = null; $("delete-wallet-lost-name-input").value = ""; $("delete-wallet-lost-flash").textContent = ""; $("delete-wallet-lost-flash").style.visibility = "hidden"; const btn = $("btn-delete-wallet-lost-confirm"); btn.disabled = false; btn.classList.remove("text-muted"); } function show(walletIdx) { clear(); deleteWalletIndex = walletIdx; $("delete-wallet-name").textContent = displayName(walletIdx); showView("delete-wallet-confirm"); } // The two delete screens are siblings, not parent and child: nothing is // pushed on the way here, and Back goes to show() rather than goBack(). // Both then have the same Back target — Settings, the screen that pushed // delete-wallet-confirm — and re-entering through show() hands the confirm // screen its wallet selection back, which a bare goBack() onto a view // whose leave hook has already nulled that selection would not. function showLostPassword() { const walletIdx = deleteWalletIndex; if (walletIdx === null) { goBack(); return; } const name = displayName(walletIdx); clearLostPassword(); $("delete-wallet-lost-name").textContent = name; $("delete-wallet-lost-name-echo").textContent = name; // showView() runs the leave hook of delete-wallet-confirm, which nulls // deleteWalletIndex, so this screen's own selection is recorded after // it and not before. showView("delete-wallet-lost-password"); lostPasswordIndex = walletIdx; } // Remove the wallet and put the user somewhere sensible. Shared by both // routes onto this screen, so the selection repair, the site-permission // cleanup and the accountsChanged broadcast cannot drift apart between // them. async function finishDelete(walletIdx) { const { activeAddressChanged } = removeWalletFromState(state, walletIdx); deleteWalletIndex = null; lostPasswordIndex = null; if (!state.hasWallet) { clearViewStack(); await saveState(); // Save before broadcasting: the background reads the active // address back out of storage to build accountsChanged. if (activeAddressChanged) broadcastActiveChanged(); showView("welcome"); return; } await saveState(); if (activeAddressChanged) broadcastActiveChanged(); // Reset stack to [main] so Settings back goes home. // Use require() lazily to avoid circular dependency // (settings.js requires deleteWallet.js). clearViewStack(); state.viewStack.push("main"); ctx.renderWalletList(); const settings = require("./settings"); settings.show(); showFlash("Wallet deleted."); } function init(_ctx) { ctx = _ctx; onViewLeave("delete-wallet-confirm", clear); onViewLeave("delete-wallet-lost-password", clearLostPassword); // No wipe here: goBack() routes through showView(), which runs the // leave hook. $("btn-delete-wallet-back").addEventListener("click", () => { goBack(); }); // The escape hatch, and deliberately not gated on anything a user who // has lost the password cannot produce. A password in front of // DISCARDING a secret protects nobody: an attacker at the popup who // wants the wallet gone can uninstall the extension, so the only // person such a gate stops is the owner who forgot it — and before // this route existed that owner could neither delete the wallet nor // import its recovery phrase again, because AddWallet refuses the xpub // as a duplicate while the wallet is still stored. $("btn-delete-wallet-lost-password").addEventListener("click", () => { showLostPassword(); }); $("btn-delete-wallet-lost-back").addEventListener("click", () => { const walletIdx = lostPasswordIndex; if (walletIdx === null) { goBack(); return; } show(walletIdx); }); $("btn-delete-wallet-lost-confirm").addEventListener("click", async () => { if (lostPasswordIndex === null) { $("delete-wallet-lost-flash").textContent = "No wallet selected for deletion."; $("delete-wallet-lost-flash").style.visibility = "visible"; return; } // Case, surrounding spaces and repeated inner spaces are not part // of the confirmation; see confirmKey(). This asks whether the // user knows which wallet they are on; it is not a secret, and // refusing "wallet 2" for "Wallet 2" would only teach the user to // distrust the control. const typed = $("delete-wallet-lost-name-input").value; const expected = displayName(lostPasswordIndex); if (confirmKey(typed) !== confirmKey(expected)) { $("delete-wallet-lost-flash").textContent = "That is not the name of this wallet. Type " + expected + " to confirm."; $("delete-wallet-lost-flash").style.visibility = "visible"; return; } const btn = $("btn-delete-wallet-lost-confirm"); btn.disabled = true; btn.classList.add("text-muted"); // finishDelete() navigates, and the leave hook re-enables the // button and wipes the typed name on the way out. await finishDelete(lostPasswordIndex); }); $("btn-delete-wallet-confirm").addEventListener("click", async () => { const pw = $("delete-wallet-password").value; if (!pw) { $("delete-wallet-flash").textContent = "Please enter your password."; $("delete-wallet-flash").style.visibility = "visible"; return; } if (deleteWalletIndex === null) { $("delete-wallet-flash").textContent = "No wallet selected for deletion."; $("delete-wallet-flash").style.visibility = "visible"; return; } const btn = $("btn-delete-wallet-confirm"); btn.disabled = true; btn.classList.add("text-muted"); const walletIdx = deleteWalletIndex; const wallet = state.wallets[walletIdx]; // Verify password against the wallet's encrypted data try { await decryptWithPassword(wallet.encryptedSecret, pw); } catch { $("delete-wallet-flash").textContent = "That password is incorrect. Please try again."; $("delete-wallet-flash").style.visibility = "visible"; btn.disabled = false; btn.classList.remove("text-muted"); return; } await finishDelete(walletIdx); }); } module.exports = { init, show };