aboutsummaryrefslogtreecommitdiffstats
path: root/src/features/nostrlogin.js
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-11-15 13:20:24 -0700
committerAdam Soltys <[email protected]>2026-11-15 13:20:24 -0700
commite4d23a8e32a8feaf0cf7aeb93ce847732ff93330 (patch)
treed3641c81250abd49a8ced3e5daa37cf1daf5799a /src/features/nostrlogin.js
parent79d4f77f3f70877c0c4951aff5dc0c42800ab73c (diff)
downloadcoinosv3-e4d23a8e32a8feaf0cf7aeb93ce847732ff93330.tar.gz
coinosv3-e4d23a8e32a8feaf0cf7aeb93ce847732ff93330.zip
Signing up is a name and a look: no seed phrase to copy or check, no Savings until you want it, and ways back in you add afterwards. "i think i'd like to move away from requiring people to backup and verify a seed phrase to sign up. we should just have them claim a name and pick their avatar/cover and they should be in. we should also not create a savings account by default but let them add it later if they want to. adding additional auth methods like passkey, nostr, or google should be possible once they've created their account so that they can get back into it with one or all of those methods or by seed phrase." (Adam, 2026-11-15). Where a name can be claimed (mainnet, mutinynet, with Ark and the registrar), the front door's "Create an account" makes the account at once (seed saved to the device, never shown) and goes Claim your name (no Not now) → Choose your look → in. The account is Spending alone; Settings → Wallet → Balance settings has "Add a savings account" (same seed, account 0), and on-chain money or history turning up adds it by itself. Settings → Wallet opens with "Ways back in": add a passkey, Google or a Nostr account, each of which gets a copy of the seed encrypted to its own key (kind 30078, marked recovery), and signing in with it on another device opens this account as itself, no identity adopted; the recovery phrase sits in the same list with Show. Until one exists the wallet carries a quiet "Add a way back into your account" line. The shape and the list survive the session (directory) and sync between devices (snapshot). Regtest and builds without Ark keep the classic phrase flow, and so does the /migrate door's Create. tools/signup-quick-test.js (mainnet, mocked registrar, virtual passkey): the whole sign-up, Add savings, a reload, then a wiped page signs back in with the passkey to the same account and identity. balance-settings-row, send-lnaddr-advance and onboard-backup reach their seed wallets through the phrase door or on regtest; features-smoke, onboard-cover, onboard-migrate-door, nostr-login, gift-profile-setup, passkey-lock and seed-cache pass
Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01VMan6SwkivduFDDkMmnXjZ
Diffstat (limited to 'src/features/nostrlogin.js')
-rw-r--r--src/features/nostrlogin.js128
1 files changed, 128 insertions, 0 deletions
diff --git a/src/features/nostrlogin.js b/src/features/nostrlogin.js
index 4a811fc..e48e9a2 100644
--- a/src/features/nostrlogin.js
+++ b/src/features/nostrlogin.js
@@ -158,6 +158,17 @@ export function nostrLoginFeature(ctx) {
// and the payment name too — the Receive pane should open with its QR
const nameHintP = ctx.hook('namesLookupByPk', signer.pubkey);
const res = await walletForSigner(signer);
+ // A way back in (added from Settings): the key holds a copy of an
+ // account's seed, and opens that account as ITSELF — its own name,
+ // profile and identity. Nothing about the key is adopted or kept.
+ if (res.mode === 'restored' && res.recovery) {
+ try { if (signer.kind === 'bunker' && signer.close) signer.close(); } catch {}
+ ctx.onbNostrLogin(true);
+ ui.navAnimSkip = true;
+ await ctx.openMnemonic(res.mnemonic, res.passphrase || '', { spendingHint: !!res.spending, spendingOnly: !!res.spendingOnly });
+ busy(false);
+ return;
+ }
if (res.mode === 'new') {
// No wallet on this identity yet — make one and walk straight in.
// The interstitial that used to ask first read as an error screen to
@@ -681,6 +692,122 @@ export function nostrLoginFeature(ctx) {
resumeLogin().then((s) => { if (s) render(); }).catch(() => {});
}
+ // ---- ways back in ------------------------------------------------------
+ // Settings → Wallet: the sign-in methods that can open THIS account on
+ // another device, added after the fact — a passkey, Google, a nostr key —
+ // next to the recovery phrase. Each one gets a copy of the seed encrypted
+ // to its own key (kind 30078, marked `recovery`), so signing in with it
+ // anywhere opens this account as itself. Unlike linking a login, the
+ // account's identity doesn't change.
+ async function addRecovery(makeSigner, via, label = '') {
+ ui.waysBackError = ''; ui.waysBackBusy = via; ui.nostrLoginBusy = true; render();
+ let signer = null;
+ try {
+ if (!wallet.mnemonic) throw new Error(t('nlNeedSeed'));
+ signer = await makeSigner();
+ const st = load();
+ if (st.pubkey && st.pubkey === signer.pubkey) throw new Error(t('waysBackIsLogin'));
+ // a key that already opens a DIFFERENT wallet keeps it: never overwrite
+ const existing = await fetchWalletBackup(signer);
+ if (existing && existing.mnemonic
+ && (existing.mnemonic !== wallet.mnemonic || (existing.passphrase || '') !== (wallet.passphrase || ''))) {
+ throw new Error(t('waysBackTaken'));
+ }
+ await publishWalletBackup(signer, {
+ mnemonic: wallet.mnemonic, passphrase: wallet.passphrase || '',
+ spending: true, recovery: true, spendingOnly: !!ctx.spendingOnlyAccount(),
+ });
+ ctx.addRecoveryMethod({ via, id: signer.pubkey, label });
+ ui.waysBackNostr = false;
+ toast(t('waysBackAdded'));
+ } catch (e) {
+ const msg = String((e && e.message) || e || '');
+ // a dismissed passkey or Google dialog is a plain cancel
+ if (!/NotAllowed|abort/i.test(String((e && e.name) || '') + ' ' + msg)) ui.waysBackError = msg;
+ } finally {
+ try { if (signer && signer.kind === 'bunker' && signer.close) signer.close(); } catch {}
+ ui.waysBackBusy = null; ui.nostrLoginBusy = false;
+ render();
+ }
+ }
+ async function passkeyRecovery() {
+ const pass = await import('../passkey.js');
+ const name = ctx.hook('namesAddress') || 'coinos';
+ await addRecovery(async () => keySigner(await pass.passkeyCreate({ name, displayName: name })), 'passkey');
+ }
+ async function googleRecovery() {
+ // opened in the click itself (user activation) — see googleLogin
+ const popup = window.open('about:blank', 'OAuth', 'width=600,height=600');
+ ui.waysBackError = ''; ui.waysBackBusy = 'google'; render();
+ try {
+ const pome = await import('../pomegranate.js');
+ const { uri, email } = await pome.loginWithGoogle(popup, {});
+ await addRecovery(() => bunkerSigner(uri, { onAuth: (url) => { ui.nostrLoginAuthUrl = url; render(); } }), 'google', email || '');
+ } catch (e) {
+ try { popup && popup.close(); } catch {}
+ ui.waysBackError = (e && e.message) || String(e);
+ ui.waysBackBusy = null;
+ render();
+ }
+ }
+ 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;
+ const methods = ctx.recoveryMethods() || [];
+ const st = load();
+ const busyVia = ui.waysBackBusy;
+ const hasPasskey = typeof window !== 'undefined' && !!window.PublicKeyCredential && !!navigator.credentials;
+ const done = (mark, title, detail, key) => h('div', { class: 'row gap6 ways-back-row', 'data-way': key, style: 'align-items:center;padding:8px 2px' },
+ h('span', { style: 'display:flex;flex-shrink:0;width:23px;justify-content:center', html: mark }),
+ h('span', { class: 'col', style: 'flex:1;gap:1px;min-width:0' },
+ h('span', { style: 'font-weight:600' }, title),
+ detail ? h('span', { class: 'small muted break' }, detail) : null),
+ h('span', { class: 'ways-back-check', 'aria-label': t('waysBackOn') }, '✓'));
+ const add = (mark, label, via, onClick) => h('button', {
+ class: 'btn-block ways-back-add', 'data-way': via, disabled: !!busyVia, onClick,
+ }, markedLabel(mark, label, busyVia === via));
+ const rows = [];
+ // the sign-in this account came through is a way back in already
+ if (st.pubkey) {
+ const via = st.via || 'nostr';
+ rows.push(done(via === 'google' ? GOOGLE_MARK : via === 'passkey' ? PASSKEY_MARK : OSTRICH,
+ t('waysBackLogin', { via: via === 'google' ? 'Google' : via === 'passkey' ? t('doorPasskey') : 'Nostr' }),
+ via === 'google' || via === 'passkey' ? '' : (npubOf(st.pubkey) || '').slice(0, 20) + '…', 'login'));
+ }
+ for (const m of methods) {
+ if (m.via === 'passkey') rows.push(done(PASSKEY_MARK, t('doorPasskey'), t('waysBackAddedOn', { date: fmtDay(m.at) }), 'passkey'));
+ else if (m.via === 'google') rows.push(done(GOOGLE_MARK, 'Google', m.label || t('waysBackAddedOn', { date: fmtDay(m.at) }), 'google'));
+ 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,
+ !have('google') ? add(GOOGLE_MARK, t('waysBackAddGoogle'), 'google', () => googleRecovery()) : null,
+ ui.waysBackNostr
+ ? h('div', { class: 'col', style: 'gap:8px' },
+ h('div', { class: 'row between' },
+ h('strong', { class: 'small' }, t('waysBackAddNostr')),
+ 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(); }),
+ ].filter(Boolean);
+ return h('div', { class: 'card col ways-back', style: 'gap:6px' },
+ h('h3', {}, t('waysBackTitle')),
+ h('p', { class: 'small muted', style: 'margin:0 0 4px' }, t('waysBackDesc')),
+ h('div', { class: 'col', style: 'gap:0' }, ...rows),
+ adds.length ? h('div', { class: 'col', style: 'gap:8px;margin-top:6px' }, ...adds) : null,
+ ui.waysBackError ? h('div', { class: 'notice err' }, ui.waysBackError) : null,
+ h('p', { class: 'small faint', style: 'margin:4px 0 0' }, t('waysBackNote')));
+ }
+
function settingsCard() {
if (wallet.watchOnly || !wallet.mnemonic) return null;
const st = load();
@@ -879,5 +1006,6 @@ export function nostrLoginFeature(ctx) {
].filter(Boolean);
},
nostrSettingsCards() { return [settingsCard()]; },
+ waysBackCard() { return waysBackCard(); },
};
}