fix(ui): seed QRs use the SeedQR standard so hardware wallets can scan them
All checks were successful
Demo images / Build & push demo images (push) Successful in 2m52s
All checks were successful
Demo images / Build & push demo images (push) Successful in 2m52s
Plain-text seed QRs didn't scan into Passport Prime — wallets that import seeds by QR (Passport, SeedSigner, Keystone, Nunchuk, Sparrow) expect the SeedQR standard: each BIP39 word as its zero-padded 4-digit wordlist index, concatenated into a numeric QR. - new utils/seedqr.ts encodes BIP39 words per the SeedSigner spec (@scure/bip39 wordlist; vector-checked abandon=0000, zoo=2047) - new shared SeedRevealPanel (Words/QR tabs, tap-to-reveal blur) now backs the LND reveal AND the Settings→Backup recovery-phrase reveal, so every current and future seed reveal behaves the same - onboarding seed + Settings reveal: QR defaults to SeedQR with a plain-text toggle - LND seed stays plain-text-only with an explicit note: aezeed is not BIP39 and only restores into LND-based wallets (Zeus/Blixt/another node) — SeedQR-encoding it would just mislead Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
c0d34bd836
commit
0880824fb3
35
neode-ui/package-lock.json
generated
35
neode-ui/package-lock.json
generated
@ -8,6 +8,7 @@
|
|||||||
"name": "neode-ui",
|
"name": "neode-ui",
|
||||||
"version": "1.7.113-alpha",
|
"version": "1.7.113-alpha",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@scure/bip39": "^2.2.0",
|
||||||
"@types/dompurify": "^3.0.5",
|
"@types/dompurify": "^3.0.5",
|
||||||
"@vue-leaflet/vue-leaflet": "^0.10.1",
|
"@vue-leaflet/vue-leaflet": "^0.10.1",
|
||||||
"buffer": "^6.0.3",
|
"buffer": "^6.0.3",
|
||||||
@ -2882,6 +2883,18 @@
|
|||||||
"url": "https://opencollective.com/js-sdsl"
|
"url": "https://opencollective.com/js-sdsl"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@noble/hashes": {
|
||||||
|
"version": "2.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz",
|
||||||
|
"integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 20.19.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://paulmillr.com/funding/"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@nodelib/fs.scandir": {
|
"node_modules/@nodelib/fs.scandir": {
|
||||||
"version": "2.1.5",
|
"version": "2.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
||||||
@ -3538,6 +3551,28 @@
|
|||||||
"win32"
|
"win32"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"node_modules/@scure/base": {
|
||||||
|
"version": "2.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@scure/base/-/base-2.2.0.tgz",
|
||||||
|
"integrity": "sha512-b8XEupJibegiXV+tDUseI8oLQc8ei3d/4Jkb2RpbHh3MfE054ov3uIz2dhFkB3FI8iwYkEh0gGCApkrYggkPNg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://paulmillr.com/funding/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@scure/bip39": {
|
||||||
|
"version": "2.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-2.2.0.tgz",
|
||||||
|
"integrity": "sha512-T/Bj/YvYMNkIPq6EENO6/rcs2e7qTNuyoUXf0KBFDmp0ZDu0H2X4Lq6yC3i0c8PcWkov5EbW+yQZZbdMmk154A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@noble/hashes": "2.2.0",
|
||||||
|
"@scure/base": "2.2.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://paulmillr.com/funding/"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@trickfilm400/rollup-plugin-off-main-thread": {
|
"node_modules/@trickfilm400/rollup-plugin-off-main-thread": {
|
||||||
"version": "3.0.0-pre1",
|
"version": "3.0.0-pre1",
|
||||||
"resolved": "https://registry.npmjs.org/@trickfilm400/rollup-plugin-off-main-thread/-/rollup-plugin-off-main-thread-3.0.0-pre1.tgz",
|
"resolved": "https://registry.npmjs.org/@trickfilm400/rollup-plugin-off-main-thread/-/rollup-plugin-off-main-thread-3.0.0-pre1.tgz",
|
||||||
|
|||||||
@ -24,6 +24,7 @@
|
|||||||
"generate-welcome-speech": "node scripts/generate-welcome-speech.js"
|
"generate-welcome-speech": "node scripts/generate-welcome-speech.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@scure/bip39": "^2.2.0",
|
||||||
"@types/dompurify": "^3.0.5",
|
"@types/dompurify": "^3.0.5",
|
||||||
"@vue-leaflet/vue-leaflet": "^0.10.1",
|
"@vue-leaflet/vue-leaflet": "^0.10.1",
|
||||||
"buffer": "^6.0.3",
|
"buffer": "^6.0.3",
|
||||||
|
|||||||
124
neode-ui/src/components/SeedRevealPanel.vue
Normal file
124
neode-ui/src/components/SeedRevealPanel.vue
Normal file
@ -0,0 +1,124 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="flex gap-1 mb-3 p-1 bg-white/5 rounded-lg">
|
||||||
|
<button
|
||||||
|
v-for="tab in ([{ key: 'words', label: 'Words' }, { key: 'qr', label: 'QR code' }] as const)"
|
||||||
|
:key="tab.key"
|
||||||
|
type="button"
|
||||||
|
@click="seedTab = tab.key"
|
||||||
|
class="flex-1 px-2 py-1.5 rounded text-xs font-medium transition-colors"
|
||||||
|
:class="seedTab === tab.key ? 'bg-white/15 text-white' : 'text-white/50 hover:text-white/80'"
|
||||||
|
>{{ tab.label }}</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-if="seedTab === 'words'">
|
||||||
|
<p class="text-sm text-white/60 mb-3">Write these down and store them offline. Tap to {{ hidden ? 'reveal' : 'hide' }}.</p>
|
||||||
|
<div class="relative">
|
||||||
|
<div
|
||||||
|
class="grid grid-cols-2 sm:grid-cols-3 gap-2 p-3 bg-white/5 rounded-lg transition-all select-text"
|
||||||
|
:class="hidden ? 'blur-md' : ''"
|
||||||
|
@click="hidden = !hidden"
|
||||||
|
>
|
||||||
|
<div v-for="(w, i) in words" :key="i" class="flex items-center gap-1.5 text-sm">
|
||||||
|
<span class="text-white/30 text-xs w-5 text-right">{{ i + 1 }}.</span>
|
||||||
|
<span class="text-white font-mono">{{ w }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button v-if="hidden" type="button" class="absolute inset-0 flex items-center justify-center text-xs text-white/70 font-medium" @click="hidden = false">Tap to reveal</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<p class="text-sm text-white/60 mb-3">
|
||||||
|
{{ aezeed ? 'Scan to copy the words into another device.' : 'Scan into a wallet that imports seeds by QR.' }}
|
||||||
|
Tap to {{ hidden ? 'reveal' : 'hide' }}.
|
||||||
|
</p>
|
||||||
|
<div class="relative">
|
||||||
|
<div
|
||||||
|
class="flex justify-center p-3 bg-white/5 rounded-lg transition-all"
|
||||||
|
:class="hidden ? 'blur-md' : ''"
|
||||||
|
@click="hidden = !hidden"
|
||||||
|
>
|
||||||
|
<canvas ref="qrCanvas" class="rounded-lg bg-white p-2"></canvas>
|
||||||
|
</div>
|
||||||
|
<button v-if="hidden" type="button" class="absolute inset-0 flex items-center justify-center text-xs text-white/70 font-medium" @click="hidden = false">Tap to reveal</button>
|
||||||
|
</div>
|
||||||
|
<div v-if="!aezeed && seedQrAvailable" class="flex justify-center mt-2">
|
||||||
|
<div class="flex p-0.5 bg-white/5 rounded-md">
|
||||||
|
<button
|
||||||
|
v-for="f in ([{ key: 'seedqr', label: 'SeedQR' }, { key: 'text', label: 'Plain text' }] as const)"
|
||||||
|
:key="f.key"
|
||||||
|
type="button"
|
||||||
|
@click="qrFormat = f.key"
|
||||||
|
class="px-2.5 py-1 rounded text-[11px] font-medium transition-colors"
|
||||||
|
:class="qrFormat === f.key ? 'bg-white/15 text-white' : 'text-white/50 hover:text-white/80'"
|
||||||
|
>{{ f.label }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-white/40 mt-2">
|
||||||
|
<template v-if="aezeed">
|
||||||
|
The code contains your seed words as plain text — treat it exactly like the words
|
||||||
|
themselves. Note: this is an LND <span class="font-mono">aezeed</span>, not a BIP39
|
||||||
|
phrase — it restores into LND-based wallets (Zeus, Blixt, another Archipelago node),
|
||||||
|
not into hardware wallets like Passport.
|
||||||
|
</template>
|
||||||
|
<template v-else-if="qrFormat === 'seedqr'">
|
||||||
|
SeedQR — scans into Passport, SeedSigner, Keystone and other wallets that import
|
||||||
|
seeds by QR. Treat this code exactly like the words themselves.
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
Plain text words — for wallets that read the phrase as text. Treat this code exactly
|
||||||
|
like the words themselves.
|
||||||
|
</template>
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch, nextTick } from 'vue'
|
||||||
|
|
||||||
|
// Shared seed reveal body: Words / QR code tabs behind a tap-to-reveal blur.
|
||||||
|
// Words are always the first view. For BIP39 seeds the QR defaults to the
|
||||||
|
// SeedQR standard (4-digit wordlist indices — what Passport Prime, SeedSigner,
|
||||||
|
// Keystone etc. import), with a plain-text option. `aezeed` seeds (LND) are
|
||||||
|
// NOT BIP39 and no hardware wallet can import them, so they only ever get the
|
||||||
|
// plain-text QR plus an explanation — SeedQR-encoding one would be dishonest.
|
||||||
|
const props = defineProps<{ words: string[]; aezeed?: boolean }>()
|
||||||
|
|
||||||
|
const seedTab = ref<'words' | 'qr'>('words')
|
||||||
|
const qrFormat = ref<'seedqr' | 'text'>(props.aezeed ? 'text' : 'seedqr')
|
||||||
|
const seedQrAvailable = ref(!props.aezeed)
|
||||||
|
const hidden = ref(true)
|
||||||
|
const qrCanvas = ref<HTMLCanvasElement | null>(null)
|
||||||
|
|
||||||
|
async function renderQr() {
|
||||||
|
await nextTick()
|
||||||
|
if (!qrCanvas.value || props.words.length === 0) return
|
||||||
|
try {
|
||||||
|
let payload = props.words.join(' ')
|
||||||
|
if (!props.aezeed && qrFormat.value === 'seedqr') {
|
||||||
|
const { toSeedQrDigits } = await import('@/utils/seedqr')
|
||||||
|
const digits = await toSeedQrDigits(props.words)
|
||||||
|
if (digits) {
|
||||||
|
payload = digits
|
||||||
|
} else {
|
||||||
|
// Not a BIP39 phrase after all — only plain text is honest.
|
||||||
|
seedQrAvailable.value = false
|
||||||
|
qrFormat.value = 'text'
|
||||||
|
return // the qrFormat watcher re-renders as text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const QRCode = await import('qrcode')
|
||||||
|
await QRCode.toCanvas(qrCanvas.value, payload, { width: 260, margin: 1 })
|
||||||
|
} catch { /* QR is a convenience — the words remain authoritative */ }
|
||||||
|
}
|
||||||
|
watch(seedTab, (t) => { if (t === 'qr') void renderQr() })
|
||||||
|
watch(qrFormat, () => { if (seedTab.value === 'qr') void renderQr() })
|
||||||
|
watch(() => props.words, () => {
|
||||||
|
seedTab.value = 'words' // fresh reveal always shows words first
|
||||||
|
hidden.value = true
|
||||||
|
seedQrAvailable.value = !props.aezeed
|
||||||
|
qrFormat.value = props.aezeed ? 'text' : 'seedqr'
|
||||||
|
})
|
||||||
|
</script>
|
||||||
21
neode-ui/src/utils/seedqr.ts
Normal file
21
neode-ui/src/utils/seedqr.ts
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
/**
|
||||||
|
* SeedQR encoding (SeedSigner standard, supported by Passport/Passport Prime,
|
||||||
|
* SeedSigner, Keystone, Nunchuk, Sparrow, …): each BIP39 word becomes its
|
||||||
|
* zero-padded 4-digit wordlist index (0000–2047), concatenated into one
|
||||||
|
* digit stream and rendered as a numeric-mode QR. A 24-word seed is 96
|
||||||
|
* digits. Spec: github.com/SeedSigner/seedsigner/blob/main/docs/seed_qr
|
||||||
|
*
|
||||||
|
* Only valid for real BIP39 mnemonics — LND's aezeed shares the wordlist but
|
||||||
|
* is NOT BIP39, and hardware wallets cannot import it; never SeedQR-encode it.
|
||||||
|
*/
|
||||||
|
export async function toSeedQrDigits(words: string[]): Promise<string | null> {
|
||||||
|
if (words.length === 0) return null
|
||||||
|
const { wordlist } = await import('@scure/bip39/wordlists/english.js')
|
||||||
|
const digits: string[] = []
|
||||||
|
for (const raw of words) {
|
||||||
|
const idx = wordlist.indexOf(raw.trim().toLowerCase())
|
||||||
|
if (idx < 0) return null // not a BIP39 word — caller falls back to text
|
||||||
|
digits.push(idx.toString().padStart(4, '0'))
|
||||||
|
}
|
||||||
|
return digits.join('')
|
||||||
|
}
|
||||||
@ -69,9 +69,21 @@
|
|||||||
|
|
||||||
<div v-else class="flex flex-col items-center gap-2 py-2">
|
<div v-else class="flex flex-col items-center gap-2 py-2">
|
||||||
<canvas ref="seedQrCanvas" class="rounded-lg bg-white p-2"></canvas>
|
<canvas ref="seedQrCanvas" class="rounded-lg bg-white p-2"></canvas>
|
||||||
|
<div class="flex p-0.5 bg-white/5 rounded-md">
|
||||||
|
<button
|
||||||
|
v-for="f in ([{ key: 'seedqr', label: 'SeedQR' }, { key: 'text', label: 'Plain text' }] as const)"
|
||||||
|
:key="f.key"
|
||||||
|
type="button"
|
||||||
|
@click="qrFormat = f.key"
|
||||||
|
class="px-2.5 py-1 rounded text-[11px] font-medium transition-colors"
|
||||||
|
:class="qrFormat === f.key ? 'bg-white/15 text-white' : 'text-white/50 hover:text-white/80'"
|
||||||
|
>{{ f.label }}</button>
|
||||||
|
</div>
|
||||||
<p class="text-xs text-white/50 text-center max-w-[420px]">
|
<p class="text-xs text-white/50 text-center max-w-[420px]">
|
||||||
The code contains your seed words — scan only into a wallet you trust,
|
{{ qrFormat === 'seedqr'
|
||||||
and treat it exactly like the words themselves.
|
? 'SeedQR — scans into Passport, SeedSigner, Keystone and other wallets that import seeds by QR.'
|
||||||
|
: 'Plain text words — for wallets that read the phrase as text.' }}
|
||||||
|
Treat this code exactly like the words themselves.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -121,17 +133,30 @@ const continueButton = ref<HTMLButtonElement | null>(null)
|
|||||||
const words = ref<string[]>([])
|
const words = ref<string[]>([])
|
||||||
|
|
||||||
// Words / QR code view of the seed — words are the default first view.
|
// Words / QR code view of the seed — words are the default first view.
|
||||||
|
// The QR tab defaults to SeedQR (BIP39 word-index digit stream), the format
|
||||||
|
// hardware wallets like Passport Prime / SeedSigner actually import; plain
|
||||||
|
// text stays available for wallets that read the phrase as text.
|
||||||
const seedTab = ref<'words' | 'qr'>('words')
|
const seedTab = ref<'words' | 'qr'>('words')
|
||||||
|
const qrFormat = ref<'seedqr' | 'text'>('seedqr')
|
||||||
const seedQrCanvas = ref<HTMLCanvasElement | null>(null)
|
const seedQrCanvas = ref<HTMLCanvasElement | null>(null)
|
||||||
watch(seedTab, async (tab) => {
|
|
||||||
if (tab !== 'qr') return
|
async function renderSeedQr() {
|
||||||
await nextTick()
|
await nextTick()
|
||||||
if (!seedQrCanvas.value || words.value.length === 0) return
|
if (!seedQrCanvas.value || words.value.length === 0) return
|
||||||
try {
|
try {
|
||||||
|
let payload = words.value.join(' ')
|
||||||
|
if (qrFormat.value === 'seedqr') {
|
||||||
|
const { toSeedQrDigits } = await import('@/utils/seedqr')
|
||||||
|
const digits = await toSeedQrDigits(words.value)
|
||||||
|
if (digits) payload = digits
|
||||||
|
else qrFormat.value = 'text' // non-BIP39 word — only text is honest
|
||||||
|
}
|
||||||
const QRCode = await import('qrcode')
|
const QRCode = await import('qrcode')
|
||||||
await QRCode.toCanvas(seedQrCanvas.value, words.value.join(' '), { width: 240, margin: 1 })
|
await QRCode.toCanvas(seedQrCanvas.value, payload, { width: 240, margin: 1 })
|
||||||
} catch { /* QR is a convenience — the words remain authoritative */ }
|
} catch { /* QR is a convenience — the words remain authoritative */ }
|
||||||
})
|
}
|
||||||
|
watch(seedTab, (tab) => { if (tab === 'qr') void renderSeedQr() })
|
||||||
|
watch(qrFormat, () => { if (seedTab.value === 'qr') void renderSeedQr() })
|
||||||
const confirmed = ref(false)
|
const confirmed = ref(false)
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const waitingForServer = ref(false)
|
const waitingForServer = ref(false)
|
||||||
|
|||||||
@ -1,7 +1,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, watch, nextTick, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { rpcClient } from '@/api/rpc-client'
|
import { rpcClient } from '@/api/rpc-client'
|
||||||
|
import SeedRevealPanel from '@/components/SeedRevealPanel.vue'
|
||||||
|
|
||||||
// Lightning seed (aezeed) backup card. Shown on the LND app detail page.
|
// Lightning seed (aezeed) backup card. Shown on the LND app detail page.
|
||||||
// The backend captures the aezeed at wallet-init time; until the user
|
// The backend captures the aezeed at wallet-init time; until the user
|
||||||
@ -65,25 +66,9 @@ function openReveal() {
|
|||||||
revealError.value = ''
|
revealError.value = ''
|
||||||
revealedWords.value = []
|
revealedWords.value = []
|
||||||
wordsHidden.value = true
|
wordsHidden.value = true
|
||||||
seedTab.value = 'words' // words first — QR is the opt-in second view
|
|
||||||
showRevealModal.value = true
|
showRevealModal.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Words / QR code tabs (wallet-settings segmented style). The QR encodes the
|
|
||||||
// space-joined seed words for wallets that support seed import by scan.
|
|
||||||
const seedTab = ref<'words' | 'qr'>('words')
|
|
||||||
const seedQrCanvas = ref<HTMLCanvasElement | null>(null)
|
|
||||||
|
|
||||||
async function renderSeedQr() {
|
|
||||||
await nextTick()
|
|
||||||
if (!seedQrCanvas.value || revealedWords.value.length === 0) return
|
|
||||||
try {
|
|
||||||
const QRCode = await import('qrcode')
|
|
||||||
await QRCode.toCanvas(seedQrCanvas.value, revealedWords.value.join(' '), { width: 260, margin: 1 })
|
|
||||||
} catch { /* QR is a convenience — the words remain authoritative */ }
|
|
||||||
}
|
|
||||||
watch(seedTab, (t) => { if (t === 'qr') void renderSeedQr() })
|
|
||||||
|
|
||||||
async function submitReveal() {
|
async function submitReveal() {
|
||||||
if (revealing.value || !revealPassword.value) return
|
if (revealing.value || !revealPassword.value) return
|
||||||
revealing.value = true
|
revealing.value = true
|
||||||
@ -182,48 +167,7 @@ async function confirmBackedUp() {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<div class="flex gap-1 mb-3 p-1 bg-white/5 rounded-lg">
|
<SeedRevealPanel :words="revealedWords" aezeed />
|
||||||
<button
|
|
||||||
v-for="tab in ([{ key: 'words', label: 'Words' }, { key: 'qr', label: 'QR code' }] as const)"
|
|
||||||
:key="tab.key"
|
|
||||||
type="button"
|
|
||||||
@click="seedTab = tab.key"
|
|
||||||
class="flex-1 px-2 py-1.5 rounded text-xs font-medium transition-colors"
|
|
||||||
:class="seedTab === tab.key ? 'bg-white/15 text-white' : 'text-white/50 hover:text-white/80'"
|
|
||||||
>{{ tab.label }}</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<template v-if="seedTab === 'words'">
|
|
||||||
<p class="text-sm text-white/60 mb-3">Write these down and store them offline. Tap to {{ wordsHidden ? 'reveal' : 'hide' }}.</p>
|
|
||||||
<div class="relative">
|
|
||||||
<div
|
|
||||||
class="grid grid-cols-2 sm:grid-cols-3 gap-2 p-3 bg-white/5 rounded-lg transition-all select-text"
|
|
||||||
:class="wordsHidden ? 'blur-md' : ''"
|
|
||||||
@click="wordsHidden = !wordsHidden"
|
|
||||||
>
|
|
||||||
<div v-for="(w, i) in revealedWords" :key="i" class="flex items-center gap-1.5 text-sm">
|
|
||||||
<span class="text-white/30 text-xs w-5 text-right">{{ i + 1 }}.</span>
|
|
||||||
<span class="text-white font-mono">{{ w }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button v-if="wordsHidden" type="button" class="absolute inset-0 flex items-center justify-center text-xs text-white/70 font-medium" @click="wordsHidden = false">Tap to reveal</button>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template v-else>
|
|
||||||
<p class="text-sm text-white/60 mb-3">Scan with a wallet that supports seed import by QR. Tap to {{ wordsHidden ? 'reveal' : 'hide' }}.</p>
|
|
||||||
<div class="relative">
|
|
||||||
<div
|
|
||||||
class="flex justify-center p-3 bg-white/5 rounded-lg transition-all"
|
|
||||||
:class="wordsHidden ? 'blur-md' : ''"
|
|
||||||
@click="wordsHidden = !wordsHidden"
|
|
||||||
>
|
|
||||||
<canvas ref="seedQrCanvas" class="rounded-lg bg-white p-2"></canvas>
|
|
||||||
</div>
|
|
||||||
<button v-if="wordsHidden" type="button" class="absolute inset-0 flex items-center justify-center text-xs text-white/70 font-medium" @click="wordsHidden = false">Tap to reveal</button>
|
|
||||||
</div>
|
|
||||||
<p class="text-xs text-white/40 mt-2">The code contains your seed words — treat it exactly like the words themselves.</p>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<div class="flex gap-2 pt-4">
|
<div class="flex gap-2 pt-4">
|
||||||
<button type="button" @click="copyRevealedWords" class="flex-1 glass-button rounded-lg px-4 py-2 text-sm font-medium">{{ wordsCopied ? 'Copied!' : 'Copy' }}</button>
|
<button type="button" @click="copyRevealedWords" class="flex-1 glass-button rounded-lg px-4 py-2 text-sm font-medium">{{ wordsCopied ? 'Copied!' : 'Copy' }}</button>
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { rpcClient } from '@/api/rpc-client'
|
import { rpcClient } from '@/api/rpc-client'
|
||||||
|
import SeedRevealPanel from '@/components/SeedRevealPanel.vue'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
@ -329,20 +330,7 @@ defineExpose({ loadBackups })
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<p class="text-sm text-white/60 mb-3">Write these down and store them offline. Tap to {{ wordsHidden ? 'reveal' : 'hide' }}.</p>
|
<SeedRevealPanel :words="revealedWords" />
|
||||||
<div class="relative">
|
|
||||||
<div
|
|
||||||
class="grid grid-cols-2 sm:grid-cols-3 gap-2 p-3 bg-white/5 rounded-lg transition-all select-text"
|
|
||||||
:class="wordsHidden ? 'blur-md' : ''"
|
|
||||||
@click="wordsHidden = !wordsHidden"
|
|
||||||
>
|
|
||||||
<div v-for="(w, i) in revealedWords" :key="i" class="flex items-center gap-1.5 text-sm">
|
|
||||||
<span class="text-white/30 text-xs w-5 text-right">{{ i + 1 }}.</span>
|
|
||||||
<span class="text-white font-mono">{{ w }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button v-if="wordsHidden" type="button" class="absolute inset-0 flex items-center justify-center text-xs text-white/70 font-medium" @click="wordsHidden = false">Tap to reveal</button>
|
|
||||||
</div>
|
|
||||||
<div class="flex gap-2 pt-4">
|
<div class="flex gap-2 pt-4">
|
||||||
<button type="button" @click="copyRevealedWords" class="flex-1 glass-button rounded-lg px-4 py-2 text-sm font-medium">{{ wordsCopied ? 'Copied!' : 'Copy' }}</button>
|
<button type="button" @click="copyRevealedWords" class="flex-1 glass-button rounded-lg px-4 py-2 text-sm font-medium">{{ wordsCopied ? 'Copied!' : 'Copy' }}</button>
|
||||||
<button type="button" @click="closeReveal" class="flex-1 glass-button rounded-lg px-4 py-2 text-sm font-medium bg-orange-500/20 border-orange-400/30">Done</button>
|
<button type="button" @click="closeReveal" class="flex-1 glass-button rounded-lg px-4 py-2 text-sm font-medium bg-orange-500/20 border-orange-400/30">Done</button>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user