diff --git a/neode-ui/src/components/OnboardingNetworkCallout.vue b/neode-ui/src/components/OnboardingNetworkCallout.vue
new file mode 100644
index 00000000..4a5c3934
--- /dev/null
+++ b/neode-ui/src/components/OnboardingNetworkCallout.vue
@@ -0,0 +1,117 @@
+
+
+
+
+
+
No network connection
+
This node has no cable or WiFi link yet. You can set up WiFi now — it also works without internet.
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/neode-ui/src/components/PasswordRevealInput.vue b/neode-ui/src/components/PasswordRevealInput.vue
new file mode 100644
index 00000000..fccca750
--- /dev/null
+++ b/neode-ui/src/components/PasswordRevealInput.vue
@@ -0,0 +1,55 @@
+
+
+
+
+
+
+
+
diff --git a/neode-ui/src/components/__tests__/OnboardingNetworkCallout.test.ts b/neode-ui/src/components/__tests__/OnboardingNetworkCallout.test.ts
new file mode 100644
index 00000000..bc6b2248
--- /dev/null
+++ b/neode-ui/src/components/__tests__/OnboardingNetworkCallout.test.ts
@@ -0,0 +1,109 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
+import { mount, flushPromises } from '@vue/test-utils'
+import OnboardingNetworkCallout, { hasPhysicalLink } from '../OnboardingNetworkCallout.vue'
+import { rpcClient } from '@/api/rpc-client'
+
+// #145: a fresh install with no cable strands the user — the callout points
+// at the WiFi picker, and ONLY when no physical link exists. Archipelago is
+// offline-first, so "no internet" must never nag: only "no link at all".
+
+vi.mock('@/api/rpc-client', () => ({
+ rpcClient: { call: vi.fn() },
+}))
+
+const push = vi.fn()
+vi.mock('vue-router', () => ({
+ useRouter: () => ({ push }),
+}))
+
+const call = vi.mocked(rpcClient.call)
+
+function mountCallout() {
+ return mount(OnboardingNetworkCallout)
+}
+
+afterEach(() => {
+ vi.clearAllMocks()
+})
+
+describe('hasPhysicalLink (pure decision)', () => {
+ it('no interfaces at all → no link', () => {
+ expect(hasPhysicalLink([])).toBe(false)
+ })
+
+ it('ethernet up → link', () => {
+ expect(hasPhysicalLink([{ type: 'ethernet', state: 'up' }])).toBe(true)
+ })
+
+ it('wifi up → link', () => {
+ expect(hasPhysicalLink([{ type: 'wifi', state: 'up' }])).toBe(true)
+ })
+
+ it('physical interface present but down → no link', () => {
+ expect(
+ hasPhysicalLink([
+ { type: 'ethernet', state: 'down' },
+ { type: 'wifi', state: 'down' },
+ ]),
+ ).toBe(false)
+ })
+
+ it('virtual interfaces that happen to be up do NOT count as a link', () => {
+ expect(
+ hasPhysicalLink([
+ { type: 'bridge', state: 'up' },
+ { type: 'loopback', state: 'up' },
+ ]),
+ ).toBe(false)
+ })
+})
+
+describe('OnboardingNetworkCallout (component)', () => {
+ beforeEach(() => {
+ call.mockReset()
+ })
+
+ it('shows when the node has no physical link, and offers the WiFi picker', async () => {
+ call.mockResolvedValue({
+ interfaces: [
+ { type: 'ethernet', state: 'down' },
+ { type: 'wifi', state: 'down' },
+ ],
+ })
+ const wrapper = mountCallout()
+ await flushPromises()
+
+ expect(wrapper.text()).toContain('No network connection')
+ expect(wrapper.text()).toContain('Connect to WiFi')
+
+ await wrapper.findAll('button').find(b => b.text() === 'Connect to WiFi')!.trigger('click')
+ expect(push).toHaveBeenCalledWith('/dashboard/server?open=wifi')
+ })
+
+ it('stays hidden once any physical link exists — offline-first, no nagging', async () => {
+ call.mockResolvedValue({ interfaces: [{ type: 'ethernet', state: 'up' }] })
+ const wrapper = mountCallout()
+ await flushPromises()
+
+ expect(wrapper.find('div.fixed').exists()).toBe(false)
+ })
+
+ it('never shows on a failed probe — early onboarding, RPC not ready yet', async () => {
+ call.mockRejectedValue(new Error('not ready'))
+ const wrapper = mountCallout()
+ await flushPromises()
+
+ expect(wrapper.find('div.fixed').exists()).toBe(false)
+ })
+
+ it('hides when dismissed, even with no link', async () => {
+ call.mockResolvedValue({ interfaces: [{ type: 'wifi', state: 'down' }] })
+ const wrapper = mountCallout()
+ await flushPromises()
+
+ const dismiss = wrapper.findAll('button').find(b => b.text() === 'Dismiss')!
+ expect(dismiss).toBeDefined()
+ await dismiss.trigger('click')
+ expect(wrapper.find('div.fixed').exists()).toBe(false)
+ })
+})
diff --git a/neode-ui/src/components/__tests__/PasswordRevealInput.test.ts b/neode-ui/src/components/__tests__/PasswordRevealInput.test.ts
new file mode 100644
index 00000000..7cda3e2c
--- /dev/null
+++ b/neode-ui/src/components/__tests__/PasswordRevealInput.test.ts
@@ -0,0 +1,45 @@
+import { describe, it, expect } from 'vitest'
+import { mount } from '@vue/test-utils'
+import PasswordRevealInput from '../PasswordRevealInput.vue'
+
+// #145: the reveal toggle exists so a fresh-install user typing a WiFi key
+// from across the room can see what they typed. The contract: masked by
+// default, one tap reveals, v-model and enter behave like a plain input.
+
+describe('PasswordRevealInput', () => {
+ it('masks by default and reveals on toggle', async () => {
+ const wrapper = mount(PasswordRevealInput, {
+ props: { modelValue: 'hunter2', placeholder: 'WiFi password' },
+ })
+ const input = wrapper.find('input')
+ expect(input.attributes('type')).toBe('password')
+
+ await wrapper.find('button').trigger('click')
+ expect(input.attributes('type')).toBe('text')
+
+ await wrapper.find('button').trigger('click')
+ expect(input.attributes('type')).toBe('password')
+ })
+
+ it('syncs v-model through update:modelValue', async () => {
+ const wrapper = mount(PasswordRevealInput, { props: { modelValue: '' } })
+ await wrapper.find('input').setValue('s3cret')
+ const emitted = wrapper.emitted('update:modelValue') as string[][]
+ expect(emitted[emitted.length - 1]).toEqual(['s3cret'])
+ })
+
+ it('emits enter on Enter keyup — the WiFi modal submits from the keyboard', async () => {
+ const wrapper = mount(PasswordRevealInput, { props: { modelValue: 'pw' } })
+ await wrapper.find('input').trigger('keyup.enter')
+ expect(wrapper.emitted('enter')).toHaveLength(1)
+ })
+
+ it('passes placeholder and disabled through to the input', () => {
+ const wrapper = mount(PasswordRevealInput, {
+ props: { modelValue: '', placeholder: 'WiFi password', disabled: true },
+ })
+ const input = wrapper.find('input')
+ expect(input.attributes('placeholder')).toBe('WiFi password')
+ expect(input.attributes('disabled')).toBeDefined()
+ })
+})
diff --git a/neode-ui/src/views/OnboardingWrapper.vue b/neode-ui/src/views/OnboardingWrapper.vue
index 571d89b6..9f656fd4 100644
--- a/neode-ui/src/views/OnboardingWrapper.vue
+++ b/neode-ui/src/views/OnboardingWrapper.vue
@@ -48,6 +48,10 @@
+
+
+
@@ -64,9 +68,15 @@
import { ref, watch, onMounted, computed } from 'vue'
import { useRoute } from 'vue-router'
import { resumeAudioContext, startSynthwave } from '@/composables/useLoginSounds'
+import OnboardingNetworkCallout from '@/components/OnboardingNetworkCallout.vue'
const route = useRoute()
const currentBackground = ref('bg-intro.jpg')
+
+// #145: the no-network callout follows the user across onboarding steps, but
+// this wrapper also hosts /login (and could host more later) — scope the
+// callout to the onboarding flow only.
+const isOnboardingRoute = computed(() => route.path.startsWith('/onboarding/'))
const isGlitching = ref(false)
const isTransitioning = ref(false)
const videoElement = ref
(null)
diff --git a/neode-ui/src/views/Server.vue b/neode-ui/src/views/Server.vue
index 2e9e6784..8b7cce92 100644
--- a/neode-ui/src/views/Server.vue
+++ b/neode-ui/src/views/Server.vue
@@ -970,6 +970,7 @@ onUnmounted(() => disarmVpnPoll())
// outside a boundary (confirmed by ServerNetworkRefresh.test.ts,
// which mounts this view bare), so a bare mount must not silently skip them.
onMounted(() => {
+ consumeOpenWifiQuery()
checkTorStatus(); loadNetworkData(); loadInterfaces(); loadTorServices(); loadVpnPeers(); loadFipsSummary(); loadDiskStatus()
armServerEntryEffects()
})
@@ -977,6 +978,20 @@ onMounted(() => {
watch(showWifiModal, (open) => { if (open) scanWifi() })
watch(showDnsModal, (open) => { if (open) { dnsSelectedProvider.value = networkData.value.dnsProvider || 'system'; dnsError.value = '' } })
+// #145: onboarding's no-network callout deep-links here with ?open=wifi so a
+// fresh-install user lands straight in the WiFi picker. Read from the real
+// URL (the dashboard's SPA router keeps it in sync) rather than vue-router —
+// the KeepAlive-mounted view has no router guarantee at test-mount time —
+// and consume it (history.replaceState) so a tab-return never re-pops.
+function consumeOpenWifiQuery() {
+ const params = new URLSearchParams(window.location.search)
+ if (params.get('open') !== 'wifi') return
+ params.delete('open')
+ const qs = params.toString()
+ history.replaceState(history.state, '', window.location.pathname + (qs ? `?${qs}` : '') + window.location.hash)
+ showWifiModal.value = true
+}
+
async function restartServices() {
restarting.value = true; servicesRunning.value = false
try { await rpcClient.restartServer(); logsToast.value = 'Services restarting...'; setTimeout(() => { logsToast.value = '' }, 4000) }
diff --git a/neode-ui/src/views/server/ServerModals.vue b/neode-ui/src/views/server/ServerModals.vue
index 5ee5c34f..ae0d95c8 100644
--- a/neode-ui/src/views/server/ServerModals.vue
+++ b/neode-ui/src/views/server/ServerModals.vue
@@ -147,12 +147,12 @@
Connect to {{ wifiSelectedSsid }}
-
{{ wifiError }}
@@ -231,6 +231,7 @@