Merge ngit external-access PR 79ca68c1 into combined UAT candidate
Preserve current maintenance/session guards, Firewall UI and existing catalogs. Retain scoped guest access, publishing journeys and local Blossom integration. Normalize Blossom/router memory units to supported quadlet suffixes. Validation: 108 dashboard tests, 10 gateway policy tests, strict source catalog check. Integrated isolated backend qualification remains required before main.
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<BaseModal :show="show && !funding" title="Connect your AI" max-width="max-w-xl" @close="close">
|
||||
<BaseModal :manage-history="false" :show="show && !funding" title="Connect your AI" max-width="max-w-xl" @close="close">
|
||||
<p class="text-sm text-white/60 mb-4">Choose how your assistant connects. API keys stay on this node and never enter your chat.</p>
|
||||
<p v-if="loading" role="status" class="text-sm text-white/60 mb-3">Checking this node…</p>
|
||||
<p v-if="error" role="alert" class="text-sm text-amber-200 mb-3">{{ error }}</p>
|
||||
@@ -8,19 +8,22 @@
|
||||
</div>
|
||||
<form v-if="mode === 'claude' || mode === 'openai'" class="space-y-3" @submit.prevent="save">
|
||||
<p class="text-xs text-white/60">{{ mode === 'openai' ? 'Use an OpenAI API key with API billing. Choose a chat model your project can access.' : 'Use your Anthropic API key. Usage is billed to that API account.' }}</p>
|
||||
<p v-if="configured" class="text-sm text-white/70">A key is already configured. Leave this field empty to keep it.</p>
|
||||
<label class="block text-sm text-white/80" for="ai-connection-key">{{ mode === 'openai' ? 'OpenAI' : 'Claude' }} API key</label>
|
||||
<input id="ai-connection-key" v-model="key" type="password" autocomplete="off" spellcheck="false" class="input-glass w-full" placeholder="Paste API key" :disabled="saving" />
|
||||
<p v-if="configured" class="text-sm text-white/70" role="status">Your {{ mode === 'claude' ? 'Claude' : 'OpenAI' }} API key from Settings is ready to use.</p>
|
||||
<button v-if="configured && !replacingKey" type="button" class="glass-button rounded-lg px-4 py-2 text-sm text-white" @click="replacingKey = true">Change API key</button>
|
||||
<template v-if="status && (!configured || replacingKey)">
|
||||
<label class="block text-sm text-white/80" for="ai-connection-key">{{ mode === 'openai' ? 'OpenAI' : 'Claude' }} API key</label>
|
||||
<input id="ai-connection-key" v-model="key" type="password" autocomplete="off" spellcheck="false" class="input-glass w-full" placeholder="Paste API key" :disabled="saving" />
|
||||
</template>
|
||||
<template v-if="mode === 'openai'">
|
||||
<label class="block text-sm text-white/80" for="ai-connection-model">Model ID</label>
|
||||
<input id="ai-connection-model" v-model="model" type="text" spellcheck="false" class="input-glass w-full" placeholder="Enter your OpenAI model ID" :disabled="saving" />
|
||||
</template>
|
||||
<button class="glass-button rounded-lg px-4 py-2 text-sm text-white w-full" :disabled="saving || loading || (!key.trim() && !configured) || (mode === 'openai' && !model.trim())">{{ saving ? 'Saving…' : 'Save and continue' }}</button>
|
||||
<button class="glass-button rounded-lg px-4 py-2 text-sm text-white w-full" :disabled="saving || loading || (!key.trim() && !configured) || (mode === 'openai' && !model.trim())">{{ saving ? 'Saving…' : configured && !key.trim() ? (mode === 'claude' ? 'Use Claude' : 'Use OpenAI') : 'Save and continue' }}</button>
|
||||
</form>
|
||||
<div v-else-if="mode === 'routstr'" class="space-y-3">
|
||||
<p class="text-sm text-white/70">Pay for AI with ecash. Add funds to this node’s wallet and set the most it may spend.</p>
|
||||
<p class="text-sm text-white/70">Ecash balance: {{ balance === null ? 'Unavailable' : balance.toLocaleString() + ' sats' }}</p>
|
||||
<button class="glass-button rounded-lg px-4 py-2 text-sm text-white" @click="funding = true">Add ecash</button>
|
||||
<button class="glass-button rounded-lg px-4 py-2 text-sm text-white" @click="funding = true">Top up with ecash</button>
|
||||
<RoutstrBudgetSection />
|
||||
<button class="glass-button rounded-lg px-4 py-2 text-sm text-white w-full" @click="continueRoutstr">Use Routstr</button>
|
||||
<p class="text-xs text-white/50">The selected model’s price and accepted mint still apply. No payment is sent by opening this setup.</p>
|
||||
@@ -28,14 +31,17 @@
|
||||
<button v-if="status?.local_ready" class="glass-button rounded-lg px-4 py-2 text-sm text-white mt-4 w-full" @click="useLocal">Use local AI</button>
|
||||
<p class="text-xs text-white/40 mt-4">Your draft stays in place when you close this window.</p>
|
||||
</BaseModal>
|
||||
<ReceiveBitcoinModal :show="show && funding" initial-method="ecash" @close="finishFunding" @received="finishFunding" />
|
||||
<ReceiveBitcoinModal :manage-history="false" :show="show && funding && !scanning" initial-method="ecash" @close="finishFunding" @received="finishFunding" @scan="scanning = true" />
|
||||
<WalletScanModal :show="show && scanning" @close="finishScan" @sent="finishScan" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useModalHistory } from '@/composables/useModalHistory'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import BaseModal from './BaseModal.vue'
|
||||
import ReceiveBitcoinModal from './ReceiveBitcoinModal.vue'
|
||||
import WalletScanModal from './WalletScanModal.vue'
|
||||
import RoutstrBudgetSection from '@/views/settings/RoutstrBudgetSection.vue'
|
||||
|
||||
type Choice = 'claude' | 'openai' | 'routstr'
|
||||
@@ -49,9 +55,12 @@ interface ProviderStatus {
|
||||
}
|
||||
const props = defineProps<{ show: boolean }>()
|
||||
const emit = defineEmits<{ close: []; configured: [provider: Choice | 'auto' | 'local', model?: string] }>()
|
||||
const choices: { id: Choice; label: string }[] = [{ id: 'claude', label: 'Claude API' }, { id: 'openai', label: 'OpenAI API' }, { id: 'routstr', label: 'Routstr · sats' }]
|
||||
const mode = ref<Choice | null>(null)
|
||||
const choices: { id: Choice; label: string }[] = [{ id: 'routstr', label: 'Routstr · sats' }, { id: 'claude', label: 'Claude API' }, { id: 'openai', label: 'OpenAI API' }]
|
||||
// Keep one browser Back entry while switching between connection, funding and scan.
|
||||
useModalHistory(computed(() => props.show), close)
|
||||
const mode = ref<Choice | null>('routstr')
|
||||
const key = ref('')
|
||||
const replacingKey = ref(false)
|
||||
const model = ref('')
|
||||
const status = ref<ProviderStatus | null>(null)
|
||||
const balance = ref<number | null>(null)
|
||||
@@ -59,6 +68,7 @@ const error = ref('')
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const funding = ref(false)
|
||||
const scanning = ref(false)
|
||||
const configured = computed(() => mode.value === 'claude' ? status.value?.claude_configured : status.value?.openai_configured)
|
||||
let refreshPromise: Promise<ProviderStatus | null> | null = null
|
||||
async function refresh(): Promise<ProviderStatus | null> {
|
||||
@@ -95,8 +105,8 @@ async function refreshBalance() {
|
||||
try { const result = await rpcClient.call<{ balance_sats: number }>({ method: 'wallet.ecash-balance', timeout: 8000 }); balance.value = Number.isFinite(result.balance_sats) ? result.balance_sats : null }
|
||||
catch { balance.value = null }
|
||||
}
|
||||
function choose(choice: Choice) { key.value = ''; error.value = ''; mode.value = choice; if (choice === 'routstr') void refreshBalance() }
|
||||
function close() { key.value = ''; funding.value = false; emit('close') }
|
||||
function choose(choice: Choice) { key.value = ''; replacingKey.value = false; error.value = ''; mode.value = choice; if (choice === 'routstr') void refreshBalance() }
|
||||
function close() { key.value = ''; funding.value = false; scanning.value = false; emit('close') }
|
||||
async function save() {
|
||||
if ((mode.value !== 'claude' && mode.value !== 'openai') || saving.value) return
|
||||
const provider = mode.value
|
||||
@@ -128,8 +138,9 @@ async function continueRoutstr() {
|
||||
emit('configured', 'routstr'); close()
|
||||
} catch { error.value = 'Could not select Routstr. Try again.' }
|
||||
}
|
||||
async function finishScan() { scanning.value = false; await refreshBalance() }
|
||||
async function finishFunding() { funding.value = false; await refreshBalance() }
|
||||
watch(() => props.show, open => { if (open) { error.value = ''; void refresh() } else { key.value = ''; funding.value = false } })
|
||||
watch(() => props.show, open => { if (open) { error.value = ''; void refresh(); if (mode.value === 'routstr') void refreshBalance() } else { key.value = ''; replacingKey.value = false; funding.value = false; scanning.value = false } })
|
||||
async function syncSelection() { const state = await refresh(); if (state) emit('configured', state.settings.provider, state.settings.provider === 'openai' ? state.settings.openai_model : undefined) }
|
||||
defineExpose({ checkNeeded, syncSelection, showRoutstr: () => choose('routstr') })
|
||||
</script>
|
||||
|
||||
@@ -62,10 +62,13 @@ const props = withDefaults(defineProps<{
|
||||
maxWidth?: string
|
||||
zIndex?: string
|
||||
contentClass?: string
|
||||
/** A parent flow may own one history entry across several modal panels. */
|
||||
manageHistory?: boolean
|
||||
}>(), {
|
||||
maxWidth: 'max-w-md',
|
||||
zIndex: 'z-[3000]',
|
||||
contentClass: '',
|
||||
manageHistory: true,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -109,7 +112,7 @@ useBodyScrollLock(computed(() => props.show))
|
||||
// Browser/mouse/gesture Back closes the modal instead of navigating the
|
||||
// router out from under it — the native-app behaviour kiosk and mobile
|
||||
// browsers expect (the companion webview already provides it natively).
|
||||
useModalHistory(computed(() => props.show), close)
|
||||
useModalHistory(computed(() => props.show && props.manageHistory), close)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<RouterLink
|
||||
v-for="(goal, idx) in goals"
|
||||
:key="goal.id"
|
||||
:to="`/dashboard/goals/${goal.id}`"
|
||||
:to="goal.route || `/dashboard/goals/${goal.id}`"
|
||||
class="goal-card glass-card p-6 block"
|
||||
:class="{ 'home-card-animate': animate }"
|
||||
:style="{ '--card-stagger': idx }"
|
||||
@@ -85,6 +85,8 @@ function goalAppIcons(goal: GoalDefinition): { appId: string; url: string }[] {
|
||||
|
||||
function goalIcon(icon: string): string {
|
||||
const icons: Record<string, string> = {
|
||||
globe: '🌐',
|
||||
website: '📝',
|
||||
shop: '🏪',
|
||||
payments: '⚡',
|
||||
photos: '📸',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<BaseModal :show="show && !receiveSuccess" :title="t('web5.receiveBitcoinTitle')" max-width="max-w-2xl" content-class="max-h-[90vh] overflow-y-auto" @close="close">
|
||||
<BaseModal :manage-history="manageHistory !== false" :show="show && !receiveSuccess" :title="t('web5.receiveBitcoinTitle')" max-width="max-w-2xl" content-class="max-h-[90vh] overflow-y-auto" @close="close">
|
||||
<!-- Method tabs -->
|
||||
<div class="flex gap-1 mb-4 p-1 bg-white/5 rounded-lg">
|
||||
<button
|
||||
@@ -111,6 +111,7 @@
|
||||
<!-- Completion is deliberately its own modal, matching the Lightning
|
||||
payment moment. It is not an inline status inside the receive form. -->
|
||||
<BaseModal
|
||||
:manage-history="manageHistory !== false"
|
||||
:show="show && !!receiveSuccess"
|
||||
title="Payment received"
|
||||
max-width="max-w-2xl"
|
||||
@@ -147,6 +148,7 @@ const lightning = useLightningRequired()
|
||||
|
||||
const props = defineProps<{
|
||||
show: boolean
|
||||
manageHistory?: boolean
|
||||
initialMethod?: 'lightning' | 'onchain' | 'ecash' | 'ark'
|
||||
/** Optional info banner shown on the on-chain tab (e.g. Zeus channel limits) */
|
||||
note?: string
|
||||
|
||||
@@ -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,64 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
|
||||
export interface SetupStep { id: string; title: string; description: string; complete: boolean }
|
||||
const props = defineProps<{ steps: SetupStep[]; busy?: boolean; continueLabel?: string; continueDisabled?: boolean }>()
|
||||
const emit = defineEmits<{ next: [step: string] }>()
|
||||
const active = defineModel<string>({ required: true })
|
||||
const root = ref<HTMLElement>()
|
||||
const index = computed(() => Math.max(0, props.steps.findIndex(step => step.id === active.value)))
|
||||
const completed = computed(() => props.steps.filter(step => step.complete).length)
|
||||
watch(() => props.steps, steps => {
|
||||
if (!steps.some(step => step.id === active.value)) active.value = steps.find(step => !step.complete)?.id ?? steps[0]?.id ?? ''
|
||||
})
|
||||
watch(active, async () => {
|
||||
await nextTick()
|
||||
const heading = root.value?.querySelector<HTMLButtonElement>('button[aria-expanded="true"]')
|
||||
heading?.focus({ preventScroll: true })
|
||||
heading?.scrollIntoView?.({ block: 'nearest', behavior: window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ? 'instant' : 'smooth' })
|
||||
})
|
||||
function move(offset: number) {
|
||||
const step = props.steps[index.value + offset]
|
||||
if (step) active.value = step.id
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="root">
|
||||
<div class="mb-8">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-sm text-white/60">Step {{ index + 1 }} of {{ steps.length }}</span>
|
||||
<span class="goal-status-badge goal-status-badge-in-progress">In progress</span>
|
||||
</div>
|
||||
<div class="w-full h-2 bg-white/10 rounded-full overflow-hidden" role="progressbar" aria-label="Saved setup steps" :aria-valuenow="completed" :aria-valuemax="steps.length" aria-valuemin="0">
|
||||
<div class="h-full rounded-full bg-orange-400 transition-all duration-500 ease-out" :style="{ width: `${(completed / steps.length) * 100}%` }" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
<section v-for="(step, stepIndex) in steps" :key="step.id" class="glass-card p-0 overflow-hidden">
|
||||
<div class="goal-step" :class="{ 'goal-step-completed': step.complete, 'goal-step-active': active === step.id, 'goal-step-pending': stepIndex > index && !step.complete }">
|
||||
<button type="button" class="flex items-start gap-4 w-full text-left" :disabled="busy" :aria-expanded="active === step.id" :aria-controls="`setup-step-${step.id}`" @click="active = step.id">
|
||||
<span class="mt-0.5 shrink-0 w-6 h-6 rounded-full flex items-center justify-center" :class="step.complete ? 'bg-green-500/20' : 'bg-white/10'">
|
||||
<svg v-if="step.complete" class="w-4 h-4 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" /></svg>
|
||||
<span v-else class="text-xs text-white/40 font-medium">{{ stepIndex + 1 }}</span>
|
||||
</span>
|
||||
<span class="flex-1 min-w-0"><span class="block text-base font-semibold text-white/90 mb-1">{{ step.title }}</span><span class="block text-sm text-white/55 leading-relaxed">{{ step.description }}</span></span>
|
||||
</button>
|
||||
<div v-if="active === step.id" :id="`setup-step-${step.id}`" class="mt-5 sm:ml-10">
|
||||
<fieldset :disabled="busy" class="space-y-8 min-w-0"><slot :name="step.id" /></fieldset>
|
||||
<div class="flex flex-wrap items-center gap-3 mt-8 pt-6 border-t border-white/10">
|
||||
<button v-if="stepIndex > 0" type="button" class="glass-button glass-button-sm rounded-lg px-5 py-2 text-sm font-medium" :disabled="busy" @click="move(-1)">Back</button>
|
||||
<button v-if="stepIndex < steps.length - 1" type="button" class="glass-button glass-button-sm rounded-lg px-5 py-2 text-sm font-medium" :disabled="busy || continueDisabled" @click="emit('next', steps[stepIndex + 1]!.id)">{{ continueLabel || (step.id === 'storage' ? 'Continue without installing' : 'Continue') }}<span aria-hidden="true">→</span></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.goal-step-pending { opacity: .72; }
|
||||
button:focus-visible { outline: 2px solid #fb923c; outline-offset: 4px; }
|
||||
@media (prefers-reduced-motion: reduce) { .transition-all { transition: none; } }
|
||||
</style>
|
||||
@@ -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>
|
||||
@@ -1,15 +1,49 @@
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import AIConnectionModal from '../AIConnectionModal.vue'
|
||||
import ReceiveBitcoinModal from '../ReceiveBitcoinModal.vue'
|
||||
import WalletScanModal from '../WalletScanModal.vue'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
vi.mock('@/api/rpc-client', () => ({ rpcClient: { call: vi.fn() } }))
|
||||
vi.mock('../ReceiveBitcoinModal.vue', () => ({ default: { props: ['show', 'initialMethod'], template: '<div />' } }))
|
||||
vi.mock('../WalletScanModal.vue', () => ({ default: { props: ['show'], template: '<div />' } }))
|
||||
vi.mock('../ReceiveBitcoinModal.vue', () => ({ default: { props: ['show', 'initialMethod', 'manageHistory'], template: '<div />' } }))
|
||||
vi.mock('@/views/settings/RoutstrBudgetSection.vue', () => ({ default: { template: '<div />' } }))
|
||||
const state = () => ({ schema: 1, settings: { provider: 'auto', openai_model: '' }, claude_configured: false, openai_configured: false, local_ready: false, routstr_remaining_sats: 0 })
|
||||
function mountModal() { return mount(AIConnectionModal, { props: { show: false }, global: { stubs: { BaseModal: { props: ['show'], template: '<div v-if="show"><slot /></div>' } } } }) }
|
||||
function button(w: ReturnType<typeof mountModal>, label: string) { return w.findAll('button').find(b => b.text() === label)! }
|
||||
beforeEach(() => { vi.clearAllMocks(); vi.mocked(rpcClient.call).mockImplementation(async ({ method }) => method === 'system.settings.get' ? { value: state() } : {}) })
|
||||
describe('AI connection setup', () => {
|
||||
it('recognizes the existing Claude key and selects it without reading or rewriting the secret', async () => {
|
||||
vi.mocked(rpcClient.call).mockImplementation(async ({ method }) => method === 'system.settings.get' ? { value: { ...state(), claude_configured: true } } : {})
|
||||
const w = mountModal(); await w.setProps({ show: true }); await flushPromises()
|
||||
await button(w, 'Claude API').trigger('click')
|
||||
expect(w.text()).toContain('Your Claude API key from Settings is ready to use.')
|
||||
expect(w.find('#ai-connection-key').exists()).toBe(false)
|
||||
expect(button(w, 'Use Claude').attributes('disabled')).toBeUndefined()
|
||||
await w.get('form').trigger('submit'); await flushPromises()
|
||||
const writes = vi.mocked(rpcClient.call).mock.calls.map(([r]) => r).filter(r => r.method === 'system.settings.set')
|
||||
expect(writes.map(r => r.params)).toEqual([{ key: 'ai_provider', value: JSON.stringify({ provider: 'claude', openai_model: '' }) }])
|
||||
expect(w.emitted('configured')).toEqual([['claude', undefined]])
|
||||
await button(w, 'Change API key').trigger('click')
|
||||
expect((w.get('#ai-connection-key').element as HTMLInputElement).value).toBe('')
|
||||
await w.setProps({ show: false }); w.unmount()
|
||||
})
|
||||
|
||||
it('opens on Routstr with a top-up action without authorizing spending', async () => {
|
||||
const w = mountModal(); await w.setProps({ show: true }); await flushPromises()
|
||||
expect(button(w, 'Routstr · sats').attributes('aria-pressed')).toBe('true')
|
||||
expect(button(w, 'Top up with ecash').exists()).toBe(true)
|
||||
expect(vi.mocked(rpcClient.call).mock.calls.every(([r]) => !['assistant.budget-set', 'system.settings.set'].includes(r.method))).toBe(true)
|
||||
await button(w, 'Top up with ecash').trigger('click')
|
||||
expect(w.findComponent(ReceiveBitcoinModal).props()).toMatchObject({ show: true, initialMethod: 'ecash', manageHistory: false })
|
||||
w.findComponent(ReceiveBitcoinModal).vm.$emit('scan'); await flushPromises()
|
||||
expect(w.findComponent(WalletScanModal).props('show')).toBe(true)
|
||||
expect(w.findComponent(ReceiveBitcoinModal).props('show')).toBe(false)
|
||||
w.findComponent(WalletScanModal).vm.$emit('close'); await flushPromises()
|
||||
expect(w.findComponent(ReceiveBitcoinModal).props('show')).toBe(true)
|
||||
w.unmount()
|
||||
})
|
||||
|
||||
it('detects absent configuration without treating a failed status query as missing keys', async () => {
|
||||
const w = mountModal()
|
||||
expect(await (w.vm as any).checkNeeded()).toBe(true)
|
||||
@@ -26,7 +60,7 @@ describe('AI connection setup', () => {
|
||||
await w.get('form').trigger('submit'); await flushPromises()
|
||||
const writes = vi.mocked(rpcClient.call).mock.calls.map(([r]) => r).filter(r => r.method === 'system.settings.set')
|
||||
expect(writes.map(r => r.params)).toEqual([{ key: 'openai_api_key', value: 'test-private-key' }, { key: 'ai_provider', value: JSON.stringify({ provider: 'openai', openai_model: 'test-chat-model' }) }])
|
||||
expect((w.get('#ai-connection-key').element as HTMLInputElement).value).toBe('')
|
||||
expect(w.find('#ai-connection-key').exists()).toBe(false)
|
||||
expect(w.emitted('configured')).toEqual([['openai', 'test-chat-model']])
|
||||
expect(JSON.stringify(w.emitted())).not.toContain('test-private-key')
|
||||
w.unmount()
|
||||
|
||||
@@ -1,9 +1,20 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createRouter, createMemoryHistory } from 'vue-router'
|
||||
import BaseModal from '../BaseModal.vue'
|
||||
|
||||
describe('BaseModal', () => {
|
||||
it('lets a parent flow own history while panels change', async () => {
|
||||
const push = vi.spyOn(window.history, 'pushState')
|
||||
const back = vi.spyOn(window.history, 'back')
|
||||
const wrapper = mount(BaseModal, { props: { show: false, title: 'Panel', manageHistory: false } })
|
||||
await wrapper.setProps({ show: true })
|
||||
await wrapper.setProps({ show: false })
|
||||
expect(push).not.toHaveBeenCalled()
|
||||
expect(back).not.toHaveBeenCalled()
|
||||
wrapper.unmount(); push.mockRestore(); back.mockRestore()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
document.body.style.overflow = ''
|
||||
})
|
||||
|
||||
@@ -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]])
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user