Files
archy/neode-ui/src/views/publishing/PublicWebGateway.vue
T

107 lines
8.4 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, ref } from 'vue'
import { rpcClient } from '@/api/rpc-client'
import { installPublishingApp } from '@/services/installPublishingApp'
import { useAppStore } from '@/stores/app'
import type { WebsiteProject } from '@/services/publishing'
interface GatewayStatus { configured: boolean; host?: string; port?: number; domains?: string[]; certificate_mode?: string; routes: { id: string; domain: string }[]; error?: string }
const props = defineProps<{ gateway: GatewayStatus; project?: WebsiteProject | null; app?: { id: string; name: string } | null }>()
const emit = defineEmits<{ refresh: [] }>()
const appStore = useAppStore()
const appDomain = ref('')
const busy = ref(false)
const error = ref('')
const message = ref('')
const enrollment = ref<Record<string, unknown> | null>(null)
const fileInput = ref<HTMLInputElement | null>(null)
const acknowledge = ref(false)
const testCertificates = ref(false)
const installed = computed(() => !!appStore.data?.['package-data']?.['public-web-router'])
const routeId = computed(() => props.project?.id ?? (props.app ? `app-${props.app.id}` : ''))
const connected = computed(() => props.gateway.routes.some(r => r.id === routeId.value))
async function perform(fn: () => Promise<void>) {
busy.value = true; error.value = ''; message.value = ''
try { await fn() } catch (e) { error.value = e instanceof Error ? e.message : 'Gateway action failed' }
finally { busy.value = false }
}
async function readEnrollment(event: Event) {
enrollment.value = null; acknowledge.value = false; error.value = ''
const file = (event.target as HTMLInputElement).files?.[0]
if (!file) return
try {
if (file.size > 65536) throw new Error('Enrollment file is too large')
const data = JSON.parse(await file.text())
if (!data || typeof data !== 'object' || typeof data.host !== 'string' || !Array.isArray(data.domains) || !data.domains.every((d: unknown) => typeof d === 'string')) throw new Error('Choose the enrollment file supplied by your gateway operator')
enrollment.value = data
} catch { error.value = 'Choose a valid gateway enrollment JSON file. Its contents stay in this setup session until you connect.' }
}
async function configure() {
if (!enrollment.value || !acknowledge.value) return
await perform(async () => {
await rpcClient.call({ method: 'publishing.gateway-configure', params: { enrollment: enrollment.value, certificate_mode: testCertificates.value ? 'test' : 'public', acknowledge: true }, maxRetries: 0 })
enrollment.value = null; acknowledge.value = false
if (fileInput.value) fileInput.value.value = ''
message.value = 'Gateway saved privately. Connect each published website when you are ready.'
emit('refresh')
})
}
async function route(enabled: boolean) {
if (!props.project && !props.app) return
await perform(async () => {
if (props.app) await rpcClient.call({ method: 'publishing.gateway-app-route', params: { app_id: props.app.id, domain: appDomain.value.trim(), enabled }, maxRetries: 0 })
else await rpcClient.call({ method: 'publishing.gateway-route', params: { id: props.project!.id, enabled }, maxRetries: 0 })
message.value = enabled ? 'Route requested. Check HTTPS and guest access before sharing the address.' : 'Gateway route removed. Other connections remain available.'
emit('refresh')
})
}
async function disconnect() {
await perform(async () => {
await rpcClient.call({ method: 'publishing.gateway-disconnect', maxRetries: 0 })
message.value = 'Gateway disconnected. Its local enrollment was removed; website drafts and certificates are retained.'
emit('refresh')
})
}
async function install() {
await perform(async () => { await installPublishingApp('public-web-router'); message.value = 'Router installation requested through the app catalogue.' })
}
</script>
<template>
<section class="space-y-4" aria-label="Your public gateway">
<h3 class="font-medium">Keep HTTPS on this node</h3>
<p class="text-sm text-white/60">Connect to a gateway you control using the open-source Public Web Router. The gateway forwards encrypted traffic; website certificates and keys stay here. You can reuse this connection for websites and supported apps.</p>
<p v-if="gateway.error" role="alert" class="text-sm text-amber-200">{{ gateway.error }}</p>
<button v-if="!installed" class="glass-button glass-button-sm rounded-lg px-5 py-2 text-sm font-medium" :disabled="busy" @click="install">Install Public Web Router</button>
<template v-if="gateway.configured">
<p class="text-sm break-all">Gateway: {{ gateway.host }} · control port {{ gateway.port }}</p>
<p class="text-sm break-all">Assigned domains: {{ gateway.domains?.join(', ') }}</p>
<p v-if="gateway.certificate_mode === 'test'" class="text-sm text-amber-200">Test certificates only. Ordinary browsers will not trust this route.</p>
<p v-else class="text-sm text-white/60">Point your domain at the gateway’s public IP. It must forward public port 443 to this node so a certificate can be issued.</p>
<div v-if="project" class="flex flex-wrap gap-3">
<button v-if="!connected" class="glass-button glass-button-sm rounded-lg px-5 py-2 text-sm font-medium" :disabled="busy || !installed || !project.fips_publication || !project.domain || !gateway.domains?.includes(project.domain.hostname)" @click="route(true)">Connect this published website</button>
<button v-else class="glass-button glass-button-sm rounded-lg px-5 py-2 text-sm font-medium" :disabled="busy" @click="route(false)">Disconnect this website from gateway</button>
</div>
<div v-if="app" class="space-y-3">
<p class="text-sm">Connect {{ app.name }} through its existing app gate. Guests still need app-only access; this does not grant dashboard access.</p>
<label v-if="!connected" class="block text-sm">Assigned domain for this app<input v-model="appDomain" maxlength="253" autocomplete="off" class="w-full mt-2 rounded-lg border border-white/15 bg-white/5 px-3 py-2 text-sm" placeholder="app.yourdomain.com" :disabled="busy" /></label>
<button class="glass-button glass-button-sm rounded-lg px-5 py-2 text-sm font-medium" :disabled="busy || (!connected && (!installed || !gateway.domains?.includes(appDomain.trim())))" @click="route(!connected)">{{ connected ? 'Disconnect this app from gateway' : 'Connect this app through its gate' }}</button>
</div>
<p v-if="project && !project.fips_publication" class="text-sm text-white/60">Publish the website upstream in Review and publish, then return here to connect it.</p>
<button class="glass-button glass-button-sm rounded-lg px-5 py-2 text-sm font-medium" :disabled="busy" @click="disconnect">Disconnect all gateway routes</button>
</template>
<details class="space-y-4">
<summary class="cursor-pointer">{{ gateway.configured ? 'Replace gateway enrollment' : 'Connect your gateway' }}</summary>
<p class="text-sm text-white/60">Choose the private enrollment file supplied by your gateway operator. It contains connection credentials: keep it off Nostr and public file storage.</p>
<label class="block text-sm">Gateway enrollment file<input ref="fileInput" type="file" accept="application/json,.json" class="block w-full mt-2 text-sm" :disabled="busy" @change="readEnrollment" /></label>
<template v-if="enrollment">
<p class="text-sm break-all">Connect to {{ enrollment.host }} · assigned domains: {{ (enrollment.domains as string[]).join(', ') }}</p>
<label class="flex items-start gap-3 text-sm"><input v-model="acknowledge" type="checkbox" class="mt-1" :disabled="busy" /><span>I trust this gateway operator. Replacing enrollment disconnects existing gateway routes until I connect them again.</span></label>
<details><summary class="cursor-pointer text-sm">Testing options</summary><label class="flex items-start gap-3 mt-3 text-sm"><input v-model="testCertificates" type="checkbox" class="mt-1" :disabled="busy" /><span>Use private test certificates for isolated-port testing.</span></label></details>
<button class="glass-button glass-button-sm rounded-lg px-5 py-2 text-sm font-medium" :disabled="busy || !acknowledge" @click="configure">Save private gateway connection</button>
</template>
</details>
<p v-if="error" role="alert" class="text-sm text-red-300">{{ error }}</p>
<p v-if="message" role="status" class="text-sm text-white/70">{{ message }}</p>
</section>
</template>