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 @@ + + + + + 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 @@