feat: BIP-39 master seed for unified key derivation
Replace fragmented random key generation with a single 24-word BIP-39 mnemonic that deterministically derives all node keys: Ed25519 (DID), secp256k1 (Nostr/Bitcoin), BIP-84 xprv (Bitcoin Core), and LND aezeed entropy. New onboarding flow: seed generate → word verification → identity naming. Restore path enabled via 24-word entry. Includes seed RPC handlers, mock backend support, LND/Bitcoin Core wallet-from-seed integration, and UI polish across settings and discover views. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -225,6 +225,43 @@ class RPCClient {
|
||||
})
|
||||
}
|
||||
|
||||
// ─── Seed Management ───────────────────────────────────────────────
|
||||
|
||||
async generateSeed(): Promise<{ words: string[] }> {
|
||||
return this.call({ method: 'seed.generate' })
|
||||
}
|
||||
|
||||
async verifySeed(words: string[], indices: number[]): Promise<{
|
||||
verified: boolean
|
||||
did: string
|
||||
nostr_npub: string
|
||||
}> {
|
||||
return this.call({ method: 'seed.verify', params: { words, indices } })
|
||||
}
|
||||
|
||||
async restoreSeed(words: string[]): Promise<{
|
||||
did: string
|
||||
nostr_npub: string
|
||||
restored: boolean
|
||||
}> {
|
||||
return this.call({ method: 'seed.restore', params: { words } })
|
||||
}
|
||||
|
||||
async saveSeedEncrypted(passphrase: string): Promise<{ saved: boolean }> {
|
||||
return this.call({ method: 'seed.save-encrypted', params: { passphrase } })
|
||||
}
|
||||
|
||||
async seedStatus(): Promise<{
|
||||
has_seed: boolean
|
||||
is_legacy: boolean
|
||||
identity_count: number
|
||||
next_index: number
|
||||
}> {
|
||||
return this.call({ method: 'seed.status' })
|
||||
}
|
||||
|
||||
// ─── Node Identity ───────────────────────────────────────────────
|
||||
|
||||
async getNodeDid(): Promise<{ did: string; pubkey: string }> {
|
||||
return this.call({
|
||||
method: 'node.did',
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
type="button"
|
||||
role="switch"
|
||||
:aria-checked="modelValue"
|
||||
tabindex="-1"
|
||||
data-controller-ignore
|
||||
class="w-10 h-6 rounded-full shrink-0 transition-colors relative"
|
||||
:class="modelValue ? 'bg-orange-500' : 'bg-white/15'"
|
||||
@click="$emit('update:modelValue', !modelValue)"
|
||||
|
||||
@@ -84,9 +84,13 @@ function getNavBarItems(): HTMLElement[] {
|
||||
|
||||
function isNavBarItem(el: HTMLElement | null): boolean {
|
||||
if (!el) return false
|
||||
return isInZone(el, 'main') &&
|
||||
!el.hasAttribute('data-controller-container') &&
|
||||
!el.closest('[data-controller-container]')
|
||||
if (!isInZone(el, 'main')) return false
|
||||
if (el.hasAttribute('data-controller-container') || el.closest('[data-controller-container]')) return false
|
||||
// On container-free pages (e.g. Settings), don't classify elements as nav bar items —
|
||||
// let them fall through to the main zone handler which supports linear up/down/right nav.
|
||||
const zone = document.querySelector('[data-controller-zone="main"]')
|
||||
if (zone && !zone.querySelector('[data-controller-container]')) return false
|
||||
return true
|
||||
}
|
||||
|
||||
/** Inner focusables within a container (buttons, links — not the container itself) */
|
||||
@@ -425,6 +429,13 @@ export function useControllerNav(containerRef?: { value: HTMLElement | null }) {
|
||||
if (dest) {
|
||||
focusEl(dest)
|
||||
} else {
|
||||
// Check if this is a container-free page (e.g. Settings) — focus first button immediately
|
||||
const zone = document.querySelector('[data-controller-zone="main"]') as HTMLElement | null
|
||||
const hasAnyContainers = zone?.querySelector('[data-controller-container]')
|
||||
if (!hasAnyContainers && zone) {
|
||||
const focusable = getFocusableElements(zone)
|
||||
if (focusable[0]) { focusEl(focusable[0]); return }
|
||||
}
|
||||
// Containers not rendered yet (route transition / animation in progress)
|
||||
// Poll until they appear, up to 1s
|
||||
let attempts = 0
|
||||
@@ -436,9 +447,8 @@ export function useControllerNav(containerRef?: { value: HTMLElement | null }) {
|
||||
focusEl(retryContainers[0])
|
||||
} else if (attempts >= 10) {
|
||||
clearInterval(poll)
|
||||
// No containers on this page (e.g. Settings) — focus first focusable element
|
||||
const z = document.querySelector('[data-controller-zone="main"]') as HTMLElement | null
|
||||
if (z) { const f = getFocusableElements(z); if (f[0]) focusEl(f[0]) }
|
||||
// Last resort: focus first focusable element
|
||||
if (zone) { const f = getFocusableElements(zone); if (f[0]) focusEl(f[0]) }
|
||||
}
|
||||
}, 100)
|
||||
}
|
||||
@@ -477,31 +487,20 @@ export function useControllerNav(containerRef?: { value: HTMLElement | null }) {
|
||||
return
|
||||
}
|
||||
|
||||
if (dir === 'down') {
|
||||
// Down from nav bar → jump to containers (remember tab for Up return)
|
||||
rememberFocus('navBar', activeEl)
|
||||
const containers = getContainers()
|
||||
const nearest = findNearestInDirection(activeEl, containers, 'down')
|
||||
if (nearest) { rememberFocus('main', nearest); focusEl(nearest); return }
|
||||
// Fallback: just focus first container
|
||||
if (containers[0]) { rememberFocus('main', containers[0]); focusEl(containers[0]); return }
|
||||
// Containers not rendered yet — poll until they appear
|
||||
let attempts = 0
|
||||
const poll = setInterval(() => {
|
||||
attempts++
|
||||
const retryContainers = getContainers()
|
||||
if (retryContainers[0]) {
|
||||
clearInterval(poll)
|
||||
rememberFocus('main', retryContainers[0])
|
||||
focusEl(retryContainers[0])
|
||||
} else if (attempts >= 10) {
|
||||
clearInterval(poll)
|
||||
}
|
||||
}, 100)
|
||||
return
|
||||
if (dir === 'down' || dir === 'up') {
|
||||
// Up/Down from standalone element → find nearest focusable (container or button) in direction.
|
||||
// Searches containers + standalone elements together so mixed pages (Settings) don't jump.
|
||||
if (dir === 'down') rememberFocus('navBar', activeEl)
|
||||
const zone = document.querySelector('[data-controller-zone="main"]') as HTMLElement | null
|
||||
if (zone) {
|
||||
const allFocusable = getFocusableElements(zone).filter(el =>
|
||||
el.hasAttribute('data-controller-container') ||
|
||||
!el.closest('[data-controller-container]')
|
||||
)
|
||||
const target = findNearestInDirection(activeEl, allFocusable, dir)
|
||||
if (target) { focusEl(target); return }
|
||||
}
|
||||
}
|
||||
|
||||
// Up from nav bar → nothing (use Escape to go to sidebar)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -509,8 +508,14 @@ export function useControllerNav(containerRef?: { value: HTMLElement | null }) {
|
||||
if (isInZone(activeEl, 'main')) {
|
||||
const containers = getContainers()
|
||||
|
||||
// Try spatial nav to another container
|
||||
const next = findNearestInDirection(activeEl, containers, dir)
|
||||
// Try spatial nav to containers + standalone focusables (not inner buttons).
|
||||
// This handles mixed pages (e.g. Settings) where containers and buttons coexist.
|
||||
const zone = document.querySelector('[data-controller-zone="main"]') as HTMLElement | null
|
||||
const navTargets = zone ? getFocusableElements(zone).filter(el =>
|
||||
el.hasAttribute('data-controller-container') ||
|
||||
!el.closest('[data-controller-container]')
|
||||
) : containers
|
||||
const next = findNearestInDirection(activeEl, navTargets, dir)
|
||||
if (next) {
|
||||
rememberFocus('main', next)
|
||||
focusEl(next)
|
||||
|
||||
@@ -35,6 +35,21 @@ const router = createRouter({
|
||||
name: 'onboarding-path',
|
||||
component: () => import('../views/OnboardingPath.vue'),
|
||||
},
|
||||
{
|
||||
path: 'onboarding/seed',
|
||||
name: 'onboarding-seed',
|
||||
component: () => import('../views/OnboardingSeedGenerate.vue'),
|
||||
},
|
||||
{
|
||||
path: 'onboarding/seed-verify',
|
||||
name: 'onboarding-seed-verify',
|
||||
component: () => import('../views/OnboardingSeedVerify.vue'),
|
||||
},
|
||||
{
|
||||
path: 'onboarding/seed-restore',
|
||||
name: 'onboarding-seed-restore',
|
||||
component: () => import('../views/OnboardingSeedRestore.vue'),
|
||||
},
|
||||
{
|
||||
path: 'onboarding/did',
|
||||
name: 'onboarding-did',
|
||||
|
||||
@@ -136,6 +136,7 @@ export const useSyncStore = defineStore('sync', () => {
|
||||
unread: 0,
|
||||
'wifi-ssids': [],
|
||||
'zram-enabled': false,
|
||||
'seed-backed': false,
|
||||
},
|
||||
'package-data': {},
|
||||
ui: {
|
||||
|
||||
@@ -29,6 +29,7 @@ export interface ServerInfo {
|
||||
unread: number
|
||||
'wifi-ssids': string[]
|
||||
'zram-enabled': boolean
|
||||
'seed-backed': boolean
|
||||
}
|
||||
|
||||
export interface StatusInfo {
|
||||
|
||||
@@ -115,13 +115,13 @@
|
||||
<span class="discover-terminal-tag text-orange-400/80">manifesto</span>
|
||||
<div class="flex-1 h-px bg-white/10"></div>
|
||||
</div>
|
||||
<blockquote class="text-white/60 text-sm leading-relaxed italic max-w-3xl">
|
||||
<blockquote class="text-white/80 text-xl leading-relaxed italic max-w-3xl">
|
||||
"Privacy is not about having something to hide. Privacy is about having the right to choose
|
||||
what to reveal. In a world of surveillance capitalism, self-hosting is an act of resistance.
|
||||
Every service you run on your own hardware is a vote for a future where individuals — not
|
||||
corporations — control their digital lives."
|
||||
</blockquote>
|
||||
<p class="text-white/30 text-xs mt-4 font-mono">// Cypherpunks write code. We run nodes.</p>
|
||||
<p class="text-white/60 text-xl mt-4 font-mono">// Cypherpunks write code. We run nodes.</p>
|
||||
</div>
|
||||
|
||||
<FilterModal
|
||||
|
||||
@@ -119,7 +119,7 @@ async function createIdentity() {
|
||||
}
|
||||
})
|
||||
playNavSound('action')
|
||||
router.push('/onboarding/backup').catch(() => {})
|
||||
router.push('/onboarding/done').catch(() => {})
|
||||
} catch (err) {
|
||||
if (isServerStartingError(err)) {
|
||||
serverStarting.value = true
|
||||
|
||||
@@ -29,40 +29,11 @@
|
||||
tabindex="0"
|
||||
role="button"
|
||||
class="text-white/50 hover:text-white/80 underline text-sm cursor-pointer mt-4 block text-center onb-cta"
|
||||
@click="showRestore = true"
|
||||
@keydown.enter="showRestore = true"
|
||||
@click="goToRestore"
|
||||
@keydown.enter="goToRestore"
|
||||
>
|
||||
Restore from backup
|
||||
Restore from seed phrase
|
||||
</a>
|
||||
|
||||
<!-- Restore Panel -->
|
||||
<div v-if="showRestore" class="mt-6 glass-card px-6 py-6 text-left">
|
||||
<h3 class="text-sm font-semibold text-white/80 mb-3 uppercase tracking-wide">Restore Identity from Backup</h3>
|
||||
<input
|
||||
type="file"
|
||||
accept=".json"
|
||||
class="block w-full text-sm text-white/60 file:mr-4 file:py-2 file:px-4 file:rounded file:border-0 file:text-sm file:bg-white/10 file:text-white/80 hover:file:bg-white/20 mb-3"
|
||||
@change="onFileSelect"
|
||||
/>
|
||||
<input
|
||||
v-model="passphrase"
|
||||
type="password"
|
||||
placeholder="Backup passphrase"
|
||||
class="w-full px-4 py-2 bg-white/10 border border-white/20 rounded-lg text-white text-sm focus:outline-none focus:border-white/40 mb-3"
|
||||
/>
|
||||
<p v-if="restoreError" class="text-red-400 text-xs mb-2">{{ restoreError }}</p>
|
||||
<p v-if="restoreSuccess" class="text-green-400 text-xs mb-2">Identity restored successfully!</p>
|
||||
<div class="flex gap-3">
|
||||
<button class="glass-button text-sm px-4 py-2" @click="showRestore = false">Cancel</button>
|
||||
<button
|
||||
class="glass-button text-sm px-4 py-2"
|
||||
:disabled="!restoreFile || !passphrase || restoreLoading"
|
||||
@click="performRestore"
|
||||
>
|
||||
{{ restoreLoading ? 'Restoring...' : 'Restore' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -72,7 +43,6 @@
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import AnimatedLogo from '@/components/AnimatedLogo.vue'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import { playNavSound } from '@/composables/useNavSounds'
|
||||
|
||||
const router = useRouter()
|
||||
@@ -90,49 +60,9 @@ function goToOptions() {
|
||||
router.push('/onboarding/path').catch(() => {})
|
||||
}
|
||||
|
||||
// Restore from backup
|
||||
const showRestore = ref(false)
|
||||
const restoreFile = ref<Record<string, unknown> | null>(null)
|
||||
const passphrase = ref('')
|
||||
const restoreLoading = ref(false)
|
||||
const restoreError = ref('')
|
||||
const restoreSuccess = ref(false)
|
||||
|
||||
function onFileSelect(e: Event) {
|
||||
const target = e.target as HTMLInputElement
|
||||
const file = target.files?.[0]
|
||||
if (!file) return
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => {
|
||||
try {
|
||||
restoreFile.value = JSON.parse(reader.result as string)
|
||||
restoreError.value = ''
|
||||
} catch {
|
||||
restoreError.value = 'Invalid backup file format'
|
||||
restoreFile.value = null
|
||||
}
|
||||
}
|
||||
reader.readAsText(file)
|
||||
}
|
||||
|
||||
async function performRestore() {
|
||||
if (!restoreFile.value || !passphrase.value) return
|
||||
restoreLoading.value = true
|
||||
restoreError.value = ''
|
||||
try {
|
||||
await rpcClient.call({
|
||||
method: 'backup.restore-identity',
|
||||
params: { backup: restoreFile.value, passphrase: passphrase.value },
|
||||
})
|
||||
restoreSuccess.value = true
|
||||
setTimeout(() => {
|
||||
router.push('/onboarding/did')
|
||||
}, 1500)
|
||||
} catch (err) {
|
||||
restoreError.value = err instanceof Error ? err.message : 'Restore failed'
|
||||
} finally {
|
||||
restoreLoading.value = false
|
||||
}
|
||||
function goToRestore() {
|
||||
playNavSound('action')
|
||||
router.push('/onboarding/seed-restore').catch(() => {})
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -33,8 +33,12 @@
|
||||
</p>
|
||||
</button>
|
||||
|
||||
<!-- Restore Backup (Coming Soon) -->
|
||||
<div class="path-option-card text-center opacity-40 cursor-not-allowed">
|
||||
<!-- Restore from Seed -->
|
||||
<button
|
||||
@click="selectOption('restore')"
|
||||
class="path-option-card text-center"
|
||||
:class="{ 'path-option-card--selected': selected === 'restore' }"
|
||||
>
|
||||
<div class="mb-3 sm:mb-4">
|
||||
<div class="w-12 h-12 sm:w-16 sm:h-16 mx-auto bg-white/10 rounded-full flex items-center justify-center">
|
||||
<svg class="w-6 h-6 sm:w-8 sm:h-8 text-white/60" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -42,12 +46,11 @@
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<h3 class="text-lg sm:text-xl font-semibold text-white mb-1 sm:mb-2">Restore Backup</h3>
|
||||
<h3 class="text-lg sm:text-xl font-semibold text-white mb-1 sm:mb-2">Restore from Seed</h3>
|
||||
<p class="text-white/70 text-xs sm:text-sm">
|
||||
Restore from a previous backup
|
||||
Enter your 24-word recovery phrase
|
||||
</p>
|
||||
<span class="text-xs text-white/50 mt-1 block">(Coming Soon)</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<!-- Connect Existing (Coming Soon) -->
|
||||
<div class="path-option-card text-center opacity-40 cursor-not-allowed">
|
||||
@@ -81,7 +84,6 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { completeOnboarding } from '@/composables/useOnboarding'
|
||||
import { playNavSound } from '@/composables/useNavSounds'
|
||||
|
||||
const router = useRouter()
|
||||
@@ -96,12 +98,11 @@ function selectOption(option: string) {
|
||||
}
|
||||
|
||||
async function proceed() {
|
||||
try {
|
||||
await completeOnboarding()
|
||||
} catch (e) {
|
||||
if (import.meta.env.DEV) console.warn('completeOnboarding failed, localStorage fallback ensures onboarding is marked complete', e)
|
||||
}
|
||||
playNavSound('action')
|
||||
router.push('/login').catch(() => {})
|
||||
if (selected.value === 'restore') {
|
||||
router.push('/onboarding/seed-restore').catch(() => {})
|
||||
} else {
|
||||
router.push('/onboarding/seed').catch(() => {})
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -109,6 +109,6 @@ onMounted(() => {
|
||||
|
||||
function proceed() {
|
||||
playNavSound('action')
|
||||
router.push('/onboarding/did').catch(() => {})
|
||||
router.push('/onboarding/seed').catch(() => {})
|
||||
}
|
||||
</script>
|
||||
|
||||
164
neode-ui/src/views/OnboardingSeedGenerate.vue
Normal file
164
neode-ui/src/views/OnboardingSeedGenerate.vue
Normal file
@@ -0,0 +1,164 @@
|
||||
<template>
|
||||
<div class="min-h-full flex items-center justify-center p-3 sm:p-4 md:p-6">
|
||||
<div class="max-w-[800px] w-full relative z-10 path-glass-container onb-scroll-container flex flex-col" style="max-height: calc(100dvh - 2rem);">
|
||||
<!-- Header -->
|
||||
<div class="text-center flex-shrink-0 px-3 sm:px-4 pt-4 sm:pt-6 pb-2 sm:pb-3">
|
||||
<h1 class="text-xl sm:text-2xl md:text-[26px] font-semibold text-white/96 mb-1.5 drop-shadow-[0_2px_6px_rgba(0,0,0,0.4)]">
|
||||
Your Recovery Seed
|
||||
</h1>
|
||||
<p class="text-xs sm:text-sm md:text-base text-white/75 leading-relaxed max-w-[600px] mx-auto">
|
||||
Write down these 24 words in order. They are the only way to recover your node.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Scrollable Content -->
|
||||
<div class="flex-1 overflow-y-auto overflow-x-hidden px-6 sm:px-8 min-h-0">
|
||||
<div class="flex flex-col items-center gap-3 sm:gap-4 py-3">
|
||||
<!-- Loading State -->
|
||||
<div v-if="loading" class="text-center py-8">
|
||||
<div class="flex justify-center mb-4">
|
||||
<div class="w-16 h-16 rounded-full bg-white/10 flex items-center justify-center onb-lock-spin">
|
||||
<svg class="w-8 h-8 text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="waitingForServer" class="flex items-center justify-center gap-3 mb-2">
|
||||
<p class="text-lg text-white/80">Server starting up</p>
|
||||
<span class="text-sm text-white/40 font-mono tabular-nums">{{ elapsedDisplay }}</span>
|
||||
</div>
|
||||
<p v-if="waitingForServer" class="text-sm text-white/50">This usually takes 1-3 minutes after first boot</p>
|
||||
<p v-else class="text-lg text-white/80">Generating your seed phrase...</p>
|
||||
</div>
|
||||
|
||||
<!-- Error -->
|
||||
<p v-if="errorMessage" class="text-red-400 text-sm">{{ errorMessage }}</p>
|
||||
|
||||
<!-- Word Grid -->
|
||||
<div v-if="words.length > 0" class="w-full max-w-[600px]">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-3 md:grid-cols-4 gap-1.5 sm:gap-2">
|
||||
<div
|
||||
v-for="(word, i) in words"
|
||||
:key="i"
|
||||
class="bg-black/60 rounded-lg px-3 py-1.5 sm:py-2 border border-white/10"
|
||||
>
|
||||
<span class="text-white/40 text-[1rem] font-mono mr-1.5">{{ i + 1 }}.</span>
|
||||
<span class="text-white/95 text-[1.2rem] font-mono">{{ word }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Warning -->
|
||||
<div class="mt-3 bg-orange-500/10 border border-orange-500/20 rounded-lg px-3 py-2.5">
|
||||
<p class="text-xs sm:text-sm text-orange-300/90">
|
||||
Never share these words. Anyone with them controls your node, identities, and Bitcoin wallet.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Confirmation Checkbox -->
|
||||
<label class="flex items-center justify-center gap-3 mt-3 cursor-pointer select-none">
|
||||
<input
|
||||
v-model="confirmed"
|
||||
type="checkbox"
|
||||
class="w-5 h-5 rounded border-white/20 bg-black/40 accent-orange-400"
|
||||
/>
|
||||
<span class="text-xs sm:text-sm text-white/80">I have written down these 24 words in a safe place</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Fixed Footer -->
|
||||
<div v-if="words.length > 0" class="flex-shrink-0 flex justify-center px-3 sm:px-4 pt-3 pb-4 sm:pb-6">
|
||||
<button
|
||||
ref="continueButton"
|
||||
@click="proceed"
|
||||
:disabled="!confirmed"
|
||||
class="path-action-button path-action-button--continue disabled:opacity-50"
|
||||
>
|
||||
Continue
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, watch, nextTick } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import { playNavSound } from '@/composables/useNavSounds'
|
||||
|
||||
const router = useRouter()
|
||||
const continueButton = ref<HTMLButtonElement | null>(null)
|
||||
const words = ref<string[]>([])
|
||||
const confirmed = ref(false)
|
||||
const loading = ref(false)
|
||||
const waitingForServer = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const elapsedDisplay = ref('0:00')
|
||||
let retryTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let elapsedTimer: ReturnType<typeof setInterval> | null = null
|
||||
let startTime = 0
|
||||
|
||||
function startElapsedTimer() {
|
||||
startTime = Date.now()
|
||||
elapsedTimer = setInterval(() => {
|
||||
const secs = Math.floor((Date.now() - startTime) / 1000)
|
||||
const m = Math.floor(secs / 60)
|
||||
const s = secs % 60
|
||||
elapsedDisplay.value = `${m}:${s.toString().padStart(2, '0')}`
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
function stopTimers() {
|
||||
if (retryTimer) { clearTimeout(retryTimer); retryTimer = null }
|
||||
if (elapsedTimer) { clearInterval(elapsedTimer); elapsedTimer = null }
|
||||
}
|
||||
|
||||
async function generateSeed() {
|
||||
loading.value = true
|
||||
errorMessage.value = ''
|
||||
|
||||
try {
|
||||
const res = await rpcClient.call<{ words: string[] }>({ method: 'seed.generate' })
|
||||
stopTimers()
|
||||
words.value = res.words
|
||||
loading.value = false
|
||||
waitingForServer.value = false
|
||||
} catch {
|
||||
loading.value = false
|
||||
if (!waitingForServer.value) {
|
||||
waitingForServer.value = true
|
||||
startElapsedTimer()
|
||||
}
|
||||
retryTimer = setTimeout(generateSeed, 4000)
|
||||
}
|
||||
}
|
||||
|
||||
watch(confirmed, (val) => {
|
||||
if (val) {
|
||||
nextTick(() => {
|
||||
setTimeout(() => continueButton.value?.focus({ preventScroll: true }), 100)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => { generateSeed() })
|
||||
onUnmounted(() => { stopTimers() })
|
||||
|
||||
function proceed() {
|
||||
playNavSound('action')
|
||||
sessionStorage.setItem('_seed_words', JSON.stringify(words.value))
|
||||
router.push('/onboarding/seed-verify').catch(() => {})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.onb-lock-spin {
|
||||
animation: onb-lock-pulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
@keyframes onb-lock-pulse {
|
||||
0%, 100% { transform: scale(1); opacity: 1; }
|
||||
50% { transform: scale(1.08); opacity: 0.7; }
|
||||
}
|
||||
</style>
|
||||
183
neode-ui/src/views/OnboardingSeedRestore.vue
Normal file
183
neode-ui/src/views/OnboardingSeedRestore.vue
Normal file
@@ -0,0 +1,183 @@
|
||||
<template>
|
||||
<div class="min-h-full flex items-center justify-center p-3 sm:p-4 md:p-6">
|
||||
<div class="max-w-[800px] w-full relative z-10 path-glass-container onb-scroll-container flex flex-col" style="max-height: calc(100dvh - 2rem);">
|
||||
<!-- Header -->
|
||||
<div class="text-center flex-shrink-0 px-3 sm:px-4 pt-4 sm:pt-6 pb-2 sm:pb-3">
|
||||
<h1 class="text-xl sm:text-2xl md:text-[26px] font-semibold text-white/96 mb-1.5 drop-shadow-[0_2px_6px_rgba(0,0,0,0.4)]">
|
||||
Restore from Seed
|
||||
</h1>
|
||||
<p class="text-xs sm:text-sm md:text-base text-white/75 leading-relaxed max-w-[600px] mx-auto">
|
||||
Enter your 24-word recovery seed to restore your node identity.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Scrollable Content -->
|
||||
<div class="flex-1 overflow-y-auto overflow-x-hidden px-6 sm:px-8 min-h-0">
|
||||
<div class="flex flex-col items-center gap-3 sm:gap-4 py-3">
|
||||
<p v-if="errorMessage" class="text-red-400 text-sm">{{ errorMessage }}</p>
|
||||
<p v-if="serverStarting" class="text-orange-400/80 text-sm">Server is still starting up. Please try again shortly.</p>
|
||||
|
||||
<!-- Restore Success -->
|
||||
<div v-if="restored" class="w-full max-w-[600px]">
|
||||
<div class="text-center mb-4">
|
||||
<div class="flex justify-center mb-4">
|
||||
<div class="path-option-card cursor-default w-16 h-16 sm:w-20 sm:h-20 rounded-full flex items-center justify-center">
|
||||
<svg class="w-8 h-8 sm:w-10 sm:h-10 text-black" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="3">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-base sm:text-[20px] text-white/80 leading-relaxed max-w-[600px] mx-auto mb-2">
|
||||
Identity restored successfully
|
||||
</p>
|
||||
</div>
|
||||
<div class="path-option-card cursor-default px-4 py-4 sm:px-6 sm:py-5">
|
||||
<div class="text-left">
|
||||
<h3 class="text-xs sm:text-sm font-semibold text-white/80 mb-2 uppercase tracking-wide">Your DID</h3>
|
||||
<div class="bg-black/40 rounded-lg p-3 sm:p-4 backdrop-blur-sm border border-white/10">
|
||||
<p class="text-white/95 font-mono text-xs sm:text-sm break-all leading-relaxed">{{ restoredDid }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Word Input Grid -->
|
||||
<div v-if="!restored" class="w-full max-w-[600px]">
|
||||
<div class="path-option-card cursor-default px-3 py-3 sm:px-5 sm:py-4">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-1.5 sm:gap-2">
|
||||
<div v-for="i in 24" :key="i" class="relative">
|
||||
<span class="absolute left-3 top-1/2 -translate-y-1/2 text-white/30 text-[1rem] font-mono pointer-events-none">{{ i }}.</span>
|
||||
<input
|
||||
:ref="el => { if (el) wordInputs[i - 1] = el as HTMLInputElement }"
|
||||
v-model="seedWords[i - 1]"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
autocapitalize="none"
|
||||
spellcheck="false"
|
||||
class="w-full bg-black/40 border border-white/10 rounded-lg pl-9 pr-3 py-2 text-[1.2rem] text-white/95 font-mono placeholder-white/20 focus:outline-none focus:ring-1 focus:ring-inset focus:ring-white/30 focus:bg-black/50 transition-all"
|
||||
:placeholder="`word ${i}`"
|
||||
@keydown.enter="i < 24 ? wordInputs[i]?.focus() : restore()"
|
||||
@input="onWordInput(i - 1)"
|
||||
@paste="i === 1 ? onPaste($event) : undefined"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs text-white/40 mt-2 text-center">
|
||||
Paste all 24 words into the first field to auto-fill
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Fixed Footer -->
|
||||
<div class="flex-shrink-0 flex justify-center px-3 sm:px-4 pt-3 pb-4 sm:pb-6">
|
||||
<button
|
||||
v-if="!restored"
|
||||
@click="restore"
|
||||
:disabled="isRestoring || !allFilled"
|
||||
class="path-action-button path-action-button--continue disabled:opacity-50"
|
||||
>
|
||||
<span v-if="isRestoring" class="flex items-center justify-center gap-2">
|
||||
<svg class="animate-spin h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Restoring...
|
||||
</span>
|
||||
<span v-else>Restore Identity</span>
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
ref="continueButton"
|
||||
@click="proceed"
|
||||
class="path-action-button path-action-button--continue"
|
||||
>
|
||||
Continue
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, nextTick } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import { playNavSound } from '@/composables/useNavSounds'
|
||||
|
||||
const router = useRouter()
|
||||
const continueButton = ref<HTMLButtonElement | null>(null)
|
||||
const wordInputs = ref<HTMLInputElement[]>([])
|
||||
const seedWords = ref<string[]>(Array(24).fill(''))
|
||||
const restored = ref(false)
|
||||
const isRestoring = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const serverStarting = ref(false)
|
||||
const restoredDid = ref('')
|
||||
|
||||
const allFilled = computed(() => seedWords.value.every(w => w.trim().length > 0))
|
||||
|
||||
onMounted(() => {
|
||||
nextTick(() => {
|
||||
setTimeout(() => wordInputs.value[0]?.focus({ preventScroll: true }), 300)
|
||||
})
|
||||
})
|
||||
|
||||
function onWordInput(index: number) {
|
||||
seedWords.value[index] = (seedWords.value[index] ?? '').trim().toLowerCase()
|
||||
}
|
||||
|
||||
function onPaste(event: ClipboardEvent) {
|
||||
const text = event.clipboardData?.getData('text')?.trim()
|
||||
if (!text) return
|
||||
|
||||
const pastedWords = text.split(/\s+/)
|
||||
if (pastedWords.length === 24) {
|
||||
event.preventDefault()
|
||||
for (let i = 0; i < 24; i++) {
|
||||
seedWords.value[i] = (pastedWords[i] ?? '').toLowerCase()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function restore() {
|
||||
if (!allFilled.value) return
|
||||
isRestoring.value = true
|
||||
errorMessage.value = ''
|
||||
serverStarting.value = false
|
||||
|
||||
try {
|
||||
const words = seedWords.value.map(w => w.trim().toLowerCase())
|
||||
const res = await rpcClient.call<{ did: string; nostr_npub: string; restored: boolean }>({
|
||||
method: 'seed.restore',
|
||||
params: { words },
|
||||
})
|
||||
|
||||
if (res.restored) {
|
||||
restored.value = true
|
||||
restoredDid.value = res.did
|
||||
if (res.did) localStorage.setItem('neode_did', res.did)
|
||||
if (res.nostr_npub) localStorage.setItem('neode_nostr_npub', res.nostr_npub)
|
||||
|
||||
nextTick(() => {
|
||||
setTimeout(() => continueButton.value?.focus({ preventScroll: true }), 100)
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err)
|
||||
if (/502|503|504|timeout|fetch|network|Failed to fetch/i.test(msg)) {
|
||||
serverStarting.value = true
|
||||
} else {
|
||||
errorMessage.value = msg || 'Restore failed. Check your seed words and try again.'
|
||||
}
|
||||
} finally {
|
||||
isRestoring.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function proceed() {
|
||||
playNavSound('action')
|
||||
router.push('/onboarding/identity').catch(() => {})
|
||||
}
|
||||
</script>
|
||||
254
neode-ui/src/views/OnboardingSeedVerify.vue
Normal file
254
neode-ui/src/views/OnboardingSeedVerify.vue
Normal file
@@ -0,0 +1,254 @@
|
||||
<template>
|
||||
<div class="min-h-full flex items-center justify-center p-3 sm:p-4 md:p-6">
|
||||
<div class="max-w-[800px] w-full relative z-10 path-glass-container onb-scroll-container flex flex-col" style="max-height: calc(100dvh - 2rem);">
|
||||
<!-- Header (hidden after verification) -->
|
||||
<div v-if="!verified" class="text-center flex-shrink-0 px-3 sm:px-4 pt-4 sm:pt-6 pb-2 sm:pb-3">
|
||||
<h1 class="text-xl sm:text-2xl md:text-[26px] font-semibold text-white/96 mb-1.5 drop-shadow-[0_2px_6px_rgba(0,0,0,0.4)]">
|
||||
Verify Your Seed
|
||||
</h1>
|
||||
<p class="text-xs sm:text-sm md:text-base text-white/75 leading-relaxed max-w-[600px] mx-auto">
|
||||
Confirm you wrote down your seed correctly by entering the requested words.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Scrollable Content -->
|
||||
<div class="flex-1 overflow-y-auto overflow-x-hidden min-h-0 px-6 sm:px-8 py-4">
|
||||
<div class="flex flex-col items-center gap-3 sm:gap-4">
|
||||
<p v-if="errorMessage" class="text-red-400 text-sm">{{ errorMessage }}</p>
|
||||
|
||||
<!-- Verification Success -->
|
||||
<div v-if="verified" class="w-full max-w-[600px] pt-2">
|
||||
<div class="text-center mb-4">
|
||||
<div class="flex justify-center mb-4">
|
||||
<div class="path-option-card cursor-default w-16 h-16 sm:w-20 sm:h-20 rounded-full flex items-center justify-center">
|
||||
<svg class="w-8 h-8 sm:w-10 sm:h-10 text-black" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="3">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-base sm:text-[20px] text-white/80 leading-relaxed max-w-[600px] mx-auto mb-2">
|
||||
Seed verified successfully
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- DID -->
|
||||
<div class="path-option-card cursor-default px-4 py-4 sm:px-6 sm:py-5 mb-3">
|
||||
<div class="text-left w-full">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<h3 class="text-xs sm:text-sm font-semibold text-white/80 uppercase tracking-wide">Your DID</h3>
|
||||
<button @click="copyText(did)" class="text-xs text-white/40 hover:text-white/70 transition-colors">
|
||||
{{ copiedField === 'did' ? 'Copied!' : 'Copy' }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="bg-black/40 rounded-lg p-3 sm:p-4 backdrop-blur-sm border border-white/10">
|
||||
<p class="text-white/95 font-mono text-xs sm:text-sm break-all leading-relaxed">{{ did }}</p>
|
||||
</div>
|
||||
<p class="text-xs text-white/50 mt-2">For Web5, federation, and verifiable credentials</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Nostr npub -->
|
||||
<div v-if="nostrNpub" class="path-option-card cursor-default px-4 py-4 sm:px-6 sm:py-5">
|
||||
<div class="text-left w-full">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<h3 class="text-xs sm:text-sm font-semibold text-white/80 uppercase tracking-wide">Your Nostr ID</h3>
|
||||
<button @click="copyText(nostrNpub)" class="text-xs text-white/40 hover:text-white/70 transition-colors">
|
||||
{{ copiedField === 'npub' ? 'Copied!' : 'Copy' }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="bg-black/40 rounded-lg p-3 sm:p-4 backdrop-blur-sm border border-white/10">
|
||||
<p class="text-white/95 font-mono text-xs sm:text-sm break-all leading-relaxed">{{ nostrNpub }}</p>
|
||||
</div>
|
||||
<p class="text-xs text-white/50 mt-2">For Nostr social apps and NIP-07 signing</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Word Input Fields -->
|
||||
<div v-if="!verified" class="w-full max-w-[600px] space-y-2 sm:space-y-3">
|
||||
<div
|
||||
v-for="(idx, i) in challengeIndices"
|
||||
:key="idx"
|
||||
class="path-option-card cursor-default px-3 py-3 sm:px-5 sm:py-4"
|
||||
>
|
||||
<label class="block text-xs font-semibold text-white/80 mb-1.5 sm:mb-2 uppercase tracking-wide">
|
||||
Word #{{ idx + 1 }}
|
||||
</label>
|
||||
<input
|
||||
:ref="el => { if (el) inputRefs[i] = el as HTMLInputElement }"
|
||||
v-model="answers[i]"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
autocapitalize="none"
|
||||
spellcheck="false"
|
||||
:placeholder="`Enter word #${idx + 1}`"
|
||||
class="w-full bg-black/40 border border-white/10 rounded-lg px-3 py-2.5 text-white/95 placeholder-white/40 focus:outline-none focus:ring-1 focus:ring-inset focus:ring-white/30 focus:bg-black/50 transition-all font-mono text-[1.2rem]"
|
||||
@keydown.enter.prevent="i < challengeIndices.length - 1 ? inputRefs[i + 1]?.focus() : verify()"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Fixed Footer -->
|
||||
<div class="flex-shrink-0 flex items-center justify-center gap-4 max-w-[600px] mx-auto w-full px-6 sm:px-8 pt-3 pb-4 sm:pb-6">
|
||||
<span
|
||||
v-if="!verified"
|
||||
@click="goBack"
|
||||
class="path-action-button path-action-button--continue cursor-pointer select-none inline-flex items-center justify-center"
|
||||
>
|
||||
Back
|
||||
</span>
|
||||
<button
|
||||
v-if="!verified"
|
||||
@click="verify"
|
||||
type="button"
|
||||
:disabled="isVerifying || !allFilled"
|
||||
class="path-action-button path-action-button--continue disabled:opacity-50"
|
||||
>
|
||||
<span v-if="isVerifying">Verifying...</span>
|
||||
<span v-else>Verify</span>
|
||||
</button>
|
||||
<span
|
||||
v-if="verified"
|
||||
@click="downloadIdentity"
|
||||
class="path-action-button path-action-button--continue cursor-pointer select-none inline-flex items-center justify-center gap-2"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
Download
|
||||
</span>
|
||||
<button
|
||||
v-if="verified"
|
||||
ref="continueButton"
|
||||
@click="proceed"
|
||||
class="path-action-button path-action-button--continue"
|
||||
>
|
||||
Continue
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, nextTick } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import { playNavSound } from '@/composables/useNavSounds'
|
||||
|
||||
const router = useRouter()
|
||||
const continueButton = ref<HTMLButtonElement | null>(null)
|
||||
const inputRefs = ref<HTMLInputElement[]>([])
|
||||
const words = ref<string[]>([])
|
||||
const challengeIndices = ref<number[]>([])
|
||||
const answers = ref<string[]>(['', '', '', ''])
|
||||
const verified = ref(false)
|
||||
const isVerifying = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const did = ref('')
|
||||
const nostrNpub = ref('')
|
||||
const copiedField = ref('')
|
||||
|
||||
const allFilled = computed(() => answers.value.every(a => a.trim().length > 0))
|
||||
|
||||
function pickRandomIndices(count: number, max: number): number[] {
|
||||
const indices = new Set<number>()
|
||||
while (indices.size < count) {
|
||||
indices.add(Math.floor(Math.random() * max))
|
||||
}
|
||||
return Array.from(indices).sort((a, b) => a - b)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
const stored = sessionStorage.getItem('_seed_words')
|
||||
if (!stored) {
|
||||
router.replace('/onboarding/seed').catch(() => {})
|
||||
return
|
||||
}
|
||||
words.value = JSON.parse(stored)
|
||||
challengeIndices.value = pickRandomIndices(4, 24)
|
||||
|
||||
nextTick(() => {
|
||||
setTimeout(() => inputRefs.value[0]?.focus({ preventScroll: true }), 300)
|
||||
})
|
||||
})
|
||||
|
||||
function goBack() {
|
||||
playNavSound('action')
|
||||
router.push('/onboarding/seed').catch(() => {})
|
||||
}
|
||||
|
||||
function copyText(text: string) {
|
||||
navigator.clipboard.writeText(text).catch(() => {})
|
||||
copiedField.value = text === did.value ? 'did' : 'npub'
|
||||
setTimeout(() => { copiedField.value = '' }, 2000)
|
||||
}
|
||||
|
||||
function downloadIdentity() {
|
||||
const data = {
|
||||
did: did.value,
|
||||
nostr_npub: nostrNpub.value || undefined,
|
||||
created: new Date().toISOString(),
|
||||
}
|
||||
const json = JSON.stringify(data, null, 2)
|
||||
const blob = new Blob([json], { type: 'application/json' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = 'archipelago-identity.json'
|
||||
a.style.display = 'none'
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
setTimeout(() => { document.body.removeChild(a); URL.revokeObjectURL(url) }, 1000)
|
||||
}
|
||||
|
||||
async function verify() {
|
||||
if (!allFilled.value) return
|
||||
isVerifying.value = true
|
||||
errorMessage.value = ''
|
||||
|
||||
const correct = challengeIndices.value.every(
|
||||
(wordIdx, i) => (answers.value[i] ?? '').trim().toLowerCase() === (words.value[wordIdx] ?? '')
|
||||
)
|
||||
|
||||
if (!correct) {
|
||||
isVerifying.value = false
|
||||
errorMessage.value = 'One or more words are incorrect. Please check your written seed and try again.'
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await rpcClient.call<{ verified: boolean; did: string; nostr_npub: string }>({
|
||||
method: 'seed.verify',
|
||||
params: { words: words.value, indices: challengeIndices.value },
|
||||
})
|
||||
|
||||
if (res.verified) {
|
||||
verified.value = true
|
||||
did.value = res.did
|
||||
nostrNpub.value = res.nostr_npub || ''
|
||||
localStorage.setItem('neode_did', res.did)
|
||||
if (res.nostr_npub) localStorage.setItem('neode_nostr_npub', res.nostr_npub)
|
||||
sessionStorage.removeItem('_seed_words')
|
||||
|
||||
nextTick(() => {
|
||||
setTimeout(() => continueButton.value?.focus({ preventScroll: true }), 100)
|
||||
})
|
||||
} else {
|
||||
errorMessage.value = 'Verification failed. Please try again.'
|
||||
}
|
||||
} catch (err) {
|
||||
errorMessage.value = err instanceof Error ? err.message : 'Verification failed'
|
||||
} finally {
|
||||
isVerifying.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function proceed() {
|
||||
playNavSound('action')
|
||||
router.push('/onboarding/identity').catch(() => {})
|
||||
}
|
||||
</script>
|
||||
@@ -75,6 +75,7 @@ const transitionName = ref('depth-forward')
|
||||
// Ordered onboarding steps for direction detection
|
||||
const onboardingOrder = [
|
||||
'/onboarding/intro', '/onboarding/path', '/onboarding/options',
|
||||
'/onboarding/seed', '/onboarding/seed-verify', '/onboarding/seed-restore',
|
||||
'/onboarding/did', '/onboarding/identity', '/onboarding/backup',
|
||||
'/onboarding/verify', '/onboarding/done', '/login'
|
||||
]
|
||||
@@ -96,8 +97,11 @@ const routeBackgrounds: Record<string, string> = {
|
||||
'/onboarding/intro': 'bg-intro.jpg', // Video will be used instead
|
||||
'/onboarding/options': 'bg-intro-4.jpg',
|
||||
'/onboarding/path': 'bg-intro-3.jpg',
|
||||
'/onboarding/did': 'bg-intro-5.jpg',
|
||||
'/onboarding/identity': 'bg-intro-5.jpg',
|
||||
'/onboarding/seed': 'bg-intro-5.jpg',
|
||||
'/onboarding/seed-verify': 'bg-intro-6.jpg',
|
||||
'/onboarding/seed-restore': 'bg-intro-2.jpg',
|
||||
'/onboarding/did': 'bg-intro-4.jpg',
|
||||
'/onboarding/identity': 'bg-intro-1.jpg',
|
||||
'/onboarding/backup': 'bg-intro-6.jpg',
|
||||
'/onboarding/verify': 'bg-intro-2.jpg',
|
||||
'/onboarding/done': 'bg-intro-1.jpg',
|
||||
|
||||
@@ -3,32 +3,37 @@
|
||||
<!-- Hero Section -->
|
||||
<div class="discover-hero glass-card p-8 md:p-12 mb-8 relative overflow-hidden">
|
||||
<div class="discover-hero-scanline" aria-hidden="true"></div>
|
||||
<div class="relative z-10">
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<span class="discover-terminal-tag">~ $</span>
|
||||
<span class="text-white/40 text-sm font-mono tracking-wider">ARCHIPELAGO://DISCOVER</span>
|
||||
<div class="discover-hero-layout relative z-10">
|
||||
<div class="discover-hero-content">
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<span class="discover-terminal-tag">~ $</span>
|
||||
<span class="text-white/40 text-sm font-mono tracking-wider">ARCHIPELAGO://DISCOVER</span>
|
||||
</div>
|
||||
<h1 class="text-4xl md:text-5xl font-extrabold text-white mb-4 tracking-tight font-archipelago">
|
||||
Reclaim Your<br />
|
||||
<span class="discover-hero-accent">Digital Sovereignty</span>
|
||||
</h1>
|
||||
<p class="text-white/70 text-lg md:text-xl max-w-2xl leading-relaxed mb-6">
|
||||
Your node. Your rules. Every app runs on <em>your</em> hardware, verified by <em>your</em> Bitcoin node.
|
||||
No cloud. No custodians. No permission needed.
|
||||
</p>
|
||||
<div class="flex flex-wrap gap-4 text-sm">
|
||||
<div class="discover-stat-pill">
|
||||
<span class="text-white font-bold">{{ totalApps }}</span>
|
||||
<span class="text-white/50">apps available</span>
|
||||
</div>
|
||||
<div class="discover-stat-pill">
|
||||
<span class="text-white font-bold">{{ installedCount }}</span>
|
||||
<span class="text-white/50">installed</span>
|
||||
</div>
|
||||
<div class="discover-stat-pill">
|
||||
<span class="text-white font-bold">100%</span>
|
||||
<span class="text-white/50">self-hosted</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<h1 class="text-4xl md:text-5xl font-extrabold text-white mb-4 tracking-tight font-archipelago">
|
||||
Reclaim Your<br />
|
||||
<span class="discover-hero-accent">Digital Sovereignty</span>
|
||||
</h1>
|
||||
<p class="text-white/70 text-lg md:text-xl max-w-2xl leading-relaxed mb-6">
|
||||
Your node. Your rules. Every app runs on <em>your</em> hardware, verified by <em>your</em> Bitcoin node.
|
||||
No cloud. No custodians. No permission needed.
|
||||
</p>
|
||||
<div class="flex flex-wrap gap-4 text-sm">
|
||||
<div class="discover-stat-pill">
|
||||
<span class="text-white font-bold">{{ totalApps }}</span>
|
||||
<span class="text-white/50">apps available</span>
|
||||
</div>
|
||||
<div class="discover-stat-pill">
|
||||
<span class="text-white font-bold">{{ installedCount }}</span>
|
||||
<span class="text-white/50">installed</span>
|
||||
</div>
|
||||
<div class="discover-stat-pill">
|
||||
<span class="text-white font-bold">100%</span>
|
||||
<span class="text-white/50">self-hosted</span>
|
||||
</div>
|
||||
<div class="discover-hero-face">
|
||||
<BitcoinFaceAscii />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -39,35 +44,37 @@
|
||||
<svg class="w-6 h-6 text-orange-400 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
<h3 class="text-white text-sm font-bold mb-1">Privacy First</h3>
|
||||
<p class="text-white/40 text-xs leading-relaxed">No telemetry. No tracking. Your data never leaves your hardware.</p>
|
||||
<h3 class="text-white text-base font-bold mb-1">Privacy First</h3>
|
||||
<p class="text-white/50 text-sm leading-relaxed">No telemetry. No tracking. Your data never leaves your hardware.</p>
|
||||
</div>
|
||||
<div class="discover-principle-card">
|
||||
<svg class="w-6 h-6 text-orange-400 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||
</svg>
|
||||
<h3 class="text-white text-sm font-bold mb-1">Verify, Don't Trust</h3>
|
||||
<p class="text-white/40 text-xs leading-relaxed">Run your own node. Validate every transaction. Be your own bank.</p>
|
||||
<h3 class="text-white text-base font-bold mb-1">Verify, Don't Trust</h3>
|
||||
<p class="text-white/50 text-sm leading-relaxed">Run your own node. Validate every transaction. Be your own bank.</p>
|
||||
</div>
|
||||
<div class="discover-principle-card">
|
||||
<svg class="w-6 h-6 text-orange-400 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" />
|
||||
</svg>
|
||||
<h3 class="text-white text-sm font-bold mb-1">Open Source</h3>
|
||||
<p class="text-white/40 text-xs leading-relaxed">Every app is open source. Audit the code. Trust the math, not the company.</p>
|
||||
<h3 class="text-white text-base font-bold mb-1">Open Source</h3>
|
||||
<p class="text-white/50 text-sm leading-relaxed">Every app is open source. Audit the code. Trust the math, not the company.</p>
|
||||
</div>
|
||||
<div class="discover-principle-card">
|
||||
<svg class="w-6 h-6 text-orange-400 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<h3 class="text-white text-sm font-bold mb-1">No Permission Needed</h3>
|
||||
<p class="text-white/40 text-xs leading-relaxed">Permissionless commerce. Permissionless money. Permissionless freedom.</p>
|
||||
<h3 class="text-white text-base font-bold mb-1">No Permission Needed</h3>
|
||||
<p class="text-white/50 text-sm leading-relaxed">Permissionless commerce. Permissionless money. Permissionless freedom.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import BitcoinFaceAscii from './BitcoinFaceAscii.vue'
|
||||
|
||||
defineProps<{
|
||||
totalApps: number
|
||||
installedCount: number
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
<svg class="w-4 h-4 text-orange-400/70" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M5 9V7a5 5 0 0110 0v2a2 2 0 012 2v5a2 2 0 01-2 2H5a2 2 0 01-2-2v-5a2 2 0 012-2zm8-2v2H7V7a3 3 0 016 0z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
<span class="text-white/40 text-xs font-mono">{{ app.privacyTag }}</span>
|
||||
<span class="text-white/60 text-sm font-mono">{{ app.privacyTag }}</span>
|
||||
</div>
|
||||
<button
|
||||
v-if="isInstalled(app.id) && !isStartingUp(app.id)"
|
||||
|
||||
@@ -114,9 +114,9 @@ init()
|
||||
</div>
|
||||
|
||||
<!-- Info Grid -->
|
||||
<div data-controller-container tabindex="0" class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
|
||||
<!-- Server Name Card (editable) -->
|
||||
<div class="bg-black/20 rounded-xl px-5 py-4 border border-white/10">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
|
||||
<!-- Server Name Card (editable) — container: Enter to edit, Enter to save, Escape to exit -->
|
||||
<div data-controller-container tabindex="0" class="bg-black/20 rounded-xl px-5 py-4 border border-white/10 transition-all hover:-translate-y-1">
|
||||
<div class="flex items-center gap-3 mb-2">
|
||||
<svg class="w-5 h-5 text-white/70" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01" />
|
||||
|
||||
@@ -50,7 +50,7 @@ checkClaudeStatus()
|
||||
<div class="glass-card px-6 py-6 mb-6">
|
||||
<h2 class="text-xl font-semibold text-white/96 mb-2">{{ t('settings.claudeAuth') }}</h2>
|
||||
<p class="text-sm text-white/60 mb-6">{{ t('settings.claudeAuthDesc') }}</p>
|
||||
<div class="bg-black/20 rounded-xl px-5 py-4 border border-white/10 mb-4">
|
||||
<div class="bg-black/20 rounded-xl px-5 py-4 border border-white/10 mb-4" data-controller-ignore>
|
||||
<div class="flex items-center gap-3 mb-2">
|
||||
<svg class="w-5 h-5 shrink-0" :class="claudeConnected ? 'text-green-400' : 'text-white/40'" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path v-if="claudeConnected" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
|
||||
@@ -42,7 +42,7 @@ async function changeLocale(code: string) {
|
||||
<div class="glass-card px-6 py-6 mb-6">
|
||||
<h2 class="text-xl font-semibold text-white/96 mb-2">{{ t('settings.interfaceMode') }}</h2>
|
||||
<p class="text-sm text-white/60 mb-6">{{ t('settings.interfaceModeDesc') }}</p>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div data-controller-container tabindex="0" class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<button
|
||||
v-for="m in interfaceModes"
|
||||
:key="m.id"
|
||||
|
||||
@@ -48,7 +48,7 @@ async function performFactoryReset() {
|
||||
|
||||
<template>
|
||||
<!-- Network Diagnostics Link -->
|
||||
<div data-controller-container tabindex="0" class="glass-card px-6 py-6 mb-6">
|
||||
<div class="glass-card px-6 py-6 mb-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-white/96">{{ t('common.network') }}</h2>
|
||||
@@ -64,7 +64,7 @@ async function performFactoryReset() {
|
||||
</div>
|
||||
|
||||
<!-- Reboot Section -->
|
||||
<div data-controller-container tabindex="0" class="path-option-card px-6 py-6 mt-6">
|
||||
<div class="path-option-card px-6 py-6 mt-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-white/90 mb-1">Reboot</h2>
|
||||
@@ -109,7 +109,7 @@ async function performFactoryReset() {
|
||||
</Teleport>
|
||||
|
||||
<!-- Factory Reset Section -->
|
||||
<div data-controller-container tabindex="0" class="path-option-card px-6 py-6 mt-6 border-red-500/30">
|
||||
<div class="path-option-card px-6 py-6 mt-6 border-red-500/30">
|
||||
<h2 class="text-xl font-semibold text-red-400/90 mb-3">Factory Reset</h2>
|
||||
<p class="text-sm text-white/60 mb-4">
|
||||
Wipe all user data, identities, and credentials. Container images are preserved. The node will restart and show the onboarding screen.
|
||||
|
||||
@@ -128,7 +128,7 @@ loadTotpStatus()
|
||||
|
||||
<template>
|
||||
<!-- Two-Factor Authentication -->
|
||||
<div data-controller-container tabindex="0" class="mb-6">
|
||||
<div class="mb-6">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<svg class="w-5 h-5 text-white/70" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
|
||||
Reference in New Issue
Block a user