From f7fe37ef75836715580e3097c7e7bc75df819bef Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Sun, 15 Nov 2026 14:13:08 -0700 Subject: Ways back in: Passkey auth, Google auth, Nostr auth, Pen and paper. "Let's call recovery phrase 'Pen and paper' with a suitable icon and no need to show it twice just make it similar to the other buttons. Instead of saying Add... for everything let's try 'Passkey auth', Google auth, Nostr auth" (Adam, 2026-11-15). The card's buttons drop Add…; the recovery phrase is the fourth button (a pen over a sheet, βœ“ once the words have been seen) instead of a row with Show, and the separate Recovery phrase card under it appears only when Pen and paper asks for it (scrolled to, words masked first). Accounts without the card (watch-only, imported keys) keep the phrase card as before. signup-quick-test, onboard-backup, balance-settings-row and features-smoke pass MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VMan6SwkivduFDDkMmnXjZ --- src/app.js | 14 ++++++++++---- src/features/nostrlogin.js | 16 +++++++++------- src/i18n.js | 7 ++++--- tools/signup-quick-test.js | 6 ++++-- 4 files changed, 27 insertions(+), 16 deletions(-) diff --git a/src/app.js b/src/app.js index cead211..f3c2dcb 100644 --- a/src/app.js +++ b/src/app.js @@ -3247,7 +3247,10 @@ function settingsTab() { // matters if the phone is lost tonight case 'wallet': if (ui.delAcct && a) return deleteAccountView(a); - return page(a ? [featureHook('waysBackCard'), recoveryCard(a), balanceSettingsCard(a), walletNameCard(a), pubkeyCard(a), historyExportCard(), deleteAccountCard(a)] : []); + // the phrase is one of the ways back in: its card shows only once + // Pen and paper asks for it (accounts without the card keep it always) + { const ways = a ? featureHook('waysBackCard') : null; + return page(a ? [ways, ways && !ui.revealShown ? null : recoveryCard(a), balanceSettingsCard(a), walletNameCard(a), pubkeyCard(a), historyExportCard(), deleteAccountCard(a)] : []); } case 'payments': return page(featureAll('settingsCards').reverse()); case 'pos': return page(featureAll('posSettingsCards').reverse()); // the till's own card first, then the terminal's case 'network': return page([networkCard(), explorerCard()]); // an old bookmark of the tile; the cards live under Advanced now @@ -3907,12 +3910,12 @@ function recoveryCard(a) { // a warning) before anything readable is over your shoulder. The second // actually shows them. if (!shown) - return h('div', { class: 'card col' }, + return h('div', { class: 'card col recovery-card' }, h('h3', {}, t('recoveryPhrase')), h('p', { class: 'small muted', style: 'margin:0' }, t('recoveryDesc')), h('button', { class: 'btn-block', onClick: () => { ui.revealShown = 'masked'; render(); } }, t('revealRecovery'))); const unmasked = shown === 'words'; - return h('div', { class: 'card col' }, + return h('div', { class: 'card col recovery-card' }, h('h3', {}, t('recoveryPhrase')), h('div', { class: 'warn-box' }, t('recoveryWarn')), h('div', { class: 'words', style: '--rows-3:' + Math.ceil(words.length / 3) + ';--rows-2:' + Math.ceil(words.length / 2) }, words.map((w, i) => @@ -7021,7 +7024,10 @@ const ctx = { recoveryMethods: () => recoveryMethods(), addRecoveryMethod, spendingOnlyAccount: () => { const a = activeAccount(); return !!(a && a.kind === 'spending' && a.savingsLater); }, seedSeen: () => !!(activeAccount() || {}).seedSeen, - showRecoveryPhrase: () => { ui.tab = 'settings'; ui.settingsPage = 'wallet'; ui.revealShown = 'masked'; render(); }, + showRecoveryPhrase: () => { + ui.tab = 'settings'; ui.settingsPage = 'wallet'; ui.revealShown = ui.revealShown || 'masked'; render(); + requestAnimationFrame(() => { try { document.querySelector('.recovery-card')?.scrollIntoView({ behavior: 'smooth', block: 'start' }); } catch {} }); + }, // Open (or create) a wallet from a mnemonic β€” used by nostr login. openMnemonic: async (mnemonic, passphrase, opts) => enterWallet(mnemonic, passphrase, opts), // Profile pictures retain the metadata-scrubbing image uploader. Public diff --git a/src/features/nostrlogin.js b/src/features/nostrlogin.js index e48e9a2..6f700b1 100644 --- a/src/features/nostrlogin.js +++ b/src/features/nostrlogin.js @@ -750,6 +750,8 @@ export function nostrLoginFeature(ctx) { render(); } } + // pen over a sheet of paper, in the line style of the passkey mark + const PAPER_MARK = ''; const fmtDay = (ms) => { try { return new Date(ms).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }); } catch { return ''; } }; function waysBackCard() { if (wallet.watchOnly || !wallet.mnemonic) return null; @@ -780,13 +782,6 @@ export function nostrLoginFeature(ctx) { else rows.push(done(OSTRICH, 'Nostr', (npubOf(m.id) || '').slice(0, 20) + '…', 'nostr')); } const seen = ctx.seedSeen(); - rows.push(h('div', { class: 'row gap6 ways-back-row', 'data-way': 'phrase', style: 'align-items:center;padding:8px 2px' }, - h('span', { style: 'display:flex;flex-shrink:0;width:23px;justify-content:center;font-size:17px' }, 'πŸ”‘'), - h('span', { class: 'col', style: 'flex:1;gap:1px' }, - h('span', { style: 'font-weight:600' }, t('recoveryPhrase')), - h('span', { class: 'small muted' }, t(seen ? 'waysBackPhraseSeen' : 'waysBackPhraseUnseen'))), - seen ? h('span', { class: 'ways-back-check' }, 'βœ“') - : h('button', { class: 'btn-sm', onClick: () => ctx.showRecoveryPhrase() }, t('waysBackShow')))); const have = (via) => methods.some((m) => m.via === via) || (via !== 'nostr' && st.via === via); const adds = [ hasPasskey && !have('passkey') ? add(PASSKEY_MARK, t('waysBackAddPasskey'), 'passkey', () => passkeyRecovery()) : null, @@ -798,6 +793,13 @@ export function nostrLoginFeature(ctx) { h('span', { class: 'linklike small', onClick: () => { ui.waysBackNostr = false; ui.waysBackError = ''; render(); } }, t('cancel'))), signerButtons((make) => addRecovery(make, 'nostr'), { nostrOnly: true })) : add(OSTRICH, t('waysBackAddNostr'), 'nostr', () => { ui.waysBackNostr = true; ui.waysBackError = ''; ui.nostrLoginError = ''; render(); }), + // the recovery phrase, as one more button (Adam, 2026-11-15): it opens + // the masked words below, ticked once they've been seen + h('button', { class: 'btn-block ways-back-add', 'data-way': 'phrase', disabled: !!busyVia, onClick: () => ctx.showRecoveryPhrase() }, + h('span', { style: 'display:flex;align-items:center;justify-content:center;gap:8px' }, + h('span', { style: 'display:flex;flex-shrink:0', html: PAPER_MARK }), + t('waysBackPaper'), + seen ? h('span', { class: 'ways-back-check', 'aria-label': t('waysBackOn') }, 'βœ“') : null)), ].filter(Boolean); return h('div', { class: 'card col ways-back', style: 'gap:6px' }, h('h3', {}, t('waysBackTitle')), diff --git a/src/i18n.js b/src/i18n.js index 9b59710..c901576 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -1515,9 +1515,10 @@ const STR = { waysBackPhraseSeen: 'Seen. Keep it written down somewhere safe', waysBackPhraseUnseen: 'Twelve words that open this account in any wallet', waysBackShow: 'Show', - waysBackAddPasskey: 'Add a passkey', - waysBackAddGoogle: 'Add Google', - waysBackAddNostr: 'Add a Nostr account', + waysBackAddPasskey: 'Passkey auth', + waysBackAddGoogle: 'Google auth', + waysBackAddNostr: 'Nostr auth', + waysBackPaper: 'Pen and paper', waysBackAdded: 'Added. You can sign in with it now', waysBackIsLogin: 'That is the account you signed in with; it already opens this one.', waysBackTaken: 'That sign-in already opens a different coinos account, so it can’t open this one too.', diff --git a/tools/signup-quick-test.js b/tools/signup-quick-test.js index c80547d..a26a9ef 100644 --- a/tools/signup-quick-test.js +++ b/tools/signup-quick-test.js @@ -90,6 +90,7 @@ try { await openWalletPage(); check('Settings β†’ Wallet opens with Ways back in', !!(await page.$('.ways-back')) && /ways back in/i.test(await text())); const views = await page.$$eval('.balance-settings-row[data-view]', (l) => l.map((e) => e.dataset.view)); + check('the ways back in read as auth buttons', (await page.$$eval('.ways-back-add', (l) => l.map((e) => e.textContent.trim()))).join('|') === 'Passkey auth|Google auth|Nostr auth|Pen and paper', (await page.$$eval('.ways-back-add', (l) => l.map((e) => e.textContent.trim()))).join('|')); check('only Spending is listed, with Add a savings account', views.join() === 'spending' && !!(await page.$('#add-savings')), views.join()); if (process.env.SHOT) { await page.evaluate(() => document.querySelector('.ways-back').scrollIntoView()); await page.screenshot({ path: process.env.SHOT }); } // a passkey: the relays are stubbed, so the publish fails offline β€” the @@ -101,9 +102,10 @@ try { const backups = await page.evaluate(() => (window.__relayGuard.events || []).filter((e) => e.kind === 30078 && e.tags.some((t) => t[0] === 'd' && t[1] === 'coinos:wallet:v1'))); check('the reminder line is gone once there is a way back in', await (async () => { await page.goBack(); await page.goBack(); await sleep(600); const gone = !/add a way back into your account/i.test(await text()); await openWalletPage(); return gone; })()); // the list and the reminder, as the app sees them (record a method directly) - await page.evaluate(() => [...document.querySelectorAll('button')].find((b) => b.textContent.trim() === 'Show')?.click()); + check('the phrase card waits behind Pen and paper', !(await page.$('.recovery-card'))); + await page.evaluate(() => document.querySelector('.ways-back-add[data-way="phrase"]')?.click()); await sleep(500); - check('Show opens the recovery phrase card', /recovery phrase/i.test(await text())); + check('Pen and paper opens the recovery phrase card', !!(await page.$('.recovery-card .words'))); await page.evaluate(() => document.getElementById('add-savings')?.click()); await sleep(800); const views2 = await page.$$eval('.balance-settings-row[data-view]', (l) => l.map((e) => e.dataset.view)); -- cgit v1.2.3