Compare commits
3
Commits
ee5123af68
...
b4714f1773
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b4714f1773 | ||
|
|
d79ca54019 | ||
|
|
758332d63d |
@@ -50,6 +50,7 @@ const showRevealModal = ref(false)
|
|||||||
const revealPassword = ref('')
|
const revealPassword = ref('')
|
||||||
const revealCode = ref('')
|
const revealCode = ref('')
|
||||||
const revealPassphrase = ref('')
|
const revealPassphrase = ref('')
|
||||||
|
const showRevealPassphrase = ref(false)
|
||||||
const revealing = ref(false)
|
const revealing = ref(false)
|
||||||
const revealError = ref('')
|
const revealError = ref('')
|
||||||
const revealedWords = ref<string[]>([])
|
const revealedWords = ref<string[]>([])
|
||||||
@@ -60,6 +61,7 @@ function openReveal() {
|
|||||||
revealPassword.value = ''
|
revealPassword.value = ''
|
||||||
revealCode.value = ''
|
revealCode.value = ''
|
||||||
revealPassphrase.value = ''
|
revealPassphrase.value = ''
|
||||||
|
showRevealPassphrase.value = false
|
||||||
revealError.value = ''
|
revealError.value = ''
|
||||||
revealedWords.value = []
|
revealedWords.value = []
|
||||||
showRevealModal.value = true
|
showRevealModal.value = true
|
||||||
@@ -83,7 +85,17 @@ async function submitReveal() {
|
|||||||
// to set up a backup that now exists.
|
// to set up a backup that now exists.
|
||||||
void loadStatus()
|
void loadStatus()
|
||||||
} catch (e: unknown) {
|
} 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 {
|
} finally {
|
||||||
revealing.value = false
|
revealing.value = false
|
||||||
}
|
}
|
||||||
@@ -95,6 +107,7 @@ function closeReveal() {
|
|||||||
revealPassword.value = ''
|
revealPassword.value = ''
|
||||||
revealCode.value = ''
|
revealCode.value = ''
|
||||||
revealPassphrase.value = ''
|
revealPassphrase.value = ''
|
||||||
|
showRevealPassphrase.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
async function copyRevealedWords() {
|
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>
|
<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" />
|
<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>
|
||||||
<div v-if="!status?.active">
|
<div v-if="showRevealPassphrase">
|
||||||
<label class="block text-xs text-white/60 mb-1">Backup passphrase <span class="text-white/30">(only if different from password)</span></label>
|
<label class="block text-xs text-white/60 mb-1">Separate backup passphrase</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" />
|
<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>
|
</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>
|
<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">
|
<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'"
|
:class="tierLabel === 'core' ? 'tier-badge-core' : 'tier-badge-recommended'"
|
||||||
>{{ tierLabel }}</span>
|
>{{ tierLabel }}</span>
|
||||||
</h3>
|
</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>
|
<p v-if="app.author" class="text-xs text-white/50 mt-1">by {{ app.author }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -175,7 +176,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
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'
|
import { DEFAULT_APP_ICON } from '@/views/apps/appsConfig'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
@@ -200,6 +201,8 @@ defineEmits<{
|
|||||||
launch: [app: MarketplaceApp]
|
launch: [app: MarketplaceApp]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
const isMultiVersion = computed(() => MULTI_VERSION_APP_IDS.has(props.app.id))
|
||||||
|
|
||||||
const signatureLabel = computed(() => {
|
const signatureLabel = computed(() => {
|
||||||
switch (props.app.signature?.status) {
|
switch (props.app.signature?.status) {
|
||||||
case 'valid': return 'signed'
|
case 'valid': return 'signed'
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ const app: MarketplaceApp = {
|
|||||||
source: 'community',
|
source: 'community',
|
||||||
}
|
}
|
||||||
|
|
||||||
function mountCard(installed: boolean, installBlockedReason?: string) {
|
function mountCard(installed: boolean, installBlockedReason?: string, appOverride: MarketplaceApp = app) {
|
||||||
const i18n = createI18n({
|
const i18n = createI18n({
|
||||||
legacy: false,
|
legacy: false,
|
||||||
locale: 'en',
|
locale: 'en',
|
||||||
@@ -29,7 +29,7 @@ function mountCard(installed: boolean, installBlockedReason?: string) {
|
|||||||
|
|
||||||
return mount(MarketplaceAppCard, {
|
return mount(MarketplaceAppCard, {
|
||||||
props: {
|
props: {
|
||||||
app,
|
app: appOverride,
|
||||||
index: 0,
|
index: 0,
|
||||||
stagger: false,
|
stagger: false,
|
||||||
installed,
|
installed,
|
||||||
@@ -65,4 +65,16 @@ describe('MarketplaceAppCard', () => {
|
|||||||
expect(wrapper.text()).toContain('Requires a full archive Bitcoin node before install.')
|
expect(wrapper.text()).toContain('Requires a full archive Bitcoin node before install.')
|
||||||
expect(wrapper.text()).toContain('Bitcoin Pruned')
|
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
|
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 */
|
/** Archipelago app registry — all app images are mirrored here */
|
||||||
const REGISTRY = 'source.archipelago-foundation.org/lfg2025'
|
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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -260,6 +260,7 @@ function pickDetectedRouter(ip: string) {
|
|||||||
function disconnectRouter() {
|
function disconnectRouter() {
|
||||||
host.value = status.value?.host ?? host.value
|
host.value = status.value?.host ?? host.value
|
||||||
connectedParams.value = null
|
connectedParams.value = null
|
||||||
|
error.value = ''
|
||||||
detectError.value = ''
|
detectError.value = ''
|
||||||
detectedCandidates.value = []
|
detectedCandidates.value = []
|
||||||
showConnectForm.value = true
|
showConnectForm.value = true
|
||||||
@@ -533,7 +534,10 @@ onMounted(() => {
|
|||||||
<!-- Error state -->
|
<!-- Error state -->
|
||||||
<div v-else-if="error" class="glass-card p-6 mb-4">
|
<div v-else-if="error" class="glass-card p-6 mb-4">
|
||||||
<p class="text-sm text-red-300">{{ error }}</p>
|
<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>
|
</div>
|
||||||
|
|
||||||
<!-- Status panels -->
|
<!-- Status panels -->
|
||||||
|
|||||||
Reference in New Issue
Block a user