feat: backup and security settings pages, nostr restore onboarding, passkey unlock entry

This commit is contained in:
2026-08-19 23:55:59 +02:00
parent 7db34083e6
commit 1920a00cd6
11 changed files with 990 additions and 7 deletions
+243
View File
@@ -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>
+233
View File
@@ -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>
+7 -1
View File
@@ -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
View File
@@ -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 {