From c5eeb3105534a046a45b14c02a344a02cb358f8d Mon Sep 17 00:00:00 2001 From: archipelago Date: Mon, 31 Aug 2026 07:47:47 -0400 Subject: [PATCH] =?UTF-8?q?fix(ui):=20wifi=20setup=20on=20a=20fresh=20inst?= =?UTF-8?q?all=20=E2=80=94=20reveal=20toggle=20+=20a=20no-network=20callou?= =?UTF-8?q?t=20(#145)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two reports from a fresh install without a cable: (a) No way to see the WiFi password being typed. Every password field in the app was a bare type=password input. PasswordRevealInput is the reusable fix — masked by default, one-tap eye toggle, v-model and enter pass-through — first applied to the WiFi prompt in ServerModals so a long key typed from across the room can be verified. (b) WiFi settings are undiscoverable with no wired internet. New OnboardingNetworkCallout floats over every onboarding step when the node has NO physical link at all (no ethernet up, no WiFi associated — polled from network.list-interfaces, self-dismissing the moment a link exists) and deep-links 'Connect to WiFi' to /dashboard/server?open=wifi, which Server.vue consumes by popping the WiFi picker on arrival. Deliberately scoped the other way too: Archipelago is offline-first, so 'no internet' never nags — only 'no link at all', only during onboarding (the wrapper hosts /login too; the callout is restricted to /onboarding/* routes), and a failed probe stays silent. The query is consumed via history.replaceState so a KeepAlive tab-return never re-pops the modal, and Server.vue keeps reading it from the real URL rather than vue-router — its KeepAlive-mounted tests have no router context to give. Verification: full frontend suite 1023/1023; type-check clean; production build clean with both new strings confirmed in the emitted bundles (OnboardingWrapper + Server chunks). --- .../components/OnboardingNetworkCallout.vue | 117 ++++++++++++++++++ .../src/components/PasswordRevealInput.vue | 55 ++++++++ .../OnboardingNetworkCallout.test.ts | 109 ++++++++++++++++ .../__tests__/PasswordRevealInput.test.ts | 45 +++++++ neode-ui/src/views/OnboardingWrapper.vue | 10 ++ neode-ui/src/views/Server.vue | 15 +++ neode-ui/src/views/server/ServerModals.vue | 9 +- 7 files changed, 356 insertions(+), 4 deletions(-) create mode 100644 neode-ui/src/components/OnboardingNetworkCallout.vue create mode 100644 neode-ui/src/components/PasswordRevealInput.vue create mode 100644 neode-ui/src/components/__tests__/OnboardingNetworkCallout.test.ts create mode 100644 neode-ui/src/components/__tests__/PasswordRevealInput.test.ts 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 @@