diff options
Diffstat (limited to 'src/app.js')
| -rw-r--r-- | src/app.js | 291 |
1 files changed, 210 insertions, 81 deletions
@@ -77,7 +77,7 @@ const ui = { confirmClear: false, // "Clear all" confirmation shown editId: null, // account being renamed editLabel: '', - createStep: 'gen', // 'gen' | 'confirm' + createStep: 'intro', // 'intro' (the heads-up) | 'gen' (the words) | 'confirm' (the one-word check) draftMnemonic: '', confirm: [], // [{ index, value }] confirmPass: '', // re-entered passphrase (confirm field, shown once one is typed) @@ -1338,19 +1338,36 @@ function unitTag(cls = '') { // ================================================================ UNLOCK // The create/import card on its own — the unlock screen wraps it in the // classic chrome, the onboarding wizard's "Get started" step shows it bare. +// The front door's seed pages (first run, or "Sign into another account"): +// no tabs, and the page's own Back steps through the create flow. +function seedPages() { + return ui.screen === 'unlock' && !!((ui.onb && ui.onb.step === 'seed') || (ui.fromWallet && ui.addFlow)); +} +// Back within the create flow: the check → the passphrase → the words → the +// heads-up. False when there's no earlier step (the caller leaves the flow). +const CREATE_PREV = { gen: 'intro', pass: 'gen', confirm: 'pass' }; +function createStepBack() { + if (ui.unlockTab !== 'create' || ui.entropyPage || !CREATE_PREV[ui.createStep]) return false; + ui.createStep = CREATE_PREV[ui.createStep]; + ui.unlockError = ''; + render(); + return true; +} + function unlockCard() { // The entropy page takes the card over entirely — seed words, tabs and // other controls step aside until Submit or Back. if (ui.entropyPage) return h('div', { class: 'card col' }, entropyPage()); // Inside the first-run wizard the words are already on screen: no tabs, // just a line underneath for whoever brought their own seed (and one back). - const wizard = ui.screen === 'unlock' && ui.onb && ui.onb.step === 'seed'; - const swap = wizard ? h('button', { + const wizard = seedPages(); + // only before the words are up: once they're shown, it was already offered + const swap = wizard && (ui.unlockTab === 'import' || ui.createStep === 'intro') ? h('button', { class: 'linklike small', style: 'align-self:center', onClick: () => { clearSeedDrafts(); ui.unlockError = ''; if (ui.unlockTab === 'create') ui.unlockTab = 'import'; - else { ui.unlockTab = 'create'; ui.createStep = 'gen'; ui.draftRandom = ui.draftMnemonic = newMnemonic(); } + else { ui.unlockTab = 'create'; ui.createStep = 'intro'; ui.draftRandom = ui.draftMnemonic = newMnemonic(); } render(); }, }, ui.unlockTab === 'create' ? t('onbHaveSeed') : t('onbCreateInstead')) : null; @@ -1369,7 +1386,77 @@ function unlockCard() { ); } +// "Sign in to another account", laid out as the front door is (Adam, +// 2026-11-07): two choices, then the sign-in list with the seed phrase +// first. Everything here adds an account beside the open ones; Back returns +// to the wallet. +function addAccountScreen() { + const page = (kids) => h('div', { class: 'col onb', style: 'gap:20px' }, ...kids); + const title = (txt) => h('h2', { class: 'onb-title' }, txt); + const toWallet = () => { + ui.fromWallet = false; ui.addFlow = false; ui.addStep = null; ui.nostrLoginOpen = false; + ui.screen = 'wallet'; ui.unlockError = ''; ui.nostrLoginError = ''; render(); + }; + if (ui.addStep === 'doors') { + if (ui.nostrLoginOpen) { + return page([ + h('div', { class: 'onb-mark', html: NOSTR_MARK }), + title(t('onbSigninTitle')), + featureHook('unlockExtra') || h('div', { class: 'notice err' }, 'nostr login unavailable'), + h('button', { class: 'linklike small', onClick: () => { ui.nostrLoginOpen = false; ui.nostrLoginError = ''; render(); } }, t('back')), + ]); + } + return page([ + h('div', { class: 'onb-hero' }, brandHeader(false)), + title(t('onbSigninTitle')), + h('div', { class: 'col', style: 'gap:10px' }, + h('button', { id: 'add-restore', class: 'btn-block', style: 'padding:14px', onClick: () => { + clearSeedDrafts(); + ui.unlockTab = 'import'; ui.unlockError = ''; ui.addStep = null; + render(); + } }, t('onbRestoreDoor')), + featureHook('frontDoorSignin', { short: true })), + h('button', { class: 'linklike small', onClick: () => { ui.nostrLoginError = ''; ui.addStep = 'welcome'; render(); } }, t('back')), + ]); + } + return page([ + h('div', { class: 'onb-hero' }, brandHeader(false)), + title(t('addAccountTitle')), + h('div', { class: 'col', style: 'gap:10px' }, + h('button', { + id: 'add-create', + class: 'btn-primary btn-block', style: 'font-size:17px;padding:14px', onClick: () => { + clearSeedDrafts(); + ui.unlockTab = 'create'; ui.unlockError = ''; ui.createStep = 'intro'; ui.confirm = []; + ui.draftRandom = ui.draftMnemonic = newMnemonic(); + ui.addStep = null; + render(); + }, + }, t('onbStart')), + h('button', { + id: 'add-signin', + class: 'btn-block', style: 'font-size:17px;padding:14px', + onClick: () => { ui.nostrLoginError = ''; ui.addStep = 'doors'; render(); }, + }, t('onbSignInExisting'))), + accounts.length ? h('button', { class: 'linklike small', onClick: toWallet }, t('back')) : null, + ]); +} + function unlockScreen() { + if (ui.fromWallet && ui.addFlow) { + if (ui.addStep && !ui.entropyPage) return addAccountScreen(); + // a choice made there: the same seed pages as the front door, whose + // Back steps back to where it was made + return h('div', { class: 'col onb', style: 'gap:20px' }, + h('h2', { class: 'onb-title' }, t(ui.unlockTab === 'import' ? 'onbRestoreTitle' : 'onbSeedTitle')), + unlockCard(), + ui.entropyPage ? null : h('button', { class: 'linklike small', onClick: () => { + if (createStepBack()) return; + const from = ui.unlockTab === 'import' ? 'doors' : 'welcome'; + clearSeedDrafts(); ui.draftMnemonic = ''; ui.createStep = 'intro'; ui.confirm = []; ui.unlockError = ''; + ui.addStep = from; render(); + } }, t('back'))); + } return h( 'div', { class: 'col', style: 'gap:16px' }, @@ -1476,6 +1563,27 @@ function tabBtn(label, active, onClick) { } function createPane() { + // A heads-up before the words appear: they are secret, and the screen is + // about to show them (Adam, 2026-11-07). No warning icon — a plain note. + if (ui.createStep === 'intro' || !ui.createStep) { + const n = ui.draftMnemonic ? ui.draftMnemonic.split(' ').length : 12; + return h( + 'div', + { class: 'col seed-intro' }, + h('p', { style: 'margin:0' }, t('seedIntroBody', { n })), + h('p', { class: 'muted', style: 'margin:0' }, t('seedIntroPrivate')), + h('button', { + id: 'seed-intro-next', + class: 'btn-primary btn-block', style: 'padding:14px', + onClick: () => { + if (!ui.draftMnemonic) ui.draftRandom = ui.draftMnemonic = newMnemonic(); + ui.unlockError = ''; + ui.createStep = 'gen'; + render(); + }, + }, t('seedIntroShow')), + ); + } if (ui.createStep === 'gen') { // The wizard shows words, never a Generate button: a refresh restores // the step but not the draft (drafts live in memory only), so draw a @@ -1513,74 +1621,78 @@ function createPane() { h('button', { class: 'btn-block', style: 'display:flex;align-items:center;justify-content:center;gap:8px', onClick: () => copy(ui.draftMnemonic) }, h('span', { style: 'display:flex', html: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg>' }), t('copy')), - optionsPanel(), h( 'button', { + id: 'seed-next', class: 'btn-primary btn-block', - onClick: () => { - // catch a mistyped passphrase here, while the field to fix it is - // still on screen — the verify step only samples the words - if (ui.passphrase && ui.confirmPass !== ui.passphrase) { - ui.unlockError = t('passphraseMismatch'); render(); return; - } - ui.confirm = pickConfirm(words); - ui.unlockError = ''; - ui.createStep = 'confirm'; - render(); - }, + onClick: () => { ui.unlockError = ''; ui.createStep = 'pass'; render(); }, }, - t('verifyBackup') - ), - h( - 'button', - { class: 'btn-block', onClick: () => openWallet(ui.draftMnemonic, { generated: true }) }, - t('skipVerification') + t('next') ) ); } - // confirm step (optional — reachable via "Verify backup") + // The passphrase, on a page of its own (Adam, 2026-11-07): what it is, + // that it's optional but recommended, and the field — a small "Add + // passphrase" button under the words was easy to miss. + if (ui.createStep === 'pass') { + const words = ui.draftMnemonic.split(' '); + const next = () => { + // a typo here IS a different wallet: caught while the fields are up + if (ui.passphrase && ui.confirmPass !== ui.passphrase) { + ui.unlockError = t('passphraseMismatch'); render(); return; + } + ui.confirm = pickConfirm(words); + ui.unlockError = ''; + ui.createStep = 'confirm'; + render(); + }; + return h( + 'div', + { class: 'col' }, + h('h3', { style: 'margin:0' }, t('passStepTitle')), + h('p', { style: 'margin:0' }, t('passStepBody', { n: words.length })), + h('p', { class: 'muted', style: 'margin:0' }, t('passStepNote')), + ...passFields(), + h('button', { id: 'pass-next', class: 'btn-primary btn-block', onClick: next }, t(ui.passphrase ? 'next' : 'passStepNone')), + ); + } + // The check: which word is #5? One tap from a few of the phrase's own + // words, or Skip (Adam, 2026-11-07) — no typing. + const q = ui.confirm; + if (!q || !q.options) { ui.createStep = 'pass'; return createPane(); } return h( 'div', { class: 'col' }, - h('p', { class: 'muted' }, t('confirmBackupIntro')), - ...ui.confirm.map((c, i) => - h( - 'label', - { class: 'field' }, - h('span', { class: 'lab' }, t('wordN', { n: c.index + 1 })), - h('input', { - type: 'text', - class: 'mono-input', - autocapitalize: 'none', - autocomplete: 'off', - spellcheck: 'false', - value: c.value, - onInput: (e) => (ui.confirm[i].value = e.target.value.trim()), - }) - ) - ), - h('div', { class: 'row gap6' }, - h('button', { class: 'btn-ghost', onClick: () => { ui.createStep = 'gen'; render(); } }, t('back')), + h('p', { style: 'margin:0' }, t('quizPrompt', { n: q.index + 1 })), + h('div', { class: 'quiz-opts' }, q.options.map((w) => h('button', { - class: 'btn-primary grow', + type: 'button', + class: 'quiz-opt' + (q.wrong && q.wrong.includes(w) ? ' wrong' : ''), onClick: () => { const words = ui.draftMnemonic.split(' '); - const ok = ui.confirm.every((c) => c.value.toLowerCase() === words[c.index]); - if (!ok) { ui.unlockError = t('wordsMismatch'); render(); return; } - openWallet(ui.draftMnemonic, { generated: true }); + if (w === words[q.index]) { ui.unlockError = ''; openWallet(ui.draftMnemonic, { generated: true }); return; } + (q.wrong ||= []).includes(w) || q.wrong.push(w); + render(); }, - }, t('openWallet')) - ), - h('button', { class: 'btn-block', onClick: () => openWallet(ui.draftMnemonic, { generated: true }) }, t('skipVerification')) + }, w))), + q.wrong && q.wrong.length ? h('div', { class: 'notice err' }, t('quizWrong')) : null, + h('button', { id: 'seed-skip', class: 'btn-ghost btn-block', onClick: () => { ui.unlockError = ''; openWallet(ui.draftMnemonic, { generated: true }); } }, t('skip')), + // outside the seed pages (no Back of their own), a way back to the words + seedPages() ? null : h('button', { class: 'linklike small', style: 'align-self:center', onClick: () => { ui.createStep = 'pass'; ui.unlockError = ''; render(); } }, t('back')), ); } -function pickConfirm(words) { - const idx = new Set(); - while (idx.size < 3) idx.add(Math.floor(Math.random() * words.length)); - return [...idx].sort((a, b) => a - b).map((index) => ({ index, value: '' })); +// The 5th word and three others from the same phrase, shuffled: knowing the +// words isn't enough, you need them in order. +function pickConfirm(words, index = Math.min(4, words.length - 1)) { + const right = words[index]; + const others = [...new Set(words.filter((w) => w !== right))]; + for (let i = others.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [others[i], others[j]] = [others[j], others[i]]; } + const options = [right, ...others.slice(0, 3)]; + for (let i = options.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [options[i], options[j]] = [options[j], options[i]]; } + return { index, options, wrong: [] }; } // The BYO-entropy page, shared by Create (re-derives the draft phrase) and @@ -1674,22 +1786,28 @@ function optionsPanel() { // already typed keeps it open. if (!ui.passOpen && !ui.passphrase) { return [h('button', { - class: 'btn-ghost btn-sm', type: 'button', style: 'align-self:flex-start', + class: 'btn-block', type: 'button', onClick: (e) => { e.preventDefault(); ui.passOpen = true; render(); }, }, t('addPassphrase'))]; } + return passFields({ label: true }); +} + +// The passphrase field (+ its confirmation once something is typed). The +// create flow's own page explains it above; the import pane labels it here. +function passFields({ label = false } = {}) { const pass = h( 'label', { class: 'field' }, h('span', { class: 'lab' }, - t('passphraseOptional'), + t(label ? 'passphraseOptional' : 'passphrase'), // People assume a password logs in or encrypts; a BIP39 passphrase does // neither, and getting that wrong loses money — worth a sentence here. h('button', { class: 'linklike small', type: 'button', style: 'margin-left:8px', onClick: (e) => { e.preventDefault(); ui.passInfoOpen = !ui.passInfoOpen; render(); }, }, t('passWhatsThis'))), - ui.passInfoOpen ? h('p', { class: 'small muted', style: 'margin:0 0 6px' }, t('passInfo')) : h('p', { class: 'small muted', style: 'margin:0 0 6px' }, t('passOptionalHint')), + ui.passInfoOpen ? h('p', { class: 'small muted', style: 'margin:0 0 6px' }, t('passInfo')) : label ? h('p', { class: 'small muted', style: 'margin:0 0 6px' }, t('passOptionalHint')) : null, h( 'div', { class: 'input-group' }, @@ -1718,7 +1836,7 @@ function optionsPanel() { class: 'mono-input', autocomplete: 'off', value: ui.confirmPass, - onInput: (e) => (ui.confirmPass = e.target.value), + onInput: (e) => { ui.confirmPass = e.target.value; if (ui.unlockError && ui.confirmPass === ui.passphrase) { ui.unlockError = ''; render(); } }, }) ) : null; @@ -1929,8 +2047,9 @@ async function activateAccount(acc, opts = {}) { function signInAnother() { clearSeedDrafts(); ui.fromWallet = true; ui.unlockError = ''; ui.screen = 'unlock'; - // Starts on Create new (its Generate button), not the seed-import box. - ui.unlockTab = 'create'; ui.createStep = 'gen'; ui.draftMnemonic = ''; ui.confirm = []; + ui.addFlow = true; ui.addStep = 'welcome'; ui.nostrLoginOpen = false; ui.nostrLoginError = ''; + // Opens on the front door's two choices (addAccountScreen). + ui.unlockTab = 'create'; ui.createStep = 'intro'; ui.draftMnemonic = ''; ui.confirm = []; ui.profilePk = null; ui.profEdit = null; ui.profEditFilled = false; ui.chatOpen = false; render(); } @@ -2803,7 +2922,7 @@ function lock({ offerPassword = false } = {}) { ui.confirmRemove = null; ui.editId = null; ui.editLabel = ''; - ui.createStep = 'gen'; + ui.createStep = 'intro'; ui.draftMnemonic = ''; ui.importText = ''; ui.entropyPage = null; @@ -3385,7 +3504,7 @@ function goHome() { ui.sendError = ''; ui.fromWallet = false; } else if ((ui.onb && ui.onb.step !== 'welcome') - || ui.draftMnemonic || ui.createStep !== 'gen' || ui.importText) { + || ui.draftMnemonic || !['intro', 'gen'].includes(ui.createStep) || ui.importText) { // Mid-create with no wallet minted yet (wizard step, a generated seed on // screen, the confirm quiz, a pasted import): the logo backs out to the // start page this began from — not the vault prompt, which reads as a @@ -3395,7 +3514,7 @@ function goHome() { ui.nostrLoginOpen = false; ui.screen = 'unlock'; ui.unlockTab = 'create'; - ui.createStep = 'gen'; + ui.createStep = 'intro'; ui.draftMnemonic = ''; clearSeedDrafts(); ui.entropyPage = null; @@ -3414,7 +3533,7 @@ function goHome() { } else { ui.screen = 'unlock'; ui.unlockTab = 'create'; - ui.createStep = 'gen'; + ui.createStep = 'intro'; ui.draftMnemonic = ''; ui.confirm = []; ui.unlockError = ''; @@ -3494,8 +3613,8 @@ function accountsScreen() { if (aw.from === 'new') { ui.newWalletKind = kind; ui.addWallet = null; - ui.draftMnemonic = ''; ui.createStep = 'gen'; ui.confirm = []; ui.passphrase = ''; ui.confirmPass = ''; ui.passOpen = false; - ui.showPass = false; ui.screen = 'unlock'; ui.unlockTab = 'create'; ui.fromWallet = true; ui.unlockError = ''; + ui.draftMnemonic = ''; ui.createStep = 'intro'; ui.confirm = []; ui.passphrase = ''; ui.confirmPass = ''; ui.passOpen = false; + ui.showPass = false; ui.screen = 'unlock'; ui.unlockTab = 'create'; ui.fromWallet = true; ui.unlockError = ''; ui.addFlow = false; render(); } else { createDerivedWallet(aw.from, kind); @@ -4190,7 +4309,7 @@ function onboardScreen() { const startCreate = () => { ui.unlockTab = 'create'; ui.unlockError = ''; - ui.createStep = 'gen'; + ui.createStep = 'intro'; if (!ui.draftMnemonic) ui.draftRandom = ui.draftMnemonic = newMnemonic(); o.step = 'seed'; render(); @@ -4292,26 +4411,36 @@ function onboardScreen() { ui.tab = 'receive'; render(); }; + // Later means later, not never: seedSeen stays false, so the wallet + // keeps a quiet reminder until the phrase has actually been seen. + // Except on the way to a coinos.io move: the balance about to arrive + // must have its twelve words written down first, so Later isn't offered. + const later = migrateLinkPending() ? null : h('button', { class: 'btn-ghost btn-block', onClick: () => { + if (canOfferSpending()) { o.step = 'spend'; o.enterAddr = undefined; render(); return; } + ui.onb = null; + try { localStorage.removeItem(ONB_STEP_KEY); } catch {} + ui.account = 'savings'; + try { localStorage.setItem(ACCOUNT_KEY, 'savings'); } catch {} + ui.tab = 'receive'; + render(); + } }, t('onbBackupLater')); + // a heads-up first: the next screen shows the secret (Adam, 2026-11-07) + if (!o.backupShown) { + return page([ + title(t('onbBackupTitle')), + h('p', { class: 'muted', style: 'margin:0' }, t('onbBackupBody')), + h('p', { class: 'muted', style: 'margin:0' }, t('seedIntroPrivate')), + h('button', { id: 'onb-backup-show', class: 'btn-primary btn-block', style: 'padding:14px', onClick: () => { o.backupShown = true; render(); } }, t('onbBackupShow')), + later, + ]); + } return page([ title(t('onbBackupTitle')), - h('p', { class: 'muted', style: 'margin:0' }, t('onbBackupBody')), h('div', { class: 'words', style: '--rows-3:' + Math.ceil(words.length / 3) + ';--rows-2:' + Math.ceil(words.length / 2) }, words.map((w, i) => h('div', { class: 'w' }, h('span', { class: 'n' }, i + 1), h('span', { class: 't' }, w)))), h('div', { class: 'row gap6' }, copyBtn(acc.mnemonic, t('copyPhrase'))), h('button', { class: 'btn-primary btn-block', style: 'padding:14px', onClick: done }, t('onbBackupDone')), - // Later means later, not never: seedSeen stays false, so the wallet - // keeps a quiet reminder until the phrase has actually been seen. - // Except on the way to a coinos.io move: the balance about to arrive - // must have its twelve words written down first, so Later isn't offered. - migrateLinkPending() ? null : h('button', { class: 'btn-ghost btn-block', onClick: () => { - if (canOfferSpending()) { o.step = 'spend'; o.enterAddr = undefined; render(); return; } - ui.onb = null; - try { localStorage.removeItem(ONB_STEP_KEY); } catch {} - ui.account = 'savings'; - try { localStorage.setItem(ACCOUNT_KEY, 'savings'); } catch {} - ui.tab = 'receive'; - render(); - } }, t('onbBackupLater')), + later, ]); } if (o.step === 'seed') { @@ -4322,7 +4451,7 @@ function onboardScreen() { title(t(ui.unlockTab === 'import' ? 'onbRestoreTitle' : 'onbSeedTitle')), unlockCard(), // the entropy page brings its own Back — one is enough - ui.entropyPage ? null : h('button', { class: 'linklike small', onClick: () => { ui.unlockError = ''; o.step = ui.unlockTab === 'import' ? 'doors' : 'welcome'; render(); } }, t('back')), + ui.entropyPage ? null : h('button', { class: 'linklike small', onClick: () => { if (createStepBack()) return; ui.unlockError = ''; o.step = ui.unlockTab === 'import' ? 'doors' : 'welcome'; render(); } }, t('back')), ]); } if (o.step === 'signin') { |
