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
+78
View File
@@ -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>
+31
View File
@@ -39,6 +39,17 @@
@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">
<q-btn
flat
@@ -57,6 +68,7 @@
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { hasPasskeySlots } from '@/lnurlcash/passkeys';
import { useWalletStore } from '@/stores/wallet';
const emit = defineEmits<{ unlocked: [] }>();
@@ -69,6 +81,25 @@ const showPassword = ref(false);
const busy = ref(false);
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 () => {
if (busy.value || password.value === '') return;
busy.value = true;