feat: polish publishing guides around existing setup patterns

This commit is contained in:
archipelago
2026-10-08 10:48:50 -04:00
parent 779d4d66e1
commit d63c90cb5f
7 changed files with 157 additions and 34 deletions
+28 -1
View File
@@ -100,7 +100,34 @@ been created. The earlier standalone proxy route/certificate were removed. A new
route now connects the dashboard-published synthetic site to route now connects the dashboard-published synthetic site to
`https://free.archipelago.builders` through Yaya. Trusted TLS, exact page bytes, `https://free.archipelago.builders` through Yaya. Trusted TLS, exact page bytes,
`/rpc` returning 404, and traversal rejection (400) pass. The route remains for UAT; `/rpc` returning 404, and traversal rejection (400) pass. The route remains for UAT;
full revoke/restart qualification is still in progress. FIPS/HTTPS revocation retained the Tor publication and archive, and Tor
revocation retained HTTPS. Republishing retained the onion hostname. The existing
Yaya Tor client timed out after republish, while a separate fresh Tor client
fetched the exact restored page; do not treat the first timeout as a confirmed
publisher defect or a universal reachability pass. Temporary notice logging was
removed and the isolated test client was stopped after qualification.
A management-service restart retained exact project/archive state, all app
container IDs/states, and native Bitcoin/LND PIDs/start times. Public HTTPS and
Tor both returned exact content after that restart. A full machine reboot is
separate and awaits the operator's recovery arrangement. The actual dashboard
Blossom iframe passed profile selection, explicit denial with zero uploads, and
an approved local upload through the canonical signer. Physical companion
acceptance remains separate; this was a browser iframe test.
The operator requested a further UX pass informed by all existing guides. The
seven existing goal guides, help tree, shared walkthrough and onboarding patterns
were reviewed. The revised flow uses concise visitor-oriented multiselect cards,
an AIUI-first creation path, optional HTML/model controls, a saved-version preview,
explicit Save and continue, and contextual Help entries. The Home shortcuts now
respect the same dedicated guide routes as the Setup cards. The final polish is active on Framework. The production typecheck and build pass,
as do 23 focused tests. The deployed flow again passed real draft save, local
Blossom archive/readback, FIPS publishing and the mobile overflow check, with zero
external browser requests. It preserves original Setup visuals, a single main
Save and continue action, keyboard focus/scroll handling, and visible revoke
controls even when an already-published route is deselected.
No local Ollama service responded on Framework; live model generation remains
unqualified. No proprietary fallback was used or added.
New source work includes local Blossom website archives, explicit app-only guest New source work includes local Blossom website archives, explicit app-only guest
credentials, and an on-demand HTTPS check against exact published page bytes. credentials, and an on-demand HTTPS check against exact published page bytes.
+18 -4
View File
@@ -1,14 +1,22 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, watch } from 'vue' import { computed, nextTick, ref, watch } from 'vue'
export interface SetupStep { id: string; title: string; description: string; complete: boolean } export interface SetupStep { id: string; title: string; description: string; complete: boolean }
const props = defineProps<{ steps: SetupStep[]; busy?: 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 active = defineModel<string>({ required: true })
const root = ref<HTMLElement>()
const index = computed(() => Math.max(0, props.steps.findIndex(step => step.id === active.value))) const index = computed(() => Math.max(0, props.steps.findIndex(step => step.id === active.value)))
const completed = computed(() => props.steps.filter(step => step.complete).length) const completed = computed(() => props.steps.filter(step => step.complete).length)
watch(() => props.steps, steps => { watch(() => props.steps, steps => {
if (!steps.some(step => step.id === active.value)) active.value = steps.find(step => !step.complete)?.id ?? steps[0]?.id ?? '' 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) { function move(offset: number) {
const step = props.steps[index.value + offset] const step = props.steps[index.value + offset]
if (step) active.value = step.id if (step) active.value = step.id
@@ -16,7 +24,7 @@ function move(offset: number) {
</script> </script>
<template> <template>
<div> <div ref="root">
<div class="mb-8"> <div class="mb-8">
<div class="flex items-center justify-between mb-2"> <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="text-sm text-white/60">Step {{ index + 1 }} of {{ steps.length }}</span>
@@ -40,7 +48,7 @@ function move(offset: number) {
<fieldset :disabled="busy" class="space-y-6 min-w-0"><slot :name="step.id" /></fieldset> <fieldset :disabled="busy" class="space-y-6 min-w-0"><slot :name="step.id" /></fieldset>
<div class="flex flex-wrap justify-between gap-3 mt-6 pt-4 border-t border-white/10"> <div class="flex flex-wrap justify-between gap-3 mt-6 pt-4 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 > 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 ml-auto" :disabled="busy" @click="move(1)">{{ step.id === 'storage' ? 'Continue without installing' : 'Continue' }}</button> <button v-if="stepIndex < steps.length - 1" type="button" class="path-action-button path-action-button--continue ml-auto" :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> </div>
</div> </div>
@@ -48,3 +56,9 @@ function move(offset: number) {
</div> </div>
</div> </div>
</template> </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>
+12
View File
@@ -82,6 +82,18 @@ export const helpTree: HelpSection[] = [
content: 'Share files and media with connected peers through the Content section in Web5. Add content from your Cloud storage, set it as free or paid (ecash-gated), and connected peers can browse and access your catalog. For paid content, peers pay with ecash micropayments — the sats appear in your wallet instantly.', content: 'Share files and media with connected peers through the Content section in Web5. Add content from your Cloud storage, set it as free or paid (ecash-gated), and connected peers can browse and access your catalog. For paid content, peers pay with ecash micropayments — the sats appear in your wallet instantly.',
relatedPath: '/dashboard/web5', relatedPath: '/dashboard/web5',
}, },
{
id: 'external-access-guide',
label: 'Allowing External Connections',
content: 'Open Setup → Allow external connections. Choose how visitors will connect: FIPS, an HTTPS domain through your own gateway, or Tor. You can select more than one. Save your choices, then choose a supported app and create an expiring guest token. Share that token privately. Guests cannot use it to sign in to your dashboard. Check the app address from the visitor’s device; saved settings alone do not confirm a working connection.',
relatedPath: '/dashboard/setup/external-access',
},
{
id: 'website-guide',
label: 'Publishing a Website',
content: 'Open Setup → Publish a website. The guide reuses your saved connections and skips Blossom installation when it is already installed. Create a page with AIUI, use Continue to website setup on its HTML preview, then review and save the draft. You can keep a signed copy in local Blossom. Choose an address and explicitly publish each connection. For Nostr, review the exact page, profile identity, storage server and relays before sharing: public copies may remain even after a removal request. FIPS and Tor do not require a purchased domain.',
relatedPath: '/dashboard/setup/website',
},
{ {
id: 'self-hosting', id: 'self-hosting',
label: 'Self-Hosting', label: 'Self-Hosting',
+4 -4
View File
@@ -29,10 +29,10 @@ export interface DnsPlan {
} }
export interface HttpsCheck { hostname: string; sha256: string; checked_at: string } export interface HttpsCheck { hostname: string; sha256: string; checked_at: string }
export const PUBLISH_ROUTES: { id: PublishRoute; title: string; description: string }[] = [ export const PUBLISH_ROUTES: { id: PublishRoute; title: string; description: string }[] = [
{ id: 'fips', title: 'FIPS network', description: 'Reach your node through FIPS. Visitors need a FIPS connection or a configured LAN gateway.' }, { id: 'fips', title: 'FIPS network', description: 'Use your node’s FIPS address without a public gateway.' },
{ id: 'public-web', title: 'Public web', description: 'An HTTPS address for ordinary browsers, using your selected gateway or a direct public connection.' }, { id: 'public-web', title: 'Public web', description: 'Use a domain and a reverse proxy you control.' },
{ id: 'tor', title: 'Tor', description: 'An onion address controlled by your node. Visitors use Tor Browser.' }, { id: 'tor', title: 'Tor', description: 'Your node controls its onion address and keeps it when you unpublish.' },
{ id: 'nostr', title: 'Nostr / nsites', description: 'Publish a static website through Nostr and Blossom. Public copies may remain after you unpublish.' }, { id: 'nostr', title: 'Nostr / nsites', description: 'Share a signed static copy through Nostr and Blossom.' },
] ]
export const publishing = { export const publishing = {
status: () => rpcClient.call<PublishingStatus>({ method: 'publishing.status', maxRetries: 1 }), status: () => rpcClient.call<PublishingStatus>({ method: 'publishing.status', maxRetries: 1 }),
+1 -1
View File
@@ -260,7 +260,7 @@
</button> </button>
</div> </div>
<div class="grid grid-cols-1 gap-3 mt-auto"> <div class="grid grid-cols-1 gap-3 mt-auto">
<RouterLink v-for="goal in topGoals" :key="goal.id" :to="`/dashboard/goals/${goal.id}`" class="home-card-btn path-action-button path-action-button--continue flex items-center justify-center gap-3"> <RouterLink v-for="goal in topGoals" :key="goal.id" :to="goal.route || `/dashboard/goals/${goal.id}`" class="home-card-btn path-action-button path-action-button--continue flex items-center justify-center gap-3">
<span>{{ goal.title }}</span> <span>{{ goal.title }}</span>
</RouterLink> </RouterLink>
</div> </div>
@@ -79,6 +79,21 @@ const walkthroughSteps = computed(() => websiteMode.value ? [
{ id: 'sharing', title: 'Choose an app and grant access', description: 'Give a guest access to one supported app with an expiry date. Your dashboard stays private.', complete: false }, { id: 'sharing', title: 'Choose an app and grant access', description: 'Give a guest access to one supported app with an expiry date. Your dashboard stays private.', complete: false },
]) ])
const routeHints: Record<PublishRoute, string> = {
fips: 'For people connected to FIPS · No domain needed',
'public-web': 'For ordinary browsers · Domain and gateway needed',
tor: 'For Tor Browser · No domain needed',
nostr: 'For nsite gateways · Public copies may remain',
}
const continueLabel = computed(() => walkthroughStep.value === 'storage' ? 'Continue without installing' : 'Save and continue')
const continueDisabled = computed(() => walkthroughStep.value === 'connections' ? !selected.value.length : ['design', 'domain'].includes(walkthroughStep.value) && (!current.value || !html.value.trim()))
async function continueSetup(next: string) {
if (walkthroughStep.value === 'connections' && websiteMode.value && !current.value) await saveSharedConnections()
else if (['connections', 'design', 'domain'].includes(walkthroughStep.value)) await save()
if (!error.value) walkthroughStep.value = next
}
const publishedRoutes = computed(() => [current.value?.fips_publication ? 'FIPS' : '', current.value?.tor_publication ? 'Tor' : '', current.value?.nsite_receipt?.accepted_relays.length ? 'Nostr' : ''].filter(Boolean))
const routes = computed(() => PUBLISH_ROUTES.filter(r => websiteMode.value || r.id !== 'nostr')) const routes = computed(() => PUBLISH_ROUTES.filter(r => websiteMode.value || r.id !== 'nostr'))
async function perform(work: () => Promise<void>) { async function perform(work: () => Promise<void>) {
@@ -289,7 +304,7 @@ onMounted(refresh)
</script> </script>
<template> <template>
<main class="pb-6"> <main class="publishing-guide pb-6">
<BackButton label="Back to Setup" desktop-margin="mb-6" @click="router.push({ path: '/dashboard', query: { tab: 'setup' } })" /> <BackButton label="Back to Setup" desktop-margin="mb-6" @click="router.push({ path: '/dashboard', query: { tab: 'setup' } })" />
<div class="flex items-start justify-between gap-4 mb-8"> <div class="flex items-start justify-between gap-4 mb-8">
<div><h1 class="text-3xl font-bold text-white mb-2 drop-shadow-[0_2px_8px_rgba(0,0,0,0.6)]">{{ websiteMode ? 'Publish a website' : 'Allow external connections' }}</h1> <div><h1 class="text-3xl font-bold text-white mb-2 drop-shadow-[0_2px_8px_rgba(0,0,0,0.6)]">{{ websiteMode ? 'Publish a website' : 'Allow external connections' }}</h1>
@@ -299,21 +314,26 @@ onMounted(refresh)
<p v-if="error" role="alert" class="rounded-xl p-4 mb-4 bg-red-500/10 text-red-200">{{ error }}</p> <p v-if="error" role="alert" class="rounded-xl p-4 mb-4 bg-red-500/10 text-red-200">{{ error }}</p>
<p v-if="message" role="status" class="rounded-xl p-4 mb-4 bg-green-500/10 text-green-200">{{ message }}</p> <p v-if="message" role="status" class="rounded-xl p-4 mb-4 bg-green-500/10 text-green-200">{{ message }}</p>
<p v-if="busy" role="status" class="text-white/60 mb-4">Working…</p> <p v-if="busy" role="status" class="text-white/60 mb-4">Working…</p>
<SetupWalkthrough v-if="status" v-model="walkthroughStep" :steps="walkthroughSteps" :busy="busy"> <div v-if="websiteMode" class="flex flex-wrap items-center gap-2 text-xs text-white/65 mb-6">
<span class="rounded-full border border-white/15 bg-white/5 px-3 py-1.5">{{ publishedRoutes.length ? `Published on ${publishedRoutes.join(', ')}` : 'Private until you publish' }}</span>
<span v-if="blossomInstalled" class="rounded-full border border-green-400/20 bg-green-400/5 px-3 py-1.5 text-green-200">Blossom installed</span>
<span v-if="status?.state.connections.length" class="rounded-full border border-white/15 bg-white/5 px-3 py-1.5">Saved connections ready to reuse</span>
</div>
<SetupWalkthrough v-if="status" v-model="walkthroughStep" :steps="walkthroughSteps" :busy="busy" :continue-label="continueLabel" :continue-disabled="continueDisabled" @next="continueSetup">
<template #connections> <template #connections>
<section class="space-y-4"> <section class="space-y-4">
<h2 class="text-lg font-semibold">Where should it be available?</h2> <p class="text-sm text-white/65">Select all that apply. You can change these choices later.</p>
<p class="text-sm text-white/60">Choose any combination. Each connection will be checked separately.</p> <div class="grid gap-3 sm:grid-cols-2">
<label v-for="option in routes" :key="option.id" class="flex items-start gap-3 rounded-xl border border-white/10 p-4 cursor-pointer"> <label v-for="option in routes" :key="option.id" class="connection-option flex items-start gap-3 rounded-xl border p-4 cursor-pointer transition-colors" :class="selected.includes(option.id) ? 'border-orange-300/50 bg-orange-400/10' : 'border-white/10 bg-white/[0.03] hover:bg-white/[0.07]'">
<input v-model="selected" type="checkbox" :value="option.id" class="mt-1" /> <input v-model="selected" type="checkbox" :value="option.id" class="mt-1" />
<span><span class="font-medium">{{ option.title }}</span><span class="block text-sm text-white/60 mt-1">{{ option.description }}</span> <span class="min-w-0"><span class="font-semibold text-white/95">{{ option.title }}</span><span class="block text-xs text-orange-100/75 mt-1">{{ routeHints[option.id] }}</span><span class="block text-sm text-white/60 mt-1">{{ option.description }}</span>
<span v-if="websiteMode && status.state.connections.includes(option.id)" class="block text-xs text-amber-200 mt-2">Already selected in connection setup; reachability still needs verification.</span> <span v-if="websiteMode && status.state.connections.includes(option.id)" class="block text-xs text-amber-200 mt-2">Already selected in connection setup; reachability still needs verification.</span>
</span> </span>
</label> </label>
<p v-if="selected.includes('fips')" class="text-sm text-white/60">{{ status.fips_address ? 'A local FIPS address exists. This does not yet verify a website or app route.' : 'No local FIPS address detected. FIPS must be connected before its routes can be verified.' }}</p> </div>
<p v-if="selected.includes('fips')" class="text-sm text-white/60">{{ status.fips_address ? 'Your node has a FIPS address. Check visitor access after publishing or sharing an app.' : 'Your node does not have a FIPS address yet. Connect FIPS in Network settings before publishing here.' }}</p>
<RouterLink v-if="websiteMode" to="/dashboard/setup/external-access" class="inline-block text-sm underline">Manage shared connections</RouterLink> <RouterLink v-if="websiteMode" to="/dashboard/setup/external-access" class="inline-block text-sm underline">Manage shared connections</RouterLink>
</section> </section>
<button class="glass-button px-5 py-3" :disabled="busy || (websiteMode && !current && walkthroughStep !== 'connections')" @click="walkthroughStep === 'connections' && websiteMode && !current ? saveSharedConnections() : save()">{{ walkthroughStep === 'connections' ? 'Save connection choices' : 'Save website draft and choices' }}</button>
</template> </template>
<template #storage> <template #storage>
<section v-if="websiteMode" class="space-y-3" data-testid="blossom-setup"> <section v-if="websiteMode" class="space-y-3" data-testid="blossom-setup">
@@ -347,7 +367,12 @@ onMounted(refresh)
<button class="text-sm underline ml-3" @click="pendingWebsiteHtml = null">Discard import</button> <button class="text-sm underline ml-3" @click="pendingWebsiteHtml = null">Discard import</button>
</section> </section>
<section v-if="websiteMode" class="space-y-4"> <section v-if="websiteMode" class="space-y-4">
<h2 class="text-lg font-semibold">Your websites</h2> <h2 class="text-lg font-semibold">{{ projects.length ? 'Your websites' : 'Start with an idea' }}</h2>
<div v-if="!current" class="space-y-3">
<p class="text-sm text-white/65">Ask AIUI to make a simple HTML website. When its preview is ready, choose “Continue to website setup” to bring it here.</p>
<RouterLink to="/dashboard/chat" class="path-action-button path-action-button--continue inline-flex">Create with AIUI <span aria-hidden="true">↗</span></RouterLink>
<p class="text-sm text-white/50">Or give your website a name and start with a blank page below.</p>
</div>
<div v-if="projects.length" class="flex flex-wrap gap-2"> <div v-if="projects.length" class="flex flex-wrap gap-2">
<button v-for="p in projects" :key="p.id" class="glass-button px-3 py-2" :aria-pressed="p.id === projectId" @click="selectProject(p)">{{ p.name }}</button> <button v-for="p in projects" :key="p.id" class="glass-button px-3 py-2" :aria-pressed="p.id === projectId" @click="selectProject(p)">{{ p.name }}</button>
</div> </div>
@@ -356,18 +381,23 @@ onMounted(refresh)
</section> </section>
<section v-if="websiteMode && current" class="space-y-4"> <section v-if="websiteMode && current" class="space-y-4">
<h2 class="text-lg font-semibold">Describe and preview</h2> <h2 class="text-lg font-semibold">Describe and preview</h2>
<RouterLink to="/dashboard/chat" class="glass-button glass-button-sm rounded-lg px-5 py-2 text-sm font-medium">Open AIUI to create a website</RouterLink> <RouterLink to="/dashboard/chat" class="path-action-button path-action-button--continue inline-flex">Create with AIUI <span aria-hidden="true">↗</span></RouterLink>
<p class="text-sm text-white/60">In AIUI, use “Continue to website setup” on your HTML preview to bring it back here for review.</p> <p class="text-sm text-white/60">In AIUI, use “Continue to website setup” on your HTML preview to bring it back here for review.</p>
<p class="text-sm text-white/60">Generate a simple page with a model installed on this node, or paste HTML from AIUI. Generation stays on your node.</p> <details class="guide-details"><summary>Generate with a model on this node</summary><div class="space-y-4 pt-4">
<p class="text-sm text-white/60">Use an installed Ollama model to create a draft locally.</p>
<label class="block">What would you like to make?<textarea v-model="prompt" maxlength="16000" rows="3" class="field mt-2" placeholder="A simple website for my community garden…" /></label> <label class="block">What would you like to make?<textarea v-model="prompt" maxlength="16000" rows="3" class="field mt-2" placeholder="A simple website for my community garden…" /></label>
<label class="block">Installed Ollama model<input v-model="model" class="field mt-2" placeholder="Enter the exact installed model name" /></label> <label class="block">Installed Ollama model<input v-model="model" class="field mt-2" placeholder="Enter the exact installed model name" /></label>
<button class="glass-button px-4 py-2" :disabled="!prompt.trim() || !model.trim()" @click="generate">Generate locally</button> <button class="glass-button px-4 py-2" :disabled="!prompt.trim() || !model.trim()" @click="generate">Generate locally</button>
</div></details>
<details class="guide-details"><summary>Edit or paste HTML</summary><div class="pt-4">
<label class="block">Website HTML<textarea v-model="html" rows="8" class="field mt-2 font-mono text-xs" spellcheck="false" /></label> <label class="block">Website HTML<textarea v-model="html" rows="8" class="field mt-2 font-mono text-xs" spellcheck="false" /></label>
</div></details>
<div class="flex items-center justify-between gap-2"><h3 class="font-medium">Your preview</h3><span class="text-xs text-white/50">Private preview</span></div>
<iframe :srcdoc="preview" sandbox="" referrerpolicy="no-referrer" title="Isolated website preview" class="w-full h-96 rounded-xl bg-white" /> <iframe :srcdoc="preview" sandbox="" referrerpolicy="no-referrer" title="Isolated website preview" class="w-full h-96 rounded-xl bg-white" />
<p class="text-xs text-white/50">Preview blocks scripts, forms and external requests. It cannot access your dashboard.</p> <p class="text-xs text-white/50">Your preview stays private. This first version supports static pages; scripts and external resources are blocked.</p>
<button class="glass-button px-4 py-2" :disabled="!html" @click="download">Download HTML</button> <button class="glass-button px-4 py-2" :disabled="!html" @click="download">Download HTML</button>
</section> </section>
<section v-if="websiteMode && blossomInstalled" class="space-y-3" data-testid="blossom-setup"> <details v-if="websiteMode && blossomInstalled" class="guide-details" data-testid="blossom-setup"><summary>Keep a signed copy in local Blossom</summary><div class="space-y-3 pt-4">
<h2 class="text-lg font-semibold">Local website files</h2> <h2 class="text-lg font-semibold">Local website files</h2>
<template v-if="blossomInstalled"> <template v-if="blossomInstalled">
<p>Blossom is installed. You can skip installation.</p> <p>Blossom is installed. You can skip installation.</p>
@@ -388,7 +418,7 @@ onMounted(refresh)
<p class="text-sm text-white/60">Return here after installation. This step is optional for a simple HTML page served directly by the node.</p> <p class="text-sm text-white/60">Return here after installation. This step is optional for a simple HTML page served directly by the node.</p>
</template> </template>
<p class="text-sm text-white/60">Installation and local uploads do not announce anything on Nostr. Publishing files externally requires a separate review of the content and destinations.</p> <p class="text-sm text-white/60">Installation and local uploads do not announce anything on Nostr. Publishing files externally requires a separate review of the content and destinations.</p>
</section> </div></details>
<section v-if="websiteMode && current?.revisions.length" class="space-y-3"> <section v-if="websiteMode && current?.revisions.length" class="space-y-3">
<h2 class="text-lg font-semibold">Saved revisions</h2> <h2 class="text-lg font-semibold">Saved revisions</h2>
<div v-for="revision in [...current.revisions].reverse()" :key="revision.id" class="flex justify-between gap-3"><span class="text-sm text-white/60">{{ new Date(revision.created_at).toLocaleString() }}</span><button class="text-sm underline" @click="restore(revision.id)">Restore draft</button></div> <div v-for="revision in [...current.revisions].reverse()" :key="revision.id" class="flex justify-between gap-3"><span class="text-sm text-white/60">{{ new Date(revision.created_at).toLocaleString() }}</span><button class="text-sm underline" @click="restore(revision.id)">Restore draft</button></div>
@@ -428,7 +458,6 @@ onMounted(refresh)
<a href="https://mynymbox.io/docs?doc=domains/dns-records" target="_blank" rel="noopener noreferrer" class="underline text-sm">Mynymbox’s DNS guide ↗</a> <a href="https://mynymbox.io/docs?doc=domains/dns-records" target="_blank" rel="noopener noreferrer" class="underline text-sm">Mynymbox’s DNS guide ↗</a>
</div> </div>
</section> </section>
<button class="glass-button px-5 py-3" :disabled="busy || (websiteMode && !current && walkthroughStep !== 'connections')" @click="walkthroughStep === 'connections' && websiteMode && !current ? saveSharedConnections() : save()">{{ walkthroughStep === 'connections' ? 'Save connection choices' : 'Save website draft and choices' }}</button>
</template> </template>
<template #sharing> <template #sharing>
<section v-if="!websiteMode" class="space-y-3"> <section v-if="!websiteMode" class="space-y-3">
@@ -438,7 +467,9 @@ onMounted(refresh)
</section> </section>
<section v-if="!websiteMode" class="space-y-3"> <section v-if="!websiteMode" class="space-y-3">
<h2 class="text-lg font-semibold">Grant access to an app</h2> <h2 class="text-lg font-semibold">Grant access to an app</h2>
<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 offer guest sharing. Supported apps appear here when their catalogue policy enables it. <RouterLink to="/dashboard/marketplace" class="underline">Browse apps</RouterLink></p>
<p 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> <p 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<select v-model="guestApp" class="field mt-2"><option value="">Choose an application</option><option v-for="app in shareableApps" :key="app.id" :value="app.id">{{ app.name }}</option></select></label> <label class="block">Application<select v-model="guestApp" class="field mt-2"><option value="">Choose an application</option><option v-for="app in shareableApps" :key="app.id" :value="app.id">{{ app.name }}</option></select></label>
<label class="block">Who is this for?<input v-model="guestLabel" maxlength="64" class="field mt-2" /></label> <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> <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>
@@ -448,6 +479,7 @@ onMounted(refresh)
<p>This creates permission to use the app. A reachable FIPS connection, onion service or configured public proxy is also needed.</p> <p>This creates permission to use the app. A reachable FIPS connection, onion service or configured public proxy is also needed.</p>
</div> </div>
<button class="glass-button px-4 py-2" :disabled="!guestApp || !guestLabel.trim()" @click="createGuestAccess">Create app-only access</button> <button class="glass-button px-4 py-2" :disabled="!guestApp || !guestLabel.trim()" @click="createGuestAccess">Create app-only access</button>
</template>
<div v-if="issuedAccess" class="rounded-xl border border-amber-300/30 p-4 space-y-2"> <div v-if="issuedAccess" class="rounded-xl border border-amber-300/30 p-4 space-y-2">
<p>Copy this token and share it privately with the intended guest. It is shown once and is never posted to Nostr or another service.</p> <p>Copy this token and share it privately with the intended guest. It is shown once and is never posted to Nostr or another service.</p>
<code class="block break-all select-all">{{ issuedAccess.token }}</code> <code class="block break-all select-all">{{ issuedAccess.token }}</code>
@@ -461,9 +493,16 @@ onMounted(refresh)
</section> </section>
</template> </template>
<template #publish> <template #publish>
<section v-if="websiteMode && current && status.publication_enabled" class="space-y-3"> <section v-if="current?.draft" class="space-y-3">
<h2 class="text-lg font-semibold">Publish the saved version on FIPS or your proxy</h2> <div class="flex flex-wrap items-center justify-between gap-2"><h2 class="text-lg font-semibold">Ready to share?</h2><span class="text-xs text-white/55">{{ current.name }} · Saved version</span></div>
<p class="text-sm text-white/60">Anyone who can reach this node through FIPS can view this website. Save your draft first. Scripts and external resources remain blocked in this first static-site version.</p> <p class="text-sm text-white/65">Check the page below, then publish using your chosen connections. Nothing is sent just by opening this step.</p>
<p v-if="html !== current.draft" class="text-sm text-amber-200">You have unsaved edits. This preview shows the saved version that will be published.</p>
<iframe :srcdoc="websitePreview(current.draft)" sandbox="" referrerpolicy="no-referrer" title="Saved website publication preview" class="w-full h-64 rounded-xl bg-white" />
</section>
<p v-else class="text-sm text-white/65">Create and save your website first, then return here to publish it.</p>
<section v-if="websiteMode && current && status.publication_enabled && (selected.includes('fips') || selected.includes('public-web') || current.fips_publication)" class="space-y-3">
<h2 class="text-lg font-semibold">Publish from your node</h2>
<p class="text-sm text-white/60">Visitors on FIPS can read this page. If you chose public web, your domain’s proxy uses this same publication.</p>
<label class="flex items-start gap-3"><input v-model="acknowledgeFips" type="checkbox" class="mt-1" /><span>I want the saved website to be visible to visitors on FIPS.</span></label> <label class="flex items-start gap-3"><input v-model="acknowledgeFips" type="checkbox" class="mt-1" /><span>I want the saved website to be visible to visitors on FIPS.</span></label>
<button class="glass-button px-4 py-2" :disabled="!acknowledgeFips || (!current.routes.includes('fips') && !current.routes.includes('public-web')) || !current.draft" @click="setFipsPublication(true)">{{ current.fips_publication ? 'Publish saved update on FIPS' : 'Publish saved website on FIPS' }}</button> <button class="glass-button px-4 py-2" :disabled="!acknowledgeFips || (!current.routes.includes('fips') && !current.routes.includes('public-web')) || !current.draft" @click="setFipsPublication(true)">{{ current.fips_publication ? 'Publish saved update on FIPS' : 'Publish saved website on FIPS' }}</button>
<button v-if="current.fips_publication" class="glass-button px-4 py-2 ml-2" @click="setFipsPublication(false)">Unpublish from FIPS</button> <button v-if="current.fips_publication" class="glass-button px-4 py-2 ml-2" @click="setFipsPublication(false)">Unpublish from FIPS</button>
@@ -471,10 +510,12 @@ onMounted(refresh)
<p>{{ listener?.listening ? 'Local FIPS listener is ready.' : 'FIPS listener is not confirmed yet. Reload to check.' }}</p> <p>{{ listener?.listening ? 'Local FIPS listener is ready.' : 'FIPS listener is not confirmed yet. Reload to check.' }}</p>
<p v-if="listener?.error" role="alert">{{ listener.error }}</p> <p v-if="listener?.error" role="alert">{{ listener.error }}</p>
<p v-if="listener?.address" class="font-mono select-all break-all">{{ listener.address }}</p> <p v-if="listener?.address" class="font-mono select-all break-all">{{ listener.address }}</p>
<p class="text-amber-200">External access is not verified. The FIPS firewall must allow this website’s port, {{ current.fips_publication.port }}.</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 px-4 py-2" @click="walkthroughStep = 'domain'">Connect or check my HTTPS domain</button>
<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> </div>
</section> </section>
<section v-if="websiteMode && current && status.publication_enabled && selected.includes('tor')" class="space-y-3"> <section v-if="websiteMode && current && status.publication_enabled && (selected.includes('tor') || current.tor_publication)" class="space-y-3">
<h2 class="text-lg font-semibold">Publish the saved version on Tor</h2> <h2 class="text-lg font-semibold">Publish the saved version on Tor</h2>
<p class="text-sm text-white/60">Share an onion address without buying a domain. Anyone who knows the address can read the page in Tor Browser. Your node keeps the address keys when you unpublish.</p> <p class="text-sm text-white/60">Share an onion address without buying a domain. Anyone who knows the address can read the page in Tor Browser. Your node keeps the address keys when you unpublish.</p>
<label class="flex items-start gap-3"><input v-model="acknowledgeTor" type="checkbox" class="mt-1" /><span>I want the saved website to be visible to visitors using Tor.</span></label> <label class="flex items-start gap-3"><input v-model="acknowledgeTor" type="checkbox" class="mt-1" /><span>I want the saved website to be visible to visitors using Tor.</span></label>
@@ -487,7 +528,7 @@ onMounted(refresh)
<p class="text-amber-200">An address alone does not confirm that Tor has connected or that visitors can reach the page.</p> <p class="text-amber-200">An address alone does not confirm that Tor has connected or that visitors can reach the page.</p>
</div> </div>
</section> </section>
<section v-if="websiteMode && current && selected.includes('nostr')" class="space-y-3"> <section v-if="websiteMode && current && (selected.includes('nostr') || current.nsite_receipt)" class="space-y-3">
<h2 class="text-lg font-semibold">Publish a named nsite</h2> <h2 class="text-lg font-semibold">Publish a named nsite</h2>
<p class="text-sm text-white/60">Upload a public static copy to a Blossom server, then announce it on your chosen Nostr relays. Your saved source stays on your node. A compatible nsite gateway can give it a browser address without buying a domain.</p> <p class="text-sm text-white/60">Upload a public static copy to a Blossom server, then announce it on your chosen Nostr relays. Your saved source stays on your node. A compatible nsite gateway can give it a browser address without buying a domain.</p>
<button class="glass-button px-4 py-2" @click="loadIdentities">Load signing identities</button> <button class="glass-button px-4 py-2" @click="loadIdentities">Load signing identities</button>
@@ -531,4 +572,10 @@ onMounted(refresh)
.field { display: block; width: 100%; border: 1px solid rgb(255 255 255 / .15); border-radius: .5rem; padding: .75rem; background: rgb(0 0 0 / .2); color: white; } .field { display: block; width: 100%; border: 1px solid rgb(255 255 255 / .15); border-radius: .5rem; padding: .75rem; background: rgb(0 0 0 / .2); color: white; }
button:disabled { opacity: .5; cursor: not-allowed; } button:disabled { opacity: .5; cursor: not-allowed; }
th, td { padding: .5rem; } th, td { padding: .5rem; }
.guide-details { border: 1px solid rgb(255 255 255 / .1); border-radius: .75rem; padding: 1rem; background: rgb(255 255 255 / .025); }
.guide-details > summary { cursor: pointer; color: rgb(255 255 255 / .8); font-size: .875rem; font-weight: 500; }
.connection-option input { accent-color: #fb923c; width: 1.125rem; height: 1.125rem; flex-shrink: 0; }
.field:focus-visible, summary:focus-visible { outline: 2px solid #fb923c; outline-offset: 3px; }
.publishing-guide { max-width: 64rem; }
@media (prefers-reduced-motion: reduce) { .transition-colors { transition: none; } }
</style> </style>
@@ -18,17 +18,40 @@ beforeEach(() => {
api.update.mockResolvedValue({ state: { ...state(), version: 3 }, project_id: null }) api.update.mockResolvedValue({ state: { ...state(), version: 3 }, project_id: null })
}) })
describe('publishing setup', () => { describe('publishing setup', () => {
it('keeps unpublish controls available when a published route is deselected', async () => {
page.name = 'publish-website'
appStore.data['package-data'].blossom = { state: 'installed' }
api.status.mockResolvedValue({ state: { ...state(), projects: { site: { id: 'site', name: 'Site', draft: '<h1>Public</h1>', routes: ['tor'], domain: null, revisions: [], tor_publication: { html: '<h1>Public</h1>', port: 32100 } } } }, apps: [], publication_enabled: true })
const wrapper = mount(PublishingSetup); await flushPromises()
await wrapper.get('[aria-controls="setup-step-connections"]').trigger('click')
await wrapper.get('input[value="tor"]').setValue(false)
await wrapper.get('[aria-controls="setup-step-publish"]').trigger('click')
expect(wrapper.findAll('button').some(button => button.text() === 'Unpublish from Tor')).toBe(true)
expect(api.update).not.toHaveBeenCalled()
expect(rpcClient.call).not.toHaveBeenCalled()
})
it('reuses saved routes and advances the walkthrough without publishing or signing', async () => { it('reuses saved routes and advances the walkthrough without publishing or signing', async () => {
page.name = 'publish-website' page.name = 'publish-website'
appStore.data['package-data'].blossom = { state: 'installed' } appStore.data['package-data'].blossom = { state: 'installed' }
const wrapper = mount(PublishingSetup); await flushPromises() const wrapper = mount(PublishingSetup); await flushPromises()
expect(wrapper.get('[aria-controls="setup-step-design"]').attributes('aria-expanded')).toBe('true') expect(wrapper.get('[aria-controls="setup-step-design"]').attributes('aria-expanded')).toBe('true')
expect(wrapper.get('[aria-controls="setup-step-connections"]').attributes('aria-expanded')).toBe('false') expect(wrapper.get('[aria-controls="setup-step-connections"]').attributes('aria-expanded')).toBe('false')
await wrapper.findAll('button').find(b => b.text() === 'Continue')!.trigger('click') expect(wrapper.findAll('button').find(b => b.text().startsWith('Save and continue'))!.attributes('disabled')).toBeDefined()
await wrapper.get('[aria-controls="setup-step-publish"]').trigger('click')
expect(wrapper.get('[aria-controls="setup-step-publish"]').attributes('aria-expanded')).toBe('true') expect(wrapper.get('[aria-controls="setup-step-publish"]').attributes('aria-expanded')).toBe('true')
expect(api.update).not.toHaveBeenCalled() expect(api.update).not.toHaveBeenCalled()
expect(rpcClient.call).not.toHaveBeenCalled() expect(rpcClient.call).not.toHaveBeenCalled()
}) })
it('saves before advancing and keeps failed saves on the same step', async () => {
const wrapper = mount(PublishingSetup); await flushPromises()
api.update.mockRejectedValueOnce(new Error('Connection choices could not be saved'))
await wrapper.findAll('button').find(b => b.text().startsWith('Save and continue'))!.trigger('click'); await flushPromises()
expect(wrapper.get('[aria-controls="setup-step-connections"]').attributes('aria-expanded')).toBe('true')
expect(wrapper.get('[role="alert"]').text()).toContain('could not be saved')
await wrapper.findAll('button').find(b => b.text().startsWith('Save and continue'))!.trigger('click'); await flushPromises()
expect(wrapper.get('[aria-controls="setup-step-sharing"]').attributes('aria-expanded')).toBe('true')
expect(rpcClient.call).not.toHaveBeenCalled()
})
it('carries the existing connection choices into a new website draft', async () => { it('carries the existing connection choices into a new website draft', async () => {
page.name = 'publish-website' page.name = 'publish-website'
appStore.data['package-data'].blossom = { state: 'installed' } appStore.data['package-data'].blossom = { state: 'installed' }
@@ -86,7 +109,7 @@ describe('publishing setup', () => {
const inputs = wrapper.findAll('input[type="checkbox"][value]') const inputs = wrapper.findAll('input[type="checkbox"][value]')
expect((inputs[0]!.element as HTMLInputElement).checked).toBe(true) expect((inputs[0]!.element as HTMLInputElement).checked).toBe(true)
await inputs[2]!.setValue(true) await inputs[2]!.setValue(true)
await wrapper.findAll('button').find(b => b.text() === 'Save connection choices')!.trigger('click') await wrapper.findAll('button').find(b => b.text().startsWith('Save and continue'))!.trigger('click')
await flushPromises() await flushPromises()
expect(api.update).toHaveBeenCalledWith(2, { action: 'connections', routes: ['fips', 'tor'] }) expect(api.update).toHaveBeenCalledWith(2, { action: 'connections', routes: ['fips', 'tor'] })
expect(wrapper.text()).toContain('Existing app access has not changed') expect(wrapper.text()).toContain('Existing app access has not changed')
@@ -94,7 +117,7 @@ describe('publishing setup', () => {
it('keeps a failed save visible and does not pretend it succeeded', async () => { it('keeps a failed save visible and does not pretend it succeeded', async () => {
api.update.mockRejectedValue(new Error('Reload before saving')) api.update.mockRejectedValue(new Error('Reload before saving'))
const wrapper = mount(PublishingSetup); await flushPromises() const wrapper = mount(PublishingSetup); await flushPromises()
await wrapper.findAll('button').find(b => b.text() === 'Save connection choices')!.trigger('click'); await flushPromises() await wrapper.findAll('button').find(b => b.text().startsWith('Save and continue'))!.trigger('click'); await flushPromises()
expect(wrapper.get('[role="alert"]').text()).toContain('Reload before saving') expect(wrapper.get('[role="alert"]').text()).toContain('Reload before saving')
expect(wrapper.text()).not.toContain('Connection preferences saved') expect(wrapper.text()).not.toContain('Connection preferences saved')
}) })