refactor: split wallet onboarding panels

This commit is contained in:
2026-08-22 16:56:49 +02:00
parent 3c2a2cc4a5
commit 578613c869
6 changed files with 509 additions and 546 deletions
@@ -0,0 +1,109 @@
<template>
<div class="text-body1 q-mb-md">
Sets this device up from a downloaded backup file no recovery phrase needed, as long as you
still know the password the backup was encrypted with. Notes from the file are merged into
storage either way.
</div>
<q-banner v-if="keySkipped" class="sattle-card text-warning q-mb-md" rounded>
<template #avatar><q-icon name="warning" color="warning" /></template>
This device already has a wallet, so the backup's own key was <strong>not</strong> installed.
Its notes were merged and will appear if the existing wallet is the one this backup belongs to.
</q-banner>
<template v-if="keyRestored">
<q-banner class="sattle-card text-warning q-mb-md" rounded>
<template #avatar><q-icon name="warning" color="warning" /></template>
The backup's key was installed. Whoever wrote that file may know it only continue if you
trust the file's source completely. Otherwise set up a fresh wallet from your own recovery
phrase instead.
</q-banner>
<q-btn
unelevated
color="primary"
text-color="dark"
label="I trust this file — continue"
class="full-width"
@click="proceed"
/>
</template>
<template v-else>
<div v-if="result" class="text-positive q-mb-md">
Backup restored: {{ result.added }} note(s) added, {{ result.skipped }} already present.
<span v-if="!result.linkingKeyRestored">
The file carried no usable key of its own — restore its recovery phrase to unlock the notes.
</span>
</div>
<div v-if="error" class="text-negative q-mb-md">{{ error }}</div>
<input
ref="fileInput"
type="file"
accept="application/json,.json"
class="hidden"
@change="restoreFile"
/>
<q-btn
unelevated
color="primary"
text-color="dark"
label="Choose backup file"
icon="upload_file"
class="full-width"
:loading="busy"
@click="fileInput?.click()"
/>
</template>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { Notify } from 'quasar';
import { MAX_BACKUP_FILE_BYTES } from '@/lnurlcash/storage';
import type { RestoreResult } from '@/lnurlcash/storage';
import { useWalletStore } from '@/stores/wallet';
const wallet = useWalletStore();
const router = useRouter();
const fileInput = ref<HTMLInputElement | null>(null);
const busy = ref(false);
const error = ref('');
const result = ref<RestoreResult | null>(null);
const keySkipped = ref(false);
const keyRestored = ref(false);
const restoreFile = async (event: Event): Promise<void> => {
if (!(event.currentTarget instanceof HTMLInputElement)) return;
const input = event.currentTarget;
const file = input.files?.[0];
input.value = '';
if (!file) return;
busy.value = true;
error.value = '';
result.value = null;
keySkipped.value = false;
try {
if (file.size > MAX_BACKUP_FILE_BYTES) {
throw new Error('That file is far too large to be a wallet backup.');
}
const data: unknown = JSON.parse(await file.text());
const restored = await wallet.restoreFromBackup(data);
if (restored.linkingKeyRestored) {
keyRestored.value = true;
return;
}
if (restored.linkingKeySkipped) {
keySkipped.value = true;
return;
}
result.value = restored;
Notify.create({ type: 'positive', message: 'Backup restored.' });
} catch (caught) {
error.value = caught instanceof Error ? caught.message : 'Something went wrong.';
Notify.create({ type: 'negative', message: error.value });
} finally {
busy.value = false;
}
};
const proceed = async (): Promise<void> => {
await wallet.init();
await router.push('/');
};
</script>
@@ -0,0 +1,121 @@
<template>
<template v-if="!createdPhrase">
<div class="text-body1 q-mb-md">
A fresh recovery phrase is generated in your browser. It is the master key to your wallet
and the only way to recover your notes on another device.
</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="password"
type="password"
dark
outlined
color="primary"
label="Password"
autocomplete="new-password"
class="q-mb-sm"
/>
<q-input
v-if="password !== ''"
v-model="confirmation"
type="password"
dark
outlined
color="primary"
label="Confirm password"
autocomplete="new-password"
class="q-mb-sm"
/>
<div
v-if="password !== '' && password.length < MIN_PASSWORD_LENGTH"
class="text-warning text-caption q-mb-sm"
>
At least {{ MIN_PASSWORD_LENGTH }} characters this password is the only thing standing
between an offline brute-force and your notes.
</div>
<div
v-if="confirmation !== '' && password !== confirmation"
class="text-warning text-caption q-mb-sm"
>
Passwords do not match.
</div>
<q-btn
unelevated
color="primary"
text-color="dark"
label="Create wallet"
class="full-width q-mt-sm"
:loading="busy"
:disable="!passwordValid(password, confirmation)"
@click="createWallet"
/>
</template>
<template v-else>
<div class="text-body1 text-weight-medium q-mb-sm">
Your recovery phrase shown once, never stored:
</div>
<div class="row q-gutter-xs q-mb-md">
<div v-for="(word, index) in createdPhrase.split(' ')" :key="index" class="word-chip">
<span class="text-grey-5 q-mr-xs">{{ index + 1 }}.</span>{{ word }}
</div>
</div>
<q-banner class="sattle-card text-warning q-mb-md" rounded>
<template #avatar><q-icon name="warning" color="warning" /></template>
Write these 12 words down and keep them somewhere safe. Anyone who knows them can spend your
notes; if you lose them, your notes are gone forever.
</q-banner>
<q-checkbox v-model="phraseConfirmed" color="primary" label="I wrote it down" class="q-mb-md" />
<q-btn
unelevated
color="primary"
text-color="dark"
label="Continue"
class="full-width"
:disable="!phraseConfirmed"
@click="router.push('/')"
/>
</template>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { Notify } from 'quasar';
import { useWalletStore } from '@/stores/wallet';
import { MIN_PASSWORD_LENGTH, passwordValid } from '@/composables/welcomePassword';
const wallet = useWalletStore();
const router = useRouter();
const password = ref('');
const confirmation = ref('');
const busy = ref(false);
const createdPhrase = ref<string | null>(null);
const phraseConfirmed = ref(false);
const createWallet = async (): Promise<void> => {
busy.value = true;
try {
createdPhrase.value = await wallet.create(password.value || undefined);
phraseConfirmed.value = false;
} catch (error) {
Notify.create({
type: 'negative',
message: error instanceof Error ? error.message : 'Something went wrong.',
});
} finally {
busy.value = false;
}
};
</script>
<style scoped>
.word-chip {
background: rgba(85, 255, 204, 0.08);
border: 1px solid rgba(85, 255, 204, 0.25);
border-radius: 6px;
padding: 4px 8px;
font-size: 0.85rem;
}
</style>
@@ -0,0 +1,163 @@
<template>
<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="phrase"
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="relays" />
<div v-if="error" class="text-negative q-my-sm">{{ error }}</div>
<template v-if="!found">
<div v-if="looked" 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="busy"
:disable="!phraseValid || relays.length === 0"
@click="lookForBackup"
/>
</template>
<template v-else>
<div class="text-body2 text-grey-4 q-my-md">
Found a backup: {{ found.notes }} note(s), {{ found.mints }} mint(s)<template
v-if="found.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="password"
type="password"
dark
outlined
color="primary"
label="Password"
autocomplete="new-password"
class="q-mb-sm"
/>
<q-input
v-if="password !== ''"
v-model="confirmation"
type="password"
dark
outlined
color="primary"
label="Confirm password"
autocomplete="new-password"
class="q-mb-sm"
/>
<div
v-if="password !== '' && password.length < MIN_PASSWORD_LENGTH"
class="text-warning text-caption q-mb-sm"
>
At least {{ MIN_PASSWORD_LENGTH }} characters.
</div>
<div
v-if="confirmation !== '' && password !== confirmation"
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="busy"
:disable="!passwordValid(password, confirmation)"
@click="restoreBackup"
/>
</template>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { useRouter } from 'vue-router';
import { Notify } from 'quasar';
import { deriveWalletLinkingKey, isValidSeedPhrase } from '@/lnurlcash/keys';
import { backupPubkey, deriveBackupKey, fetchBackup } from '@/lnurlcash/nostrBackup';
import { useWalletStore } from '@/stores/wallet';
import { DEFAULT_NOSTR_RELAYS } from '@/stores/nostrBackup';
import RelaysEditor from '@/components/RelaysEditor.vue';
import { MIN_PASSWORD_LENGTH, passwordValid } from '@/composables/welcomePassword';
const wallet = useWalletStore();
const router = useRouter();
const phrase = ref('');
const relays = ref<string[]>([...DEFAULT_NOSTR_RELAYS]);
const password = ref('');
const confirmation = ref('');
const busy = ref(false);
const error = ref('');
const looked = ref(false);
const found = ref<{ notes: number; mints: number; settings: boolean } | null>(null);
const phraseValid = computed(() => isValidSeedPhrase(phrase.value));
const linkingKey = (): Uint8Array => deriveWalletLinkingKey(phrase.value.trim().toLowerCase());
const errorMessage = (caught: unknown): string =>
caught instanceof Error ? caught.message : 'Something went wrong.';
const lookForBackup = async (): Promise<void> => {
busy.value = true;
error.value = '';
found.value = null;
looked.value = false;
try {
const secretKey = deriveBackupKey(linkingKey());
const parts = await fetchBackup(backupPubkey(secretKey), relays.value, { secretKey });
looked.value = true;
if (parts.notes || parts.mints || parts.settings) {
found.value = {
notes: parts.notes?.length ?? 0,
mints: parts.mints?.length ?? 0,
settings: parts.settings !== undefined,
};
}
} catch (caught) {
error.value = errorMessage(caught);
} finally {
busy.value = false;
}
};
const restoreBackup = async (): Promise<void> => {
busy.value = true;
error.value = '';
try {
await wallet.restoreFromNostr(
phrase.value.trim().toLowerCase(),
relays.value,
password.value || undefined,
);
Notify.create({ type: 'positive', message: 'Backup restored - welcome back.' });
void router.push('/');
} catch (caught) {
error.value = errorMessage(caught);
Notify.create({ type: 'negative', message: error.value });
} finally {
busy.value = false;
}
};
</script>
@@ -0,0 +1,96 @@
<template>
<q-input
v-model="phrase"
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-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="password"
type="password"
dark
outlined
color="primary"
label="Password"
autocomplete="new-password"
class="q-mb-sm"
/>
<q-input
v-if="password !== ''"
v-model="confirmation"
type="password"
dark
outlined
color="primary"
label="Confirm password"
autocomplete="new-password"
class="q-mb-sm"
/>
<div
v-if="password !== '' && password.length < MIN_PASSWORD_LENGTH"
class="text-warning text-caption q-mb-sm"
>
At least {{ MIN_PASSWORD_LENGTH }} characters.
</div>
<div
v-if="confirmation !== '' && password !== confirmation"
class="text-warning text-caption q-mb-sm"
>
Passwords do not match.
</div>
<div v-if="restoreError" class="text-negative q-mt-sm">{{ restoreError }}</div>
<q-btn
unelevated
color="primary"
text-color="dark"
label="Restore wallet"
class="full-width q-mt-sm"
:loading="busy"
:disable="!phrase.trim() || !passwordValid(password, confirmation)"
@click="restoreWallet"
/>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { Notify } from 'quasar';
import { useWalletStore } from '@/stores/wallet';
import { MIN_PASSWORD_LENGTH, passwordValid } from '@/composables/welcomePassword';
const wallet = useWalletStore();
const router = useRouter();
const phrase = ref('');
const password = ref('');
const confirmation = ref('');
const busy = ref(false);
const restoreError = ref('');
const restoreWallet = async (): Promise<void> => {
busy.value = true;
restoreError.value = '';
try {
await wallet.restoreFromSeed(phrase.value.trim().toLowerCase(), password.value || undefined);
Notify.create({ type: 'positive', message: 'Wallet restored.' });
void router.push('/');
} catch (error) {
restoreError.value = error instanceof Error ? error.message : 'Something went wrong.';
Notify.create({ type: 'negative', message: restoreError.value });
} finally {
busy.value = false;
}
};
</script>