feat: share reviewed website archives and repair companion setup flows
This commit is contained in:
@@ -0,0 +1,57 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, useId, watch } from 'vue'
|
||||
const props = defineProps<{ options: { id: string; name: string }[]; disabled?: boolean }>()
|
||||
const selected = defineModel<string>({ required: true })
|
||||
const root = ref<HTMLElement>()
|
||||
const trigger = ref<HTMLButtonElement>()
|
||||
const search = ref<HTMLInputElement>()
|
||||
const open = ref(false)
|
||||
const query = ref('')
|
||||
const active = ref(0)
|
||||
const uid = useId()
|
||||
const filtered = computed(() => props.options.filter(option => `${option.name} ${option.id}`.toLowerCase().includes(query.value.trim().toLowerCase())))
|
||||
const label = computed(() => props.options.find(option => option.id === selected.value)?.name || 'Choose an app')
|
||||
watch(query, () => { active.value = 0 })
|
||||
watch(() => props.disabled, value => { if (value) open.value = false })
|
||||
watch(() => props.options, options => { if (selected.value && !options.some(option => option.id === selected.value)) selected.value = '' })
|
||||
function toggle() { open.value = !open.value; query.value = ''; active.value = 0 }
|
||||
function choose(id: string) { selected.value = id; open.value = false; trigger.value?.focus() }
|
||||
function outside(event: Event) { if (!root.value?.contains(event.target as Node)) open.value = false }
|
||||
async function keyboard(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape') { event.preventDefault(); open.value = false; trigger.value?.focus(); return }
|
||||
if (!['ArrowDown', 'ArrowUp', 'Enter'].includes(event.key)) return
|
||||
if (!open.value) {
|
||||
if (event.key === 'Enter') return // native button click opens it
|
||||
event.preventDefault(); toggle(); await nextTick(); search.value?.focus(); return
|
||||
}
|
||||
if (event.key === 'Enter' && event.target === search.value) { event.preventDefault(); if (filtered.value[active.value]) choose(filtered.value[active.value]!.id); return }
|
||||
if (event.key === 'Enter') return
|
||||
event.preventDefault()
|
||||
active.value = Math.max(0, Math.min(filtered.value.length - 1, active.value + (event.key === 'ArrowDown' ? 1 : -1)))
|
||||
search.value?.focus()
|
||||
await nextTick()
|
||||
document.getElementById(`${uid}-option-${active.value}`)?.scrollIntoView?.({ block: 'nearest' })
|
||||
}
|
||||
onMounted(() => { document.addEventListener('pointerdown', outside); document.addEventListener('focusin', outside) })
|
||||
onBeforeUnmount(() => { document.removeEventListener('pointerdown', outside); document.removeEventListener('focusin', outside) })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="root" class="min-w-0" @keydown="keyboard">
|
||||
<span :id="`${uid}-label`" class="block mb-2">Application</span>
|
||||
<button ref="trigger" type="button" class="w-full flex items-center justify-between gap-3 rounded-lg border border-white/15 bg-black/20 px-3 py-3 text-left text-sm text-white/90" :disabled="disabled" :aria-labelledby="`${uid}-label ${uid}-value`" aria-haspopup="listbox" :aria-expanded="open" :aria-controls="`${uid}-list`" @click="toggle">
|
||||
<span :id="`${uid}-value`" class="truncate">{{ label }}</span>
|
||||
<svg class="w-4 h-4 shrink-0 text-white/55" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="m6 9 6 6 6-6" /></svg>
|
||||
</button>
|
||||
<!-- In normal flow so a walkthrough card, keyboard or WebView cannot clip it. -->
|
||||
<div v-if="open" class="glass-card rounded-lg mt-2 p-2 border border-white/10 shadow-xl">
|
||||
<input ref="search" v-model="query" type="search" role="combobox" aria-label="Search apps" aria-autocomplete="list" aria-expanded="true" :aria-controls="`${uid}-list`" :aria-activedescendant="filtered.length ? `${uid}-option-${active}` : undefined" autocomplete="off" class="w-full rounded-lg border border-white/15 bg-black/20 px-3 py-3 text-sm text-white mb-2" placeholder="Search apps…" />
|
||||
<div :id="`${uid}-list`" role="listbox" aria-label="Supported applications" class="max-h-56 overflow-y-auto overscroll-contain">
|
||||
<button v-for="(option, index) in filtered" :id="`${uid}-option-${index}`" :key="option.id" type="button" role="option" :aria-selected="selected === option.id" class="w-full flex items-center justify-between gap-3 rounded-lg px-3 py-3 text-left text-sm text-white/80 hover:bg-white/10 focus-visible:bg-white/10" :class="{ 'bg-white/10 text-white': active === index || selected === option.id }" @click="choose(option.id)">
|
||||
<span class="truncate">{{ option.name }}</span><span v-if="selected === option.id" aria-hidden="true" class="text-orange-300">✓</span>
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="!filtered.length" role="status" class="px-3 py-4 text-sm text-white/60">No matching apps. Try another name.</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,38 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { websitePreview } from '@/services/publishing'
|
||||
const props = defineProps<{
|
||||
publication: { html: string; public_archive?: string | null }
|
||||
archive?: { sha256: string; size: number } | null
|
||||
address?: string | null
|
||||
busy?: boolean
|
||||
}>()
|
||||
const emit = defineEmits<{ change: [enabled: boolean] }>()
|
||||
const approved = ref(false)
|
||||
watch(() => [props.publication, props.archive], () => { approved.value = false })
|
||||
const fileAddress = computed(() => props.address && props.publication.public_archive
|
||||
? `${props.address.replace(/\/$/, '')}/${props.publication.public_archive}` : null)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<details class="rounded-xl border border-white/10 p-4">
|
||||
<summary class="cursor-pointer text-sm font-medium text-white/80">Share the archived website file</summary>
|
||||
<div class="space-y-4 pt-4">
|
||||
<p class="text-sm text-white/60">Let visitors download this exact website snapshot by its Blossom content hash. Other files and the Blossom app stay private. No upload or Nostr announcement is made.</p>
|
||||
<template v-if="publication.public_archive">
|
||||
<p class="text-sm">This snapshot is shared on this connection.</p>
|
||||
<p v-if="fileAddress" class="font-mono text-xs break-all select-all">{{ fileAddress }}</p>
|
||||
<button class="glass-button glass-button-sm rounded-lg px-5 py-2 text-sm font-medium" :disabled="busy" @click="emit('change', false)">Stop sharing this file</button>
|
||||
</template>
|
||||
<template v-else-if="archive">
|
||||
<p class="text-sm text-white/60">The archived file must match the published preview below. Store and publish the same version before sharing.</p>
|
||||
<p class="font-mono text-xs break-all">Archived SHA-256: {{ archive.sha256 }} · {{ archive.size }} bytes</p>
|
||||
<iframe :srcdoc="websitePreview(publication.html)" sandbox="" referrerpolicy="no-referrer" title="Archived file publication preview" class="w-full h-64 rounded-xl bg-white" />
|
||||
<label class="flex items-start gap-3 text-sm"><input v-model="approved" type="checkbox" class="mt-1" /><span>I approve public downloads of this exact snapshot on this connection. Copies may remain after I stop sharing.</span></label>
|
||||
<button class="glass-button glass-button-sm rounded-lg px-5 py-2 text-sm font-medium" :disabled="busy || !approved" @click="emit('change', true)">Share this archived file</button>
|
||||
</template>
|
||||
<p v-else class="text-sm text-white/60">In “Create your website”, save a signed copy in local Blossom first.</p>
|
||||
<p class="text-xs text-white/50">Publishing an update turns file sharing off until you review that version again.</p>
|
||||
</div>
|
||||
</details>
|
||||
</template>
|
||||
@@ -0,0 +1,29 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import SearchableAppSelect from '../SearchableAppSelect.vue'
|
||||
const options = [{ id: 'homeassistant', name: 'Home Assistant' }, { id: 'immich', name: 'Immich' }]
|
||||
describe('searchable app picker', () => {
|
||||
it('renders actual touchable options, filters them and selects only an offered app', async () => {
|
||||
const wrapper = mount(SearchableAppSelect, { props: { modelValue: '', options } })
|
||||
await wrapper.get('button').trigger('click')
|
||||
expect(wrapper.findAll('[role="option"]')).toHaveLength(2)
|
||||
await wrapper.get('input').setValue('imm')
|
||||
expect(wrapper.findAll('[role="option"]')).toHaveLength(1)
|
||||
await wrapper.get('[role="option"]').trigger('click')
|
||||
expect(wrapper.emitted('update:modelValue')).toEqual([['immich']])
|
||||
expect(wrapper.find('[role="listbox"]').exists()).toBe(false)
|
||||
})
|
||||
it('supports keyboard choice and escape without selecting arbitrary search text', async () => {
|
||||
const wrapper = mount(SearchableAppSelect, { props: { modelValue: '', options } })
|
||||
await wrapper.get('button').trigger('keydown', { key: 'ArrowDown' })
|
||||
await wrapper.get('input').setValue('home')
|
||||
await wrapper.get('input').trigger('keydown', { key: 'Enter' })
|
||||
expect(wrapper.emitted('update:modelValue')).toEqual([['homeassistant']])
|
||||
await wrapper.get('button').trigger('click')
|
||||
await wrapper.get('input').setValue('unknown')
|
||||
await wrapper.get('input').trigger('keydown', { key: 'Enter' })
|
||||
expect(wrapper.emitted('update:modelValue')).toHaveLength(1)
|
||||
await wrapper.get('input').trigger('keydown', { key: 'Escape' })
|
||||
expect(wrapper.find('[role="listbox"]').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,26 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import WebsiteArchiveSharing from '../WebsiteArchiveSharing.vue'
|
||||
|
||||
describe('archived website sharing consent', () => {
|
||||
it('requires fresh approval after the reviewed snapshot changes', async () => {
|
||||
const wrapper = mount(WebsiteArchiveSharing, { props: { publication: { html: '<h1>Reviewed</h1>' }, archive: { sha256: 'a'.repeat(64), size: 17 } } })
|
||||
expect(wrapper.get('button').attributes('disabled')).toBeDefined()
|
||||
expect(wrapper.get('iframe').attributes('sandbox')).toBe('')
|
||||
expect(wrapper.get('iframe').attributes('srcdoc')).toContain('<h1>Reviewed</h1>')
|
||||
await wrapper.get('input').setValue(true)
|
||||
await wrapper.get('button').trigger('click')
|
||||
expect(wrapper.emitted('change')).toEqual([[true]])
|
||||
await wrapper.setProps({ publication: { html: '<h1>Different</h1>' } })
|
||||
expect(wrapper.get('button').attributes('disabled')).toBeDefined()
|
||||
expect(wrapper.emitted('change')).toHaveLength(1)
|
||||
})
|
||||
it('keeps removal available without new approval and exposes only the selected hash', async () => {
|
||||
const hash = 'a'.repeat(64)
|
||||
const wrapper = mount(WebsiteArchiveSharing, { props: { publication: { html: 'public', public_archive: hash }, address: 'https://example.com/' } })
|
||||
expect(wrapper.text()).toContain('https://example.com/'+hash)
|
||||
expect(wrapper.find('input').exists()).toBe(false)
|
||||
await wrapper.get('button').trigger('click')
|
||||
expect(wrapper.emitted('change')).toEqual([[false]])
|
||||
})
|
||||
})
|
||||
@@ -7,8 +7,8 @@ export interface WebsiteRevision { id: string; created_at: string; html: string
|
||||
export interface WebsiteProject {
|
||||
id: string; name: string; routes: PublishRoute[]; domain: PublishDomain | null
|
||||
draft: string; revisions: WebsiteRevision[]
|
||||
fips_publication?: { port: number; html: string; created_at: string } | null
|
||||
tor_publication?: { port: number; html: string; created_at: string } | null
|
||||
fips_publication?: { port: number; html: string; created_at: string; public_archive?: string | null } | null
|
||||
tor_publication?: { port: number; html: string; created_at: string; public_archive?: string | null } | null
|
||||
nsite_receipt?: NsiteReceipt | null
|
||||
local_archive?: { sha256: string; size: number; pubkey: string; created_at: string } | null
|
||||
}
|
||||
@@ -16,7 +16,7 @@ export interface PublishingState {
|
||||
schema: number; version: number; connections: PublishRoute[]; projects: Record<string, WebsiteProject>
|
||||
}
|
||||
export interface PublishingStatus {
|
||||
state: PublishingState; fips_address: string | null; publication_enabled: boolean; notice: string
|
||||
state: PublishingState; fips_address: string | null; publication_enabled: boolean; public_archive_enabled?: boolean; notice: string
|
||||
listeners?: { project_id: string; address: string | null; listening: boolean; externally_verified: boolean; error: string | null }[]
|
||||
onions?: { project_id: string; onion_address: string | null; listening: boolean; externally_verified: boolean; error: string | null }[]
|
||||
nostr_relays?: string[]
|
||||
|
||||
@@ -74,7 +74,14 @@ function hideSigner(delay = 0) {
|
||||
}
|
||||
|
||||
function sendIdentity(identity: SelectedIdentity) {
|
||||
parentPost({ type: 'archipelago:signer-identity', identity })
|
||||
// Picker entries are Vue reactive objects. postMessage cannot clone a Proxy;
|
||||
// sending it directly closes the picker and then throws, stranding the app
|
||||
// behind the otherwise empty signer frame. Copy only the public fields.
|
||||
parentPost({ type: 'archipelago:signer-identity', identity: {
|
||||
id: identity.id, name: identity.name, did: identity.did,
|
||||
pubkey: identity.pubkey, nostr_pubkey: identity.nostr_pubkey,
|
||||
nostr_npub: identity.nostr_npub,
|
||||
} })
|
||||
}
|
||||
|
||||
const bridge = useNostrBridge(getStoredIdentity, {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { reactive } from 'vue'
|
||||
import { shallowMount } from '@vue/test-utils'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import NostrTabSigner from '@/views/NostrTabSigner.vue'
|
||||
@@ -16,6 +17,23 @@ describe('NostrTabSigner visibility', () => {
|
||||
window.dispatchEvent(event)
|
||||
}
|
||||
|
||||
it('sends cloneable public identity fields and hides the frame after picker selection', async () => {
|
||||
vi.useFakeTimers()
|
||||
const postMessage = vi.spyOn(window.parent, 'postMessage').mockImplementation(message => {
|
||||
structuredClone(message) // Browser postMessage rejects Vue reactive proxies.
|
||||
})
|
||||
const wrapper = shallowMount(NostrTabSigner)
|
||||
try {
|
||||
parentMessage({ type: 'archipelago:signer-init', appId: 'blossom', appName: 'Blossom' })
|
||||
const identity = reactive({ id: 'profile', name: 'Alice', did: 'did:example:alice', pubkey: 'public', nostr_pubkey: 'a'.repeat(64) })
|
||||
wrapper.findComponent({ name: 'NostrIdentityPicker' }).vm.$emit('select', identity)
|
||||
await Promise.resolve()
|
||||
expect(postMessage).toHaveBeenCalledWith(expect.objectContaining({ type: 'archipelago:signer-identity', identity: expect.objectContaining({ id: 'profile' }) }), window.location.origin)
|
||||
await vi.advanceTimersByTimeAsync(450)
|
||||
expect(postMessage).toHaveBeenCalledWith({ type: 'archipelago:signer-hide' }, window.location.origin)
|
||||
} finally { wrapper.unmount(); vi.useRealTimers() }
|
||||
})
|
||||
|
||||
it('does not reveal the full-screen frame for a silent remembered request', async () => {
|
||||
localStorage.setItem('archipelago_app_identity_archipelago-source', JSON.stringify({
|
||||
id: 'identity-a',
|
||||
|
||||
@@ -11,6 +11,8 @@ import type { NsiteIdentity, PreparedNsite } from '@/services/nsitePublishing'
|
||||
|
||||
import BackButton from '@/components/BackButton.vue'
|
||||
import SetupWalkthrough from '@/components/SetupWalkthrough.vue'
|
||||
import WebsiteArchiveSharing from '@/components/WebsiteArchiveSharing.vue'
|
||||
import SearchableAppSelect from '@/components/SearchableAppSelect.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
@@ -41,16 +43,12 @@ const relays = ref('')
|
||||
const gateway = ref('')
|
||||
const nsiteUrl = ref('')
|
||||
const guestApp = ref('')
|
||||
const guestSearch = ref('')
|
||||
const guestLabel = ref('Guest')
|
||||
const guestHours = ref(24)
|
||||
const issuedAccess = ref<{ id: string; token: string; app_id: string; expires_at: number } | null>(null)
|
||||
onDeactivated(() => { issuedAccess.value = null })
|
||||
onBeforeUnmount(() => { issuedAccess.value = null })
|
||||
const shareableApps = computed(() => status.value?.apps.filter(a => a.guest_access).filter((a, i, all) => all.findIndex(b => b.id === a.id) === i) ?? [])
|
||||
watch(guestSearch, value => {
|
||||
guestApp.value = shareableApps.value.find(app => `${app.name} (${app.id})` === value)?.id ?? ''
|
||||
})
|
||||
const guestTarget = computed(() => shareableApps.value.find(a => a.id === guestApp.value))
|
||||
const acknowledgeNostr = ref(false)
|
||||
const acknowledgeUpload = ref(false)
|
||||
@@ -195,6 +193,16 @@ async function setFipsPublication(enable: boolean) {
|
||||
message.value = enable ? 'Saved version selected for FIPS publication. Check listener status, firewall and access from another FIPS device.' : 'FIPS website unpublished. Your draft and revisions are retained.'
|
||||
})
|
||||
}
|
||||
async function setArchiveSharing(route: 'fips' | 'tor', enable: boolean) {
|
||||
await perform(async () => {
|
||||
if (!status.value || !current.value) return
|
||||
const result = await publishing.update(status.value.state.version, enable
|
||||
? { action: 'share-archive', id: projectId.value, route, acknowledge_public: true }
|
||||
: { action: 'unshare-archive', id: projectId.value, route })
|
||||
status.value.state = result.state
|
||||
message.value = enable ? 'This archived snapshot is available on the selected website connection. Other Blossom files remain private.' : 'File sharing stopped on this connection. Copies already downloaded may remain.'
|
||||
})
|
||||
}
|
||||
async function generate() {
|
||||
await perform(async () => {
|
||||
const result = await publishing.generate(prompt.value, model.value.trim())
|
||||
@@ -471,9 +479,7 @@ onMounted(refresh)
|
||||
<p v-if="!shareableApps.length" class="rounded-xl border border-white/10 bg-white/5 p-4 text-sm text-white/70">No installed apps currently support guest sharing. <RouterLink to="/dashboard/marketplace" class="underline">Browse apps</RouterLink></p>
|
||||
<p v-if="shareableApps.length" class="text-sm text-white/60">Give someone access to one application without sharing your dashboard login. Only apps that explicitly support guest sharing are offered. Their own account permissions still apply.</p>
|
||||
<template v-if="shareableApps.length">
|
||||
<label class="block">Application<input v-model="guestSearch" list="guest-sharing-apps" autocomplete="off" class="field mt-2" placeholder="Search or choose an app" aria-describedby="guest-app-help" /></label>
|
||||
<datalist id="guest-sharing-apps"><option v-for="app in shareableApps" :key="app.id" :value="`${app.name} (${app.id})`" /></datalist>
|
||||
<p id="guest-app-help" class="text-sm text-white/60">Choose a supported app from the suggestions to continue.</p>
|
||||
<SearchableAppSelect v-model="guestApp" :options="shareableApps" :disabled="busy" />
|
||||
<template v-if="guestTarget">
|
||||
<label class="block">Who is this for?<input v-model="guestLabel" maxlength="64" class="field mt-2" /></label>
|
||||
<label class="block">Access expires<select v-model.number="guestHours" class="field mt-2"><option :value="1">After one hour</option><option :value="24">After one day</option><option :value="168">After one week</option><option :value="720">After 30 days</option></select></label>
|
||||
@@ -519,6 +525,7 @@ onMounted(refresh)
|
||||
<p v-if="listener?.address" class="font-mono select-all break-all">{{ listener.address }}</p>
|
||||
<p class="text-white/60">Open this address from another FIPS device to check visitor access.</p>
|
||||
<button v-if="selected.includes('public-web')" class="glass-button glass-button-sm rounded-lg px-5 py-2 text-sm font-medium" @click="walkthroughStep = 'domain'">Connect or check my HTTPS domain</button>
|
||||
<WebsiteArchiveSharing v-if="status.public_archive_enabled" :publication="current.fips_publication" :archive="current.local_archive" :address="selected.includes('public-web') && current.domain?.hostname ? `https://${current.domain.hostname}/` : listener?.address" :busy="busy" @change="setArchiveSharing('fips', $event)" />
|
||||
<details class="text-xs text-white/50"><summary>Connection details</summary><p class="mt-2">Website port {{ current.fips_publication.port }}. A local listener does not confirm access from another device.</p></details>
|
||||
</div>
|
||||
</section>
|
||||
@@ -534,6 +541,7 @@ onMounted(refresh)
|
||||
<p v-if="onion?.error" role="alert">{{ onion.error }}</p>
|
||||
<p v-if="onion?.onion_address" class="font-mono select-all break-all">http://{{ onion.onion_address }}/</p>
|
||||
<p>{{ onion?.listening ? 'Local website listener is ready. Open the address in Tor Browser to check external access.' : 'Waiting for the website listener. Reload to check.' }}</p>
|
||||
<WebsiteArchiveSharing v-if="status.public_archive_enabled" :publication="current.tor_publication" :archive="current.local_archive" :address="onion?.onion_address ? `http://${onion.onion_address}/` : null" :busy="busy" @change="setArchiveSharing('tor', $event)" />
|
||||
<p class="text-amber-200">An address alone does not confirm that Tor has connected or that visitors can reach the page.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -82,9 +82,12 @@ describe('publishing setup', () => {
|
||||
const wrapper = mount(PublishingSetup); await flushPromises()
|
||||
await wrapper.get('[aria-controls="setup-step-sharing"]').trigger('click')
|
||||
expect(rpcClient.call).not.toHaveBeenCalled()
|
||||
await wrapper.get('input[list="guest-sharing-apps"]').setValue('not a supported app')
|
||||
await wrapper.get('button[aria-haspopup="listbox"]').trigger('click')
|
||||
await wrapper.get('input[role="combobox"]').setValue('not a supported app')
|
||||
expect(wrapper.text()).not.toContain('Create app-only access')
|
||||
await wrapper.get('input[list="guest-sharing-apps"]').setValue('Nextcloud (nextcloud)')
|
||||
expect(wrapper.text()).toContain('No matching apps')
|
||||
await wrapper.get('input[role="combobox"]').setValue('Nextcloud')
|
||||
await wrapper.get('[role="option"]').trigger('click')
|
||||
await wrapper.findAll('button').find(b => b.text() === 'Create app-only access')!.trigger('click'); await flushPromises()
|
||||
expect(rpcClient.call).toHaveBeenCalledWith({ method: 'publishing.access-create', params: { app_id: 'nextcloud', label: 'Guest', hours: 24 }, maxRetries: 0 })
|
||||
expect(wrapper.text()).toContain('synthetic-test-token')
|
||||
|
||||
Reference in New Issue
Block a user