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:
@@ -1,5 +1,6 @@
|
|||||||
import { defineBoot } from '#q-app';
|
import { defineBoot } from '#q-app';
|
||||||
import { useWalletStore } from '@/stores/wallet';
|
import { useWalletStore } from '@/stores/wallet';
|
||||||
|
import { useNostrBackupStore } from '@/stores/nostrBackup';
|
||||||
|
|
||||||
// Wallet lifecycle bootstrap: reflects whatever is on this device into the
|
// Wallet lifecycle bootstrap: reflects whatever is on this device into the
|
||||||
// wallet store at app start - a plaintext-stored key unlocks straight away,
|
// wallet store at app start - a plaintext-stored key unlocks straight away,
|
||||||
@@ -8,4 +9,7 @@ import { useWalletStore } from '@/stores/wallet';
|
|||||||
export default defineBoot(async () => {
|
export default defineBoot(async () => {
|
||||||
const wallet = useWalletStore();
|
const wallet = useWalletStore();
|
||||||
await wallet.init();
|
await wallet.init();
|
||||||
|
// instantiating the store arms its watchers: while the wallet is unlocked
|
||||||
|
// and nostr backup is enabled, store changes schedule debounced publishes
|
||||||
|
useNostrBackupStore();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div v-if="modelValue.length" class="row q-gutter-xs q-mb-sm">
|
||||||
|
<q-chip
|
||||||
|
v-for="relay in modelValue"
|
||||||
|
:key="relay"
|
||||||
|
removable
|
||||||
|
dense
|
||||||
|
color="secondary"
|
||||||
|
text-color="primary"
|
||||||
|
:aria-label="`Remove ${relay}`"
|
||||||
|
@remove="remove(relay)"
|
||||||
|
>
|
||||||
|
{{ relay }}
|
||||||
|
</q-chip>
|
||||||
|
</div>
|
||||||
|
<div v-else class="text-caption text-warning q-mb-sm">
|
||||||
|
No relays configured - a backup has nowhere to go.
|
||||||
|
</div>
|
||||||
|
<q-input
|
||||||
|
v-model="draft"
|
||||||
|
dark
|
||||||
|
outlined
|
||||||
|
dense
|
||||||
|
color="primary"
|
||||||
|
label="Add a relay"
|
||||||
|
placeholder="wss://relay.example.com"
|
||||||
|
autocomplete="off"
|
||||||
|
autocapitalize="off"
|
||||||
|
spellcheck="false"
|
||||||
|
:error="error !== ''"
|
||||||
|
:error-message="error"
|
||||||
|
class="q-mb-sm"
|
||||||
|
@keyup.enter="add"
|
||||||
|
/>
|
||||||
|
<q-btn
|
||||||
|
flat
|
||||||
|
dense
|
||||||
|
no-caps
|
||||||
|
color="primary"
|
||||||
|
label="Add relay"
|
||||||
|
:disable="draft.trim() === ''"
|
||||||
|
@click="add"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue';
|
||||||
|
|
||||||
|
import { normalizeRelay } from '@/stores/nostrBackup';
|
||||||
|
|
||||||
|
const props = defineProps<{ modelValue: string[] }>();
|
||||||
|
const emit = defineEmits<{ 'update:modelValue': [relays: string[]] }>();
|
||||||
|
|
||||||
|
const draft = ref('');
|
||||||
|
const error = ref('');
|
||||||
|
|
||||||
|
const add = () => {
|
||||||
|
error.value = '';
|
||||||
|
try {
|
||||||
|
const relay = normalizeRelay(draft.value);
|
||||||
|
if (!props.modelValue.includes(relay)) {
|
||||||
|
emit('update:modelValue', [...props.modelValue, relay]);
|
||||||
|
}
|
||||||
|
draft.value = '';
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err instanceof Error ? err.message : 'That is not a relay address.';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const remove = (relay: string) => {
|
||||||
|
emit(
|
||||||
|
'update:modelValue',
|
||||||
|
props.modelValue.filter((r) => r !== relay),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
</script>
|
||||||
@@ -39,6 +39,17 @@
|
|||||||
@click="unlock"
|
@click="unlock"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<q-btn
|
||||||
|
v-if="passkeyAvailable"
|
||||||
|
outline
|
||||||
|
color="primary"
|
||||||
|
icon="fingerprint"
|
||||||
|
label="Unlock with passkey"
|
||||||
|
class="full-width q-mt-sm"
|
||||||
|
:loading="passkeyBusy"
|
||||||
|
@click="unlockViaPasskey"
|
||||||
|
/>
|
||||||
|
|
||||||
<div class="text-center q-mt-md">
|
<div class="text-center q-mt-md">
|
||||||
<q-btn
|
<q-btn
|
||||||
flat
|
flat
|
||||||
@@ -57,6 +68,7 @@
|
|||||||
import { ref } from 'vue';
|
import { ref } from 'vue';
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
|
|
||||||
|
import { hasPasskeySlots } from '@/lnurlcash/passkeys';
|
||||||
import { useWalletStore } from '@/stores/wallet';
|
import { useWalletStore } from '@/stores/wallet';
|
||||||
|
|
||||||
const emit = defineEmits<{ unlocked: [] }>();
|
const emit = defineEmits<{ unlocked: [] }>();
|
||||||
@@ -69,6 +81,25 @@ const showPassword = ref(false);
|
|||||||
const busy = ref(false);
|
const busy = ref(false);
|
||||||
const error = ref('');
|
const error = ref('');
|
||||||
|
|
||||||
|
// slots live in plain localStorage - a sync read at setup is enough; they
|
||||||
|
// can only change from the security page while unlocked
|
||||||
|
const passkeyAvailable = hasPasskeySlots();
|
||||||
|
const passkeyBusy = ref(false);
|
||||||
|
|
||||||
|
const unlockViaPasskey = async () => {
|
||||||
|
if (passkeyBusy.value) return;
|
||||||
|
passkeyBusy.value = true;
|
||||||
|
error.value = '';
|
||||||
|
try {
|
||||||
|
await wallet.unlockWithPasskey();
|
||||||
|
emit('unlocked');
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err instanceof Error ? err.message : 'Passkey unlock failed.';
|
||||||
|
} finally {
|
||||||
|
passkeyBusy.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const unlock = async () => {
|
const unlock = async () => {
|
||||||
if (busy.value || password.value === '') return;
|
if (busy.value || password.value === '') return;
|
||||||
busy.value = true;
|
busy.value = true;
|
||||||
|
|||||||
@@ -4,6 +4,10 @@
|
|||||||
|
|
||||||
export type WalletSettings = {
|
export type WalletSettings = {
|
||||||
defaultMint?: string
|
defaultMint?: string
|
||||||
|
// nostr backup (see nostrBackup.ts): off unless the holder turns it on;
|
||||||
|
// relays are only persisted once edited - absent means the UI's defaults
|
||||||
|
nostrBackupEnabled?: boolean
|
||||||
|
nostrBackupRelays?: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
const SETTINGS_STORAGE_KEY = 'sattle_settings'
|
const SETTINGS_STORAGE_KEY = 'sattle_settings'
|
||||||
@@ -17,7 +21,14 @@ export const loadSettings = (): WalletSettings => {
|
|||||||
const s = parsed as Record<string, unknown>
|
const s = parsed as Record<string, unknown>
|
||||||
return {
|
return {
|
||||||
defaultMint:
|
defaultMint:
|
||||||
typeof s.defaultMint === 'string' ? s.defaultMint : undefined
|
typeof s.defaultMint === 'string' ? s.defaultMint : undefined,
|
||||||
|
nostrBackupEnabled:
|
||||||
|
typeof s.nostrBackupEnabled === 'boolean'
|
||||||
|
? s.nostrBackupEnabled
|
||||||
|
: undefined,
|
||||||
|
nostrBackupRelays: Array.isArray(s.nostrBackupRelays)
|
||||||
|
? s.nostrBackupRelays.filter((r): r is string => typeof r === 'string')
|
||||||
|
: undefined
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
return {}
|
return {}
|
||||||
|
|||||||
@@ -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 };
|
type SettingsItem = { label: string; to?: string };
|
||||||
|
|
||||||
const groups: { label: string; items: SettingsItem[] }[] = [
|
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: 'Connections', items: [{ label: 'Nostr Wallet Connect' }, { label: 'Nostr' }] },
|
||||||
{
|
{
|
||||||
label: 'Mints',
|
label: 'Mints',
|
||||||
|
|||||||
+183
-5
@@ -32,6 +32,7 @@
|
|||||||
{ label: 'Create new', value: 'create' },
|
{ label: 'Create new', value: 'create' },
|
||||||
{ label: 'Restore seed', value: 'restore' },
|
{ label: 'Restore seed', value: 'restore' },
|
||||||
{ label: 'Backup file', value: 'backup' },
|
{ label: 'Backup file', value: 'backup' },
|
||||||
|
{ label: 'Nostr backup', value: 'nostr' },
|
||||||
]"
|
]"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -199,6 +200,107 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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 -->
|
<!-- restore from backup file -->
|
||||||
<div v-else>
|
<div v-else>
|
||||||
<div class="text-body1 q-mb-md">
|
<div class="text-body1 q-mb-md">
|
||||||
@@ -269,13 +371,22 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue';
|
import { ref, computed } from 'vue';
|
||||||
import { useRoute, useRouter } from 'vue-router';
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
import { Notify } from 'quasar';
|
import { Notify } from 'quasar';
|
||||||
|
|
||||||
|
import { deriveWalletLinkingKey, isValidSeedPhrase } from '@/lnurlcash/keys';
|
||||||
|
import {
|
||||||
|
backupPubkey,
|
||||||
|
deriveBackupKey,
|
||||||
|
fetchBackup,
|
||||||
|
restoreFromNostr,
|
||||||
|
} from '@/lnurlcash/nostrBackup';
|
||||||
import { useWalletStore } from '@/stores/wallet';
|
import { useWalletStore } from '@/stores/wallet';
|
||||||
|
import { DEFAULT_NOSTR_RELAYS } from '@/stores/nostrBackup';
|
||||||
import { applyBackup, MAX_BACKUP_FILE_BYTES } from '@/lnurlcash/storage';
|
import { applyBackup, MAX_BACKUP_FILE_BYTES } from '@/lnurlcash/storage';
|
||||||
import type { RestoreResult } 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
|
// 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
|
// 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
|
// no password at all
|
||||||
const MIN_PASSWORD_LENGTH = 8;
|
const MIN_PASSWORD_LENGTH = 8;
|
||||||
|
|
||||||
type Tab = 'create' | 'restore' | 'backup';
|
type Tab = 'create' | 'restore' | 'backup' | 'nostr';
|
||||||
|
|
||||||
const wallet = useWalletStore();
|
const wallet = useWalletStore();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
|
|
||||||
const tab = ref<Tab>(
|
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 =>
|
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
|
// 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
|
// 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)
|
// against the restored key (unlock screen if it was password-encrypted)
|
||||||
const proceedWithBackupKey = () => {
|
const proceedWithBackupKey = () => {
|
||||||
window.location.assign('/');
|
window.location.assign('/');
|
||||||
};
|
};</script>
|
||||||
</script>
|
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.onboarding-panel {
|
.onboarding-panel {
|
||||||
|
|||||||
@@ -7,6 +7,14 @@ const routes: RouteRecordRaw[] = [
|
|||||||
children: [
|
children: [
|
||||||
{ path: '', component: () => import('@/pages/IndexPage.vue') },
|
{ path: '', component: () => import('@/pages/IndexPage.vue') },
|
||||||
{ path: 'settings', component: () => import('@/pages/SettingsPage.vue') },
|
{ path: 'settings', component: () => import('@/pages/SettingsPage.vue') },
|
||||||
|
{
|
||||||
|
path: 'settings/backup',
|
||||||
|
component: () => import('@/pages/BackupPage.vue'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'settings/security',
|
||||||
|
component: () => import('@/pages/SecurityPage.vue'),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'settings/mints',
|
path: 'settings/mints',
|
||||||
component: () => import('@/pages/ManageMintsPage.vue'),
|
component: () => import('@/pages/ManageMintsPage.vue'),
|
||||||
|
|||||||
@@ -0,0 +1,167 @@
|
|||||||
|
import { computed, ref, watch } from 'vue';
|
||||||
|
import { defineStore } from 'pinia';
|
||||||
|
|
||||||
|
import type { BackupPartPayload, BackupPublisher } from '@/lnurlcash/nostrBackup';
|
||||||
|
import {
|
||||||
|
backupPubkey,
|
||||||
|
createBackupPublisher,
|
||||||
|
deriveBackupKey,
|
||||||
|
publishBackup,
|
||||||
|
restoreFromNostr,
|
||||||
|
} from '@/lnurlcash/nostrBackup';
|
||||||
|
import type { NostrRestoreResult } from '@/lnurlcash/nostrBackup';
|
||||||
|
import { loadSettings, persistSettings, readEncryptedBearers } from '@/lnurlcash/storage';
|
||||||
|
import { readTrustedMints } from '@/lnurlcash/trustedMints';
|
||||||
|
import { useWalletStore } from './wallet';
|
||||||
|
import { useMintsStore } from './mints';
|
||||||
|
|
||||||
|
// a small set of well-known, long-lived relays - editable in the UI, and
|
||||||
|
// only persisted once the holder actually changes them (absent = defaults)
|
||||||
|
export const DEFAULT_NOSTR_RELAYS: readonly string[] = [
|
||||||
|
'wss://relay.damus.io',
|
||||||
|
'wss://nos.lol',
|
||||||
|
'wss://relay.primal.net',
|
||||||
|
];
|
||||||
|
|
||||||
|
// bursts of edits (a receive plus its mint trust plus a settings change)
|
||||||
|
// collapse into one publish of the final state per quiet window
|
||||||
|
const PUBLISH_DEBOUNCE_MS = 5000;
|
||||||
|
|
||||||
|
export const normalizeRelay = (input: string): string => {
|
||||||
|
const relay = input.trim().toLowerCase().replace(/\/+$/, '');
|
||||||
|
if (!/^wss?:\/\/\S+$/.test(relay)) {
|
||||||
|
throw new Error('A relay is a WebSocket URL, like wss://relay.example.com.');
|
||||||
|
}
|
||||||
|
return relay;
|
||||||
|
};
|
||||||
|
|
||||||
|
// The nostr-backup control surface: the enabled/relays settings (persisted
|
||||||
|
// in wallet settings), the debounced publisher wired to store changes while
|
||||||
|
// the wallet is unlocked, and the manual back-up/restore actions. The
|
||||||
|
// engine (lnurlcash/nostrBackup.ts) stays framework-free; everything
|
||||||
|
// reactive lives here.
|
||||||
|
export const useNostrBackupStore = defineStore('nostrBackup', () => {
|
||||||
|
const wallet = useWalletStore();
|
||||||
|
const mints = useMintsStore();
|
||||||
|
|
||||||
|
const settings = loadSettings();
|
||||||
|
const enabled = ref(settings.nostrBackupEnabled ?? false);
|
||||||
|
const relays = ref<string[]>(
|
||||||
|
settings.nostrBackupRelays && settings.nostrBackupRelays.length > 0
|
||||||
|
? [...settings.nostrBackupRelays]
|
||||||
|
: [...DEFAULT_NOSTR_RELAYS],
|
||||||
|
);
|
||||||
|
|
||||||
|
// feedback for the backup page: when the last publish went out, or why
|
||||||
|
// the last attempt failed (a debounced publish has no caller to throw to)
|
||||||
|
const lastPublishAt = ref<number | null>(null);
|
||||||
|
const lastError = ref('');
|
||||||
|
|
||||||
|
// the backup identity is derived from the linking key, so it only exists
|
||||||
|
// while unlocked - locked renders must never touch requireLinkingKey
|
||||||
|
const pubkey = computed(() =>
|
||||||
|
wallet.state === 'unlocked' ? backupPubkey(deriveBackupKey(wallet.requireLinkingKey())) : null,
|
||||||
|
);
|
||||||
|
|
||||||
|
const currentPayload = (): BackupPartPayload => ({
|
||||||
|
notes: readEncryptedBearers(),
|
||||||
|
mints: readTrustedMints(),
|
||||||
|
settings: loadSettings(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const publishNow = async (): Promise<void> => {
|
||||||
|
const secretKey = deriveBackupKey(wallet.requireLinkingKey());
|
||||||
|
await publishBackup(secretKey, currentPayload(), relays.value);
|
||||||
|
lastPublishAt.value = Date.now();
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- debounced publishing lifecycle ----
|
||||||
|
// wired while (unlocked AND enabled) only; both the watchers and the
|
||||||
|
// publisher are torn down the moment either flips, so a locked wallet
|
||||||
|
// never schedules anything and holds no key-material closure
|
||||||
|
let publisher: BackupPublisher | null = null;
|
||||||
|
let stopWatchers: (() => void) | null = null;
|
||||||
|
|
||||||
|
const start = (): void => {
|
||||||
|
if (publisher) return;
|
||||||
|
publisher = createBackupPublisher({
|
||||||
|
delayMs: PUBLISH_DEBOUNCE_MS,
|
||||||
|
publish: async (parts) => {
|
||||||
|
await publishBackup(deriveBackupKey(wallet.requireLinkingKey()), parts, relays.value);
|
||||||
|
lastPublishAt.value = Date.now();
|
||||||
|
lastError.value = '';
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
lastError.value = error instanceof Error ? error.message : 'Backup publish failed.';
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const schedule = () => publisher?.schedule(currentPayload());
|
||||||
|
const stops = [
|
||||||
|
watch(() => wallet.bearers, schedule),
|
||||||
|
watch(() => mints.mints, schedule),
|
||||||
|
watch(() => mints.defaultMint, schedule),
|
||||||
|
];
|
||||||
|
stopWatchers = () => stops.forEach((stop) => stop());
|
||||||
|
// an initial publish on (re)activation, so enabling backup on a wallet
|
||||||
|
// that then sits idle still lands a backup
|
||||||
|
schedule();
|
||||||
|
};
|
||||||
|
|
||||||
|
const stop = (): void => {
|
||||||
|
stopWatchers?.();
|
||||||
|
stopWatchers = null;
|
||||||
|
publisher?.cancel();
|
||||||
|
publisher = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [wallet.state, enabled.value] as const,
|
||||||
|
([state, on]) => {
|
||||||
|
if (state === 'unlocked' && on) start();
|
||||||
|
else stop();
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---- settings ----
|
||||||
|
const setEnabled = (value: boolean): void => {
|
||||||
|
enabled.value = value;
|
||||||
|
persistSettings({ ...loadSettings(), nostrBackupEnabled: value });
|
||||||
|
};
|
||||||
|
|
||||||
|
const setRelays = (list: string[]): void => {
|
||||||
|
relays.value = list;
|
||||||
|
persistSettings({ ...loadSettings(), nostrBackupRelays: list });
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- manual actions (the backup page buttons) ----
|
||||||
|
const backupNow = async (): Promise<void> => {
|
||||||
|
lastError.value = '';
|
||||||
|
try {
|
||||||
|
await publishNow();
|
||||||
|
} catch (error) {
|
||||||
|
lastError.value = error instanceof Error ? error.message : 'Backup publish failed.';
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// pulls the newest backup for THIS wallet's key and merges it through the
|
||||||
|
// same applyBackup path as a file restore, then reloads the live list
|
||||||
|
const restore = async (): Promise<NostrRestoreResult> => {
|
||||||
|
const result = await restoreFromNostr(wallet.requireLinkingKey(), relays.value);
|
||||||
|
await wallet.reloadBearers();
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
enabled,
|
||||||
|
relays,
|
||||||
|
pubkey,
|
||||||
|
lastPublishAt,
|
||||||
|
lastError,
|
||||||
|
setEnabled,
|
||||||
|
setRelays,
|
||||||
|
backupNow,
|
||||||
|
restore,
|
||||||
|
};
|
||||||
|
});
|
||||||
@@ -30,6 +30,7 @@ import {
|
|||||||
clearTrustedMints
|
clearTrustedMints
|
||||||
} from '@/lnurlcash/trustedMints'
|
} from '@/lnurlcash/trustedMints'
|
||||||
import {clearSettings} from '@/lnurlcash/storage'
|
import {clearSettings} from '@/lnurlcash/storage'
|
||||||
|
import {unlockWithPasskey as unlockWithPasskeyEngine} from '@/lnurlcash/passkeys'
|
||||||
import {msatToSats} from '@/lnurlcash/units'
|
import {msatToSats} from '@/lnurlcash/units'
|
||||||
import {useActivityStore} from './activity'
|
import {useActivityStore} from './activity'
|
||||||
|
|
||||||
@@ -56,6 +57,10 @@ export const useWalletStore = defineStore('wallet', () => {
|
|||||||
const bearers = ref<Bearer[]>([])
|
const bearers = ref<Bearer[]>([])
|
||||||
const pubkey = ref<string | null>(null)
|
const pubkey = ref<string | null>(null)
|
||||||
let aesKey: CryptoKey | null = null
|
let aesKey: CryptoKey | null = null
|
||||||
|
// the linking key itself, only while unlocked - needed by backup/passkey
|
||||||
|
// operations (nostrBackup derives the backup key from it, passkey
|
||||||
|
// registration wraps it). Never exposed reactively; cleared on lock/forget
|
||||||
|
let currentLinkingKey: Uint8Array | null = null
|
||||||
|
|
||||||
// ---- idle auto-lock bookkeeping ----
|
// ---- idle auto-lock bookkeeping ----
|
||||||
let lastActivity = Date.now()
|
let lastActivity = Date.now()
|
||||||
@@ -97,6 +102,7 @@ export const useWalletStore = defineStore('wallet', () => {
|
|||||||
// just auto-unlock again, so the UI only offers Lock when encrypted
|
// just auto-unlock again, so the UI only offers Lock when encrypted
|
||||||
if (!savedKeyIsEncrypted()) return
|
if (!savedKeyIsEncrypted()) return
|
||||||
aesKey = null
|
aesKey = null
|
||||||
|
currentLinkingKey = null
|
||||||
pubkey.value = null
|
pubkey.value = null
|
||||||
bearers.value = []
|
bearers.value = []
|
||||||
useActivityStore().unload()
|
useActivityStore().unload()
|
||||||
@@ -145,6 +151,7 @@ export const useWalletStore = defineStore('wallet', () => {
|
|||||||
const activate = async (linkingKey: Uint8Array) => {
|
const activate = async (linkingKey: Uint8Array) => {
|
||||||
const key = await deriveBearerAesKey(linkingKey)
|
const key = await deriveBearerAesKey(linkingKey)
|
||||||
aesKey = key
|
aesKey = key
|
||||||
|
currentLinkingKey = linkingKey
|
||||||
pubkey.value = linkingPubKeyHex(linkingKey)
|
pubkey.value = linkingPubKeyHex(linkingKey)
|
||||||
const loaded = await loadBearers(key)
|
const loaded = await loadBearers(key)
|
||||||
bearers.value = loaded
|
bearers.value = loaded
|
||||||
@@ -193,6 +200,12 @@ export const useWalletStore = defineStore('wallet', () => {
|
|||||||
await activate(linkingKey)
|
await activate(linkingKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// passkey unlock (passkeys.ts): the ceremony unwraps the SAME linking key
|
||||||
|
// the password path protects, so activation is identical either way
|
||||||
|
const unlockWithPasskey = async (): Promise<void> => {
|
||||||
|
await activate(await unlockWithPasskeyEngine())
|
||||||
|
}
|
||||||
|
|
||||||
// app-start entry point (boot/wallet.ts): a plaintext-stored key unlocks
|
// app-start entry point (boot/wallet.ts): a plaintext-stored key unlocks
|
||||||
// without a password; an encrypted one waits on the unlock screen
|
// without a password; an encrypted one waits on the unlock screen
|
||||||
const init = async (): Promise<void> => {
|
const init = async (): Promise<void> => {
|
||||||
@@ -214,6 +227,7 @@ export const useWalletStore = defineStore('wallet', () => {
|
|||||||
clearSettings()
|
clearSettings()
|
||||||
useActivityStore().unloadAndClear()
|
useActivityStore().unloadAndClear()
|
||||||
aesKey = null
|
aesKey = null
|
||||||
|
currentLinkingKey = null
|
||||||
pubkey.value = null
|
pubkey.value = null
|
||||||
bearers.value = []
|
bearers.value = []
|
||||||
stopIdleWatch()
|
stopIdleWatch()
|
||||||
@@ -225,6 +239,14 @@ export const useWalletStore = defineStore('wallet', () => {
|
|||||||
return aesKey
|
return aesKey
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// narrow accessor for the operations that need the key material itself
|
||||||
|
// (nostr backup key derivation, passkey registration) - never reactive,
|
||||||
|
// throws when locked, so callers can't accidentally hold a stale key
|
||||||
|
const requireLinkingKey = (): Uint8Array => {
|
||||||
|
if (!currentLinkingKey) throw new Error('Wallet is locked.')
|
||||||
|
return currentLinkingKey
|
||||||
|
}
|
||||||
|
|
||||||
// the one entry point for new notes (minted, received, carved outputs):
|
// the one entry point for new notes (minted, received, carved outputs):
|
||||||
// persists first, then updates state. Holding a bearer from a mint
|
// persists first, then updates state. Holding a bearer from a mint
|
||||||
// trusts it by default - this is the one path that never asks (see
|
// trusts it by default - this is the one path that never asks (see
|
||||||
@@ -305,10 +327,12 @@ export const useWalletStore = defineStore('wallet', () => {
|
|||||||
create,
|
create,
|
||||||
restoreFromSeed,
|
restoreFromSeed,
|
||||||
unlock,
|
unlock,
|
||||||
|
unlockWithPasskey,
|
||||||
lock,
|
lock,
|
||||||
init,
|
init,
|
||||||
forgetWallet,
|
forgetWallet,
|
||||||
postponeLock,
|
postponeLock,
|
||||||
|
requireLinkingKey,
|
||||||
addBearers,
|
addBearers,
|
||||||
updateBearer,
|
updateBearer,
|
||||||
markSpent,
|
markSpent,
|
||||||
|
|||||||
Reference in New Issue
Block a user