diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/app.js | 119 | ||||
| -rw-r--r-- | src/i18n.js | 11 | ||||
| -rw-r--r-- | src/style.css | 9 |
3 files changed, 121 insertions, 18 deletions
@@ -1280,6 +1280,12 @@ async function enterWallet(mnemonic, passphrase, opts = {}) { mnemonic: (mnemonic || '').trim().replace(/\s+/g, ' '), passphrase: passphrase || '', }); + // Has this person ever had the recovery phrase in front of them? Creating + // a wallet shows it; importing one means they typed it. A sign-in DERIVES + // it — Google, a passkey and a nostr key all end up at a seed the user has + // never seen, and which nobody can reissue for them. That is the one case + // worth asking about, so it's recorded once and never guessed at again. + if (acc.seedSeen === undefined) acc.seedSeen = !opts.nostrPubkey; await activateAccount(acc, { ...opts, fresh: true }); } @@ -2888,7 +2894,12 @@ function recoveryCard(a) { a.passphrase ? copyBtn(a.passphrase, t('copyPassphrase')) : null, h('button', { class: 'btn-sm grow', onClick: () => { ui.revealShown = false; render(); } }, t('hide'))) : h('div', { class: 'row gap6' }, - h('button', { class: 'btn-primary grow', onClick: () => { ui.revealShown = 'words'; render(); } }, t('revealWords')), + h('button', { class: 'btn-primary grow', onClick: () => { + ui.revealShown = 'words'; + // seeing it here counts: no need to be asked again elsewhere + if (!a.seedSeen) { a.seedSeen = true; persistAccounts(); if (a.persisted) writeVault(); } + render(); + } }, t('revealWords')), h('button', { class: 'btn-sm', onClick: () => { ui.revealShown = false; render(); } }, t('hide'))) ); } @@ -3080,14 +3091,23 @@ function onboardScreen() { // the login finishes. if ((o.step === 'welcome' || o.step === 'seed' || o.step === 'signin') && ui.screen === 'wallet' && activeAccount() && !ui.nostrLoginBusy) { - ui.onb = null; - try { localStorage.removeItem(ONB_STEP_KEY); } catch {} - // On-chain first: the wizard's promise is "your keys, your coins", and - // Spending stays out of sight until the user sets it up. - ui.account = 'savings'; - try { localStorage.setItem(ACCOUNT_KEY, 'savings'); } catch {} - ui.tab = 'history'; - return null; // the caller falls through to the wallet itself + // A wallet that arrived by SIGN-IN has a recovery phrase its owner has + // never seen. Show it once, here, while it still reads as part of + // setting up — rather than leaving it buried in settings until the day + // they lose the Google account and find out it was never a backup. + // Handled further down, where page()/title() exist. + const arrived = activeAccount(); + if (arrived && arrived.mnemonic && !arrived.seedSeen) o.step = 'backup'; + else { + ui.onb = null; + try { localStorage.removeItem(ONB_STEP_KEY); } catch {} + // On-chain first: the wizard's promise is "your keys, your coins", and + // Spending stays out of sight until the user sets it up. + ui.account = 'savings'; + try { localStorage.setItem(ACCOUNT_KEY, 'savings'); } catch {} + ui.tab = 'history'; + return null; // the caller falls through to the wallet itself + } } if (o.step === 'spend') { const addr = featureHook('namesAddress'); @@ -3164,14 +3184,24 @@ function onboardScreen() { return page([ h('div', { class: 'onb-hero' }, brandHeader(false)), title(t('onbWelcomeTitle')), - // Starting is the primary path; bringing a nostr account is the - // alternative, offered here rather than as a question of its own. - h('button', { class: 'btn-primary btn-block', style: 'font-size:17px;padding:14px', onClick: () => { - ui.unlockTab = 'create'; - ui.unlockError = ''; - o.step = 'seed'; - render(); - } }, t('onbStart')), + // What this wallet IS, before the buttons. Four equally weighted + // buttons with only the sign-in ones saying what they do sent people + // straight past the seed phrase — which is the wallet — into a login + // that is only a convenience wrapped around it. + h('p', { class: 'muted', style: 'margin:0' }, t('onbWelcomeBody')), + // Making your own keys is the path; a sign-in is the alternative, + // below the line. + h('button', { + // a stable hook for tests, so this button survives its own copy + id: 'onb-create', + class: 'btn-primary btn-block', style: 'font-size:17px;padding:14px', onClick: () => { + ui.unlockTab = 'create'; + ui.unlockError = ''; + o.step = 'seed'; + render(); + } }, t('onbStart')), + h('p', { class: 'small muted', style: 'margin:-6px 0 0;text-align:center' }, t('onbStartHint')), + h('div', { class: 'onb-or' }, h('span', {}, t('onbOrSignIn'))), // All the sign-in doors right on the welcome screen (Google, passkey, // Nostr) — the feature supplies them; the single-button fallback keeps // builds without it working. @@ -3186,6 +3216,42 @@ function onboardScreen() { ui.onbError ? h('div', { class: 'notice err' }, ui.onbError) : null, ]); } + if (o.step === 'backup') { + const acc = activeAccount(); + // the wallet went away (switched, logged out) — nothing to back up + if (!acc || !acc.mnemonic) { ui.onb = null; try { localStorage.removeItem(ONB_STEP_KEY); } catch {} return null; } + const words = acc.mnemonic.split(' '); + const done = () => { + acc.seedSeen = true; + persistAccounts(); + if (acc.persisted) writeVault(); + ui.onb = null; + try { localStorage.removeItem(ONB_STEP_KEY); } catch {} + ui.account = 'savings'; + try { localStorage.setItem(ACCOUNT_KEY, 'savings'); } catch {} + ui.tab = 'history'; + render(); + }; + return page([ + title(t('onbBackupTitle')), + h('p', { class: 'muted', style: 'margin:0' }, t('onbBackupBody')), + h('div', { class: 'warn-box' }, t('writeDownWarn')), + h('div', { class: 'words' }, 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. + h('button', { class: 'btn-ghost btn-block', onClick: () => { + ui.onb = null; + try { localStorage.removeItem(ONB_STEP_KEY); } catch {} + ui.account = 'savings'; + try { localStorage.setItem(ACCOUNT_KEY, 'savings'); } catch {} + ui.tab = 'history'; + render(); + } }, t('onbBackupLater')), + ]); + } if (o.step === 'seed') { // The classic create/import card, inside the wizard: getting started // means seeing (or bringing) your seed phrase, not a wallet minted @@ -3439,12 +3505,31 @@ function walletScreen() { brandHeader(true), h('div', { class: 'mt16' }, balanceCard()), ui.offlineFallback && wallet.offline ? offlineBanner() : null, + backupNotice(), ...featureAll('walletNotices'), tabsBar(), pane ); } +// A wallet whose recovery phrase has never been on screen keeps one quiet +// line about it. "Later" is a real answer, so this is a row and not a modal — +// but it doesn't dismiss, because the risk doesn't go away on its own. It +// disappears the moment the phrase is actually seen (here or in settings). +function backupNotice() { + const a = activeAccount(); + if (!a || !a.mnemonic || a.seedSeen || wallet.watchOnly) return null; + return h('div', { class: 'small muted', style: 'margin:10px 0 0;text-align:center' }, + t('backupReminder'), + ' ', + h('button', { class: 'linklike small', onClick: () => { + ui.tab = 'settings'; + ui.settingsPage = 'wallet'; + ui.revealShown = 'masked'; + render(); + } }, t('backupReminderGo'))); +} + function offlineBanner() { return h( 'div', diff --git a/src/i18n.js b/src/i18n.js index 3faa843..6a46823 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -716,7 +716,16 @@ const STR = { or: 'or', nlSignIn: 'Sign in', onbWelcomeTitle: 'Welcome to coinos', - onbStart: 'Get started', + onbWelcomeBody: 'Bitcoin you actually hold. Your wallet is twelve words — nobody else ever has them, and they work in any wallet, anywhere.', + onbStart: 'Create a new wallet', + onbStartHint: "Takes a minute: write down twelve words and it's yours", + onbOrSignIn: 'or, if you already have an account', + onbBackupTitle: 'Write down your recovery phrase', + onbBackupBody: "Signing in was the easy part — but the sign-in isn't your wallet. These twelve words are. They're the only way back in if you lose access to that account, and nobody can reissue them for you.", + onbBackupDone: "I've written it down", + onbBackupLater: 'Later', + backupReminder: 'Your recovery phrase is still unwritten.', + backupReminderGo: 'Write it down', onbSeedTitle: 'Set up your wallet', onbSigninTitle: 'Sign in', onbLegacyTitle: 'Move your coinos.io account', diff --git a/src/style.css b/src/style.css index a16101c..c9b4368 100644 --- a/src/style.css +++ b/src/style.css @@ -865,6 +865,15 @@ body::after { column to the 420px cap even on narrower phones, overflowing the page. */ .onb { width: 100%; max-width: 420px; margin: 8vh auto 0; padding: 0 8px; } .onb-title { margin: 0; font-size: 26px; letter-spacing: -0.02em; } +/* The line between making your own keys and signing in with someone else's + account. Without it the four buttons read as four equal choices. */ +.onb-or { + display: flex; align-items: center; gap: 12px; margin: 4px 0 -4px; + color: var(--faint); font-size: 13px; +} +.onb-or::before, .onb-or::after { + content: ''; flex: 1; height: 1px; background: var(--line); +} .onb-hero { margin-bottom: 8px; } .onb-punks { display: flex; gap: 10px; overflow-x: auto; padding: 6px 2px; } .onb-punk { width: 56px; height: 56px; border-radius: 50%; flex-shrink: 0; cursor: pointer; border: 3px solid transparent; transition: transform .15s, border-color .15s; } |
