fix(ui): wifi setup on a fresh install — reveal toggle + a no-network callout (#145)
Demo images / Build & push demo images (push) Failing after 39s

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).
This commit is contained in:
archipelago
2026-08-31 07:47:47 -04:00
parent 966db4810a
commit c5eeb31055
7 changed files with 356 additions and 4 deletions
@@ -0,0 +1,117 @@
<template>
<Transition
enter-active-class="transition duration-300 ease-out"
enter-from-class="opacity-0 translate-y-2"
enter-to-class="opacity-100 translate-y-0"
leave-active-class="transition duration-200 ease-in"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<div
v-if="visible"
class="fixed bottom-5 left-1/2 -translate-x-1/2 z-40 w-[min(92vw,420px)] glass-card px-4 py-3 flex items-start gap-3 shadow-xl"
>
<svg class="w-5 h-5 text-white/60 shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.141 0M1.394 9.393C6.957 3.83 17.043 3.83 22.606 9.393" />
</svg>
<div class="min-w-0 flex-1">
<p class="text-sm font-medium text-white">No network connection</p>
<p class="text-xs text-white/60 mt-0.5">This node has no cable or WiFi link yet. You can set up WiFi now — it also works without internet.</p>
<div class="flex gap-2 mt-2.5">
<button
class="px-3 py-1.5 glass-button rounded-lg text-xs font-medium"
@click="goToWifi"
>
Connect to WiFi
</button>
<button
class="px-3 py-1.5 text-xs text-white/50 hover:text-white transition-colors"
@click="dismissed = true"
>
Dismiss
</button>
</div>
</div>
<button class="text-white/40 hover:text-white transition-colors shrink-0" aria-label="Dismiss" @click="dismissed = true">
<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="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</Transition>
</template>
<script lang="ts">
/** True when at least one physical interface is up (ethernet or WiFi).
* Exported for tests — the component only needs this one pure decision. */
export function hasPhysicalLink(interfaces: { type: string; state: string }[]): boolean {
return interfaces.some(
(iface) => (iface.type === 'ethernet' || iface.type === 'wifi') && iface.state === 'up',
)
}
</script>
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { rpcClient } from '@/api/rpc-client'
/**
* Onboarding-only "no network at all" callout (#145).
*
* A fresh install without a cable can leave a user stranded: the WiFi
* settings live in Server → Network and nothing points there. This floats
* over the onboarding steps whenever the node has NO physical link (no
* ethernet up, no WiFi associated) and deep-links to the WiFi picker.
*
* Deliberately scoped the other way too: Archipelago is offline-first, so
* "no internet" must NEVER nag — only "no link at all" qualifies, and the
* callout is onboarding-context only (the wrapper renders it on
* /onboarding/* routes; logged-in users have their own places to look).
*/
const router = useRouter()
const dismissed = ref(false)
const hasLink = ref<boolean | null>(null)
const visible = computed(() => !dismissed.value && hasLink.value === false)
let timer: ReturnType<typeof setInterval> | null = null
let inFlight = false
async function check() {
if (inFlight) return
inFlight = true
try {
const res = await rpcClient.call<{ interfaces: { type: string; state: string }[] }>({
method: 'network.list-interfaces',
dedup: true,
maxRetries: 1,
})
hasLink.value = hasPhysicalLink(res?.interfaces ?? [])
} catch {
// Node busy or RPC not ready during early onboarding — never nag on a
// failed probe; treat unknown as "don't show".
hasLink.value = null
} finally {
inFlight = false
}
}
function goToWifi() {
dismissed.value = true
// Server.vue consumes ?open=wifi by popping the WiFi picker on arrival.
router.push('/dashboard/server?open=wifi')
}
onMounted(() => {
check()
// A cable gets plugged in mid-onboarding; poll gently so the callout
// dismisses itself the moment a link exists.
timer = setInterval(check, 15_000)
})
onUnmounted(() => {
if (timer) clearInterval(timer)
})
</script>
@@ -0,0 +1,55 @@
<template>
<div class="relative">
<input
:type="revealed ? 'text' : 'password'"
:value="modelValue"
:placeholder="placeholder"
:disabled="disabled"
:autocomplete="autocomplete"
class="w-full px-3 py-2 pr-10 bg-white/5 border border-white/10 rounded-lg text-white text-sm placeholder-white/30 focus:outline-none focus:border-white/30 disabled:opacity-50 disabled:cursor-not-allowed"
@input="$emit('update:modelValue', ($event.target as HTMLInputElement).value)"
@keyup.enter="$emit('enter')"
>
<button
type="button"
class="absolute inset-y-0 right-0 px-3 text-white/40 hover:text-white/80 transition-colors"
:aria-label="revealed ? 'Hide password' : 'Show password'"
:title="revealed ? 'Hide password' : 'Show password'"
@click="revealed = !revealed"
>
<!-- eye -->
<svg v-if="!revealed" 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="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
<!-- eye-off -->
<svg v-else 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="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21" />
</svg>
</button>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
/**
* Password input with a reveal toggle (#145). Introduced for the WiFi SSID
* password — a fresh-install user typing a long wifi key into a TV from
* across the room needs to see what they typed — and written reusable so
* other password fields can adopt it without re-deriving the eye icon.
*/
defineProps<{
modelValue: string
placeholder?: string
disabled?: boolean
autocomplete?: string
}>()
defineEmits<{
(e: 'update:modelValue', value: string): void
(e: 'enter'): void
}>()
const revealed = ref(false)
</script>
@@ -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)
})
})
@@ -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()
})
})
+10
View File
@@ -48,6 +48,10 @@
<!-- Content with 3D transitions -->
<div class="perspective-container-wrapper">
<div class="perspective-container">
<!-- #145: fresh install with no cable — point at the WiFi picker. -->
<!-- Onboarding routes only: logged-in sessions are offline-first and
must never be nagged about connectivity. -->
<OnboardingNetworkCallout v-if="isOnboardingRoute" />
<RouterView v-slot="{ Component, route }">
<Transition :name="transitionName">
<div :key="route.path" class="view-wrapper">
@@ -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<HTMLVideoElement | null>(null)
+15
View File
@@ -970,6 +970,7 @@ onUnmounted(() => disarmVpnPoll())
// outside a <KeepAlive> 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) }
+5 -4
View File
@@ -147,12 +147,12 @@
<!-- WiFi password prompt -->
<div v-if="wifiConnecting" class="mt-4 pt-4 border-t border-white/10">
<p class="text-sm text-white/80 mb-2">Connect to <span class="font-medium text-white">{{ wifiSelectedSsid }}</span></p>
<input
<PasswordRevealInput
v-model="localWifiPassword"
type="password"
placeholder="WiFi password"
class="w-full px-3 py-2 bg-white/5 border border-white/10 rounded-lg text-white text-sm placeholder-white/30 focus:outline-none focus:border-white/30 mb-3"
@keyup.enter="$emit('connectWifi', localWifiPassword)"
:disabled="wifiSubmitting"
class="mb-3"
@enter="$emit('connectWifi', localWifiPassword)"
/>
<p v-if="wifiError" class="text-sm text-red-400 mb-3">{{ wifiError }}</p>
<div class="flex gap-2">
@@ -231,6 +231,7 @@
<script setup lang="ts">
import { ref } from 'vue'
import PasswordRevealInput from '@/components/PasswordRevealInput.vue'
defineProps<{
showAddServiceModal: boolean