mirror of
https://github.com/tompro/sattle.git
synced 2026-08-27 07:15:59 +00:00
feat: wallet screens, branded header layout and mobile-first main view
This commit is contained in:
@@ -0,0 +1,10 @@
|
|||||||
|
<template>
|
||||||
|
<!-- minimal chrome-free layout for standalone pages (onboarding) -->
|
||||||
|
<q-layout view="lHh Lpr lFf">
|
||||||
|
<q-page-container>
|
||||||
|
<router-view />
|
||||||
|
</q-page-container>
|
||||||
|
</q-layout>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts"></script>
|
||||||
@@ -1,23 +1,74 @@
|
|||||||
<template>
|
<template>
|
||||||
<q-layout view="hHh lpR fFf">
|
<q-layout view="hHh lpR fFf">
|
||||||
<q-header class="bg-transparent">
|
<q-header class="app-header">
|
||||||
<q-toolbar>
|
<q-toolbar class="header-toolbar row items-center justify-center">
|
||||||
<q-toolbar-title class="text-weight-bold">sattle</q-toolbar-title>
|
<img :src="sattleWallet" alt="sattle wallet" class="brand-logo" />
|
||||||
|
<img :src="sattleText" alt="sattle" class="brand-wordmark" />
|
||||||
<q-btn
|
<q-btn
|
||||||
flat
|
flat
|
||||||
dense
|
dense
|
||||||
round
|
round
|
||||||
icon="settings"
|
color="primary"
|
||||||
|
icon="menu"
|
||||||
aria-label="Settings"
|
aria-label="Settings"
|
||||||
|
class="menu-btn"
|
||||||
@click="$router.push('/settings')"
|
@click="$router.push('/settings')"
|
||||||
/>
|
/>
|
||||||
</q-toolbar>
|
</q-toolbar>
|
||||||
</q-header>
|
</q-header>
|
||||||
|
|
||||||
<q-page-container>
|
<q-page-container>
|
||||||
<router-view />
|
<!-- mobile-first app: on wide screens keep the whole UI in a
|
||||||
|
centered phone-width column so the flex proportions and
|
||||||
|
420px-capped rows stay coherent -->
|
||||||
|
<div class="app-shell">
|
||||||
|
<router-view />
|
||||||
|
</div>
|
||||||
</q-page-container>
|
</q-page-container>
|
||||||
</q-layout>
|
</q-layout>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts"></script>
|
<script setup lang="ts">
|
||||||
|
import sattleWallet from '@/assets/sattle-wallet.png';
|
||||||
|
import sattleText from '@/assets/sattle-text.png';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.app-header {
|
||||||
|
// same fixed gradient as the body (see app.scss): fully opaque, but
|
||||||
|
// visually indistinguishable from the page background, so scrolled
|
||||||
|
// content slides underneath without a visible header bar or border
|
||||||
|
background: linear-gradient(160deg, #001616 0%, #002222 55%, #001a1a 100%);
|
||||||
|
background-attachment: fixed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-toolbar {
|
||||||
|
position: relative;
|
||||||
|
min-height: 56px;
|
||||||
|
padding-top: 6px;
|
||||||
|
padding-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-logo {
|
||||||
|
height: 36px;
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-wordmark {
|
||||||
|
height: 22px;
|
||||||
|
width: auto;
|
||||||
|
margin-left: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-btn {
|
||||||
|
position: absolute;
|
||||||
|
left: 8px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-shell {
|
||||||
|
max-width: 500px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
+254
-66
@@ -1,95 +1,283 @@
|
|||||||
<template>
|
<template>
|
||||||
<q-page class="column items-center q-pa-md">
|
<q-page class="column items-center q-pa-md">
|
||||||
<!-- balance hero: placeholder until M2 wires the note store -->
|
<!-- no wallet yet -->
|
||||||
<q-card class="sattle-card balance-card full-width q-mt-xl q-pa-lg text-center">
|
<q-card
|
||||||
<div class="text-h2 text-weight-bold text-primary">0</div>
|
v-if="wallet.state === 'none'"
|
||||||
<div class="text-subtitle1 text-grey-5">sats</div>
|
class="sattle-card full-width q-mt-xl q-pa-lg text-center welcome-card"
|
||||||
</q-card>
|
>
|
||||||
|
<div class="text-h5 text-primary q-mb-sm">Welcome to sattle</div>
|
||||||
<div class="col" />
|
<div class="text-grey-5 q-mb-md">
|
||||||
|
A simple wallet for Lightning bearer notes.
|
||||||
<!-- primary actions: visual only, protocol flows land in M2 -->
|
</div>
|
||||||
<div class="row full-width justify-center items-center q-gutter-md q-mb-xl">
|
|
||||||
<q-btn
|
<q-btn
|
||||||
unelevated
|
unelevated
|
||||||
color="secondary"
|
|
||||||
text-color="primary"
|
|
||||||
icon="call_received"
|
|
||||||
label="Receive"
|
|
||||||
class="action-btn"
|
|
||||||
@click="showReceive = true"
|
|
||||||
/>
|
|
||||||
<q-btn
|
|
||||||
fab
|
|
||||||
color="primary"
|
color="primary"
|
||||||
text-color="dark"
|
text-color="dark"
|
||||||
icon="qr_code_scanner"
|
label="Get started"
|
||||||
aria-label="Scan"
|
@click="router.push('/welcome')"
|
||||||
class="scan-btn"
|
|
||||||
@click="showScan = true"
|
|
||||||
/>
|
/>
|
||||||
<q-btn
|
</q-card>
|
||||||
unelevated
|
|
||||||
color="secondary"
|
|
||||||
text-color="primary"
|
|
||||||
icon="call_made"
|
|
||||||
label="Send"
|
|
||||||
class="action-btn"
|
|
||||||
@click="showSend = true"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<q-dialog v-model="showReceive">
|
<!-- locked -->
|
||||||
<q-card class="sattle-card q-pa-lg">
|
<q-card
|
||||||
<div class="text-h6">Receive</div>
|
v-else-if="wallet.state === 'locked'"
|
||||||
<div class="text-grey-5 q-mt-sm">Coming in milestone M2.</div>
|
class="sattle-card full-width q-mt-xl q-pa-lg welcome-card"
|
||||||
<q-card-actions align="right">
|
>
|
||||||
<q-btn v-close-popup flat label="Close" color="primary" />
|
<UnlockForm />
|
||||||
</q-card-actions>
|
</q-card>
|
||||||
</q-card>
|
|
||||||
</q-dialog>
|
|
||||||
|
|
||||||
<q-dialog v-model="showSend">
|
<!-- unlocked -->
|
||||||
<q-card class="sattle-card q-pa-lg">
|
<template v-else>
|
||||||
<div class="text-h6">Send</div>
|
<div class="unlocked-col column full-width">
|
||||||
<div class="text-grey-5 q-mt-sm">Coming in milestone M2.</div>
|
<!-- balance zone: top ~2/3, card (and warning) centered inside -->
|
||||||
<q-card-actions align="right">
|
<div class="balance-zone column items-center justify-center full-width">
|
||||||
<q-btn v-close-popup flat label="Close" color="primary" />
|
<q-card class="sattle-card balance-card q-pa-lg text-center">
|
||||||
</q-card-actions>
|
<div class="text-h2 text-weight-bold text-primary">
|
||||||
</q-card>
|
{{ formattedBalance }}
|
||||||
</q-dialog>
|
</div>
|
||||||
|
<div class="text-subtitle1 text-grey-5">sats</div>
|
||||||
|
</q-card>
|
||||||
|
|
||||||
<q-dialog v-model="showScan">
|
<div
|
||||||
<q-card class="sattle-card q-pa-lg">
|
v-if="wallet.lockWarningSecondsLeft !== null"
|
||||||
<div class="text-h6">Scan</div>
|
class="sattle-card q-pa-sm q-mt-md row items-center lock-warning"
|
||||||
<div class="text-grey-5 q-mt-sm">Coming in milestone M2.</div>
|
>
|
||||||
<q-card-actions align="right">
|
<q-icon name="lock_clock" color="warning" class="q-mx-sm" />
|
||||||
<q-btn v-close-popup flat label="Close" color="primary" />
|
<div class="col text-grey-4">
|
||||||
</q-card-actions>
|
Locking in {{ wallet.lockWarningSecondsLeft }}s
|
||||||
</q-card>
|
</div>
|
||||||
</q-dialog>
|
<q-btn
|
||||||
|
flat
|
||||||
|
dense
|
||||||
|
color="primary"
|
||||||
|
label="Stay unlocked"
|
||||||
|
@click="wallet.postponeLock()"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- action row at the ~2/3 line, same width as the balance card -->
|
||||||
|
<div class="action-row row items-center">
|
||||||
|
<q-btn
|
||||||
|
unelevated
|
||||||
|
no-wrap
|
||||||
|
color="secondary"
|
||||||
|
text-color="primary"
|
||||||
|
size="lg"
|
||||||
|
icon="call_received"
|
||||||
|
label="Receive"
|
||||||
|
class="action-btn"
|
||||||
|
@click="showReceive = true"
|
||||||
|
/>
|
||||||
|
<!-- equal spacers: the fab is centered in the gap between the two
|
||||||
|
(differently wide) labels, not just in the row -->
|
||||||
|
<div class="btn-gap" />
|
||||||
|
<q-btn
|
||||||
|
fab
|
||||||
|
color="primary"
|
||||||
|
text-color="dark"
|
||||||
|
size="lg"
|
||||||
|
icon="qr_code_scanner"
|
||||||
|
aria-label="Scan"
|
||||||
|
@click="showScan = true"
|
||||||
|
/>
|
||||||
|
<div class="btn-gap" />
|
||||||
|
<q-btn
|
||||||
|
unelevated
|
||||||
|
no-wrap
|
||||||
|
color="secondary"
|
||||||
|
text-color="primary"
|
||||||
|
size="lg"
|
||||||
|
icon="call_made"
|
||||||
|
label="Send"
|
||||||
|
class="action-btn"
|
||||||
|
@click="showSend = true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- history zone: remaining ~1/3, entries scroll internally -->
|
||||||
|
<div class="history-zone">
|
||||||
|
<q-expansion-item
|
||||||
|
dense
|
||||||
|
class="sattle-card history-expansion"
|
||||||
|
icon="history"
|
||||||
|
label="History"
|
||||||
|
header-class="text-primary text-body2"
|
||||||
|
>
|
||||||
|
<HistoryList />
|
||||||
|
</q-expansion-item>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ReceiveDialog v-model="showReceive" />
|
||||||
|
<SendDialog v-model="showSend" />
|
||||||
|
<ReceiveTokenDialog v-model="showReceiveToken" :initial-input="scanned" />
|
||||||
|
<PayInvoiceDialog v-model="showPayInvoice" :initial-input="scanned" />
|
||||||
|
|
||||||
|
<q-dialog v-model="showScan">
|
||||||
|
<q-card class="sattle-card q-pa-lg">
|
||||||
|
<div class="text-h6 text-primary q-mb-md">Scan</div>
|
||||||
|
<QrScanner @decode="onScanned" @error="onScanError" />
|
||||||
|
<q-card-actions align="right">
|
||||||
|
<q-btn v-close-popup flat label="Cancel" color="primary" />
|
||||||
|
</q-card-actions>
|
||||||
|
</q-card>
|
||||||
|
</q-dialog>
|
||||||
|
</template>
|
||||||
</q-page>
|
</q-page>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue';
|
import { computed, ref } from 'vue';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
import { useQuasar } from 'quasar';
|
||||||
|
import { isValidNoteInput } from 'lnurlcash-kit';
|
||||||
|
import { useWalletStore } from '@/stores/wallet';
|
||||||
|
import UnlockForm from '@/components/UnlockForm.vue';
|
||||||
|
import HistoryList from '@/components/HistoryList.vue';
|
||||||
|
import QrScanner from '@/components/QrScanner.vue';
|
||||||
|
import ReceiveDialog from '@/components/receive/ReceiveDialog.vue';
|
||||||
|
import ReceiveTokenDialog from '@/components/receive/ReceiveTokenDialog.vue';
|
||||||
|
import SendDialog from '@/components/send/SendDialog.vue';
|
||||||
|
import PayInvoiceDialog from '@/components/send/PayInvoiceDialog.vue';
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const $q = useQuasar();
|
||||||
|
const wallet = useWalletStore();
|
||||||
|
|
||||||
const showReceive = ref(false);
|
const showReceive = ref(false);
|
||||||
const showSend = ref(false);
|
const showSend = ref(false);
|
||||||
const showScan = ref(false);
|
const showScan = ref(false);
|
||||||
|
const showReceiveToken = ref(false);
|
||||||
|
const showPayInvoice = ref(false);
|
||||||
|
const scanned = ref('');
|
||||||
|
|
||||||
|
const formattedBalance = computed(() =>
|
||||||
|
wallet.balanceSats.toLocaleString(undefined, { maximumFractionDigits: 3 }),
|
||||||
|
);
|
||||||
|
|
||||||
|
// One scan button for everything: bearer notes go to receive, anything else
|
||||||
|
// (invoice, Lightning Address, LNURL) goes to pay.
|
||||||
|
const onScanned = (text: string) => {
|
||||||
|
const value = text.trim();
|
||||||
|
showScan.value = false;
|
||||||
|
if (!value) return;
|
||||||
|
scanned.value = value;
|
||||||
|
if (isValidNoteInput(value)) {
|
||||||
|
showReceiveToken.value = true;
|
||||||
|
} else {
|
||||||
|
showPayInvoice.value = true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onScanError = (message: string) => {
|
||||||
|
showScan.value = false;
|
||||||
|
$q.notify({ type: 'negative', message, position: 'top' });
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.balance-card {
|
// viewport-height flex column: the q-page keeps the layout-provided
|
||||||
|
// min-height (viewport minus header) and never grows past it, so the
|
||||||
|
// unlocked page itself does not scroll. The column below fills it and
|
||||||
|
// splits it into zones.
|
||||||
|
.unlocked-col {
|
||||||
|
flex: 1 1 0;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// top ~60%: balance card (plus the conditional lock warning directly
|
||||||
|
// under it) centered as a unit. The zone height is ratio-based, so the
|
||||||
|
// warning appearing never moves the action row below.
|
||||||
|
.balance-zone {
|
||||||
|
flex: 3 1 0;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.balance-card,
|
||||||
|
.welcome-card {
|
||||||
max-width: 420px;
|
max-width: 420px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.action-btn {
|
.balance-card {
|
||||||
min-width: 120px;
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lock-warning {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 420px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.scan-btn {
|
// sits at the ~60% line; same width as the balance card so the Receive /
|
||||||
margin: 0 8px;
|
// Send edges align with the card edges, scan fab centered between them
|
||||||
|
.action-row {
|
||||||
|
flex: none;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 420px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
// equal flex gaps so the scan fab sits exactly between Receive and Send
|
||||||
|
.btn-gap {
|
||||||
|
flex: 0 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Receive and Send share the remaining width equally, so the fab lands
|
||||||
|
// dead center both between the two buttons and on the card/screen axis.
|
||||||
|
// Content is shrunk a notch (padding/font/icon) so even the wider
|
||||||
|
// "Receive" label fits on one line at 360px.
|
||||||
|
.action-btn {
|
||||||
|
flex: 1 1 0;
|
||||||
|
min-width: 0;
|
||||||
|
border-radius: 16px;
|
||||||
|
padding-left: 12px;
|
||||||
|
padding-right: 12px;
|
||||||
|
|
||||||
|
:deep(.q-btn__content) {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.q-icon) {
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// remaining ~40%. min-height: 0 lets the zone shrink to its flex share
|
||||||
|
// instead of growing the page; the expansion content scrolls internally.
|
||||||
|
// The top margin keeps clear air between the action row and the header.
|
||||||
|
.history-zone {
|
||||||
|
flex: 2 1 0;
|
||||||
|
min-height: 0;
|
||||||
|
margin: 24px auto 0;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 420px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-expansion {
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
border-radius: 8px;
|
||||||
|
|
||||||
|
// quieter header: smaller label, tight padding, buttons stay the focus
|
||||||
|
:deep(.q-expansion-item__container .q-item) {
|
||||||
|
min-height: 36px;
|
||||||
|
padding: 4px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.q-expansion-item__container) {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.q-expansion-item__content) {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,6 +1,17 @@
|
|||||||
<template>
|
<template>
|
||||||
<q-page class="q-pa-md">
|
<q-page class="q-pa-md">
|
||||||
<div class="text-h5 text-weight-bold q-mb-md">Settings</div>
|
<div class="row items-center q-mb-md">
|
||||||
|
<q-btn
|
||||||
|
flat
|
||||||
|
dense
|
||||||
|
round
|
||||||
|
color="primary"
|
||||||
|
icon="arrow_back"
|
||||||
|
aria-label="Back"
|
||||||
|
@click="router.push('/')"
|
||||||
|
/>
|
||||||
|
<div class="text-h5 text-weight-bold text-primary q-ml-sm">Settings</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- group shells only - entries are placeholders until their milestone
|
<!-- group shells only - entries are placeholders until their milestone
|
||||||
lands (see project plan: M2 flows, M3 mints, M4 backup/security,
|
lands (see project plan: M2 flows, M3 mints, M4 backup/security,
|
||||||
@@ -23,6 +34,10 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
const groups: { label: string; items: string[] }[] = [
|
const groups: { label: string; items: string[] }[] = [
|
||||||
{ label: 'Wallet', items: ['Backup', 'Security'] },
|
{ label: 'Wallet', items: ['Backup', 'Security'] },
|
||||||
{ label: 'Connections', items: ['Nostr Wallet Connect', 'Nostr'] },
|
{ label: 'Connections', items: ['Nostr Wallet Connect', 'Nostr'] },
|
||||||
|
|||||||
+405
-19
@@ -1,34 +1,420 @@
|
|||||||
<template>
|
<template>
|
||||||
<q-page class="column items-center justify-center q-pa-md">
|
<q-page class="column items-center q-pa-md">
|
||||||
<div class="text-h3 text-weight-bold text-primary">sattle</div>
|
<div class="text-h3 text-weight-bold text-primary q-mt-lg">sattle</div>
|
||||||
<div class="text-subtitle1 text-grey-5 q-mt-sm q-mb-xl text-center">
|
<div class="text-subtitle1 text-grey-5 q-mt-sm q-mb-lg text-center">
|
||||||
A wallet for lnurlcash bearer notes.
|
A wallet for lnurlcash bearer notes.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- onboarding placeholder: create/restore flows land in M4 -->
|
<q-banner
|
||||||
<q-btn
|
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.
|
||||||
|
</q-banner>
|
||||||
|
|
||||||
|
<q-btn-toggle
|
||||||
|
v-model="tab"
|
||||||
|
spread
|
||||||
|
no-caps
|
||||||
unelevated
|
unelevated
|
||||||
|
toggle-color="primary"
|
||||||
|
toggle-text-color="dark"
|
||||||
color="secondary"
|
color="secondary"
|
||||||
text-color="primary"
|
text-color="primary"
|
||||||
label="Create wallet"
|
class="onboarding-panel q-mb-md"
|
||||||
class="full-width q-mb-sm onboarding-btn"
|
:options="[
|
||||||
disable
|
{ label: 'Create new', value: 'create' },
|
||||||
/>
|
{ label: 'Restore seed', value: 'restore' },
|
||||||
<q-btn
|
{ label: 'Backup file', value: 'backup' },
|
||||||
outline
|
]"
|
||||||
color="primary"
|
|
||||||
label="Restore wallet"
|
|
||||||
class="full-width onboarding-btn"
|
|
||||||
disable
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<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 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>
|
||||||
|
</q-card>
|
||||||
</q-page>
|
</q-page>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts"></script>
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
|
import { Notify } from 'quasar';
|
||||||
|
|
||||||
|
import { useWalletStore } from '@/stores/wallet';
|
||||||
|
import { applyBackup, MAX_BACKUP_FILE_BYTES } from '@/lnurlcash/storage';
|
||||||
|
import type { RestoreResult } from '@/lnurlcash/storage';
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
|
||||||
|
type Tab = 'create' | 'restore' | 'backup';
|
||||||
|
|
||||||
|
const wallet = useWalletStore();
|
||||||
|
const router = useRouter();
|
||||||
|
const route = useRoute();
|
||||||
|
|
||||||
|
const tab = ref<Tab>(
|
||||||
|
route.query.tab === 'restore' || route.query.tab === 'backup' ? route.query.tab : 'create',
|
||||||
|
);
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.onboarding-btn {
|
.onboarding-panel {
|
||||||
max-width: 320px;
|
width: 100%;
|
||||||
border-radius: 8px;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ const routes: RouteRecordRaw[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/welcome',
|
path: '/welcome',
|
||||||
component: () => import('@/pages/WelcomePage.vue'),
|
component: () => import('@/layouts/BlankLayout.vue'),
|
||||||
|
children: [{ path: '', component: () => import('@/pages/WelcomePage.vue') }],
|
||||||
},
|
},
|
||||||
|
|
||||||
// Always leave this as last one,
|
// Always leave this as last one,
|
||||||
|
|||||||
Reference in New Issue
Block a user