The password route disabled its Confirm Delete button before the decrypt and never re-enabled it on success, so a second delete in the same popup session found a dead button until the popup was closed and reopened. The lost-password route re-enabled its own button in its leave hook, so the two screens on the one screen behaved differently. Both routes now reset the button through the shared finishDelete(), the one path they both take, and the lost-password leave hook no longer handles it separately. Tests drive a password-route delete and a second delete in the same session; they fail against the prior head, where the button stays disabled after the first delete. Model: opus-4-8
242 lines
9.1 KiB
JavaScript
242 lines
9.1 KiB
JavaScript
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.
|
|
function clearLostPassword() {
|
|
lostPasswordIndex = null;
|
|
$("delete-wallet-lost-name-input").value = "";
|
|
$("delete-wallet-lost-flash").textContent = "";
|
|
$("delete-wallet-lost-flash").style.visibility = "hidden";
|
|
}
|
|
|
|
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) {
|
|
// Each route's confirm button was disabled by its own click handler
|
|
// before the delete ran. Re-enable both here, on the one path they
|
|
// share, so the two routes reset the same way and a second delete in
|
|
// the same popup session finds a live button instead of a dead one.
|
|
const passwordBtn = $("btn-delete-wallet-confirm");
|
|
passwordBtn.disabled = false;
|
|
passwordBtn.classList.remove("text-muted");
|
|
const lostPasswordBtn = $("btn-delete-wallet-lost-confirm");
|
|
lostPasswordBtn.disabled = false;
|
|
lostPasswordBtn.classList.remove("text-muted");
|
|
|
|
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() re-enables the button; navigating away then runs
|
|
// the leave hook that wipes the typed name.
|
|
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 };
|