mirror of
https://github.com/tompro/sattle.git
synced 2026-08-26 23:05:58 +00:00
refactor: split wallet onboarding panels
This commit is contained in:
@@ -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>
|
||||
@@ -0,0 +1,4 @@
|
||||
export const MIN_PASSWORD_LENGTH = 8;
|
||||
|
||||
export const passwordValid = (password: string, confirmation: string): boolean =>
|
||||
password === '' || (password.length >= MIN_PASSWORD_LENGTH && password === confirmation);
|
||||
+16
-546
@@ -4,20 +4,15 @@
|
||||
<div class="text-subtitle1 text-grey-5 q-mt-sm q-mb-lg text-center">
|
||||
A wallet for lnurlcash bearer notes.
|
||||
</div>
|
||||
|
||||
<q-banner
|
||||
v-if="wallet.state !== 'none'"
|
||||
class="sattle-card text-warning q-mb-md onboarding-panel"
|
||||
rounded
|
||||
>
|
||||
<template #avatar>
|
||||
<q-icon name="warning" color="warning" />
|
||||
</template>
|
||||
A wallet already exists on this device. Setting up a new one replaces its key —
|
||||
notes belonging to the current wallet become unreadable until its own seed is
|
||||
restored again.
|
||||
<template #avatar><q-icon name="warning" color="warning" /></template>
|
||||
A wallet already exists on this device. Setting up a new one replaces its key — notes
|
||||
belonging to the current wallet become unreadable until its own seed is restored again.
|
||||
</q-banner>
|
||||
|
||||
<q-btn-toggle
|
||||
v-model="tab"
|
||||
spread
|
||||
@@ -35,564 +30,39 @@
|
||||
{ label: 'Nostr backup', value: 'nostr' },
|
||||
]"
|
||||
/>
|
||||
|
||||
<q-card class="sattle-card onboarding-panel q-pa-lg">
|
||||
<!-- create -->
|
||||
<div v-if="tab === 'create'">
|
||||
<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="createPassword"
|
||||
type="password"
|
||||
dark
|
||||
outlined
|
||||
color="primary"
|
||||
label="Password"
|
||||
autocomplete="new-password"
|
||||
class="q-mb-sm"
|
||||
/>
|
||||
<q-input
|
||||
v-if="createPassword !== ''"
|
||||
v-model="createPasswordConfirm"
|
||||
type="password"
|
||||
dark
|
||||
outlined
|
||||
color="primary"
|
||||
label="Confirm password"
|
||||
autocomplete="new-password"
|
||||
class="q-mb-sm"
|
||||
/>
|
||||
<div
|
||||
v-if="createPassword !== '' && createPassword.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="createPasswordConfirm !== '' && createPassword !== createPasswordConfirm"
|
||||
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(createPassword, createPasswordConfirm)"
|
||||
@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, i) in createdPhrase.split(' ')" :key="i" class="word-chip">
|
||||
<span class="text-grey-5 q-mr-xs">{{ i + 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="finishCreate"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- restore from seed -->
|
||||
<div v-else-if="tab === 'restore'">
|
||||
<q-input
|
||||
v-model="restorePhrase"
|
||||
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="restorePassword"
|
||||
type="password"
|
||||
dark
|
||||
outlined
|
||||
color="primary"
|
||||
label="Password"
|
||||
autocomplete="new-password"
|
||||
class="q-mb-sm"
|
||||
/>
|
||||
<q-input
|
||||
v-if="restorePassword !== ''"
|
||||
v-model="restorePasswordConfirm"
|
||||
type="password"
|
||||
dark
|
||||
outlined
|
||||
color="primary"
|
||||
label="Confirm password"
|
||||
autocomplete="new-password"
|
||||
class="q-mb-sm"
|
||||
/>
|
||||
<div
|
||||
v-if="restorePassword !== '' && restorePassword.length < MIN_PASSWORD_LENGTH"
|
||||
class="text-warning text-caption q-mb-sm"
|
||||
>
|
||||
At least {{ MIN_PASSWORD_LENGTH }} characters.
|
||||
</div>
|
||||
<div
|
||||
v-if="restorePasswordConfirm !== '' && restorePassword !== restorePasswordConfirm"
|
||||
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="
|
||||
!restorePhrase.trim() || !passwordValid(restorePassword, restorePasswordConfirm)
|
||||
"
|
||||
@click="restoreWallet"
|
||||
/>
|
||||
</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">
|
||||
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="backupSkipped" 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="backupKeyRestored">
|
||||
<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="proceedWithBackupKey"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div v-if="backupResult" class="text-positive q-mb-md">
|
||||
Backup restored: {{ backupResult.added }} note(s) added,
|
||||
{{ backupResult.skipped }} already present.
|
||||
<span v-if="!backupResult.linkingKeyRestored">
|
||||
The file carried no usable key of its own — restore its recovery phrase
|
||||
to unlock the notes.
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="backupError" class="text-negative q-mb-md">{{ backupError }}</div>
|
||||
<input
|
||||
ref="backupFileInput"
|
||||
type="file"
|
||||
accept="application/json,.json"
|
||||
class="hidden"
|
||||
@change="restoreFromBackupFile"
|
||||
/>
|
||||
<q-btn
|
||||
unelevated
|
||||
color="primary"
|
||||
text-color="dark"
|
||||
label="Choose backup file"
|
||||
icon="upload_file"
|
||||
class="full-width"
|
||||
:loading="backupBusy"
|
||||
@click="pickBackupFile"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
<KeepAlive>
|
||||
<WelcomeCreatePanel v-if="tab === 'create'" />
|
||||
<WelcomeSeedPanel v-else-if="tab === 'restore'" />
|
||||
<WelcomeNostrPanel v-else-if="tab === 'nostr'" />
|
||||
<WelcomeBackupPanel v-else />
|
||||
</KeepAlive>
|
||||
</q-card>
|
||||
</q-page>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
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 { ref } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
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
|
||||
// brute-force and every note the wallet holds - a one-character password is
|
||||
// no password at all
|
||||
const MIN_PASSWORD_LENGTH = 8;
|
||||
import WelcomeBackupPanel from '@/components/welcome/WelcomeBackupPanel.vue';
|
||||
import WelcomeCreatePanel from '@/components/welcome/WelcomeCreatePanel.vue';
|
||||
import WelcomeNostrPanel from '@/components/welcome/WelcomeNostrPanel.vue';
|
||||
import WelcomeSeedPanel from '@/components/welcome/WelcomeSeedPanel.vue';
|
||||
|
||||
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 === 'nostr'
|
||||
? route.query.tab
|
||||
: 'create',
|
||||
);
|
||||
|
||||
const errorMessage = (err: unknown): string =>
|
||||
err instanceof Error ? err.message : 'Something went wrong.';
|
||||
|
||||
// ---- optional password (create + restore-seed share the rules) ----
|
||||
const createPassword = ref('');
|
||||
const createPasswordConfirm = ref('');
|
||||
const restorePassword = ref('');
|
||||
const restorePasswordConfirm = ref('');
|
||||
|
||||
const passwordValid = (password: string, confirm: string): boolean => {
|
||||
if (password === '') return true; // optional - empty means unencrypted
|
||||
return password.length >= MIN_PASSWORD_LENGTH && password === confirm;
|
||||
};
|
||||
|
||||
// ---- create ----
|
||||
const busy = ref(false);
|
||||
const createdPhrase = ref<string | null>(null);
|
||||
const phraseConfirmed = ref(false);
|
||||
|
||||
const createWallet = async () => {
|
||||
busy.value = true;
|
||||
try {
|
||||
createdPhrase.value = await wallet.create(createPassword.value || undefined);
|
||||
phraseConfirmed.value = false;
|
||||
} catch (err) {
|
||||
Notify.create({ type: 'negative', message: errorMessage(err) });
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const finishCreate = () => {
|
||||
void router.push('/');
|
||||
};
|
||||
|
||||
// ---- restore from seed ----
|
||||
const restorePhrase = ref('');
|
||||
const restoreError = ref('');
|
||||
|
||||
const restoreWallet = async () => {
|
||||
busy.value = true;
|
||||
restoreError.value = '';
|
||||
try {
|
||||
await wallet.restoreFromSeed(
|
||||
restorePhrase.value.trim().toLowerCase(),
|
||||
restorePassword.value || undefined,
|
||||
);
|
||||
Notify.create({ type: 'positive', message: 'Wallet restored.' });
|
||||
void router.push('/');
|
||||
} catch (err) {
|
||||
restoreError.value = errorMessage(err);
|
||||
Notify.create({ type: 'negative', message: restoreError.value });
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// ---- restore from backup file ----
|
||||
const backupFileInput = ref<HTMLInputElement | null>(null);
|
||||
const backupBusy = ref(false);
|
||||
const backupError = ref('');
|
||||
const backupResult = ref<RestoreResult | null>(null);
|
||||
const backupSkipped = ref(false);
|
||||
const backupKeyRestored = ref(false);
|
||||
|
||||
const pickBackupFile = () => backupFileInput.value?.click();
|
||||
|
||||
const restoreFromBackupFile = async (event: Event) => {
|
||||
const input = event.currentTarget as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
input.value = '';
|
||||
if (!file) return;
|
||||
backupBusy.value = true;
|
||||
backupError.value = '';
|
||||
backupResult.value = null;
|
||||
backupSkipped.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 result = applyBackup(data);
|
||||
if (result.linkingKeyRestored) {
|
||||
// never activated automatically: whoever wrote the file necessarily had
|
||||
// the key (encrypted or not), so the restore pauses for an explicit
|
||||
// source-trust acknowledgment
|
||||
backupKeyRestored.value = true;
|
||||
return;
|
||||
}
|
||||
if (result.linkingKeySkipped) {
|
||||
backupSkipped.value = true;
|
||||
return;
|
||||
}
|
||||
backupResult.value = result;
|
||||
Notify.create({ type: 'positive', message: 'Backup restored.' });
|
||||
} catch (err) {
|
||||
backupError.value = errorMessage(err);
|
||||
Notify.create({ type: 'negative', message: backupError.value });
|
||||
} finally {
|
||||
backupBusy.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// ---- 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 {
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
}
|
||||
|
||||
.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>
|
||||
|
||||
Reference in New Issue
Block a user