From de8cb7a6f2272fa0b601a155dd244fe3a3a3f331 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Fri, 9 Oct 2026 01:41:24 -0700 Subject: Front door: a "Migrate a coinos account" button under the sign-in doors, opening a page that says a wallet and its twelve words come first (Create, or the sign-in doors), and /migrate on a device without a wallet opens on that page instead of an unexplained welcome; while a move is queued the recovery-phrase step offers no Later. Spending step: an account whose custom name the registrar handed back (a wallet a sign-in restored) leaves the wizard instead of being shown a claim form it could only retype its own name into — claiming it again "succeeded" onto the same page; the claim form also waits behind "Getting things ready…" until the registrar lookups settle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- src/app.js | 91 ++++++++++++++++++++++++++++---------- src/features/names.js | 8 +++- src/i18n.js | 3 ++ tools/onboard-migrate-door-test.js | 70 +++++++++++++++++++++++++++++ 4 files changed, 147 insertions(+), 25 deletions(-) create mode 100644 tools/onboard-migrate-door-test.js diff --git a/src/app.js b/src/app.js index 52adce4..6a80f8c 100644 --- a/src/app.js +++ b/src/app.js @@ -3674,13 +3674,15 @@ function onboardScreen() { let saved = null; try { saved = localStorage.getItem(ONB_STEP_KEY); } catch {} // a saved step past wallet creation only makes sense if the wallet is here - if (saved && !['welcome', 'signin', 'seed'].includes(saved) && !activeAccount()) saved = 'welcome'; + if (saved && !['welcome', 'signin', 'seed', 'migrate'].includes(saved) && !activeAccount()) saved = 'welcome'; // 'legacy-went' survives the coinos.io round trip: the user left for the // migrate page, so coming back must offer "I'm done, continue" — the // in-memory flag died with the navigation and left them looping. ui.onb = saved === 'legacy-went' ? { step: 'legacy', wentToLegacy: true, fromLink: migrateLinkPending() } - : { step: saved || 'welcome', fromLink: saved === 'legacy' && migrateLinkPending() }; + // the /migrate link on a device with no wallet opens on the move's own + // page, which says a wallet comes first — not an unexplained welcome + : { step: saved || (!activeAccount() && migrateLinkPending() ? 'migrate' : 'welcome'), fromLink: saved === 'legacy' && migrateLinkPending() }; if (saved === 'signin') ui.nostrLoginOpen = true; // reopen the login card } const o = ui.onb; @@ -3719,9 +3721,17 @@ function onboardScreen() { if (o.step === 'spend') { const addr = featureHook('namesAddress'); if (o.enterAddr === undefined) o.enterAddr = addr || null; + const custom = !!addr && !/^npub1/.test(addr); + // A custom name this page didn't claim is an account that set itself up + // before: a wallet a sign-in restored, whose name the registrar handed + // back (owned by the login identity or the wallet key). There is nothing + // to claim — the form stood here anyway, suggesting a profile name, and + // retyping the name one already had "claimed" it again and painted the + // same page. Out of the wizard, into the wallet as it was. + if (custom && !o.claimedHere) { leaveWizard(); return null; } // Only a real (custom) name advances by itself — the background // auto-claim of the npub-shaped default doesn't count as setting up. - if (addr && addr !== o.enterAddr && !/^npub1/.test(addr)) { + if (custom && addr !== o.enterAddr) { // The avatar ask is for brand-new profiles. An identity that already // wears a face (a nostr login with a published picture) skips straight // past the punk picker — restyling a real profile helps nobody. @@ -3792,6 +3802,26 @@ function onboardScreen() { ui.onbError ? h('div', { class: 'notice err' }, ui.onbError) : null, ]); } + // Making your own keys: the words are the wallet, so show them at once, + // no Generate to tap. + const startCreate = () => { + ui.unlockTab = 'create'; + ui.unlockError = ''; + ui.createStep = 'gen'; + if (!ui.draftMnemonic) ui.draftRandom = ui.draftMnemonic = newMnemonic(); + o.step = 'seed'; + render(); + }; + // All the sign-in doors (Google, passkey, Nostr) — the feature supplies + // them; the single-button fallback keeps builds without it working. + const signinDoors = () => featureHook('frontDoorSignin') + || h('button', { class: 'btn-block', style: 'padding:14px', onClick: () => { + o.step = 'signin'; + ui.nostrLoginOpen = true; + render(); + } }, h('span', { style: 'display:flex;align-items:center;justify-content:center;gap:8px' }, + h('span', { style: 'display:flex;flex-shrink:0', html: NOSTR_MARK }), + t('nlSignIn'))); if (o.step === 'welcome') { return page([ h('div', { class: 'onb-hero' }, brandHeader(false)), @@ -3801,27 +3831,38 @@ function onboardScreen() { 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 = ''; - ui.createStep = 'gen'; - // the words are the wallet: show them at once, no Generate to tap - if (!ui.draftMnemonic) ui.draftRandom = ui.draftMnemonic = newMnemonic(); - o.step = 'seed'; - render(); - } }, t('onbStart')), + class: 'btn-primary btn-block', style: 'font-size:17px;padding:14px', onClick: startCreate, + }, t('onbStart')), 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. - featureHook('frontDoorSignin') - || h('button', { class: 'btn-block', style: 'padding:14px', onClick: () => { - o.step = 'signin'; - ui.nostrLoginOpen = true; - render(); - } }, h('span', { style: 'display:flex;align-items:center;justify-content:center;gap:8px' }, - h('span', { style: 'display:flex;flex-shrink:0', html: NOSTR_MARK }), - t('nlSignIn'))), + signinDoors(), + // The move from the original coinos.io used to wait behind the + // Spending offer, where nobody looking for it found it. Its own door + // on the front page: it stashes the intent the /migrate link stashes + // (the move needs a wallet and a payment address first) and explains + // that before any twelve words appear. + h('button', { id: 'onb-migrate', class: 'btn-block', style: 'padding:14px', onClick: () => { + try { localStorage.setItem(MIGRATE_LINK_KEY, String(Date.now())); } catch {} + ui.onbError = ''; + o.step = 'migrate'; + render(); + } }, t('onbMigrateDoor')), + ui.onbError ? h('div', { class: 'notice err' }, ui.onbError) : null, + ]); + } + if (o.step === 'migrate') { + // The intent is kept across whichever way in they pick: once a wallet + // exists, the Spending offer gives way to the coinos.io move (above). + return page([ + h('div', { class: 'onb-hero' }, brandHeader(false)), + title(t('onbLegacyTitle')), + h('p', { class: 'muted', style: 'margin:0' }, t('onbMigrateIntro')), + h('button', { + id: 'onb-migrate-create', + class: 'btn-primary btn-block', style: 'font-size:17px;padding:14px', onClick: startCreate, + }, t('onbMigrateCreate')), + h('div', { class: 'onb-or' }, h('span', {}, t('onbOrSignIn'))), + signinDoors(), + h('button', { class: 'linklike small', onClick: () => { endMigrateLink(); ui.onbError = ''; o.step = 'welcome'; render(); } }, t('back')), ui.onbError ? h('div', { class: 'notice err' }, ui.onbError) : null, ]); } @@ -3850,7 +3891,9 @@ function onboardScreen() { 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: () => { + // 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 {} diff --git a/src/features/names.js b/src/features/names.js index ea94d2a..a29ee7d 100644 --- a/src/features/names.js +++ b/src/features/names.js @@ -457,6 +457,9 @@ export function namesFeature(ctx) { // name back, which the wallet key alone could never prove. const signer = await Promise.resolve(hook('nostrLoginResume')).catch(() => null); await claimRequestedName(name, signer ? { signer, manager: wallet.nostrPubkey() } : {}); + // the wizard's Spending step tells a name claimed here from one + // the registrar handed back (an account set up before) + if (ui.onb && ui.onb.step === 'spend') ui.onb.claimedHere = name; ui.nameClaim = ''; ui.nameEditOpen = false; toast(t('namesClaimed', { name: `${name}@${DOMAIN()}` })); @@ -925,7 +928,10 @@ export function namesFeature(ctx) { // for wallets with a history. The background claim still runs. identityGenerated() { checked = true; }, // the onboarding wizard renders the same claim form on its username step - namesClaimForm() { return claimForm(true); }, + // Nothing to type into until the registrar has answered who this wallet + // already is (the wizard shows "Getting things ready…" meanwhile): a + // restored account's name arrives in that window and ends the step. + namesClaimForm() { return checked ? claimForm(true) : null; }, // claim a specific name (the migration flow, after coinos.io released it) namesClaimName(name, opts) { return claimRequestedName(String(name).toLowerCase(), opts || {}); }, stop() { clearTimeout(retryTimer); clearTimeout(deadline); deadline = null; }, diff --git a/src/i18n.js b/src/i18n.js index e6452e1..6081c2d 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -1132,6 +1132,9 @@ const STR = { onbLegacyNoAddr: "Your payment address isn't ready yet. Try again in a moment.", onbLegacyDone: "I'm done, continue", onbHaveCoinos: 'Migrate an existing coinos account', + onbMigrateDoor: 'Migrate a coinos account', + onbMigrateIntro: "Moving from the original coinos.io? Your balance and username come with you. First, set up the wallet they'll move into and write down its twelve words: they're yours alone, and the only way back in. Then we'll take you to coinos.io to finish the move.", + onbMigrateCreate: 'Create my new wallet', migrateClaimed: 'Welcome back. {name} is yours here now.', migrateClaimFailed: "Your balance moved, but {name} isn't free yet. Try claiming it in a minute.", migrateWorking: 'Claiming {name}…', diff --git a/tools/onboard-migrate-door-test.js b/tools/onboard-migrate-door-test.js new file mode 100644 index 0000000..3b16dd0 --- /dev/null +++ b/tools/onboard-migrate-door-test.js @@ -0,0 +1,70 @@ +// The front door's "Migrate a coinos account" button: people moving from +// the original coinos.io couldn't find the move, which waited behind the +// Spending offer. The door stashes the /migrate link's intent, explains that +// a wallet comes first, survives a refresh, and Back forgets the intent. +// +// Run: bun tools/onboard-migrate-door-test.js +import puppeteer from './puppeteer.js'; +import { buildHtml } from '../build.js'; +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +let ok = true; +const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; }; + +const html = await buildHtml({ minify: true, pwa: false }); +const server = Bun.serve({ port: 5293, fetch: () => new Response(html, { headers: { 'content-type': 'text/html' } }) }); +const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] }); +const text = (page) => page.evaluate(() => document.body.innerText); +const waitText = async (page, x, ms = 20000) => { + for (let i = 0; i < ms / 250; i++) { if ((await text(page)).toLowerCase().includes(x.toLowerCase())) return true; await sleep(250); } + return false; +}; +const visible = (page) => page.evaluate(() => [...document.querySelectorAll('button')].filter((b) => b.offsetParent).map((b) => ({ id: b.id, text: b.textContent.trim() }))); +const clickText = (page, x) => page.evaluate((s) => { + const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().replace(/^[^a-z]+/, '') === s); + if (e) { e.click(); return true; } return false; +}, x.toLowerCase()); + +try { + const page = await browser.newPage(); + await page.setViewport({ width: 390, height: 844 }); + await page.goto('http://localhost:5293/', { waitUntil: 'domcontentloaded' }); + check('the front door paints', await waitText(page, 'create a new wallet')); + const btns = await visible(page); + const iMigrate = btns.findIndex((b) => b.id === 'onb-migrate'); + const iLastSignin = btns.map((b) => /sign in/i.test(b.text)).lastIndexOf(true); + check('a Migrate a coinos account button sits below the sign-in buttons', iMigrate > 0 && iMigrate > iLastSignin, + btns.map((b) => b.text).join(' | ')); + await page.click('#onb-migrate'); + check('it opens the move page', await waitText(page, 'move your coinos.io account', 5000)); + check('…which says a wallet and its words come first', /first, set up the wallet.*twelve words/i.test((await text(page)).replace(/\s+/g, ' '))); + const stashed = await page.evaluate(() => Number(localStorage.getItem('btc-wallet-migrate-link')) > 0); + check('the migrate intent is stashed, as the /migrate link stashes it', stashed); + const doors = await visible(page); + check('it offers Create plus the sign-in doors', doors.some((b) => b.id === 'onb-migrate-create') && doors.some((b) => /sign in with nostr/i.test(b.text)), + doors.map((b) => b.text).join(' | ')); + await page.reload({ waitUntil: 'domcontentloaded' }); + check('a refresh stays on the move page', await waitText(page, 'move your coinos.io account', 8000)); + check('Back returns to the front door', await clickText(page, 'back') && await waitText(page, 'create a new wallet', 5000)); + const forgot = await page.evaluate(() => !localStorage.getItem('btc-wallet-migrate-link')); + check('…and forgets the intent', forgot); + await page.click('#onb-migrate'); + await waitText(page, 'move your coinos.io account', 5000); + await page.click('#onb-migrate-create'); + check('Create my new wallet shows the twelve words', await waitText(page, 'set up your wallet', 5000)); + check('the intent survives into wallet creation', await page.evaluate(() => Number(localStorage.getItem('btc-wallet-migrate-link')) > 0)); + // the /migrate link on a device with no wallet: straight to the move page + const fresh = await browser.createBrowserContext(); + const linked = await fresh.newPage(); + await linked.setViewport({ width: 390, height: 844 }); + const pageErrors = []; + linked.on('pageerror', (e) => pageErrors.push(e.message)); + await linked.goto('http://localhost:5293/migrate', { waitUntil: 'domcontentloaded' }); + check('/migrate with no wallet opens on the move page', await waitText(linked, 'move your coinos.io account', 10000)); + check('…at the address bar root', await linked.evaluate(() => location.pathname === '/')); + check('no page errors', pageErrors.length === 0, pageErrors.join('; ')); +} finally { + await browser.close(); + server.stop(true); +} +console.log(ok ? '\n✅ the migrate door' : '\n❌ failures above'); +process.exit(ok ? 0 : 1); -- cgit v1.2.3