aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-08-04 00:01:27 -0700
committerAdam Soltys <[email protected]>2026-08-04 00:01:27 -0700
commitd8964bdf6f047d058731edfae6d07e57106661c8 (patch)
treeaa55f97ff440e87beec82b7d0e94bbe6762897a8
parent5682cc88e54f00ce01fdd2ef01e5bb1a207b1be2 (diff)
downloadcoinosv3-d8964bdf6f047d058731edfae6d07e57106661c8.tar.gz
coinosv3-d8964bdf6f047d058731edfae6d07e57106661c8.zip
Offer a password the first time you lock
Locking with no password locks nothing — the wallets sit on the device under an empty one and reopen with a tap. So the first deliberate lock now asks whether to set one, explaining plainly what happens if you don't. 'Not now' is a real answer, recorded, and never asked again: a wallet that nags is a wallet people stop reading. The offer rides the deliberate lock only. lock() is also where the idle timer, the last-wallet-removed path and boot-after-expiry land, and a password form appearing because you went to lunch is a different app than the one you left. Two subtleties worth naming: the offer is set after lock()'s ui reset block, which clears ui.pw and had been swallowing it; and taking the offer re-enters lock(), which now finds a password and lands on the unlock screen instead of the wallet list. Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Wvj4aoeMZwA7VUGdJP9Swi
-rw-r--r--src/app.js46
-rw-r--r--src/i18n.js4
-rw-r--r--tools/lock-wallet-test.js59
3 files changed, 100 insertions, 9 deletions
diff --git a/src/app.js b/src/app.js
index 07dcc0b..6871ce8 100644
--- a/src/app.js
+++ b/src/app.js
@@ -1055,6 +1055,11 @@ function restoreAccountsState() {
// --- encrypted vault (optional password-persisted full accounts) ----------
const VAULT_KEY = 'btc-wallet-vault';
+// Whether we've offered a password on locking. Asked once, ever: someone who
+// says no has chosen, and a wallet that nags is a wallet people stop reading.
+const LOCK_PW_KEY = 'btc-wallet-lockpw';
+const lockPwAsked = () => { try { return !!localStorage.getItem(LOCK_PW_KEY); } catch { return true; } };
+const markLockPwAsked = () => { try { localStorage.setItem(LOCK_PW_KEY, '1'); } catch {} };
let vaultPassword = null; // in memory once unlocked/set this session; cleared on lock
function loadVaultBlob() {
@@ -1178,7 +1183,12 @@ function startForget(id) {
ui.pw = { purpose: 'forget', accId: id, mode: 'enter', v1: '', v2: '', error: '' };
render();
}
-function cancelPw() { ui.pw = null; render(); }
+function cancelPw() {
+ // Declining the lock-time offer is an answer, not a postponement.
+ if (ui.pw && ui.pw.purpose === 'lock') markLockPwAsked();
+ ui.pw = null;
+ render();
+}
function startChangePw() {
ui.pw = { purpose: 'change', mode: 'change', v0: '', v1: '', v2: '', error: '' };
render();
@@ -1198,6 +1208,17 @@ function submitPw() {
toast(t('pwChanged'));
return;
}
+ if (p.purpose === 'lock') {
+ if (!p.v1) { p.error = t('pwNeededToLock'); render(); return; }
+ if (p.v1 !== p.v2) { p.error = t('pwMismatch'); render(); return; }
+ vaultPassword = p.v1;
+ writeVault();
+ persistAccounts();
+ markLockPwAsked();
+ ui.pw = null;
+ lock(); // now there IS a password, so this lands on the unlock screen
+ return;
+ }
if (p.mode === 'set') {
// Password is optional — a blank one just persists without protection.
if (p.v1 !== p.v2) { p.error = t('pwMismatch'); render(); return; }
@@ -1384,7 +1405,7 @@ function applyBootAutoLogout() {
} catch {}
}
-function lock() {
+function lock({ offerPassword = false } = {}) {
wallet.stopRealtime();
clearAccounts();
vaultPassword = null;
@@ -1395,9 +1416,8 @@ function lock() {
// theatre — it protects nothing and asks for something nobody chose. With no
// password, logging out means dropping back to the wallet list; with one, it
// means what it says.
- ui.screen = !hasVault() ? 'unlock'
- : attemptVaultUnlock('') ? 'accounts'
- : 'vault';
+ const noPassword = hasVault() && attemptVaultUnlock('');
+ ui.screen = !hasVault() ? 'unlock' : noPassword ? 'accounts' : 'vault';
ui.unlockTab = 'create';
ui.fromWallet = false;
ui.watchXpub = '';
@@ -1427,6 +1447,12 @@ function lock() {
ui.txDetail = null;
ui.broadcastTx = null;
ui.bump = null;
+ // After the resets above (which clear ui.pw): locking with no password locks
+ // nothing, so offer one — once, and only when a person chose to lock, never
+ // when the idle timer did it for them.
+ if (noPassword && offerPassword && !lockPwAsked()) {
+ ui.pw = { purpose: 'lock', mode: 'set', v0: '', v1: '', v2: '', error: '' };
+ }
render();
}
@@ -1456,7 +1482,7 @@ function avatarMenu() {
me ? item(t('profEdit'), () => featureHook('showProfile', me)) : null,
item(t('tabSettings'), () => { ui.chatOpen = false; ui.profilePk = null; ui.screen = 'wallet'; ui.tab = 'settings'; ui.settingsPage = null; }),
me ? item(t('msgDmsTitle'), () => { ui.profilePk = null; ui.chatOpen = true; }) : null,
- item(t('lockWallet'), () => lock())); applyAnim(m, 'anim-menu', dt); return m; })())
+ item(t('lockWallet'), () => lock({ offerPassword: true }))); applyAnim(m, 'anim-menu', dt); return m; })())
: null; })());
}
@@ -2033,16 +2059,18 @@ function clearAll() {
function pwPromptCard() {
const p = ui.pw;
const change = p.purpose === 'change';
+ const atLock = p.purpose === 'lock';
const newish = change || p.mode === 'set'; // entering a (new) password with confirm
return h('div', { class: 'card col' },
- h('h3', {}, change ? t('changePassword') : p.mode === 'set' ? t('setPassword') : t('enterPassword')),
- h('p', { class: 'small muted', style: 'margin:0' }, change ? t('changePasswordDesc') : p.mode === 'set' ? t('setPasswordDesc') : t('enterPasswordDesc')),
+ h('h3', {}, atLock ? t('lockPwTitle') : change ? t('changePassword') : p.mode === 'set' ? t('setPassword') : t('enterPassword')),
+ h('p', { class: 'small muted', style: 'margin:0' },
+ atLock ? t('lockPwDesc') : change ? t('changePasswordDesc') : p.mode === 'set' ? t('setPasswordDesc') : t('enterPasswordDesc')),
change ? h('input', { type: 'password', placeholder: t('currentPassword'), value: p.v0, onInput: (e) => (p.v0 = e.target.value) }) : null,
h('input', { type: 'password', placeholder: newish ? t('passwordOptional') : t('password'), value: p.v1, onInput: (e) => (p.v1 = e.target.value) }),
newish ? h('input', { type: 'password', placeholder: t('confirmPassword'), value: p.v2, onInput: (e) => (p.v2 = e.target.value) }) : null,
p.error && h('div', { class: 'notice err' }, p.error),
h('div', { class: 'row gap6' },
- h('button', { class: 'btn-ghost grow', onClick: cancelPw }, t('back')),
+ h('button', { class: 'btn-ghost grow', onClick: cancelPw }, atLock ? t('lockPwSkip') : t('back')),
h('button', { class: 'btn-primary grow', onClick: submitPw }, newish ? t('save') : t('unlock'))
)
);
diff --git a/src/i18n.js b/src/i18n.js
index a1d5390..99640a3 100644
--- a/src/i18n.js
+++ b/src/i18n.js
@@ -82,6 +82,10 @@ const STR = {
pwTooShort: 'Use at least 8 characters.',
pwMismatch: 'Passwords do not match.',
pwWrong: 'Wrong password.',
+ pwNeededToLock: 'Enter a password, or choose "Not now".',
+ lockPwTitle: 'Protect this device?',
+ lockPwDesc: 'Your wallets stay saved here when you lock. Without a password anyone holding this device can open them again — set one and it will be asked for every time.',
+ lockPwSkip: 'Not now',
unlockSaved: 'Unlock saved wallets',
unlockSavedDesc: 'Enter your password to open the wallets saved on this device.',
unlock: 'Unlock',
diff --git a/tools/lock-wallet-test.js b/tools/lock-wallet-test.js
index 4bc56b1..1c46f2b 100644
--- a/tools/lock-wallet-test.js
+++ b/tools/lock-wallet-test.js
@@ -50,6 +50,12 @@ try {
console.log('\n[locking with no password set]');
await logout();
let txt = await body();
+ check('it offers a password once', /protect this device/i.test(txt), txt.slice(0, 120).replace(/\n+/g, ' | '));
+ check('and does not force one', /not now/i.test(txt));
+ await click('button', 'Not now');
+ await sleep(800);
+ txt = await body();
+ check('declining locks anyway', !/protect this device/i.test(txt));
check('no password is demanded', !/enter your password|unlock saved wallets/i.test(txt), txt.slice(0, 120).replace(/\n+/g, ' | '));
check('the saved wallet is still there', (await vaultSize()) === savedBytes);
check('and it is offered back', /wallet 1|wallets/i.test(txt), txt.slice(0, 140).replace(/\n+/g, ' | '));
@@ -57,6 +63,13 @@ try {
const listed = await page.evaluate(() => [...document.querySelectorAll('button, .item, .acct')].map((e) => e.textContent.trim()).filter((x) => /Wallet 1/.test(x)));
check('and only once — no phantom duplicate', listed.length === 1, JSON.stringify(listed));
+ console.log('\n[the offer is not a nag]');
+ await page.evaluate(() => { const e = [...document.querySelectorAll('*')].find((n) => n.children.length === 0 && /Wallet 1/.test(n.textContent) && n.closest('button, .item, .acct')); if (e) e.closest('button, .item, .acct').click(); });
+ await sleep(2000);
+ await waitText('receive', 15000);
+ await logout();
+ check('it does not ask a second time', !/protect this device/i.test(await body()));
+
console.log('\n[getting back in]');
await page.evaluate(() => { const e = [...document.querySelectorAll('*')].find((n) => n.children.length === 0 && /Wallet 1/.test(n.textContent) && n.closest('button, .item, .acct')); if (e) e.closest('button, .item, .acct').click(); });
await sleep(2000);
@@ -97,6 +110,52 @@ try {
const txt2 = await body();
check('it warns before wiping', /removes every wallet/i.test(txt2), txt2.slice(0, 140).replace(/\n+/g, ' | '));
check('and it has not wiped anything yet', (await vaultSize()) > 0);
+ // A clean profile, because the offer is once-per-device and the run above
+ // already answered it.
+ console.log('\n[taking the offer, on a fresh device]');
+ const ctx = await browser.createBrowserContext();
+ const p2 = await ctx.newPage();
+ const body2 = () => p2.evaluate(() => document.body.innerText);
+ const click2 = (sel, x) => p2.evaluate((s, t) => { const e = [...document.querySelectorAll(s)].find((n) => n.textContent.trim().toLowerCase().includes(t.toLowerCase())); if (e) { e.click(); return true; } return false; }, sel, x);
+ const wait2 = async (x, ms = 20000) => { for (let i = 0; i < ms / 250; i++) { if ((await body2()).toLowerCase().includes(x.toLowerCase())) return true; await sleep(250); } return false; };
+ await p2.goto('http://localhost:5235/', { waitUntil: 'domcontentloaded' });
+ await p2.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest'));
+ await p2.reload({ waitUntil: 'domcontentloaded' });
+ await sleep(400);
+ await click2('button', 'I already have a wallet');
+ await sleep(300);
+ await click2('button', 'Import existing');
+ await sleep(300);
+ await p2.waitForSelector('textarea');
+ await p2.type('textarea', generateMnemonic(wordlist));
+ await click2('button', 'Open wallet');
+ await wait2('receive', 15000);
+ await sleep(2500);
+ await p2.evaluate(() => document.querySelector('.header-avatar')?.click());
+ await sleep(400);
+ await click2('.icon-select-item', 'Lock wallet');
+ await sleep(1200);
+ check('offered on this device too', /protect this device/i.test(await body2()));
+
+ const f2 = await p2.$$('input[type=password]');
+ check('a new password and a confirmation', f2.length === 2, `${f2.length} field(s)`);
+ await f2[0].type('correct horse');
+ await f2[1].type('correct horse');
+ await click2('button', 'Save');
+ await sleep(1500);
+ check('accepting it locks for real', /unlock saved wallets/i.test(await body2()), (await body2()).slice(0, 110).replace(/\n+/g, ' | '));
+
+ const wrong = await p2.$('input[type=password]');
+ await wrong.type('not the password');
+ await click2('button', 'Unlock');
+ await sleep(800);
+ check('the wrong password is refused', /wrong password/i.test(await body2()));
+ await p2.evaluate(() => { const i = document.querySelector('input[type=password]'); if (i) { i.value = ''; i.dispatchEvent(new Event('input', { bubbles: true })); } });
+ const right = await p2.$('input[type=password]');
+ await right.type('correct horse');
+ await click2('button', 'Unlock');
+ check('the right one opens it', await wait2('receive', 15000));
+ await ctx.close();
} catch (e) {
check('run completed', false, e.message);
} finally {