fix(ui): make firewall settings consistent and gate device management
This commit is contained in:
@@ -0,0 +1,35 @@
|
|||||||
|
# Firewall and tunnel follow-up — 8 October 2026
|
||||||
|
|
||||||
|
Status: source prepared in isolation; not deployed or accepted on a live node.
|
||||||
|
Task 18 remains open for full firewall rule management, persistence and rollback.
|
||||||
|
|
||||||
|
The Network entry uses a right-aligned status and the standard black glass-button.
|
||||||
|
The settings page fills the dashboard content width. The dashboard background
|
||||||
|
resolver now inherits the Network image for its detail routes instead of selecting
|
||||||
|
the Web5 image; the explicit federation background is preserved. Copy distinguishes
|
||||||
|
actual device tunnels, mesh connections, router settings and merely saved port
|
||||||
|
entries. A running mesh no longer produces a false Protected firewall label.
|
||||||
|
|
||||||
|
The new device section uses existing WireGuard APIs for add, reveal/copy and remove.
|
||||||
|
Mutation controls require the new backend's explicit peer_management_verified flag;
|
||||||
|
older or unavailable backends cannot enable them. Private configuration is fetched
|
||||||
|
only by an explicit reveal action, QR SVG is sanitized, and cached navigation clears
|
||||||
|
private details and rejects late replies. Failed creation/removal requires a fresh
|
||||||
|
list before retrying. Pending/revoking operations get recovery guidance.
|
||||||
|
|
||||||
|
Read-only operator-node checks confirmed an existing device tunnel and active mesh.
|
||||||
|
The separate router store had no connection or forwarding entries. This does not
|
||||||
|
contradict the separately retained manual firewall/mining repair. No live VPN,
|
||||||
|
firewall, router, app or payment change was performed during these checks.
|
||||||
|
|
||||||
|
Backend audit found that existing router add/remove-forward methods only write
|
||||||
|
local JSON. They are not exposed as controls that claim to open or close real ports.
|
||||||
|
The existing OpenWrt management screen remains linked. Actual node firewall rule
|
||||||
|
inspection/editing and the complete app exposure workflow are still separate work.
|
||||||
|
|
||||||
|
Validation checkpoint: 21 focused UI tests passed before the final background
|
||||||
|
resolver and pending-operation display additions. Those additions, the complete
|
||||||
|
app typecheck, responsive rendered-route/background checks and production build
|
||||||
|
remain queued behind IndeeHub recovery qualification. No live acceptance is claimed.
|
||||||
|
The backend peer-safety changes are isolated separately and also require tests and
|
||||||
|
paired helper deployment before these new mutation controls can be enabled.
|
||||||
@@ -516,6 +516,20 @@ remain open. See `docs/https-app-gate-followup-20261006.md`.
|
|||||||
|
|
||||||
## 18. Firewall and tunnel UI/settings
|
## 18. Firewall and tunnel UI/settings
|
||||||
|
|
||||||
|
Operator refinements on 8 October, retained as acceptance requirements:
|
||||||
|
|
||||||
|
- Keep status values aligned to the right edge with the other rows. Do not
|
||||||
|
present the mesh service as proof that the node firewall is protected.
|
||||||
|
- Fill the main content width and inherit the Network tab background; opening
|
||||||
|
Firewalls & tunnels must not switch to a different page background.
|
||||||
|
- Use the standard black primary button at the bottom of the Local Network
|
||||||
|
container, matching other container actions.
|
||||||
|
- Use plain language throughout and provide real, supported configuration
|
||||||
|
management. Recognize existing connections; a missing saved router entry is
|
||||||
|
not proof that the node's tunnels or manually repaired firewall are absent.
|
||||||
|
- Verify changes against the actual saved configuration. Controls must not
|
||||||
|
claim to apply firewall rules when a backend only writes a local entry.
|
||||||
|
|
||||||
Status: in progress. The central read-only Firewall & tunnels screen, local
|
Status: in progress. The central read-only Firewall & tunnels screen, local
|
||||||
network entry points, independent status/error handling and responsive route
|
network entry points, independent status/error handling and responsive route
|
||||||
tests are implemented (ef6c10f0, 3f96be2c). Scoped configuration, persistence,
|
tests are implemented (ef6c10f0, 3f96be2c). Scoped configuration, persistence,
|
||||||
|
|||||||
@@ -137,7 +137,7 @@ import DashboardRouterView from '@/views/dashboard/DashboardRouterView.vue'
|
|||||||
import ConnectionBanner from '@/views/dashboard/ConnectionBanner.vue'
|
import ConnectionBanner from '@/views/dashboard/ConnectionBanner.vue'
|
||||||
import HealthNotifications from '@/views/dashboard/HealthNotifications.vue'
|
import HealthNotifications from '@/views/dashboard/HealthNotifications.vue'
|
||||||
import CompanionIntroOverlay from '@/components/CompanionIntroOverlay.vue'
|
import CompanionIntroOverlay from '@/components/CompanionIntroOverlay.vue'
|
||||||
import { isDetailRoute, ROUTE_BACKGROUNDS } from '@/views/dashboard/useRouteTransitions'
|
import { isDetailRoute, backgroundForRoute } from '@/views/dashboard/useRouteTransitions'
|
||||||
import { useIbdFinishWatcher } from '@/composables/useIbdFinishWatcher'
|
import { useIbdFinishWatcher } from '@/composables/useIbdFinishWatcher'
|
||||||
import '@/views/dashboard/dashboard-styles.css'
|
import '@/views/dashboard/dashboard-styles.css'
|
||||||
|
|
||||||
@@ -163,20 +163,9 @@ const showAltBackground = ref(false)
|
|||||||
const isHomeRoute = computed(() => route.path === '/dashboard' || route.path === '/dashboard/')
|
const isHomeRoute = computed(() => route.path === '/dashboard' || route.path === '/dashboard/')
|
||||||
const isGlitching = ref(false)
|
const isGlitching = ref(false)
|
||||||
|
|
||||||
const backgroundImage = computed(() => {
|
const backgroundImage = computed(() => backgroundForRoute(route.path))
|
||||||
const mapped = ROUTE_BACKGROUNDS[route.path]
|
|
||||||
if (mapped) return mapped
|
|
||||||
// Detail/sub pages inherit their parent tab's background so they stay
|
|
||||||
// visually "inside" the section instead of snapping to the home backdrop.
|
|
||||||
if (route.path.startsWith('/dashboard/cloud/')) return 'bg-cloud.webp'
|
|
||||||
if (route.path.startsWith('/dashboard/web5/')) return 'bg-web5.jpg'
|
|
||||||
if (route.path.startsWith('/dashboard/server/')) return 'bg-web5.jpg'
|
|
||||||
if (route.path.startsWith('/dashboard/settings/')) return 'bg-settings.webp'
|
|
||||||
if (isDetailRoute(route.path)) return 'bg-intro.jpg'
|
|
||||||
return 'bg-home.webp'
|
|
||||||
})
|
|
||||||
|
|
||||||
// bg-web5.jpg (web5 + server sections) is bright — the scrim overlay deepens
|
// bg-web5.jpg (Web5 and its explicitly mapped detail screens) is bright — the scrim overlay deepens
|
||||||
// while it's showing so light text keeps its contrast.
|
// while it's showing so light text keeps its contrast.
|
||||||
const isWeb5Bg = computed(() => backgroundImage.value === 'bg-web5.jpg')
|
const isWeb5Bg = computed(() => backgroundImage.value === 'bg-web5.jpg')
|
||||||
|
|
||||||
|
|||||||
@@ -101,10 +101,9 @@
|
|||||||
>
|
>
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<svg class="w-5 h-5 text-white/60" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" /></svg>
|
<svg class="w-5 h-5 text-white/60" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" /></svg>
|
||||||
<span class="text-white/80 text-sm">Firewall Active</span>
|
<span class="text-white/80 text-sm">Firewall</span>
|
||||||
</div>
|
</div>
|
||||||
<span class="text-green-400 text-sm font-medium">Protected</span>
|
<span class="ml-auto text-right text-white/60 text-sm">Check status</span>
|
||||||
<svg class="w-4 h-4 text-white/30" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" /></svg>
|
|
||||||
</router-link>
|
</router-link>
|
||||||
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
|
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
@@ -166,9 +165,9 @@
|
|||||||
</div>
|
</div>
|
||||||
<router-link
|
<router-link
|
||||||
to="/dashboard/server/firewall-tunnels"
|
to="/dashboard/server/firewall-tunnels"
|
||||||
class="mt-4 w-full flex items-center justify-center gap-2 p-3 rounded-lg bg-white/10 hover:bg-white/15 transition-colors text-sm text-white/90"
|
class="glass-button mt-4 w-full gap-2 rounded-lg text-sm font-medium"
|
||||||
>
|
>
|
||||||
Firewall & tunnels
|
Firewalls & tunnels
|
||||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" /></svg>
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" /></svg>
|
||||||
</router-link>
|
</router-link>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { backgroundForRoute } from '../useRouteTransitions'
|
||||||
|
|
||||||
|
describe('dashboard route backgrounds', () => {
|
||||||
|
it('keeps the Network tab backdrop while opening firewall settings and returning', () => {
|
||||||
|
const routes = ['/dashboard/server', '/dashboard/server/firewall-tunnels', '/dashboard/server']
|
||||||
|
expect(routes.map(backgroundForRoute)).toEqual(['bg-network.jpg', 'bg-network.jpg', 'bg-network.jpg'])
|
||||||
|
})
|
||||||
|
it('inherits other Network detail screens without overriding the explicit Web5 federation backdrop', () => {
|
||||||
|
expect(backgroundForRoute('/dashboard/server/openwrt')).toBe('bg-network.jpg')
|
||||||
|
expect(backgroundForRoute('/dashboard/server/federation')).toBe('bg-web5.jpg')
|
||||||
|
})
|
||||||
|
it('retains other parent sections and app-detail behavior', () => {
|
||||||
|
expect(backgroundForRoute('/dashboard/cloud/folder')).toBe('bg-cloud.webp')
|
||||||
|
expect(backgroundForRoute('/dashboard/settings/account')).toBe('bg-settings.webp')
|
||||||
|
expect(backgroundForRoute('/dashboard/web5/credentials')).toBe('bg-web5.jpg')
|
||||||
|
expect(backgroundForRoute('/dashboard/apps/bitcoin')).toBe('bg-intro.jpg')
|
||||||
|
expect(backgroundForRoute('/dashboard')).toBe('bg-home.webp')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -38,6 +38,16 @@ export const ROUTE_BACKGROUNDS: Record<string, string> = {
|
|||||||
'/dashboard/chat': 'bg-aiui.jpg',
|
'/dashboard/chat': 'bg-aiui.jpg',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Detail screens share the parent tab image unless explicitly mapped above. */
|
||||||
|
export function backgroundForRoute(path: string): string {
|
||||||
|
const mapped = ROUTE_BACKGROUNDS[path]
|
||||||
|
if (mapped) return mapped
|
||||||
|
for (const parent of ['/dashboard/cloud', '/dashboard/web5', '/dashboard/server', '/dashboard/settings']) {
|
||||||
|
if (path.startsWith(`${parent}/`)) return ROUTE_BACKGROUNDS[parent]!
|
||||||
|
}
|
||||||
|
return isDetailRoute(path) ? 'bg-intro.jpg' : 'bg-home.webp'
|
||||||
|
}
|
||||||
|
|
||||||
export function isDetailRoute(path: string): boolean {
|
export function isDetailRoute(path: string): boolean {
|
||||||
return (path.includes('/apps/') && !path.endsWith('/apps')) ||
|
return (path.includes('/apps/') && !path.endsWith('/apps')) ||
|
||||||
(path.includes('/marketplace/') && !path.endsWith('/marketplace'))
|
(path.includes('/marketplace/') && !path.endsWith('/marketplace'))
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { flushPromises, mount } from '@vue/test-utils'
|
||||||
|
vi.mock('@/api/rpc-client', () => ({ rpcClient: { call: vi.fn(), vpnStatus: vi.fn() } }))
|
||||||
|
import { rpcClient } from '@/api/rpc-client'
|
||||||
|
import FirewallTunnelSettings from './FirewallTunnelSettings.vue'
|
||||||
|
const mountPage = () => mount(FirewallTunnelSettings, { global: { stubs: { RouterLink: { template: '<a><slot /></a>' }, VpnDeviceSettings: { props: ['configured', 'managementVerified'], template: '<div data-testid="devices" :data-configured="configured" :data-verified="managementVerified" />' } } } })
|
||||||
|
const vpn = { connected: true, provider: 'tailscale', configured: true, configured_provider: 'nostrvpn', wg_ip: '10.44.0.1' }
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetAllMocks()
|
||||||
|
vi.mocked(rpcClient.vpnStatus).mockResolvedValue(vpn as any)
|
||||||
|
vi.mocked(rpcClient.call).mockImplementation(async ({ method }) => {
|
||||||
|
if (method === 'fips.status') return { service_active: true, authenticated_peer_count: 5 }
|
||||||
|
if (method === 'router.info') return { configured: false, router_type: 'unknown' }
|
||||||
|
if (method === 'router.list-forwards') return { forwards: [] }
|
||||||
|
throw new Error(method)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
describe('Firewall and tunnel settings', () => {
|
||||||
|
it('recognizes the existing device tunnel independently of router setup and never infers firewall protection from FIPS', async () => {
|
||||||
|
const w = mountPage(); await flushPromises()
|
||||||
|
expect(w.text()).toContain('Your device tunnel is already set up')
|
||||||
|
expect(w.text()).toContain('No router connection is saved in this section')
|
||||||
|
expect(w.text()).toContain('Not checked')
|
||||||
|
expect(w.text()).not.toContain('Protected')
|
||||||
|
expect(w.get('[data-testid="devices"]').attributes('data-configured')).toBe('true')
|
||||||
|
expect(w.get('[data-testid="devices"]').attributes('data-verified')).toBe('false')
|
||||||
|
w.unmount()
|
||||||
|
})
|
||||||
|
it('only enables device mutations when the backend explicitly verifies management support', async () => {
|
||||||
|
vi.mocked(rpcClient.vpnStatus).mockResolvedValue({ ...vpn, peer_management_verified: true } as any)
|
||||||
|
const w = mountPage(); await flushPromises()
|
||||||
|
expect(w.get('[data-testid="devices"]').attributes('data-verified')).toBe('true'); w.unmount()
|
||||||
|
})
|
||||||
|
it('clears stale success after failed checks and does not turn missing forward results into an empty list', async () => {
|
||||||
|
const w = mountPage(); await flushPromises()
|
||||||
|
vi.mocked(rpcClient.vpnStatus).mockRejectedValue(new Error('offline'))
|
||||||
|
vi.mocked(rpcClient.call).mockRejectedValue(new Error('offline'))
|
||||||
|
await w.get('button').trigger('click'); await flushPromises()
|
||||||
|
expect(w.text()).not.toContain('Your device tunnel is already set up')
|
||||||
|
expect(w.text()).toContain('Could not check saved entries.')
|
||||||
|
expect(w.text()).not.toContain('No entries are saved here.')
|
||||||
|
expect(w.get('[data-testid="devices"]').attributes('data-verified')).toBe('false')
|
||||||
|
expect(w.get('[role="alert"]').text()).toContain('could not be checked'); w.unmount()
|
||||||
|
})
|
||||||
|
it('renders named port entries without claiming they were applied or exposing raw JSON', async () => {
|
||||||
|
vi.mocked(rpcClient.call).mockImplementation(async ({ method }) => method === 'router.list-forwards' ? { forwards: [{ id: 'a', service_name: 'My app', internal_port: 80, external_port: 8080, protocol: 'TCP', unexpected_secret: 'never-render' }] } : {})
|
||||||
|
const w = mountPage(); await flushPromises()
|
||||||
|
expect(w.text()).toContain('My app'); expect(w.text()).toContain('Outside port 8080 → app port 80 (TCP)')
|
||||||
|
expect(w.text()).toContain('do not confirm that your router has opened them')
|
||||||
|
expect(w.text()).not.toContain('never-render'); w.unmount()
|
||||||
|
})
|
||||||
|
it('treats malformed forwards as unavailable, not zero entries', async () => {
|
||||||
|
vi.mocked(rpcClient.call).mockResolvedValue({ forwards: [null] })
|
||||||
|
const w = mountPage(); await flushPromises()
|
||||||
|
expect(w.text()).toContain('Could not check saved entries.'); w.unmount()
|
||||||
|
})
|
||||||
|
it('does not enable changes from a reply arriving after navigation', async () => {
|
||||||
|
let resolve!: (x: any) => void
|
||||||
|
vi.mocked(rpcClient.vpnStatus).mockImplementation(() => new Promise(r => { resolve = r }))
|
||||||
|
const w = mountPage(); w.unmount(); resolve({ ...vpn, peer_management_verified: true }); await flushPromises()
|
||||||
|
expect(vi.mocked(rpcClient.call).mock.calls.every(([arg]) => !arg.method.includes('create') && !arg.method.includes('remove'))).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,104 +1,103 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref } from 'vue'
|
import { computed, onActivated, onDeactivated, onMounted, onUnmounted, ref } from 'vue'
|
||||||
import { RouterLink } from 'vue-router'
|
import { RouterLink } from 'vue-router'
|
||||||
import { rpcClient } from '@/api/rpc-client'
|
import { rpcClient } from '@/api/rpc-client'
|
||||||
|
import VpnDeviceSettings from './VpnDeviceSettings.vue'
|
||||||
interface TunnelStatus {
|
interface TunnelStatus { peer_management_verified?: boolean; connected?: boolean; configured?: boolean; configured_provider?: string; provider?: string; ip_address?: string | null; wg_ip?: string | null }
|
||||||
connected?: boolean
|
interface FipsStatus { installed?: boolean; service_active?: boolean; authenticated_peer_count?: number }
|
||||||
provider?: string
|
interface RouterStatus { configured?: boolean; router_type?: string; address?: string }
|
||||||
ip_address?: string
|
interface Forward { id: string; service_name: string; internal_port: number; external_port: number; protocol: string }
|
||||||
wg_ip?: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
interface FipsStatus {
|
|
||||||
installed?: boolean
|
|
||||||
service_active?: boolean
|
|
||||||
anchor_connected?: boolean
|
|
||||||
authenticated_peer_count?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Forward { [key: string]: unknown }
|
|
||||||
|
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
const tunnel = ref<TunnelStatus | null>(null)
|
const tunnel = ref<TunnelStatus | null>(null)
|
||||||
const fips = ref<FipsStatus | null>(null)
|
const fips = ref<FipsStatus | null>(null)
|
||||||
const forwards = ref<Forward[]>([])
|
const router = ref<RouterStatus | null>(null)
|
||||||
|
const forwards = ref<Forward[] | null>(null)
|
||||||
const tunnelLabel = computed(() => {
|
let active = true
|
||||||
if (!tunnel.value) return 'Unknown'
|
let generation = 0
|
||||||
return tunnel.value.connected ? 'Connected' : 'Not connected'
|
const tunnelLabel = computed(() => tunnel.value?.connected === true ? 'Connected' : tunnel.value?.connected === false ? 'Not connected' : 'Could not check')
|
||||||
})
|
const meshLabel = computed(() => fips.value?.service_active === true ? 'Running' : fips.value?.service_active === false ? 'Stopped' : 'Could not check')
|
||||||
const firewallLabel = computed(() => {
|
const serviceNames: Record<string, string> = { tailscale: 'Tailscale', wireguard: 'WireGuard', 'nostr-vpn': 'Nostr VPN', nostrvpn: 'Nostr VPN' }
|
||||||
if (!fips.value) return 'Unknown'
|
const provider = computed(() => serviceNames[tunnel.value?.provider ?? ''] ?? 'Private connection')
|
||||||
if (fips.value.service_active === false) return 'Inactive'
|
|
||||||
return 'Protected'
|
|
||||||
})
|
|
||||||
|
|
||||||
async function refresh() {
|
async function refresh() {
|
||||||
loading.value = true
|
const current = ++generation
|
||||||
error.value = ''
|
loading.value = true; error.value = ''
|
||||||
const results = await Promise.allSettled([
|
const results = await Promise.allSettled([
|
||||||
rpcClient.vpnStatus(),
|
rpcClient.vpnStatus(),
|
||||||
rpcClient.call<FipsStatus>({ method: 'fips.status', dedup: true, maxRetries: 1 }),
|
rpcClient.call<FipsStatus>({ method: 'fips.status', dedup: true, maxRetries: 1 }),
|
||||||
rpcClient.call<{ forwards?: Forward[] }>({ method: 'router.list-forwards', dedup: true, maxRetries: 1 }),
|
rpcClient.call<{ forwards: Forward[] }>({ method: 'router.list-forwards', dedup: true, maxRetries: 1 }),
|
||||||
|
rpcClient.call<RouterStatus>({ method: 'router.info', dedup: true, maxRetries: 1 }),
|
||||||
])
|
])
|
||||||
const failures = results.filter((result) => result.status === 'rejected')
|
if (!active || current !== generation) return
|
||||||
if (results[0]?.status === 'fulfilled') tunnel.value = results[0].value
|
tunnel.value = results[0].status === 'fulfilled' ? results[0].value : null
|
||||||
if (results[1]?.status === 'fulfilled') fips.value = results[1].value
|
fips.value = results[1].status === 'fulfilled' ? results[1].value : null
|
||||||
if (results[2]?.status === 'fulfilled') forwards.value = results[2].value.forwards ?? []
|
const rows = results[2].status === 'fulfilled' ? results[2].value?.forwards : null
|
||||||
if (failures.length === results.length) error.value = 'Could not load network protection status'
|
forwards.value = Array.isArray(rows) && rows.every(r => r && typeof r.id === 'string' && typeof r.service_name === 'string' && Number.isInteger(r.internal_port) && Number.isInteger(r.external_port) && typeof r.protocol === 'string') ? rows : null
|
||||||
else if (failures.length) error.value = 'Some checks are unavailable; verify each route before changing it'
|
router.value = results[3].status === 'fulfilled' ? results[3].value : null
|
||||||
|
if (results.some(r => r.status === 'rejected') || forwards.value === null) error.value = 'Some information could not be checked. Try refreshing.'
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(refresh)
|
onMounted(refresh)
|
||||||
|
function leave() { active = false; generation++; tunnel.value = null }
|
||||||
|
onDeactivated(leave)
|
||||||
|
onUnmounted(leave)
|
||||||
|
onActivated(() => { if (!active) { active = true; void refresh() } })
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<main class="max-w-5xl mx-auto px-4 sm:px-6 py-6">
|
<main class="w-full min-w-0 pb-6 text-white" data-testid="firewall-settings">
|
||||||
<div class="flex items-start justify-between gap-4 mb-6">
|
<div class="flex flex-wrap items-start justify-between gap-4 mb-6">
|
||||||
<div>
|
<div class="min-w-0">
|
||||||
<RouterLink to="/dashboard/server" class="text-xs text-white/50 hover:text-white">← Network</RouterLink>
|
<RouterLink to="/dashboard/server" class="text-sm text-white/60 hover:text-white">← Network</RouterLink>
|
||||||
<h1 class="text-2xl font-semibold text-white mt-2">Firewall & tunnels</h1>
|
<h1 class="text-2xl font-semibold text-white mt-2">Firewalls & tunnels</h1>
|
||||||
<p class="text-sm text-white/60 mt-1">Review reachability before exposing a service. Transport access and app authentication are separate controls.</p>
|
<p class="text-sm text-white/60 mt-1">Choose how your devices and apps connect to this node.</p>
|
||||||
</div>
|
</div>
|
||||||
<button class="glass-button px-3 py-2 text-sm shrink-0" :disabled="loading" @click="refresh">
|
<button class="glass-button text-sm" :disabled="loading" @click="refresh">{{ loading ? 'Checking…' : 'Refresh status' }}</button>
|
||||||
<span v-if="loading" class="inline-flex items-center gap-2"><span class="h-3.5 w-3.5 rounded-full border-2 border-white/30 border-t-white animate-spin" /> Checking…</span>
|
|
||||||
<span v-else>Refresh checks</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
<p v-if="error" role="alert" class="mb-4 rounded-lg border border-orange-400/30 bg-orange-500/10 p-4 text-sm text-orange-200">{{ error }}</p>
|
||||||
<div v-if="error" class="mb-4 rounded-lg border border-orange-400/30 bg-orange-500/10 px-4 py-3 text-sm text-orange-200">{{ error }}</div>
|
<div class="grid gap-4 xl:grid-cols-2 mb-6">
|
||||||
|
<section class="glass-card p-5 min-w-0" aria-labelledby="firewall-title">
|
||||||
<section class="grid gap-4 md:grid-cols-2 mb-6">
|
<h2 id="firewall-title" class="text-lg font-medium text-white">Firewall</h2>
|
||||||
<div class="glass-card p-5">
|
<p class="text-sm text-white/60 mt-1">A firewall controls which connections can reach your node.</p>
|
||||||
<h2 class="text-lg font-medium text-white">Firewall</h2>
|
<dl class="mt-4 space-y-3 text-sm">
|
||||||
<p class="text-sm text-white/60 mt-1">Node protection status and mesh reachability.</p>
|
<div class="status-row"><dt>Node firewall</dt><dd>Not checked</dd></div>
|
||||||
<div class="mt-4 flex items-center justify-between"><span class="text-white/70">Node firewall</span><span :class="firewallLabel === 'Protected' ? 'text-green-300' : 'text-orange-300'">{{ firewallLabel }}</span></div>
|
<div class="status-row"><dt>Private mesh</dt><dd>{{ meshLabel }}</dd></div>
|
||||||
<div class="mt-2 flex items-center justify-between"><span class="text-white/70">Authenticated mesh peers</span><span class="text-white">{{ fips?.authenticated_peer_count ?? 'Unknown' }}</span></div>
|
<div class="status-row"><dt>Verified mesh connections</dt><dd>{{ fips?.authenticated_peer_count ?? 'Could not check' }}</dd></div>
|
||||||
</div>
|
</dl>
|
||||||
<div class="glass-card p-5">
|
<p class="mt-4 text-sm text-white/50">This screen cannot yet check or change the node's firewall rules. A running mesh does not confirm firewall protection.</p>
|
||||||
<h2 class="text-lg font-medium text-white">Tunnel</h2>
|
</section>
|
||||||
<p class="text-sm text-white/60 mt-1">Connection and handshake status.</p>
|
<section class="glass-card p-5 min-w-0" aria-labelledby="tunnel-title">
|
||||||
<div class="mt-4 flex items-center justify-between"><span class="text-white/70">Tunnel</span><span :class="tunnel?.connected ? 'text-green-300' : 'text-orange-300'">{{ tunnelLabel }}</span></div>
|
<h2 id="tunnel-title" class="text-lg font-medium text-white">Private connections</h2>
|
||||||
<div class="mt-2 flex items-center justify-between"><span class="text-white/70">Address</span><span class="text-white font-mono text-xs">{{ tunnel?.wg_ip || tunnel?.ip_address || 'Unknown' }}</span></div>
|
<p class="text-sm text-white/60 mt-1">Tunnels let your devices reach this node through a private connection.</p>
|
||||||
</div>
|
<dl class="mt-4 space-y-3 text-sm">
|
||||||
|
<div class="status-row"><dt>Connection</dt><dd :class="tunnel?.connected === true ? 'text-green-300' : ''">{{ tunnelLabel }}</dd></div>
|
||||||
|
<div class="status-row"><dt>Detected service</dt><dd>{{ tunnel ? provider : 'Could not check' }}</dd></div>
|
||||||
|
<div class="status-row"><dt>WireGuard device address</dt><dd>{{ tunnel?.wg_ip || (tunnel ? 'Not ready' : 'Could not check') }}</dd></div>
|
||||||
|
</dl>
|
||||||
|
<p v-if="tunnel?.wg_ip" class="mt-4 text-sm text-white/60">Your device tunnel is already set up. Add or manage devices below.</p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
<VpnDeviceSettings :status-known="tunnel !== null" :configured="!!tunnel?.wg_ip" :management-verified="tunnel?.peer_management_verified === true" />
|
||||||
|
<section class="glass-card p-5 mt-6 min-w-0" aria-labelledby="router-title">
|
||||||
|
<h2 id="router-title" class="text-lg font-medium text-white">Your router</h2>
|
||||||
|
<p class="text-sm text-white/60 mt-1">Manage a supported OpenWrt router, including its Wi-Fi and internet connection.</p>
|
||||||
|
<dl class="mt-4 text-sm"><div class="status-row"><dt>Router connection saved here</dt><dd>{{ router?.configured === true ? 'Saved' : router?.configured === false ? 'Not saved here' : 'Could not check' }}</dd></div></dl>
|
||||||
|
<p v-if="router?.configured === false" class="mt-3 text-sm text-white/50">No router connection is saved in this section. Your router and tunnels may already be configured elsewhere.</p>
|
||||||
|
<RouterLink to="/dashboard/server/openwrt" class="glass-button mt-4 text-sm">Open router settings</RouterLink>
|
||||||
</section>
|
</section>
|
||||||
|
<section class="glass-card p-5 mt-6 min-w-0" aria-labelledby="public-title">
|
||||||
<section class="glass-card p-5 mb-6">
|
<h2 id="public-title" class="text-lg font-medium text-white">Saved public access entries</h2>
|
||||||
<div class="flex items-start justify-between gap-3"><div><h2 class="text-lg font-medium text-white">Public access checks</h2><p class="text-sm text-white/60 mt-1">Existing router forwards are shown for review. No route is changed from this screen yet.</p></div><span class="text-sm text-white/60">{{ forwards.length }} forward{{ forwards.length === 1 ? '' : 's' }}</span></div>
|
<p class="text-sm text-white/60 mt-1">These saved entries describe ports for apps. They do not confirm that your router has opened them.</p>
|
||||||
<div class="mt-4 grid gap-2 sm:grid-cols-2">
|
<ul v-if="forwards?.length" class="mt-4 grid gap-3 sm:grid-cols-2">
|
||||||
<div v-for="(forward, index) in forwards" :key="index" class="rounded-lg bg-white/5 p-3 text-xs text-white/70 font-mono break-all">{{ JSON.stringify(forward) }}</div>
|
<li v-for="forward in forwards" :key="forward.id" class="rounded-lg bg-white/5 p-3 min-w-0"><h3 class="text-sm text-white break-words">{{ forward.service_name }}</h3><p class="mt-1 text-sm text-white/60">Outside port {{ forward.external_port }} → app port {{ forward.internal_port }} ({{ forward.protocol }})</p></li>
|
||||||
<div v-if="!forwards.length" class="text-sm text-white/45">No router forwards reported.</div>
|
</ul>
|
||||||
</div>
|
<p v-else class="mt-4 text-sm text-white/50">{{ forwards ? 'No entries are saved here.' : 'Could not check saved entries.' }}</p>
|
||||||
<p class="text-xs text-white/45 mt-4">Public forwarding, node firewall, service listener and external reachability are independent checks. A local pass does not claim public access.</p>
|
<p class="mt-4 text-sm text-white/50">Use your router's settings to change public access. Opening an app to the internet may also require that app's own access settings.</p>
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="glass-card p-5">
|
|
||||||
<h2 class="text-lg font-medium text-white">App exposure</h2>
|
|
||||||
<p class="text-sm text-white/60 mt-1">App manifests and owned exposure rules will be added here after validation and rollback support are qualified.</p>
|
|
||||||
<div class="mt-4 rounded-lg border border-white/10 bg-white/5 p-3 text-sm text-white/60">No changes are available yet. Existing app access remains untouched.</div>
|
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
<style scoped>
|
||||||
|
.status-row { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); align-items: start; gap: 1rem; }
|
||||||
|
.status-row dt { color: rgb(255 255 255 / 0.65); }
|
||||||
|
.status-row dd { margin: 0; text-align: right; overflow-wrap: anywhere; }
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { flushPromises, mount } from '@vue/test-utils'
|
||||||
|
import { defineComponent, h, KeepAlive, nextTick, ref } from 'vue'
|
||||||
|
vi.mock('@/api/rpc-client', () => ({ rpcClient: { call: vi.fn() } }))
|
||||||
|
import { rpcClient } from '@/api/rpc-client'
|
||||||
|
import VpnDeviceSettings from './VpnDeviceSettings.vue'
|
||||||
|
const peer = { name: 'Phone', ip: '10.44.0.2/32', config: 'PRIVATE-LIST-SECRET' }
|
||||||
|
const connection = { qr_svg: '<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script><rect width="10" height="10" /></svg>', config: 'PRIVATE-EXPLICIT-CONFIG', peer_ip: '10.44.0.2/32' }
|
||||||
|
function view(verified = true) { return mount(VpnDeviceSettings, { props: { configured: true, managementVerified: verified, statusKnown: true } }) }
|
||||||
|
function button(w: ReturnType<typeof view>, label: string) { const b = w.findAll('button').find(b => b.text() === label); expect(b).toBeDefined(); return b! }
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetAllMocks()
|
||||||
|
vi.mocked(rpcClient.call).mockImplementation(async ({ method }) => {
|
||||||
|
if (method === 'vpn.list-peers') return { peers: [peer] }
|
||||||
|
if (method === 'vpn.peer-config' || method === 'vpn.create-peer') return connection
|
||||||
|
if (method === 'vpn.remove-peer') return { removed: true }
|
||||||
|
throw new Error(method)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
describe('Private device management', () => {
|
||||||
|
it('loads saved device names without rendering private list data or making changes', async () => {
|
||||||
|
const w = view(); await flushPromises()
|
||||||
|
expect(w.text()).toContain('Phone'); expect(w.html()).not.toContain('PRIVATE-LIST-SECRET')
|
||||||
|
expect(rpcClient.call).toHaveBeenCalledTimes(1); w.unmount()
|
||||||
|
})
|
||||||
|
it('keeps mutations disabled for older backends while allowing explicit details', async () => {
|
||||||
|
const w = view(false); await flushPromises()
|
||||||
|
expect(button(w, 'Add device').attributes('disabled')).toBeDefined()
|
||||||
|
expect(w.get('[aria-label="Remove Phone"]').attributes('disabled')).toBeDefined()
|
||||||
|
await button(w, 'Connection details').trigger('click'); await flushPromises()
|
||||||
|
expect(rpcClient.call).toHaveBeenCalledWith(expect.objectContaining({ method: 'vpn.peer-config', params: { name: 'Phone' } }))
|
||||||
|
expect(w.text()).toContain('Connect Phone'); w.unmount()
|
||||||
|
})
|
||||||
|
it('sanitizes explicit QR content and never renders the private config as text', async () => {
|
||||||
|
const w = view(); await flushPromises(); await button(w, 'Connection details').trigger('click'); await flushPromises()
|
||||||
|
expect(w.find('svg rect').exists()).toBe(true); expect(w.find('script').exists()).toBe(false)
|
||||||
|
expect(w.html()).not.toContain('PRIVATE-EXPLICIT-CONFIG')
|
||||||
|
await button(w, 'Hide details').trigger('click'); expect(w.find('svg').exists()).toBe(false); w.unmount()
|
||||||
|
})
|
||||||
|
it.each(['../escape', 'a/b', '.', 'bad\\path', 'Phone', 'phone'])('rejects unsafe or duplicate device name %s without mutation', async name => {
|
||||||
|
const w = view(); await flushPromises(); await w.get('input').setValue(name)
|
||||||
|
await w.get('form').trigger('submit'); await flushPromises()
|
||||||
|
expect(vi.mocked(rpcClient.call).mock.calls.filter(([a]) => a.method === 'vpn.create-peer')).toHaveLength(0); w.unmount()
|
||||||
|
})
|
||||||
|
it('adds only after explicit submission, without automatic mutation retries', async () => {
|
||||||
|
const w = view(); await flushPromises(); await w.get('input').setValue('Laptop')
|
||||||
|
await w.get('form').trigger('submit'); await flushPromises()
|
||||||
|
expect(rpcClient.call).toHaveBeenCalledWith({ method: 'vpn.create-peer', params: { name: 'Laptop' }, maxRetries: 0 })
|
||||||
|
expect(w.text()).toContain('Connect Laptop'); w.unmount()
|
||||||
|
})
|
||||||
|
it('requires a successful refresh after an uncertain creation before retrying', async () => {
|
||||||
|
vi.mocked(rpcClient.call).mockImplementation(async ({ method }) => { if (method === 'vpn.list-peers') return { peers: [] }; throw new Error('lost response') })
|
||||||
|
const w = view(); await flushPromises(); await w.get('input').setValue('Laptop'); await w.get('form').trigger('submit'); await flushPromises()
|
||||||
|
expect(w.text()).toContain('Refresh the list before trying again')
|
||||||
|
await w.get('form').trigger('submit'); await flushPromises()
|
||||||
|
expect(vi.mocked(rpcClient.call).mock.calls.filter(([a]) => a.method === 'vpn.create-peer')).toHaveLength(1)
|
||||||
|
await button(w, 'Refresh devices').trigger('click'); await flushPromises()
|
||||||
|
expect(button(w, 'Add device').attributes('disabled')).toBeUndefined(); w.unmount()
|
||||||
|
})
|
||||||
|
it('requires removal confirmation and keeps devices when the backend does not confirm', async () => {
|
||||||
|
vi.mocked(rpcClient.call).mockImplementation(async ({ method }) => method === 'vpn.list-peers' ? { peers: [peer] } : { removed: false })
|
||||||
|
const w = view(); await flushPromises(); await w.get('[aria-label="Remove Phone"]').trigger('click')
|
||||||
|
expect(vi.mocked(rpcClient.call).mock.calls.filter(([a]) => a.method === 'vpn.remove-peer')).toHaveLength(0)
|
||||||
|
await button(w, 'Remove device').trigger('click'); await flushPromises()
|
||||||
|
expect(w.text()).toContain('Could not confirm removal'); expect(w.get('[aria-label="Remove Phone"]').exists()).toBe(true); w.unmount()
|
||||||
|
})
|
||||||
|
it('keeps a failed list distinct from no devices and disables creation', async () => {
|
||||||
|
vi.mocked(rpcClient.call).mockRejectedValue(new Error('offline'))
|
||||||
|
const w = view(); await flushPromises()
|
||||||
|
expect(w.text()).toContain('Could not load devices'); expect(w.text()).not.toContain('No devices have been added')
|
||||||
|
expect(button(w, 'Add device').attributes('disabled')).toBeDefined(); w.unmount()
|
||||||
|
})
|
||||||
|
it('does not show copied until the clipboard succeeds', async () => {
|
||||||
|
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText: vi.fn().mockRejectedValue(new Error('denied')) } })
|
||||||
|
const w = view(); await flushPromises(); await button(w, 'Connection details').trigger('click'); await flushPromises()
|
||||||
|
await button(w, 'Copy connection settings').trigger('click'); await flushPromises()
|
||||||
|
expect(w.text()).toContain('Could not copy'); expect(w.text()).not.toContain('Copied'); w.unmount()
|
||||||
|
})
|
||||||
|
it('clears private details across cached navigation and ignores an old reveal response', async () => {
|
||||||
|
let resolve!: (value: typeof connection) => void
|
||||||
|
vi.mocked(rpcClient.call).mockImplementation(async ({ method }) => {
|
||||||
|
if (method === 'vpn.list-peers') return { peers: [peer] }
|
||||||
|
return new Promise(r => { resolve = r })
|
||||||
|
})
|
||||||
|
const visible = ref(true)
|
||||||
|
const Host = defineComponent({ setup: () => () => h(KeepAlive, null, { default: () => visible.value ? h(VpnDeviceSettings, { configured: true, managementVerified: true, statusKnown: true }) : h('div', 'Elsewhere') }) })
|
||||||
|
const host = mount(Host); await flushPromises()
|
||||||
|
const details = host.findAll('button').find(b => b.text() === 'Connection details')!
|
||||||
|
await details.trigger('click')
|
||||||
|
visible.value = false; await nextTick(); visible.value = true; await nextTick(); await flushPromises()
|
||||||
|
resolve(connection); await flushPromises()
|
||||||
|
expect(host.text()).not.toContain('Connect Phone'); expect(host.find('svg').exists()).toBe(false)
|
||||||
|
host.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('offers recovery for unfinished operations without revealing an unconfirmed connection', async () => {
|
||||||
|
vi.mocked(rpcClient.call).mockResolvedValue({ peers: [{ ...peer, management_state: 'pending' }] })
|
||||||
|
const w = view(); await flushPromises()
|
||||||
|
expect(w.text()).toContain('Setup was not confirmed')
|
||||||
|
expect(button(w, 'Connection details').attributes('disabled')).toBeDefined()
|
||||||
|
expect(w.get('[aria-label="Remove Phone"]').attributes('disabled')).toBeUndefined()
|
||||||
|
w.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
|
})
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onActivated, onDeactivated, onMounted, onUnmounted, ref } from 'vue'
|
||||||
|
import DOMPurify from 'dompurify'
|
||||||
|
import { rpcClient } from '@/api/rpc-client'
|
||||||
|
|
||||||
|
const props = defineProps<{ configured: boolean; managementVerified: boolean; statusKnown: boolean }>()
|
||||||
|
interface Device { name: string; ip: string; management_state?: 'active' | 'pending' | 'revoking' }
|
||||||
|
interface Connection { qr_svg: string; config: string; peer_ip: string }
|
||||||
|
const devices = ref<Device[] | null>(null)
|
||||||
|
const loading = ref(false)
|
||||||
|
const busy = ref(false)
|
||||||
|
const error = ref('')
|
||||||
|
const notice = ref('')
|
||||||
|
const name = ref('')
|
||||||
|
const selected = ref('')
|
||||||
|
const connection = ref<Connection | null>(null)
|
||||||
|
const removeName = ref('')
|
||||||
|
const copied = ref(false)
|
||||||
|
const needsRefresh = ref(false)
|
||||||
|
const safeName = (value: string) => /^[A-Za-z0-9][A-Za-z0-9 _-]{0,63}$/.test(value)
|
||||||
|
const filename = (value: string) => value.toLowerCase().replaceAll(' ', '-')
|
||||||
|
const canCreate = computed(() => props.statusKnown && props.configured && props.managementVerified && devices.value !== null && !needsRefresh.value && safeName(name.value.trim()) && !devices.value.some(d => filename(d.name) === filename(name.value.trim())))
|
||||||
|
let active = true
|
||||||
|
let request = 0
|
||||||
|
let viewGeneration = 0
|
||||||
|
const qr = computed(() => DOMPurify.sanitize(connection.value?.qr_svg ?? '', { USE_PROFILES: { svg: true } }))
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
const generation = ++request
|
||||||
|
loading.value = true
|
||||||
|
error.value = ''
|
||||||
|
try {
|
||||||
|
const result = await rpcClient.call<{ peers: Device[] }>({ method: 'vpn.list-peers', dedup: true, maxRetries: 1 })
|
||||||
|
if (!active || generation !== request) return
|
||||||
|
if (!Array.isArray(result?.peers) || result.peers.some(p => !p || typeof p.name !== 'string' || typeof p.ip !== 'string' || (p.management_state !== undefined && !['active', 'pending', 'revoking'].includes(p.management_state)))) throw new Error('Invalid device list')
|
||||||
|
devices.value = result.peers.map(p => ({ name: p.name, ip: p.ip, management_state: p.management_state }))
|
||||||
|
needsRefresh.value = false
|
||||||
|
} catch {
|
||||||
|
if (active && generation === request) error.value = devices.value ? 'Could not refresh devices. The last list is shown.' : 'Could not load devices. Try again.'
|
||||||
|
} finally { if (active && generation === request) loading.value = false }
|
||||||
|
}
|
||||||
|
async function openConnection(deviceName?: string) {
|
||||||
|
if (busy.value || devices.value === null || (!deviceName && !canCreate.value)) return
|
||||||
|
const view = viewGeneration
|
||||||
|
const trimmed = deviceName ?? name.value.trim()
|
||||||
|
if (!safeName(trimmed)) return
|
||||||
|
busy.value = true; error.value = ''; notice.value = ''; connection.value = null; copied.value = false
|
||||||
|
try {
|
||||||
|
const result = await rpcClient.call<Connection>({ method: deviceName ? 'vpn.peer-config' : 'vpn.create-peer', params: { name: trimmed }, maxRetries: 0 })
|
||||||
|
if (!active || view !== viewGeneration) return
|
||||||
|
if (!result || typeof result.qr_svg !== 'string' || typeof result.config !== 'string' || typeof result.peer_ip !== 'string') throw new Error('Invalid connection details')
|
||||||
|
selected.value = trimmed; connection.value = result; name.value = ''
|
||||||
|
if (!deviceName) await refresh()
|
||||||
|
} catch { if (active && view === viewGeneration) { needsRefresh.value = !deviceName; error.value = deviceName ? 'Could not load connection details. Try again.' : 'Could not confirm the new device. Refresh the list before trying again.' } }
|
||||||
|
finally { busy.value = false }
|
||||||
|
}
|
||||||
|
async function removeDevice() {
|
||||||
|
if (busy.value || !props.managementVerified || needsRefresh.value || !safeName(removeName.value)) return
|
||||||
|
const view = viewGeneration
|
||||||
|
const target = removeName.value
|
||||||
|
busy.value = true; error.value = ''; notice.value = ''
|
||||||
|
try {
|
||||||
|
const result = await rpcClient.call<{ removed: boolean }>({ method: 'vpn.remove-peer', params: { name: target }, maxRetries: 0 })
|
||||||
|
if (result?.removed !== true) throw new Error('Removal unconfirmed')
|
||||||
|
if (!active || view !== viewGeneration) return
|
||||||
|
if (selected.value === target) connection.value = null
|
||||||
|
devices.value = devices.value?.filter(d => d.name !== target) ?? null
|
||||||
|
removeName.value = ''; notice.value = `${target} was removed.`
|
||||||
|
await refresh()
|
||||||
|
} catch { if (active && view === viewGeneration) { needsRefresh.value = true; error.value = 'Could not confirm removal. Refresh the list to check before trying again.' } }
|
||||||
|
finally { busy.value = false }
|
||||||
|
}
|
||||||
|
async function copyConnection() {
|
||||||
|
if (!connection.value) return
|
||||||
|
const view = viewGeneration
|
||||||
|
try { await navigator.clipboard.writeText(connection.value.config); if (active && view === viewGeneration) copied.value = true }
|
||||||
|
catch { if (active && view === viewGeneration) error.value = 'Could not copy. Use the QR code instead.' }
|
||||||
|
}
|
||||||
|
onMounted(refresh)
|
||||||
|
function leave() { active = false; request++; viewGeneration++; connection.value = null; selected.value = ''; name.value = ''; removeName.value = ''; copied.value = false }
|
||||||
|
onDeactivated(leave)
|
||||||
|
onUnmounted(leave)
|
||||||
|
onActivated(() => { if (!active) { active = true; void refresh() } })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="glass-card p-5 min-w-0" aria-labelledby="vpn-devices-title">
|
||||||
|
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div><h2 id="vpn-devices-title" class="text-lg font-medium text-white">Your devices</h2><p class="mt-1 text-sm text-white/60">Give a phone or computer a private way to reach this node using the WireGuard app.</p></div>
|
||||||
|
<button class="glass-button text-sm" :disabled="loading || busy" @click="refresh">{{ loading ? 'Checking…' : 'Refresh devices' }}</button>
|
||||||
|
</div>
|
||||||
|
<p v-if="statusKnown && !configured" class="mt-4 text-sm text-white/60">The device tunnel is not ready. Check its status above before adding a device.</p>
|
||||||
|
<p v-if="statusKnown && !managementVerified" class="mt-4 text-sm text-white/60">Device changes are unavailable on this node right now. You can still view saved connection details.</p>
|
||||||
|
<p v-if="error" role="alert" class="mt-4 text-sm text-orange-200">{{ error }}</p>
|
||||||
|
<p v-if="notice" role="status" class="mt-4 text-sm text-green-300">{{ notice }}</p>
|
||||||
|
<ul v-if="devices?.length" class="mt-4 space-y-2">
|
||||||
|
<li v-for="device in devices" :key="device.name" class="flex flex-wrap items-center justify-between gap-3 rounded-lg bg-white/5 p-3">
|
||||||
|
<div class="min-w-0"><p class="text-sm text-white break-words">{{ device.name }}</p><p class="text-xs text-white/50 break-all">{{ device.ip }}</p><p v-if="device.management_state === 'pending'" class="mt-1 text-xs text-orange-200">Setup was not confirmed. Remove this entry before adding the device again.</p><p v-if="device.management_state === 'revoking'" class="mt-1 text-xs text-orange-200">Removal needs another check. Refresh, then retry removal.</p></div>
|
||||||
|
<div class="flex flex-wrap gap-2"><button class="glass-button text-xs" :disabled="busy || !safeName(device.name) || (device.management_state !== undefined && device.management_state !== 'active')" @click="openConnection(device.name)">Connection details</button><button class="glass-button text-xs" :disabled="busy || !managementVerified || !safeName(device.name)" :aria-label="`Remove ${device.name}`" @click="removeName = device.name">Remove</button></div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p v-else-if="devices" class="mt-4 text-sm text-white/50">No devices have been added.</p>
|
||||||
|
<div v-if="removeName" class="mt-4 rounded-lg border border-orange-400/30 p-4">
|
||||||
|
<p class="text-sm text-white">Remove {{ removeName }}? It will lose this private connection.</p>
|
||||||
|
<div class="mt-3 flex flex-wrap gap-2"><button class="glass-button text-sm" :disabled="busy || needsRefresh" @click="removeDevice">{{ busy ? 'Removing…' : 'Remove device' }}</button><button class="glass-button text-sm" :disabled="busy" @click="removeName = ''">Keep device</button></div>
|
||||||
|
</div>
|
||||||
|
<form class="mt-5 flex flex-wrap items-end gap-3" @submit.prevent="openConnection()">
|
||||||
|
<label class="min-w-0 flex-1 text-sm text-white/70" for="vpn-device-name">Device name<input id="vpn-device-name" v-model="name" :disabled="busy || !configured || !managementVerified || devices === null" maxlength="64" placeholder="For example, my phone" pattern="[A-Za-z0-9][A-Za-z0-9 _\-]{0,63}" title="Use letters, numbers, spaces, dashes or underscores." class="mt-2 block w-full rounded-lg border border-white/15 bg-white/5 px-3 py-2.5 text-white" /></label>
|
||||||
|
<button type="submit" class="glass-button text-sm" :disabled="busy || !canCreate">{{ busy ? 'Please wait…' : 'Add device' }}</button>
|
||||||
|
</form>
|
||||||
|
<div v-if="connection" class="mt-5 rounded-lg border border-white/15 p-4">
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-3"><h3 class="text-base text-white break-words">Connect {{ selected }}</h3><button class="glass-button text-sm" @click="connection = null">Hide details</button></div>
|
||||||
|
<p class="mt-2 text-sm text-white/60">Open WireGuard on that device and scan this code. Keep these connection details private.</p>
|
||||||
|
<div class="connection-qr mt-4 max-w-full w-fit rounded-lg bg-white p-3" v-html="qr" />
|
||||||
|
<button class="glass-button mt-4 text-sm" @click="copyConnection">{{ copied ? 'Copied' : 'Copy connection settings' }}</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
<style scoped>
|
||||||
|
.connection-qr :deep(svg) { max-width: 100%; height: auto; }
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user