diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/app.js | 74 | ||||
| -rw-r--r-- | src/features/ark.js | 2 | ||||
| -rw-r--r-- | src/features/messages.js | 11 | ||||
| -rw-r--r-- | src/features/names.js | 22 | ||||
| -rw-r--r-- | src/i18n.js | 14 | ||||
| -rw-r--r-- | src/locales/it.json | 14 |
6 files changed, 136 insertions, 1 deletions
@@ -3209,7 +3209,9 @@ function settingsTab() { switch (ui.settingsPage) { // the recovery phrase first: it is the one thing on this page that // matters if the phone is lost tonight - case 'wallet': return page(a ? [recoveryCard(a), walletNameCard(a), pubkeyCard(a), historyExportCard()] : []); + case 'wallet': + if (ui.delAcct && a) return deleteAccountView(a); + return page(a ? [recoveryCard(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 @@ -3777,6 +3779,76 @@ function openAccountSettings(id) { // Per-wallet settings cards, shared by the per-account screen (Accounts → ⚙) // and the main Settings tab (which inlines them for the active wallet). +// Delete account: the server forgets this wallet (its name is freed, its +// records go) and the wallet leaves this device. "someone's asking for a way +// to delete their v3 account. we allowed that in v2" (Adam, 2026-11-10). The +// coins are the user's own and no server can delete them: they stay +// spendable from the recovery phrase, which is why the page says how much is +// still here before anything happens. +// The saved copy on this device has to go too, and the vault can only be +// rewritten with its password in hand: a blank one is tried here, a real one +// is known once the app has been unlocked with it this session. +function vaultWritable() { + if (vaultPassword != null || !hasVault()) return true; + try { const list = decryptVault(loadVaultBlob(), ''); vaultPassword = ''; mergeVaultList(list); return true; } catch { return false; } +} +function deleteAccountCard(a) { + if (a.type === 'watch' || wallet.watchOnly || !wallet.nostrSign) return null; + return h('div', { class: 'card col', 'data-key': 'delete-account' }, + h('h3', {}, t('delAcctTitle')), + h('p', { class: 'small muted', style: 'margin:0' }, t('delAcctBlurb')), + h('button', { class: 'btn-danger btn-block', onClick: () => { + if (!vaultWritable()) { toast(t('delAcctUnlockFirst')); return; } + const d = ui.delAcct = { accId: a.id, info: null, sure: false, busy: false, error: '' }; + render(); + Promise.resolve(featureHook('accountDelete', true)).then((info) => { if (ui.delAcct === d) { d.info = info || {}; render(); } }) + .catch((e) => { if (ui.delAcct === d) { d.info = {}; d.error = e.message; render(); } }); + } }, t('delAcctTitle'))); +} +function deleteAccountView(a) { + const d = ui.delAcct; + const heldSat = (wallet.spendable || 0) + (featureHook('arkHeldSat') || 0); + const info = d.info; + const names = (info && info.names) || []; + const owedSat = info ? (info.pendingSat || 0) + (info.withdrawSat || 0) : 0; + const blocked = !!(info && (info.blocked || owedSat > 0 || info.paying)); + const back = () => { ui.delAcct = null; render(); }; + const go = async () => { + if (d.busy || !d.sure || blocked) return; + d.busy = true; d.error = ''; render(); + try { + const r = await featureHook('accountDelete', false); + if (r && r.blocked) { d.info = { ...d.info, ...r, blocked: true }; d.sure = false; return; } + ui.delAcct = null; ui.settingsPage = null; + wipeAccountCache(a); + // out of the saved vault first, or the next unlock brings it back + if (a.persisted && vaultWritable()) { a.persisted = false; writeVault(); } + removeAccount(a.id); + toast(t('delAcctDone')); + } catch (e) { d.error = e.message; } + finally { d.busy = false; render(); } + }; + return h('div', { class: 'col', style: 'gap:16px' }, + h('div', { class: 'card col', style: 'gap:10px' }, + h('h3', {}, t('delAcctTitle')), + !info ? h('div', { class: 'row gap6', style: 'align-items:center' }, h('span', { class: 'spinner sm' }), h('span', { class: 'small muted' }, t('delAcctChecking'))) : null, + h('ul', { class: 'small', style: 'margin:0;padding-left:18px;display:flex;flex-direction:column;gap:6px' }, + names.length ? h('li', {}, t('delAcctName', { name: names.join(', ') })) : null, + h('li', {}, t('delAcctRecords')), + h('li', {}, t('delAcctDevice', { name: seedName(a) })), + h('li', { class: 'muted' }, t('delAcctStays'))), + blocked ? h('div', { class: 'warn-box' }, t('delAcctOwed', { sat: fmtAmount(owedSat) })) : null, + heldSat > 0 ? h('div', { class: 'warn-box' }, t('delAcctFunds', { sat: fmtAmount(heldSat) })) : null, + d.error ? h('div', { class: 'warn-box' }, d.error) : null, + h('label', { class: 'row gap6 small', style: 'align-items:flex-start;cursor:pointer' }, + h('input', { type: 'checkbox', checked: d.sure, disabled: blocked || d.busy, style: 'margin-top:2px', onChange: (e) => { d.sure = e.target.checked; render(); } }), + h('span', {}, t(heldSat > 0 ? 'delAcctSureFunds' : 'delAcctSure'))), + h('div', { class: 'row gap6' }, + h('button', { class: 'btn-ghost grow', disabled: d.busy, onClick: back }, t('back')), + h('button', { class: 'btn-danger grow', disabled: !info || !d.sure || blocked || d.busy, onClick: go }, + d.busy ? h('span', { class: 'spinner sm' }) : t('delAcctGo'))))); +} + function recoveryCard(a) { const isWatch = a.type === 'watch' || (!a.mnemonic && !a.xprv); const shown = ui.revealShown; diff --git a/src/features/ark.js b/src/features/ark.js index 075cf4b..1b8c591 100644 --- a/src/features/ark.js +++ b/src/features/ark.js @@ -4149,6 +4149,8 @@ export function arkFeature(ctx) { return mgr.lnOutcome(paymentHash, timeoutMs); }, arkSpendableSat() { const b = arkBalance(); return b ? b.spendableSat : 0; }, + // everything Spending holds, settled or not + arkHeldSat() { const b = arkBalance(); return b ? b.spendableSat + b.pendingSat + b.boardingSat : 0; }, arkReady() { return arkAvailable(); }, // A Lightning invoice for another feature to watch (point of sale): // the action id is the handle, the poller tells it when it settles. diff --git a/src/features/messages.js b/src/features/messages.js index f72d71d..005b20d 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -13205,6 +13205,8 @@ export function messagesFeature(ctx) { if (opts.fieldWhen && opts.fieldWhen[k] && !opts.fieldWhen[k](base)) continue; merged[k] = v; } + // fields to take out altogether (an address that no longer exists) + for (const k of opts.remove || []) if (!opts.fieldWhen || !opts.fieldWhen[k] || opts.fieldWhen[k](base)) delete merged[k]; if (merged.name && !(fill.has('name') && taken('name'))) merged.display_name = merged.name; if (JSON.stringify(merged) === JSON.stringify(base)) return true; // nothing to say const partial = { kind: 0, content: JSON.stringify(merged), tags: [], created_at: kind0Stamp(id.pubkey) }; @@ -19089,6 +19091,15 @@ export function messagesFeature(ctx) { // lud16 and nip05 — but only where they pointed at the released address // (or were empty). Deliberately different values are not ours to touch. addressRenamed(oldAddr, newAddr) { return repointAddress(oldAddr, newAddr).catch(() => {}); }, + // the address was given up with nothing in its place (account deleted): + // a profile still naming it would send zaps to nobody + addressGone(oldAddr) { + const is = (k) => (base) => base[k] === oldAddr; + return publishProfileFields({}, { + onlyWhen: (base) => is('lud16')(base) || is('nip05')(base), + remove: ['lud16', 'nip05'], fieldWhen: { lud16: is('lud16'), nip05: is('nip05') }, + }).catch(() => {}); + }, // older copies of our kind 0 on history-keeping relays, and filling the // open editor's empty fields from one (tests) profileHistory() { return findProfileHistory(); }, diff --git a/src/features/names.js b/src/features/names.js index e07e025..3f23a0c 100644 --- a/src/features/names.js +++ b/src/features/names.js @@ -132,6 +132,27 @@ export function namesFeature(ctx) { return j; } + // Delete the account at the registrar: every name this wallet's key owns + // or manages is freed and what the server filed under them goes with it + // (coinosv3-server names/forget.js). `check` only asks what stands in the + // way: sats still on their way here, or held in a withdraw link. + async function accountDelete(check) { + const url = `${REGISTRAR}/account/delete`; + const body = JSON.stringify(check ? { check: true } : {}); + const auth = await withTimeout(nip98Header(walletSigner(), url, 'POST', body), 12000, 'signing'); + const r = await withTimeout(fetch(url, { method: 'POST', headers: { 'content-type': 'application/json', authorization: auth }, body }), 30000, 'registrar'); + const j = await r.json().catch(() => ({})); + if (j.error === 'funds') return { blocked: true, ...j }; + if (!r.ok || j.error) throw new Error(j.error || `registrar refused (${r.status})`); + if (!check) { + // a profile still pointing at a freed address would pay nobody (or its next owner) + for (const key of j.names || []) + await Promise.race([Promise.resolve(hook('addressGone', key)).catch(() => {}), new Promise((res) => setTimeout(res, 8000))]); + wallet.saveFeatureState('names', {}); + } + return j; + } + async function release() { const st = load(); if (!st.name) return; @@ -1056,6 +1077,7 @@ export function namesFeature(ctx) { return null; }, settingsCards() { return namesCards(); }, + accountDelete(check) { return wallet.watchOnly || !wallet.nostrSign ? null : accountDelete(check); }, posSettingsCards() { return posSettingsCardsForName(); }, // the Ark server changed: the name's ark= instruction must follow namesRefresh() { refresh().catch(() => {}); return true; }, diff --git a/src/i18n.js b/src/i18n.js index 9c7827d..8f54f1a 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -68,6 +68,20 @@ const STR = { logoutAll: 'Log out of all accounts', clearAllWarn: '⚠ This removes every account from this device. Any account whose recovery phrase or key you have not backed up will be lost for good.', removeWalletTitle: 'Remove account', + delAcctTitle: 'Delete account', + delAcctBlurb: 'Give up your Coinos name, have our servers forget this account, and remove it from this device.', + delAcctChecking: 'Checking your account…', + delAcctName: '{name} is released. Anyone can claim it afterwards, and payments sent to it will no longer reach you.', + delAcctRecords: 'Our servers delete what they hold for this account: its payment address, notification registrations, scheduled posts, hats, point-of-sale and receipt records.', + delAcctDevice: '“{name}” is removed from this device.', + delAcctStays: 'Not deleted: your coins, which only your recovery phrase controls, and what you have published to nostr relays (profile, posts, messages).', + delAcctOwed: '⚠ {sat} sats are still on their way to you or held in your withdraw links. Wait for payments to arrive, take back your withdraw links (Send → Withdraw link), then try again.', + delAcctFunds: '⚠ This account still holds {sat} sats. Deleting does not move them. Send them somewhere first, or make sure you have the recovery phrase: without it they are lost for good.', + delAcctSure: 'I understand this cannot be undone.', + delAcctSureFunds: 'I have moved my funds or saved my recovery phrase, and I understand this cannot be undone.', + delAcctGo: 'Delete account', + delAcctDone: 'Account deleted', + delAcctUnlockFirst: 'Lock Coinos and unlock it with your password first, then try again', removeWalletWarn: '⚠ Remove “{name}” from this device? It will be gone from this browser. Make sure you have its recovery phrase. Other accounts aren’t affected.', rename: 'Rename', remove: 'Remove', diff --git a/src/locales/it.json b/src/locales/it.json index 112296f..1844905 100644 --- a/src/locales/it.json +++ b/src/locales/it.json @@ -28,6 +28,20 @@ "logoutOneBlurbSeed": "Esce solo questo account. Gli altri restano connessi. Verrà rimosso da questo dispositivo, quindi assicurati di avere la sua frase di recupero.", "logoutAll": "Esci da tutti gli account", "clearAllWarn": "⚠ Questo rimuove tutti gli account da questo dispositivo. Ogni account di cui non hai salvato la frase di recupero o la chiave andrà perso per sempre.", + "delAcctTitle": "Elimina account", + "delAcctBlurb": "Rinuncia al tuo nome Coinos, fai dimenticare questo account ai nostri server e rimuovilo da questo dispositivo.", + "delAcctChecking": "Controllo del tuo account…", + "delAcctName": "{name} viene rilasciato. Chiunque potrà richiederlo in seguito e i pagamenti inviati a quel nome non ti arriveranno più.", + "delAcctRecords": "I nostri server eliminano ciò che conservano per questo account: indirizzo di pagamento, registrazioni delle notifiche, post programmati, cappelli, dati del punto vendita e delle ricevute.", + "delAcctDevice": "“{name}” viene rimosso da questo dispositivo.", + "delAcctStays": "Non eliminati: le tue monete, controllate solo dalla tua frase di recupero, e ciò che hai pubblicato sui relay nostr (profilo, post, messaggi).", + "delAcctOwed": "⚠ {sat} sat sono ancora in arrivo o trattenuti nei tuoi link di prelievo. Attendi i pagamenti, riprendi i link di prelievo (Invia → Link di prelievo) e riprova.", + "delAcctFunds": "⚠ Questo account contiene ancora {sat} sat. L'eliminazione non li sposta. Inviali altrove prima, oppure assicurati di avere la frase di recupero: senza di essa sono persi per sempre.", + "delAcctSure": "Ho capito che non si può annullare.", + "delAcctSureFunds": "Ho spostato i miei fondi o salvato la frase di recupero, e ho capito che non si può annullare.", + "delAcctGo": "Elimina account", + "delAcctDone": "Account eliminato", + "delAcctUnlockFirst": "Blocca Coinos e sbloccalo con la tua password, poi riprova", "removeWalletTitle": "Rimuovi account", "removeWalletWarn": "⚠ Rimuovere “{name}” da questo dispositivo? Sparirà da questo browser. Assicurati di avere la sua frase di recupero. Gli altri account non vengono toccati.", "rename": "Rinomina", |
