diff options
Diffstat (limited to 'src/features')
| -rw-r--r-- | src/features/nostrlogin.js | 128 |
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(); }, }; } |
