aboutsummaryrefslogtreecommitdiffstats
path: root/src/features
diff options
context:
space:
mode:
Diffstat (limited to 'src/features')
-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(); },
};
}