Compare commits

...
3 Commits
Author SHA1 Message Date
archipelago b4714f1773 fix(store): defer multi-version app version choice (#129)
Demo images / Build & push demo images (push) Failing after 39s
2026-08-30 10:23:58 -04:00
archipelago d79ca54019 fix(wallet): disclose backup passphrase only when needed (#127) 2026-08-30 10:23:58 -04:00
archipelago 758332d63d fix(openwrt): make stale router config recoverable (#103) 2026-08-30 10:23:58 -04:00
7 changed files with 136 additions and 9 deletions
+17 -4
View File
@@ -50,6 +50,7 @@ const showRevealModal = ref(false)
const revealPassword = ref('')
const revealCode = ref('')
const revealPassphrase = ref('')
const showRevealPassphrase = ref(false)
const revealing = ref(false)
const revealError = ref('')
const revealedWords = ref<string[]>([])
@@ -60,6 +61,7 @@ function openReveal() {
revealPassword.value = ''
revealCode.value = ''
revealPassphrase.value = ''
showRevealPassphrase.value = false
revealError.value = ''
revealedWords.value = []
showRevealModal.value = true
@@ -83,7 +85,17 @@ async function submitReveal() {
// to set up a backup that now exists.
void loadStatus()
} catch (e: unknown) {
revealError.value = e instanceof Error ? e.message : 'Failed to reveal the ecash phrase'
const message = e instanceof Error ? e.message : 'Failed to reveal the ecash phrase'
// Most operators used their login password as the backup passphrase. Do
// not confront everyone with an unexplained third credential up front;
// disclose it only when the authenticated password could not decrypt the
// node seed and a distinct setup-time passphrase may actually exist.
if (!status.value?.active && /could not decrypt the saved seed/i.test(message)) {
showRevealPassphrase.value = true
revealError.value = 'Your login password did not unlock the saved seed. Enter the separate backup passphrase you chose during setup.'
} else {
revealError.value = message
}
} finally {
revealing.value = false
}
@@ -95,6 +107,7 @@ function closeReveal() {
revealPassword.value = ''
revealCode.value = ''
revealPassphrase.value = ''
showRevealPassphrase.value = false
}
async function copyRevealedWords() {
@@ -376,9 +389,9 @@ async function restoreFromPhrase() {
<label class="block text-xs text-white/60 mb-1">2FA code <span class="text-white/30">(if enabled)</span></label>
<input v-model="revealCode" inputmode="numeric" autocomplete="one-time-code" class="w-full px-3 py-2 rounded-lg bg-white/5 border border-white/10 text-white text-sm font-mono tracking-widest focus:outline-none focus:border-white/30" placeholder="123456" />
</div>
<div v-if="!status?.active">
<label class="block text-xs text-white/60 mb-1">Backup passphrase <span class="text-white/30">(only if different from password)</span></label>
<input v-model="revealPassphrase" type="password" class="w-full px-3 py-2 rounded-lg bg-white/5 border border-white/10 text-white text-sm focus:outline-none focus:border-white/30" placeholder="Leave blank to use password" />
<div v-if="showRevealPassphrase">
<label class="block text-xs text-white/60 mb-1">Separate backup passphrase</label>
<input v-model="revealPassphrase" type="password" autocomplete="off" autofocus class="w-full px-3 py-2 rounded-lg bg-white/5 border border-white/10 text-white text-sm focus:outline-none focus:border-white/30" placeholder="Passphrase chosen during setup" />
</div>
<p v-if="revealError" class="text-xs text-red-300 bg-red-500/10 border border-red-400/20 rounded-lg px-3 py-2">{{ revealError }}</p>
<div class="flex gap-2 pt-1">
@@ -0,0 +1,52 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
vi.mock('@/api/rpc-client', () => ({
rpcClient: { call: vi.fn() },
}))
import { rpcClient } from '@/api/rpc-client'
import EcashSeedBackup from '../EcashSeedBackup.vue'
let wrapper: VueWrapper | null = null
describe('EcashSeedBackup reveal credentials (#127)', () => {
beforeEach(() => {
document.body.innerHTML = ''
vi.clearAllMocks()
})
afterEach(() => {
wrapper?.unmount()
wrapper = null
document.body.innerHTML = ''
})
it('asks for a separate backup passphrase only after password decryption fails', async () => {
vi.mocked(rpcClient.call)
.mockResolvedValueOnce({
active: false,
source: null,
can_activate: true,
derivable_from_node_seed: true,
})
.mockRejectedValueOnce(new Error(
'Could not decrypt the saved seed. If you set a separate backup passphrase during setup, enter that passphrase.',
))
wrapper = mount(EcashSeedBackup, { attachTo: document.body })
await flushPromises()
await wrapper.get('button').trigger('click')
expect(document.body.textContent).not.toContain('Separate backup passphrase')
const password = document.body.querySelector<HTMLInputElement>('input[autocomplete="current-password"]')!
password.value = 'login-password'
password.dispatchEvent(new Event('input', { bubbles: true }))
document.body.querySelector('form')!.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }))
await flushPromises()
expect(document.body.textContent).toContain('Separate backup passphrase')
expect(document.body.textContent).toContain('Your login password did not unlock the saved seed')
expect(document.body.querySelector('input[placeholder="Passphrase chosen during setup"]')).not.toBeNull()
})
})
@@ -26,7 +26,8 @@
:class="tierLabel === 'core' ? 'tier-badge-core' : 'tier-badge-recommended'"
>{{ tierLabel }}</span>
</h3>
<p class="text-sm text-white/60">{{ app.version ? $ver(app.version) : 'latest' }}</p>
<p v-if="!isMultiVersion" class="text-sm text-white/60">{{ app.version ? $ver(app.version) : 'latest' }}</p>
<p v-else class="text-sm text-white/60">Choose version when installing</p>
<p v-if="app.author" class="text-xs text-white/50 mt-1">by {{ app.author }}</p>
</div>
</div>
@@ -175,7 +176,7 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { MarketplaceApp, InstallProgress } from './marketplaceData'
import { MULTI_VERSION_APP_IDS, type MarketplaceApp, type InstallProgress } from './marketplaceData'
import { DEFAULT_APP_ICON } from '@/views/apps/appsConfig'
const { t } = useI18n()
@@ -200,6 +201,8 @@ defineEmits<{
launch: [app: MarketplaceApp]
}>()
const isMultiVersion = computed(() => MULTI_VERSION_APP_IDS.has(props.app.id))
const signatureLabel = computed(() => {
switch (props.app.signature?.status) {
case 'valid': return 'signed'
@@ -15,7 +15,7 @@ const app: MarketplaceApp = {
source: 'community',
}
function mountCard(installed: boolean, installBlockedReason?: string) {
function mountCard(installed: boolean, installBlockedReason?: string, appOverride: MarketplaceApp = app) {
const i18n = createI18n({
legacy: false,
locale: 'en',
@@ -29,7 +29,7 @@ function mountCard(installed: boolean, installBlockedReason?: string) {
return mount(MarketplaceAppCard, {
props: {
app,
app: appOverride,
index: 0,
stagger: false,
installed,
@@ -65,4 +65,16 @@ describe('MarketplaceAppCard', () => {
expect(wrapper.text()).toContain('Requires a full archive Bitcoin node before install.')
expect(wrapper.text()).toContain('Bitcoin Pruned')
})
it('does not present one catalog version as definitive for multi-version apps (#129)', () => {
const wrapper = mountCard(false, undefined, {
...app,
id: 'bitcoin-core',
title: 'Bitcoin Core',
version: '28.4.0',
})
expect(wrapper.text()).toContain('Choose version when installing')
expect(wrapper.text()).not.toContain('28.4')
})
})
@@ -57,6 +57,12 @@ export interface InstallProgress {
attempt: number
}
/** Apps that ask for their concrete version in InstallVersionModal. Their
* store tiles deliberately omit a single catalog version: showing “v28.4”
* there implies that is the only version immediately before asking the user
* to choose a different one. */
export const MULTI_VERSION_APP_IDS = new Set(['bitcoin-knots', 'bitcoin-core'])
/** Archipelago app registry — all app images are mirrored here */
const REGISTRY = 'source.archipelago-foundation.org/lfg2025'
@@ -0,0 +1,37 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createPinia } from 'pinia'
import { flushPromises, mount } from '@vue/test-utils'
vi.mock('vue-router', () => ({
useRouter: () => ({ push: vi.fn() }),
}))
vi.mock('@/api/rpc-client', () => ({
rpcClient: { call: vi.fn() },
}))
import { rpcClient } from '@/api/rpc-client'
import OpenWrtGateway from './OpenWrtGateway.vue'
describe('OpenWrtGateway stale cached router recovery (#103)', () => {
beforeEach(() => {
vi.clearAllMocks()
sessionStorage.clear()
})
it('offers reconfiguration when the saved router can no longer connect', async () => {
vi.mocked(rpcClient.call).mockRejectedValue(new Error('Connection timed out'))
const wrapper = mount(OpenWrtGateway, {
global: { plugins: [createPinia()] },
})
await flushPromises()
const reconfigure = wrapper.findAll('button').find(button => button.text() === 'Reconfigure router')
expect(reconfigure).toBeDefined()
await reconfigure!.trigger('click')
expect(wrapper.text()).toContain('Connect to Router')
expect(wrapper.text()).not.toContain('Connection timed out')
wrapper.unmount()
})
})
+5 -1
View File
@@ -260,6 +260,7 @@ function pickDetectedRouter(ip: string) {
function disconnectRouter() {
host.value = status.value?.host ?? host.value
connectedParams.value = null
error.value = ''
detectError.value = ''
detectedCandidates.value = []
showConnectForm.value = true
@@ -533,7 +534,10 @@ onMounted(() => {
<!-- Error state -->
<div v-else-if="error" class="glass-card p-6 mb-4">
<p class="text-sm text-red-300">{{ error }}</p>
<button class="mt-3 text-xs text-white/50 hover:text-white transition-colors underline" @click="load()">Retry</button>
<div class="mt-3 flex items-center gap-4">
<button class="text-xs text-white/50 hover:text-white transition-colors underline" @click="load()">Retry</button>
<button class="text-xs text-orange-300/80 hover:text-orange-200 transition-colors underline" @click="disconnectRouter">Reconfigure router</button>
</div>
</div>
<!-- Status panels -->