mirror of
https://github.com/tompro/sattle.git
synced 2026-08-27 07:15:59 +00:00
feat: backup and security settings pages, nostr restore onboarding, passkey unlock entry
This commit is contained in:
@@ -0,0 +1,243 @@
|
||||
<template>
|
||||
<q-page class="q-pa-md">
|
||||
<div class="row items-center q-mb-md">
|
||||
<q-btn
|
||||
flat
|
||||
dense
|
||||
round
|
||||
color="primary"
|
||||
icon="arrow_back"
|
||||
aria-label="Back"
|
||||
@click="router.push('/settings')"
|
||||
/>
|
||||
<div class="text-h5 text-weight-bold text-primary q-ml-sm">Backup</div>
|
||||
</div>
|
||||
|
||||
<q-card v-if="wallet.state !== 'unlocked'" class="sattle-card q-pa-lg">
|
||||
<div class="text-body1 text-grey-4">
|
||||
Unlock your wallet first - backup operations need the wallet's key in memory.
|
||||
</div>
|
||||
</q-card>
|
||||
|
||||
<template v-else>
|
||||
<!-- recovery phrase: sattle never stores it, so there is nothing to
|
||||
reveal - the honest answer plus the paths that DO work -->
|
||||
<q-list class="sattle-card q-mb-md" bordered>
|
||||
<q-item-label header class="text-primary text-weight-bold"> Recovery phrase </q-item-label>
|
||||
<div class="q-pa-md q-pt-sm text-body2 text-grey-4">
|
||||
Your recovery phrase was shown exactly once when this wallet was created and is never
|
||||
stored anywhere - not encrypted, not on this device. It cannot be shown again. The backup
|
||||
file and nostr backup below are the recovery paths you can still set up.
|
||||
</div>
|
||||
</q-list>
|
||||
|
||||
<!-- backup file -->
|
||||
<q-list class="sattle-card q-mb-md" bordered>
|
||||
<q-item-label header class="text-primary text-weight-bold"> Backup file </q-item-label>
|
||||
<div class="q-pa-md q-pt-sm">
|
||||
<div class="text-body2 text-grey-4 q-mb-sm">
|
||||
Downloads a JSON file with this wallet's notes, mint list and settings. Notes are
|
||||
encrypted - nobody can read them from the file without your recovery phrase. The mint
|
||||
list and settings are readable by anyone who opens the file.
|
||||
</div>
|
||||
<div v-if="wallet.encrypted" class="text-body2 text-grey-4 q-mb-md">
|
||||
This wallet is password-protected, so the file also carries your wallet key, encrypted
|
||||
with your password - the file alone restores a device completely.
|
||||
</div>
|
||||
<q-banner v-else dense rounded class="sattle-card text-warning q-mb-md">
|
||||
<template #avatar>
|
||||
<q-icon name="warning" color="warning" />
|
||||
</template>
|
||||
This wallet has no password, so the file does NOT include your wallet key - restoring
|
||||
takes this file plus your recovery phrase.
|
||||
</q-banner>
|
||||
<q-btn
|
||||
unelevated
|
||||
color="primary"
|
||||
text-color="dark"
|
||||
icon="download"
|
||||
label="Download backup file"
|
||||
class="full-width"
|
||||
@click="downloadBackupFile"
|
||||
/>
|
||||
</div>
|
||||
</q-list>
|
||||
|
||||
<!-- nostr backup -->
|
||||
<q-list class="sattle-card q-mb-md" bordered>
|
||||
<q-item-label header class="text-primary text-weight-bold"> Nostr backup </q-item-label>
|
||||
<div class="q-pa-md q-pt-sm">
|
||||
<div class="row items-center justify-between q-mb-sm">
|
||||
<div class="text-body2 text-grey-4 col q-pr-md">
|
||||
Keeps an encrypted copy of your notes, mints and settings on public nostr relays. Only
|
||||
your recovery phrase can decrypt them.
|
||||
</div>
|
||||
<q-toggle
|
||||
:model-value="nostr.enabled"
|
||||
color="primary"
|
||||
aria-label="Enable nostr backup"
|
||||
@update:model-value="nostr.setEnabled"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<template v-if="nostr.enabled">
|
||||
<q-banner dense rounded class="sattle-card text-warning q-mb-md">
|
||||
<template #avatar>
|
||||
<q-icon name="devices" color="warning" />
|
||||
</template>
|
||||
Built for one device at a time: the newest backup replaces the older one. If you run
|
||||
two devices with the same phrase, the last one to back up wins.
|
||||
</q-banner>
|
||||
|
||||
<div class="text-caption text-grey-5 q-mb-xs">Backup address</div>
|
||||
<div class="row items-center q-mb-md no-wrap">
|
||||
<code class="backup-pubkey text-grey-4 ellipsis">{{ nostr.pubkey }}</code>
|
||||
<q-btn
|
||||
flat
|
||||
dense
|
||||
round
|
||||
size="sm"
|
||||
color="primary"
|
||||
icon="content_copy"
|
||||
aria-label="Copy backup address"
|
||||
@click="copyPubkey"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="text-caption text-grey-5 q-mb-xs">Relays</div>
|
||||
<RelaysEditor v-model="relayModel" />
|
||||
|
||||
<div class="row q-gutter-sm q-mt-md">
|
||||
<q-btn
|
||||
unelevated
|
||||
color="primary"
|
||||
text-color="dark"
|
||||
label="Back up now"
|
||||
class="col"
|
||||
:loading="backupBusy"
|
||||
:disable="nostr.relays.length === 0"
|
||||
@click="backUpNow"
|
||||
/>
|
||||
<q-btn
|
||||
outline
|
||||
color="primary"
|
||||
label="Restore from nostr"
|
||||
class="col"
|
||||
:loading="restoreBusy"
|
||||
:disable="nostr.relays.length === 0"
|
||||
@click="restoreFromNostrAction"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="nostr.lastPublishAt" class="text-caption text-grey-5 q-mt-sm">
|
||||
Last backup: {{ new Date(nostr.lastPublishAt).toLocaleString() }}
|
||||
</div>
|
||||
<div v-if="nostr.lastError" class="text-caption text-negative q-mt-sm">
|
||||
{{ nostr.lastError }}
|
||||
</div>
|
||||
<div v-if="restoreSummary" class="text-caption text-positive q-mt-sm">
|
||||
{{ restoreSummary }}
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</q-list>
|
||||
</template>
|
||||
</q-page>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { copyToClipboard, useQuasar } from 'quasar';
|
||||
|
||||
import { buildBackup } from '@/lnurlcash/storage';
|
||||
import { useWalletStore } from '@/stores/wallet';
|
||||
import { useNostrBackupStore } from '@/stores/nostrBackup';
|
||||
import RelaysEditor from '@/components/RelaysEditor.vue';
|
||||
|
||||
const router = useRouter();
|
||||
const $q = useQuasar();
|
||||
const wallet = useWalletStore();
|
||||
const nostr = useNostrBackupStore();
|
||||
|
||||
const toast = (type: 'positive' | 'negative', message: string): void => {
|
||||
if (typeof $q.notify === 'function') {
|
||||
$q.notify({ type, message, position: 'top', timeout: 3000 });
|
||||
}
|
||||
};
|
||||
|
||||
const errorMessage = (err: unknown): string =>
|
||||
err instanceof Error ? err.message : 'Something went wrong.';
|
||||
|
||||
// ---- backup file ----
|
||||
const downloadBackupFile = () => {
|
||||
const backup = buildBackup();
|
||||
const blob = new Blob([JSON.stringify(backup, null, 2)], {
|
||||
type: 'application/json',
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = url;
|
||||
anchor.download = `sattle-backup-${new Date().toISOString().slice(0, 10)}.json`;
|
||||
anchor.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
// ---- nostr backup ----
|
||||
const relayModel = computed({
|
||||
get: () => nostr.relays,
|
||||
set: (relays: string[]) => nostr.setRelays(relays),
|
||||
});
|
||||
|
||||
const copyPubkey = () => {
|
||||
if (!nostr.pubkey) return;
|
||||
void copyToClipboard(nostr.pubkey).then(() => toast('positive', 'Backup address copied.'));
|
||||
};
|
||||
|
||||
const backupBusy = ref(false);
|
||||
const restoreBusy = ref(false);
|
||||
const restoreSummary = ref('');
|
||||
|
||||
const backUpNow = async () => {
|
||||
backupBusy.value = true;
|
||||
restoreSummary.value = '';
|
||||
try {
|
||||
await nostr.backupNow();
|
||||
toast('positive', 'Backup published to your relays.');
|
||||
} catch (err) {
|
||||
toast('negative', errorMessage(err));
|
||||
} finally {
|
||||
backupBusy.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const restoreFromNostrAction = async () => {
|
||||
restoreBusy.value = true;
|
||||
restoreSummary.value = '';
|
||||
try {
|
||||
const result = await nostr.restore();
|
||||
if (result.found.length === 0) {
|
||||
restoreSummary.value = 'No backup found for this wallet on your relays.';
|
||||
return;
|
||||
}
|
||||
const parts = [
|
||||
`${result.added} note(s) added, ${result.skipped} already present`,
|
||||
`${result.trustedMintsAdded} mint(s) added`,
|
||||
];
|
||||
if (result.settingsRestored) parts.push('settings restored');
|
||||
restoreSummary.value = `Restored: ${parts.join(', ')}.`;
|
||||
toast('positive', 'Backup restored.');
|
||||
} catch (err) {
|
||||
toast('negative', errorMessage(err));
|
||||
} finally {
|
||||
restoreBusy.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.backup-pubkey {
|
||||
font-size: 0.75rem;
|
||||
word-break: break-all;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,233 @@
|
||||
<template>
|
||||
<q-page class="q-pa-md">
|
||||
<div class="row items-center q-mb-md">
|
||||
<q-btn
|
||||
flat
|
||||
dense
|
||||
round
|
||||
color="primary"
|
||||
icon="arrow_back"
|
||||
aria-label="Back"
|
||||
@click="router.push('/settings')"
|
||||
/>
|
||||
<div class="text-h5 text-weight-bold text-primary q-ml-sm">Security</div>
|
||||
</div>
|
||||
|
||||
<!-- passkeys -->
|
||||
<q-list class="sattle-card q-mb-md" bordered separator>
|
||||
<q-item-label header class="text-primary text-weight-bold">Passkeys</q-item-label>
|
||||
|
||||
<q-item v-if="supported === null">
|
||||
<q-item-section class="text-grey-5">Checking passkey support…</q-item-section>
|
||||
</q-item>
|
||||
|
||||
<q-item v-else-if="supported === false">
|
||||
<q-item-section>
|
||||
<q-item-label class="text-grey-3">Passkeys aren't available here</q-item-label>
|
||||
<q-item-label caption class="text-grey-5" style="white-space: normal">
|
||||
This browser has no passkey authenticator (like Touch ID, Windows Hello or Android
|
||||
biometrics) with the encryption support sattle needs. Your password unlock keeps working
|
||||
- nothing to do.
|
||||
</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
|
||||
<template v-else>
|
||||
<q-item v-if="wallet.state !== 'unlocked'">
|
||||
<q-item-section class="text-grey-5">
|
||||
Unlock your wallet first - adding a passkey needs the wallet's key in memory.
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
|
||||
<template v-else>
|
||||
<q-item v-if="!slots.length">
|
||||
<q-item-section class="text-grey-5">
|
||||
No passkeys yet. A passkey lets you unlock this wallet with your device's screen lock
|
||||
instead of typing the password.
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<q-item v-for="slot in slots" :key="slot.credentialId">
|
||||
<q-item-section>
|
||||
<q-item-label class="text-grey-3">{{ slot.name || 'Passkey' }}</q-item-label>
|
||||
<q-item-label caption class="text-grey-5">
|
||||
Added {{ new Date(slot.createdAt).toLocaleDateString() }}
|
||||
</q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section side>
|
||||
<q-btn flat dense no-caps color="negative" label="Remove" @click="askRemove(slot)" />
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<div class="q-pa-md">
|
||||
<q-btn
|
||||
unelevated
|
||||
color="primary"
|
||||
text-color="dark"
|
||||
icon="fingerprint"
|
||||
label="Add a passkey"
|
||||
class="full-width"
|
||||
:loading="registerBusy"
|
||||
@click="askRegister"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</q-list>
|
||||
|
||||
<!-- auto-lock -->
|
||||
<q-list class="sattle-card q-mb-md" bordered>
|
||||
<q-item-label header class="text-primary text-weight-bold">Auto-lock</q-item-label>
|
||||
<q-item>
|
||||
<q-item-section>
|
||||
<q-item-label class="text-grey-3"> Locks after 5 minutes without activity </q-item-label>
|
||||
<q-item-label caption class="text-grey-5" style="white-space: normal">
|
||||
Applies when your wallet is password-protected. You get a 30-second warning with a "stay
|
||||
unlocked" option before it locks. The duration isn't configurable yet.
|
||||
</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
|
||||
<q-banner v-if="banner" dense class="bg-negative text-white rounded-borders q-mb-md">
|
||||
{{ banner }}
|
||||
</q-banner>
|
||||
|
||||
<!-- add-passkey dialog: name it, then the authenticator ceremony runs -->
|
||||
<q-dialog v-model="registering">
|
||||
<q-card class="sattle-card q-pa-lg">
|
||||
<div class="text-h6 text-primary q-mb-sm">Add a passkey</div>
|
||||
<div class="text-body2 text-grey-4 q-mb-md">
|
||||
Your device will ask for its screen lock. The passkey only ever unlocks this wallet on
|
||||
this device - it never leaves it.
|
||||
</div>
|
||||
<q-input
|
||||
v-model="registerName"
|
||||
dark
|
||||
outlined
|
||||
color="primary"
|
||||
label="Name (optional)"
|
||||
placeholder="e.g. laptop"
|
||||
autocomplete="off"
|
||||
class="q-mb-md"
|
||||
/>
|
||||
<div class="row q-gutter-sm justify-end">
|
||||
<q-btn v-close-popup flat no-caps color="grey-5" label="Cancel" />
|
||||
<q-btn
|
||||
unelevated
|
||||
no-caps
|
||||
color="primary"
|
||||
text-color="dark"
|
||||
label="Create passkey"
|
||||
:loading="registerBusy"
|
||||
@click="doRegister"
|
||||
/>
|
||||
</div>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
|
||||
<!-- remove confirmation -->
|
||||
<q-dialog v-model="confirmingRemove">
|
||||
<q-card class="sattle-card q-pa-lg">
|
||||
<div class="text-h6 text-primary q-mb-sm">Remove passkey</div>
|
||||
<div class="text-body2 text-grey-4 q-mb-md">
|
||||
Remove <strong>{{ removeTarget?.name || 'this passkey' }}</strong
|
||||
>? It will no longer unlock this wallet. You can remove it from your device's passkey list
|
||||
separately - the wallet can't do that for you.
|
||||
</div>
|
||||
<div class="row q-gutter-sm justify-end">
|
||||
<q-btn v-close-popup flat no-caps color="grey-5" label="Cancel" />
|
||||
<q-btn
|
||||
unelevated
|
||||
no-caps
|
||||
color="negative"
|
||||
text-color="white"
|
||||
label="Remove"
|
||||
@click="doRemove"
|
||||
/>
|
||||
</div>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
</q-page>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useQuasar } from 'quasar';
|
||||
|
||||
import type { PasskeySlot } from '@/lnurlcash/passkeys';
|
||||
import {
|
||||
passkeySupported,
|
||||
readPasskeySlots,
|
||||
registerPasskey,
|
||||
removePasskey,
|
||||
} from '@/lnurlcash/passkeys';
|
||||
import { useWalletStore } from '@/stores/wallet';
|
||||
|
||||
const router = useRouter();
|
||||
const $q = useQuasar();
|
||||
const wallet = useWalletStore();
|
||||
|
||||
const toast = (type: 'positive' | 'negative', message: string): void => {
|
||||
if (typeof $q.notify === 'function') {
|
||||
$q.notify({ type, message, position: 'top', timeout: 3000 });
|
||||
}
|
||||
};
|
||||
|
||||
const banner = ref('');
|
||||
|
||||
// null while the async probe runs - the support answer decides which of
|
||||
// the three states (checking / unsupported / manage) renders
|
||||
const supported = ref<boolean | null>(null);
|
||||
const slots = ref<PasskeySlot[]>([]);
|
||||
|
||||
onMounted(async () => {
|
||||
supported.value = await passkeySupported();
|
||||
slots.value = readPasskeySlots();
|
||||
});
|
||||
|
||||
// ---- register ----
|
||||
const registering = ref(false);
|
||||
const registerName = ref('');
|
||||
const registerBusy = ref(false);
|
||||
|
||||
const askRegister = () => {
|
||||
banner.value = '';
|
||||
registerName.value = '';
|
||||
registering.value = true;
|
||||
};
|
||||
|
||||
const doRegister = async () => {
|
||||
registerBusy.value = true;
|
||||
banner.value = '';
|
||||
try {
|
||||
const name = registerName.value.trim();
|
||||
await registerPasskey(wallet.requireLinkingKey(), name ? { name } : {});
|
||||
slots.value = readPasskeySlots();
|
||||
registering.value = false;
|
||||
toast('positive', 'Passkey added.');
|
||||
} catch (err) {
|
||||
banner.value = err instanceof Error ? err.message : 'Could not add that passkey.';
|
||||
registering.value = false;
|
||||
} finally {
|
||||
registerBusy.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// ---- remove ----
|
||||
const confirmingRemove = ref(false);
|
||||
const removeTarget = ref<PasskeySlot | null>(null);
|
||||
|
||||
const askRemove = (slot: PasskeySlot) => {
|
||||
banner.value = '';
|
||||
removeTarget.value = slot;
|
||||
confirmingRemove.value = true;
|
||||
};
|
||||
|
||||
const doRemove = async () => {
|
||||
confirmingRemove.value = false;
|
||||
if (!removeTarget.value) return;
|
||||
await removePasskey(removeTarget.value.credentialId);
|
||||
slots.value = readPasskeySlots();
|
||||
toast('positive', 'Passkey removed.');
|
||||
};
|
||||
</script>
|
||||
@@ -50,7 +50,13 @@ const router = useRouter();
|
||||
type SettingsItem = { label: string; to?: string };
|
||||
|
||||
const groups: { label: string; items: SettingsItem[] }[] = [
|
||||
{ label: 'Wallet', items: [{ label: 'Backup' }, { label: 'Security' }] },
|
||||
{
|
||||
label: 'Wallet',
|
||||
items: [
|
||||
{ label: 'Backup', to: '/settings/backup' },
|
||||
{ label: 'Security', to: '/settings/security' },
|
||||
],
|
||||
},
|
||||
{ label: 'Connections', items: [{ label: 'Nostr Wallet Connect' }, { label: 'Nostr' }] },
|
||||
{
|
||||
label: 'Mints',
|
||||
|
||||
+183
-5
@@ -32,6 +32,7 @@
|
||||
{ label: 'Create new', value: 'create' },
|
||||
{ label: 'Restore seed', value: 'restore' },
|
||||
{ label: 'Backup file', value: 'backup' },
|
||||
{ label: 'Nostr backup', value: 'nostr' },
|
||||
]"
|
||||
/>
|
||||
|
||||
@@ -199,6 +200,107 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- restore from nostr backup -->
|
||||
<div v-else-if="tab === 'nostr'">
|
||||
<div class="text-body1 q-mb-md">
|
||||
If this wallet used nostr backup before, your notes, mints and settings are
|
||||
waiting on your relays - encrypted so only your recovery phrase can read
|
||||
them.
|
||||
</div>
|
||||
<q-input
|
||||
v-model="nostrPhrase"
|
||||
type="textarea"
|
||||
rows="3"
|
||||
dark
|
||||
outlined
|
||||
color="primary"
|
||||
label="Your 12-word recovery phrase"
|
||||
placeholder="twelve words separated by spaces"
|
||||
autocomplete="off"
|
||||
autocapitalize="off"
|
||||
spellcheck="false"
|
||||
data-1p-ignore
|
||||
data-lpignore="true"
|
||||
class="q-mb-md"
|
||||
/>
|
||||
<div class="text-caption text-grey-5 q-mb-xs">Relays to look on</div>
|
||||
<RelaysEditor v-model="nostrRelays" />
|
||||
|
||||
<div v-if="nostrError" class="text-negative q-my-sm">{{ nostrError }}</div>
|
||||
|
||||
<template v-if="!nostrFound">
|
||||
<div v-if="nostrLooked" class="text-warning q-my-sm">
|
||||
No backup found for this phrase on those relays. Check the phrase and the
|
||||
relay list - or restore from a backup file instead.
|
||||
</div>
|
||||
<q-btn
|
||||
unelevated
|
||||
color="primary"
|
||||
text-color="dark"
|
||||
label="Look for a backup"
|
||||
class="full-width q-mt-md"
|
||||
:loading="nostrBusy"
|
||||
:disable="!nostrPhraseValid || nostrRelays.length === 0"
|
||||
@click="lookForNostrBackup"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div class="text-body2 text-grey-4 q-my-md">
|
||||
Found a backup: {{ nostrFound.notes }} note(s), {{ nostrFound.mints }}
|
||||
mint(s)<template v-if="nostrFound.settings">, settings</template>.
|
||||
</div>
|
||||
<div class="text-caption text-grey-5 q-mb-sm">
|
||||
Password (optional) — encrypts your wallet on this device and enables
|
||||
locking. Minimum {{ MIN_PASSWORD_LENGTH }} characters. Leave empty to
|
||||
store unencrypted.
|
||||
</div>
|
||||
<q-input
|
||||
v-model="nostrPassword"
|
||||
type="password"
|
||||
dark
|
||||
outlined
|
||||
color="primary"
|
||||
label="Password"
|
||||
autocomplete="new-password"
|
||||
class="q-mb-sm"
|
||||
/>
|
||||
<q-input
|
||||
v-if="nostrPassword !== ''"
|
||||
v-model="nostrPasswordConfirm"
|
||||
type="password"
|
||||
dark
|
||||
outlined
|
||||
color="primary"
|
||||
label="Confirm password"
|
||||
autocomplete="new-password"
|
||||
class="q-mb-sm"
|
||||
/>
|
||||
<div
|
||||
v-if="nostrPassword !== '' && nostrPassword.length < MIN_PASSWORD_LENGTH"
|
||||
class="text-warning text-caption q-mb-sm"
|
||||
>
|
||||
At least {{ MIN_PASSWORD_LENGTH }} characters.
|
||||
</div>
|
||||
<div
|
||||
v-if="nostrPasswordConfirm !== '' && nostrPassword !== nostrPasswordConfirm"
|
||||
class="text-warning text-caption q-mb-sm"
|
||||
>
|
||||
Passwords do not match.
|
||||
</div>
|
||||
<q-btn
|
||||
unelevated
|
||||
color="primary"
|
||||
text-color="dark"
|
||||
label="Restore this backup"
|
||||
class="full-width q-mt-sm"
|
||||
:loading="nostrBusy"
|
||||
:disable="!passwordValid(nostrPassword, nostrPasswordConfirm)"
|
||||
@click="restoreNostrBackup"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- restore from backup file -->
|
||||
<div v-else>
|
||||
<div class="text-body1 q-mb-md">
|
||||
@@ -269,13 +371,22 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { ref, computed } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { Notify } from 'quasar';
|
||||
|
||||
import { deriveWalletLinkingKey, isValidSeedPhrase } from '@/lnurlcash/keys';
|
||||
import {
|
||||
backupPubkey,
|
||||
deriveBackupKey,
|
||||
fetchBackup,
|
||||
restoreFromNostr,
|
||||
} from '@/lnurlcash/nostrBackup';
|
||||
import { useWalletStore } from '@/stores/wallet';
|
||||
import { DEFAULT_NOSTR_RELAYS } from '@/stores/nostrBackup';
|
||||
import { applyBackup, MAX_BACKUP_FILE_BYTES } from '@/lnurlcash/storage';
|
||||
import type { RestoreResult } from '@/lnurlcash/storage';
|
||||
import RelaysEditor from '@/components/RelaysEditor.vue';
|
||||
|
||||
// the key's ciphertext sits in local storage AND travels inside every backup
|
||||
// file by design, so this password is the only thing between an offline
|
||||
@@ -283,14 +394,16 @@ import type { RestoreResult } from '@/lnurlcash/storage';
|
||||
// no password at all
|
||||
const MIN_PASSWORD_LENGTH = 8;
|
||||
|
||||
type Tab = 'create' | 'restore' | 'backup';
|
||||
type Tab = 'create' | 'restore' | 'backup' | 'nostr';
|
||||
|
||||
const wallet = useWalletStore();
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
|
||||
const tab = ref<Tab>(
|
||||
route.query.tab === 'restore' || route.query.tab === 'backup' ? route.query.tab : 'create',
|
||||
route.query.tab === 'restore' || route.query.tab === 'backup' || route.query.tab === 'nostr'
|
||||
? route.query.tab
|
||||
: 'create',
|
||||
);
|
||||
|
||||
const errorMessage = (err: unknown): string =>
|
||||
@@ -396,13 +509,78 @@ const restoreFromBackupFile = async (event: Event) => {
|
||||
}
|
||||
};
|
||||
|
||||
// ---- restore from nostr backup ----
|
||||
// phrase in -> derive the linking key (and from it the backup key) -> fetch
|
||||
// the encrypted parts -> preview what was found -> applyBackup via
|
||||
// restoreFromNostr, then install the key through the ordinary seed path
|
||||
const nostrPhrase = ref('');
|
||||
const nostrRelays = ref<string[]>([...DEFAULT_NOSTR_RELAYS]);
|
||||
const nostrPassword = ref('');
|
||||
const nostrPasswordConfirm = ref('');
|
||||
const nostrBusy = ref(false);
|
||||
const nostrError = ref('');
|
||||
const nostrLooked = ref(false);
|
||||
const nostrFound = ref<{ notes: number; mints: number; settings: boolean } | null>(null);
|
||||
|
||||
const nostrPhraseValid = computed(() => isValidSeedPhrase(nostrPhrase.value));
|
||||
|
||||
const nostrLinkingKey = (): Uint8Array =>
|
||||
deriveWalletLinkingKey(nostrPhrase.value.trim().toLowerCase());
|
||||
|
||||
const lookForNostrBackup = async () => {
|
||||
nostrBusy.value = true;
|
||||
nostrError.value = '';
|
||||
nostrFound.value = null;
|
||||
nostrLooked.value = false;
|
||||
try {
|
||||
const secretKey = deriveBackupKey(nostrLinkingKey());
|
||||
const parts = await fetchBackup(backupPubkey(secretKey), nostrRelays.value, {
|
||||
secretKey,
|
||||
});
|
||||
nostrLooked.value = true;
|
||||
if (parts.notes || parts.mints || parts.settings) {
|
||||
nostrFound.value = {
|
||||
notes: parts.notes?.length ?? 0,
|
||||
mints: parts.mints?.length ?? 0,
|
||||
settings: parts.settings !== undefined,
|
||||
};
|
||||
}
|
||||
} catch (err) {
|
||||
nostrError.value = errorMessage(err);
|
||||
} finally {
|
||||
nostrBusy.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const restoreNostrBackup = async () => {
|
||||
nostrBusy.value = true;
|
||||
nostrError.value = '';
|
||||
try {
|
||||
const linkingKey = nostrLinkingKey();
|
||||
// merge the fetched parts into local storage FIRST (the same applyBackup
|
||||
// path as a file restore), then installing the seed activates the wallet
|
||||
// and loads what was just restored
|
||||
await restoreFromNostr(linkingKey, nostrRelays.value);
|
||||
await wallet.restoreFromSeed(
|
||||
nostrPhrase.value.trim().toLowerCase(),
|
||||
nostrPassword.value || undefined,
|
||||
);
|
||||
Notify.create({ type: 'positive', message: 'Backup restored - welcome back.' });
|
||||
void router.push('/');
|
||||
} catch (err) {
|
||||
nostrError.value = errorMessage(err);
|
||||
Notify.create({ type: 'negative', message: nostrError.value });
|
||||
} finally {
|
||||
nostrBusy.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// the backup installed its own key into local storage behind the wallet
|
||||
// store's back - a full reload re-runs the boot sequence so the app comes up
|
||||
// against the restored key (unlock screen if it was password-encrypted)
|
||||
const proceedWithBackupKey = () => {
|
||||
window.location.assign('/');
|
||||
};
|
||||
</script>
|
||||
};</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.onboarding-panel {
|
||||
|
||||
Reference in New Issue
Block a user