aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/app.js208
-rw-r--r--src/features/nostrlogin.js128
-rw-r--r--src/i18n.js23
-rw-r--r--src/nostr-login.js12
-rw-r--r--src/passkey.js6
-rw-r--r--src/style.css4
6 files changed, 354 insertions, 27 deletions
diff --git a/src/app.js b/src/app.js
index 3f1daf3..5cbd9de 100644
--- a/src/app.js
+++ b/src/app.js
@@ -16,7 +16,7 @@ import { nip98Header } from './nip98.js';
import { uploadPublicMedia, uploadStream, mediaUploadSigner, MediaUploadError } from './media-upload.js';
import { NOSTR_MARK } from './features/nostrlogin.js';
import { scanQr } from './scan.js';
-import { dataSources, getSource, setSource, getNetwork, setNetwork, NETWORKS } from './api.js';
+import { dataSources, getSource, setSource, getNetwork, setNetwork, NETWORKS, getArkConfig } from './api.js';
import { STAGING } from './build-flags.js';
import { buildFeatures, loadDeferredFeatures } from './features/index.js';
import { cacheReady, cacheStore } from './cache-store.js';
@@ -1883,7 +1883,16 @@ async function enterWallet(mnemonic, passphrase, opts = {}) {
// 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;
+ // A sign-up (Create an account) shows no phrase either: it stays one tap
+ // away in Settings, next to the other ways back in (passkey, Google, nostr).
+ if (acc.seedSeen === undefined) acc.seedSeen = !opts.nostrPubkey && !opts.unseen;
+ if (opts.signup && !acc.signupAt) acc.signupAt = Date.now();
+ // Spending first: an account made by signing up has no Savings side until
+ // its owner adds one (or on-chain money turns up — see savingsEvident). A
+ // recovery sign-in carries the same shape over from the account's backup.
+ if (opts.spendingOnly && !acc.kind && !acc.deriveIndex && !acc.savingsAdded && acc.type === 'full') {
+ acc.kind = 'spending'; acc.savingsLater = true;
+ } else if (opts.signup && acc.kind === 'spending') acc.savingsLater = true;
await activateAccount(acc, { ...opts, fresh: true });
}
@@ -2193,7 +2202,11 @@ function defaultLabel(type) {
function loadWatchAccounts() {
try {
const dir = JSON.parse(localStorage.getItem(WATCH_KEY) || '[]');
- return dir.filter((d) => d.xpub).map((d) => ({ id: d.id, label: d.label, type: 'watch', xpub: d.xpub, autoLock: d.autoLock || 0, network: d.network, nostrPk: d.nostrPk }));
+ return dir.filter((d) => d.xpub).map((d) => ({ id: d.id, label: d.label, type: 'watch', xpub: d.xpub, autoLock: d.autoLock || 0, network: d.network, nostrPk: d.nostrPk,
+ // a sign-up's Spending-only shape and its ways back in outlive the session
+ ...(d.spendingOnly ? { kind: 'spending', savingsLater: true } : {}),
+ ...(d.signupAt ? { signupAt: d.signupAt } : {}),
+ ...(Array.isArray(d.recovery) && d.recovery.length ? { recovery: d.recovery } : {}) }));
} catch { return []; }
}
function saveDirectory() {
@@ -2210,7 +2223,10 @@ function saveDirectory() {
|| (wallet.nostrPubkey && wallet.nostrPubkey()) || a.nostrPk;
} catch {}
}
- return { id: a.id, label: a.label, xpub: a.xpub, autoLock: a.autoLock || 0, network: a.network, nostrPk: a.nostrPk, seedSeen: !!a.seedSeen };
+ return { id: a.id, label: a.label, xpub: a.xpub, autoLock: a.autoLock || 0, network: a.network, nostrPk: a.nostrPk, seedSeen: !!a.seedSeen,
+ ...(a.kind === 'spending' && a.savingsLater ? { spendingOnly: true } : {}),
+ ...(a.signupAt ? { signupAt: a.signupAt } : {}),
+ ...(a.recovery && a.recovery.length ? { recovery: a.recovery } : {}) };
});
localStorage.setItem(WATCH_KEY, JSON.stringify(dir));
} catch {}
@@ -3231,7 +3247,7 @@ function settingsTab() {
// matters if the phone is lost tonight
case 'wallet':
if (ui.delAcct && a) return deleteAccountView(a);
- return page(a ? [recoveryCard(a), balanceSettingsCard(a), walletNameCard(a), pubkeyCard(a), historyExportCard(), deleteAccountCard(a)] : []);
+ return page(a ? [featureHook('waysBackCard'), recoveryCard(a), balanceSettingsCard(a), walletNameCard(a), pubkeyCard(a), historyExportCard(), deleteAccountCard(a)] : []);
case 'payments': return page(featureAll('settingsCards').reverse());
case 'pos': return page(featureAll('posSettingsCards').reverse()); // the till's own card first, then the terminal's
case 'network': return page([networkCard(), explorerCard()]); // an old bookmark of the tile; the cards live under Advanced now
@@ -3939,7 +3955,21 @@ function balanceSettingsCard(a) {
h('span', { style: 'font-weight:600' }, viewLabel(a, view)),
h('span', { class: 'small muted' }, t(view === 'spending' ? 'spendingSettingsDesc' : 'savingsSettingsDesc'))),
h('span', { class: 'muted' }, '›'));
- return h('div', { class: 'card col', style: 'gap:4px' }, h('h3', {}, t('balanceSettingsTitle')), ...views.map(row));
+ // a sign-up's Spending-only account: Savings is one tap away
+ const addRow = a.kind === 'spending' && a.savingsLater && !a.deriveIndex
+ ? h('button', {
+ id: 'add-savings',
+ class: 'btn-ghost btn-block balance-settings-row',
+ style: 'display:flex;align-items:center;gap:10px;text-align:left;padding:10px 4px',
+ onClick: () => { addSavings(a); toast(t('savingsAddedToast')); },
+ },
+ h('span', { style: 'display:flex;color:var(--muted)', html: BITCOIN_ICON(18) }),
+ h('span', { class: 'col', style: 'flex:1;gap:2px' },
+ h('span', { style: 'font-weight:600' }, t('addSavings')),
+ h('span', { class: 'small muted' }, t('addSavingsDesc'))),
+ h('span', { class: 'muted' }, '+'))
+ : null;
+ return h('div', { class: 'card col', style: 'gap:4px' }, h('h3', {}, t('balanceSettingsTitle')), ...views.map(row), addRow);
}
function walletNameCard(a) {
const saveName = () => {
@@ -4274,6 +4304,21 @@ async function publicMediaUpload(file) {
// Whether the wizard can offer Spending at all: an Ark-capable build on a
// network with the names registrar, for a full (signing) account. Elsewhere
// the offer would be a spinner that never resolves.
+// Signing up (Adam, 2026-11-15): "we should just have them claim a name and
+// pick their avatar/cover and they should be in." Where a name can be
+// claimed — an Ark server and the names registrar on this network — Create
+// makes the account straight away: no twelve words to copy or check, no
+// Savings side. The phrase waits in Settings with the other ways back in
+// (passkey, Google, nostr), and a quiet line on the wallet asks for one.
+// Elsewhere (regtest, a build without Ark) there's no name to claim, the
+// wallet IS its seed, and Create keeps the classic phrase flow.
+function quickSignupOk() {
+ if (!['mainnet', 'mutinynet'].includes(getNetwork())) return false;
+ try {
+ if (!FEATURES.some((f) => f.id === 'ark') || !FEATURES.some((f) => f.id === 'names')) return false;
+ return !!getArkConfig();
+ } catch { return false; }
+}
function canOfferSpending() {
const acc = activeAccount();
if (!acc || acc.type !== 'full' || wallet.watchOnly) return false;
@@ -4310,7 +4355,8 @@ function onboardScreen() {
// 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';
+ // (a sign-up was never meant to see it: straight on to the name)
+ if (arrived && arrived.mnemonic && !arrived.seedSeen && !arrived.signupAt) o.step = 'backup';
else if (canOfferSpending()) {
// Before the home screen: one offer to set up Spending (a name and a
// payment address), with Not now a single tap away.
@@ -4318,10 +4364,11 @@ function onboardScreen() {
} 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 {}
+ // On-chain first for a seed wallet: the wizard's promise is "your
+ // keys, your coins". A sign-up is Spending alone.
+ const home = arrived && arrived.kind ? arrived.kind : 'savings';
+ ui.account = home;
+ try { localStorage.setItem(ACCOUNT_KEY, home); } catch {}
ui.tab = 'receive';
return null; // the caller falls through to the wallet itself
}
@@ -4415,6 +4462,24 @@ function onboardScreen() {
}
// Making your own keys: the words are the wallet, so show them at once,
// no Generate to tap.
+ // Create an account: a fresh seed opens at once as a Spending-only
+ // account; the wizard carries on to the name and the look.
+ const startQuick = async () => {
+ if (o.creating) return;
+ o.creating = true; ui.onbError = ''; render();
+ try {
+ clearSeedDrafts();
+ ui.passphrase = ''; ui.confirmPass = ''; ui.draftMnemonic = '';
+ ui.newWalletKind = 'spending';
+ await enterWallet(newMnemonic(), '', { generated: true, unseen: true, signup: true });
+ } catch (e) {
+ ui.onbError = e.message || String(e);
+ } finally {
+ ui.newWalletKind = null;
+ o.creating = false;
+ render();
+ }
+ };
const startCreate = () => {
ui.unlockTab = 'create';
ui.unlockError = '';
@@ -4443,11 +4508,16 @@ function onboardScreen() {
title(t('onbWelcomeTitle')),
// spaced as the sign-in list is (10px)
h('div', { class: 'col', style: 'gap:10px' },
- 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: startCreate,
- }, t('onbStart')),
+ quickSignupOk()
+ ? h('button', {
+ // a stable hook for tests, so this button survives its own copy
+ id: 'onb-create', 'data-quick': '1',
+ class: 'btn-primary btn-block', style: 'font-size:17px;padding:14px', disabled: !!o.creating, onClick: startQuick,
+ }, o.creating ? h('span', { class: 'spinner sm' }) : t('onbCreateAccount'))
+ : h('button', {
+ id: 'onb-create',
+ class: 'btn-primary btn-block', style: 'font-size:17px;padding:14px', onClick: startCreate,
+ }, t('onbStart')),
h('button', {
id: 'onb-signin',
class: 'btn-block', style: 'font-size:17px;padding:14px',
@@ -4572,14 +4642,18 @@ function onboardScreen() {
]);
}
if (o.step === 'spend') {
+ // A sign-up's name is the account itself — what people pay, follow and
+ // message — so it's claimed here, not skipped.
+ const signupAcc = activeAccount();
+ const signup = !o.fromGift && !!(signupAcc && signupAcc.signupAt && signupAcc.kind === 'spending');
return page([
- title(t(o.fromGift ? 'onbGiftNameTitle' : 'onbSpendTitle')),
- h('p', { class: 'muted', style: 'margin:0' }, t(o.fromGift ? 'onbGiftNameBody' : 'onbSpendBody')),
+ title(t(o.fromGift ? 'onbGiftNameTitle' : signup ? 'onbNameTitle' : 'onbSpendTitle')),
+ h('p', { class: 'muted', style: 'margin:0' }, t(o.fromGift ? 'onbGiftNameBody' : signup ? 'onbNameBody' : 'onbSpendBody')),
featureHook('namesClaimForm') || h('div', { class: 'row gap6', style: 'align-items:center' }, h('span', { class: 'spinner sm' }), h('span', { class: 'small muted' }, t('onbNameWait'))),
// Optional means optional: one tap to the wallet, no questions — on
// Savings, the account that exists without any setup (or, after a
// gift, the account the gift arrived in).
- h('button', { class: 'btn-ghost btn-block', onClick: () => {
+ signup ? null : h('button', { class: 'btn-ghost btn-block', onClick: () => {
const home = o.home || 'savings';
ui.onb = null;
try { localStorage.removeItem(ONB_STEP_KEY); } catch {}
@@ -4915,22 +4989,107 @@ wallet.registerCacheExtension({
},
});
+// ---- Savings, later --------------------------------------------------------
+// An account made by signing up is Spending alone (kind 'spending', marked
+// savingsLater): no on-chain face, no carousel, no Move money. Its owner adds
+// Savings from Settings → Wallet whenever they like — the same seed, account
+// 0, so it is the on-chain wallet that was always underneath. A balance must
+// never hide, so on-chain money or history arriving adds it by itself.
+function addSavings(acc, { quiet = false } = {}) {
+ if (!acc || acc.kind !== 'spending' || !acc.savingsLater) return false;
+ delete acc.kind; delete acc.savingsLater;
+ if (!acc.spendingSetup) acc.spendingSetup = Date.now();
+ acc.savingsAdded = Date.now();
+ persistAccounts();
+ if (acc.persisted) writeVault();
+ if (acc.id === activeId) { try { wallet.saveCache(); } catch {} }
+ if (!quiet) render();
+ return true;
+}
+function savingsEvident() {
+ const a = activeAccount();
+ if (!a || a.kind !== 'spending' || !a.savingsLater || !wallet.loaded) return;
+ if ((wallet.spendable || 0) > 0 || (wallet.pendingIncoming || 0) > 0 || (wallet.txs || []).length) addSavings(a, { quiet: true });
+}
+// The shape rides the synced snapshot, so the phone opens the account the
+// way the laptop has it: Spending-only until Savings is added on either.
+// Commutative: savingsAdded only ever turns on and wins over spendingOnly.
+wallet.registerCacheExtension({
+ mergeAlways: true,
+ save: () => {
+ const a = activeAccount();
+ if (!a) return {};
+ if (a.savingsAdded) return { savingsAdded: a.savingsAdded };
+ return a.kind === 'spending' && a.savingsLater ? { spendingOnly: true } : {};
+ },
+ load: (d) => {
+ const a = activeAccount();
+ if (!d || !a || a.type !== 'full' || a.deriveIndex) return;
+ if (d.savingsAdded) { if (addSavings(a, { quiet: true }) === false && !a.savingsAdded) { a.savingsAdded = d.savingsAdded; persistAccounts(); } return; }
+ if (d.spendingOnly && !a.kind && !a.savingsAdded && !(wallet.txs || []).length && !(wallet.spendable || 0)) {
+ a.kind = 'spending'; a.savingsLater = true;
+ persistAccounts();
+ }
+ },
+});
+
+// ---- ways back in ----------------------------------------------------------
+// The sign-in methods that can open this account somewhere else, besides its
+// recovery phrase: a passkey, a Google account, a nostr identity — each holds
+// a copy of the seed encrypted to its own key on the relays (see the
+// nostrlogin feature's addRecovery). Listed on the account so Settings can
+// say what's set up; the list rides the synced snapshot, union by key.
+function recoveryMethods(a = activeAccount()) {
+ return (a && Array.isArray(a.recovery)) ? a.recovery : [];
+}
+function sameSeed(a, x) {
+ return x.mnemonic && x.mnemonic === a.mnemonic && (x.passphrase || '') === (a.passphrase || '');
+}
+function mergeRecovery(list) {
+ const a = activeAccount();
+ if (!a || !a.mnemonic || !Array.isArray(list) || !list.length) return false;
+ let changed = false;
+ for (const x of accounts) {
+ if (x !== a && !sameSeed(a, x)) continue;
+ const have = new Map(recoveryMethods(x).map((m) => [m.id, m]));
+ for (const m of list) if (m && m.id && !have.has(m.id)) { have.set(m.id, m); changed = true; }
+ x.recovery = [...have.values()].sort((p, q) => (p.at || 0) - (q.at || 0));
+ }
+ if (changed) { persistAccounts(); if (a.persisted) writeVault(); }
+ return changed;
+}
+function addRecoveryMethod(m) {
+ if (!mergeRecovery([{ ...m, at: m.at || Date.now() }])) return;
+ try { wallet.saveCache(); } catch {}
+ render();
+}
+wallet.registerCacheExtension({
+ mergeAlways: true,
+ save: () => { const r = recoveryMethods(); return r.length ? { recovery: r } : {}; },
+ load: (d) => { if (d && d.recovery) mergeRecovery(d.recovery); },
+});
+
// 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() {
+ savingsEvident();
const a = activeAccount();
if (!a || !a.mnemonic || a.seedSeen || wallet.watchOnly) return null;
+ // A sign-up never saw a phrase and wasn't asked to: what it needs is A way
+ // back in, any one — and once it has one, nothing to nag about.
+ const signup = !!a.signupAt;
+ if (signup && recoveryMethods(a).length) return null;
return h('div', { class: 'small muted', style: 'margin:10px 0 0;text-align:center' },
- t('backupReminder'),
+ t(signup ? 'waysBackReminder' : 'backupReminder'),
' ',
h('button', { class: 'linklike small', onClick: () => {
ui.tab = 'settings';
ui.settingsPage = 'wallet';
- ui.revealShown = 'masked';
+ if (!signup) ui.revealShown = 'masked';
render();
- } }, t('backupReminderGo')));
+ } }, t(signup ? 'waysBackReminderGo' : 'backupReminderGo')));
}
// Safe mode (see BOOT_GUARD in build.js): the app crashed on open twice in a
@@ -6857,6 +7016,11 @@ const ctx = {
getAccount: () => accountSel(),
setAccount: (a, dir) => setAccountSel(a, dir),
offerProfileSetup, pickConfirm,
+ // the ways back in (Settings → Wallet): what's set up, and adding one
+ recoveryMethods: () => recoveryMethods(), addRecoveryMethod,
+ spendingOnlyAccount: () => { const a = activeAccount(); return !!(a && a.kind === 'spending' && a.savingsLater); },
+ seedSeen: () => !!(activeAccount() || {}).seedSeen,
+ showRecoveryPhrase: () => { ui.tab = 'settings'; ui.settingsPage = 'wallet'; ui.revealShown = 'masked'; render(); },
// Open (or create) a wallet from a mnemonic — used by nostr login.
openMnemonic: async (mnemonic, passphrase, opts) => enterWallet(mnemonic, passphrase, opts),
// Profile pictures retain the metadata-scrubbing image uploader. Public
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(); },
};
}
diff --git a/src/i18n.js b/src/i18n.js
index 3e42daa..9b59710 100644
--- a/src/i18n.js
+++ b/src/i18n.js
@@ -1498,6 +1498,29 @@ const STR = {
onbContinue: 'Continue',
onbSkipAvatar: 'Continue',
onbDoneTitle: "You're all set!",
+ onbCreateAccount: 'Create an account',
+ onbNameTitle: 'Claim your name',
+ onbNameBody: "It's your username and your payment address: people pay you, follow you and message you with just your name.",
+ waysBackReminder: 'Add a way back into your account, in case you lose this device.',
+ waysBackReminderGo: 'Add one',
+ addSavings: 'Add a savings account',
+ addSavingsDesc: 'On-chain bitcoin, for keeping. Same account, a second balance.',
+ savingsAddedToast: 'Savings added',
+ waysBackTitle: 'Ways back in',
+ waysBackDesc: 'Any one of these opens this account on a new device, or if this one is lost.',
+ waysBackNote: 'Each one can open your account and spend its money, so only add ones you control.',
+ waysBackOn: 'Set up',
+ waysBackLogin: 'Signed in with {via}',
+ waysBackAddedOn: 'Added {date}',
+ waysBackPhraseSeen: 'Seen. Keep it written down somewhere safe',
+ waysBackPhraseUnseen: 'Twelve words that open this account in any wallet',
+ waysBackShow: 'Show',
+ waysBackAddPasskey: 'Add a passkey',
+ waysBackAddGoogle: 'Add Google',
+ waysBackAddNostr: 'Add a Nostr account',
+ waysBackAdded: 'Added. You can sign in with it now',
+ waysBackIsLogin: 'That is the account you signed in with; it already opens this one.',
+ waysBackTaken: 'That sign-in already opens a different coinos account, so it can’t open this one too.',
onbDoneBody: 'Your spending account is set up. Your address is live and the coinos community chat is waiting for you.',
onbEnter: 'Enter coinos',
spendSetup: 'Set up a spending account',
diff --git a/src/nostr-login.js b/src/nostr-login.js
index 3357f61..562a5d4 100644
--- a/src/nostr-login.js
+++ b/src/nostr-login.js
@@ -514,8 +514,14 @@ export async function fetchWalletBackup(signer, relays = BACKUP_RELAYS) {
// `spending` rides along as a hint: a restored wallet paints its Spending
// side from the first frame instead of reflowing when the name lookup proves
// it seconds later.
-export async function publishWalletBackup(signer, { mnemonic, passphrase, spending }, relays = BACKUP_RELAYS) {
- const content = await signer.encryptSelf(JSON.stringify({ mnemonic, passphrase: passphrase || '', ...(spending ? { spending: true } : {}) }));
+// `recovery` marks a copy kept by a WAY BACK IN (a passkey, Google or nostr
+// key added from Settings): signing in with that key opens the account as
+// itself — its own identity, name and profile — rather than adopting the
+// key as the account's nostr identity the way a login does. `spendingOnly`
+// carries a sign-up's shape (no Savings side yet) to the next device.
+export async function publishWalletBackup(signer, { mnemonic, passphrase, spending, recovery, spendingOnly }, relays = BACKUP_RELAYS) {
+ const content = await signer.encryptSelf(JSON.stringify({ mnemonic, passphrase: passphrase || '',
+ ...(spending ? { spending: true } : {}), ...(recovery ? { recovery: true } : {}), ...(spendingOnly ? { spendingOnly: true } : {}) }));
const evt = await signer.signEvent({
kind: BACKUP_KIND, created_at: Math.floor(Date.now() / 1000),
tags: [['d', SEED_DTAG]], content,
@@ -537,7 +543,7 @@ export async function publishWalletBackup(signer, { mnemonic, passphrase, spendi
// deriving (key in hand) or generating (signer only).
export async function walletForSigner(signer) {
const found = await fetchWalletBackup(signer);
- if (found) return { mnemonic: found.mnemonic, passphrase: found.passphrase, spending: !!found.spending, mode: 'restored' };
+ if (found) return { mnemonic: found.mnemonic, passphrase: found.passphrase, spending: !!found.spending, recovery: !!found.recovery, spendingOnly: !!found.spendingOnly, mode: 'restored' };
if (signer.secret) {
// Publishing the derived seed costs nothing: the derivation is public,
// so anyone holding this key could compute it anyway. It buys the thing
diff --git a/src/passkey.js b/src/passkey.js
index 248e1e6..7d5e6e5 100644
--- a/src/passkey.js
+++ b/src/passkey.js
@@ -54,14 +54,16 @@ export async function passkeySignIn() {
// Create a passkey, then derive the key from it. Some platforms evaluate PRF
// during creation; the rest need a follow-up assertion against the new
// credential — both paths end at the same bytes.
-export async function passkeyCreate() {
+// `name` labels the credential in the platform's passkey picker (a way back
+// into a named account reads "[email protected]", not just "coinos").
+export async function passkeyCreate({ name = 'coinos', displayName = 'Coinos wallet' } = {}) {
const cred = await navigator.credentials.create({
publicKey: {
challenge: crypto.getRandomValues(new Uint8Array(32)),
rp: { name: 'Coinos', id: location.hostname },
user: {
id: crypto.getRandomValues(new Uint8Array(16)),
- name: 'coinos', displayName: 'Coinos wallet',
+ name, displayName,
},
pubKeyCredParams: [{ type: 'public-key', alg: -7 }, { type: 'public-key', alg: -257 }],
authenticatorSelection: { residentKey: 'required', userVerification: 'required' },
diff --git a/src/style.css b/src/style.css
index 1db0984..8206000 100644
--- a/src/style.css
+++ b/src/style.css
@@ -2179,3 +2179,7 @@ html:has(.call-overlay:not(.call-min)) { overflow: hidden; scrollbar-gutter: aut
/* on: the accent with its own text colour (accent-fg). accent-ink, used
before, is light on dark mode's white accent: unreadable (Adam, 2026-11-15) */
.moment-toggle.on { color: var(--accent-fg); background: var(--accent); border: 1px solid var(--accent); }
+
+/* Settings → Wallet: the ways back into an account */
+.ways-back .ways-back-row + .ways-back-row { border-top: 1px solid var(--line); }
+.ways-back .ways-back-check { color: var(--green, #2e9d5b); font-weight: 700; font-size: 16px; padding: 0 4px; }