Skip to content

Commit 9914bd0

Browse files
committed
Wire SPA captive-portal flow to bubble-netd (§6.5)
The §6.5 sign-in window flow becomes clickable end-to-end: - New shared store frontend/src/lib/netStore.svelte.ts polls /net/signin/status every 5 s and exposes the result via Svelte runes. Components subscribe; the store handles the lifecycle. - New components/SigninBanner.svelte mounts above main content in App.svelte. Renders only when state=open. Shows the deadline countdown, the allowed portal IPs, and the LAN→WAN port allowlist — every piece §6.5 says must be visible 'on every screen.' Has a one-click 'Close now' that calls /net/signin/close. - Wifi.svelte gains a captive-portal card driven by /net/captive. When captive=true and the window is closed, the card surfaces the detected portal IPs and the 'Open sign-in window (10 min)' button. When captive=false, a small green confirmation. The mocked SSID scanner is preserved underneath since iwinfo isn't wired yet. - A strict-mode toggle is exposed inline on /wifi (settings page doesn't exist yet); flipping it persists via /net/signin/strict and the captive-portal 'Open' button greys out with an explanation when strict mode is active. App.svelte starts the net poll on boot via startPolling() and returns the stop function from onMount() so the timer cleans up on unmount. Type-check 0/0; production build 73 KB JS / 26 KB gzipped, still well under the §13.3 budget. End-to-end shapes verified against a live bubble-netd via curl: status closed → open with countdown → strict-toggle → close.
1 parent de070af commit 9914bd0

4 files changed

Lines changed: 286 additions & 17 deletions

File tree

frontend/src/App.svelte

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import { onMount } from 'svelte';
33
import { currentPath, navigate, onChange } from './lib/router';
44
import { session, logout, refresh } from './lib/session.svelte';
5+
import { startPolling } from './lib/netStore.svelte';
56
import * as api from './lib/api';
67
import { ICON } from './lib/icons';
78
@@ -12,6 +13,7 @@
1213
import Vpn from './routes/Vpn.svelte';
1314
import Ssid from './routes/Ssid.svelte';
1415
import Dns from './routes/Dns.svelte';
16+
import SigninBanner from './components/SigninBanner.svelte';
1517
1618
let path = $state(currentPath());
1719
@@ -23,7 +25,12 @@
2325
onMount(() => {
2426
void api.timeSync(Date.now());
2527
void refresh();
26-
return onChange((p) => (path = p));
28+
const stopRouter = onChange((p) => (path = p));
29+
const stopNetPoll = startPolling(5000);
30+
return () => {
31+
stopRouter();
32+
stopNetPoll();
33+
};
2734
});
2835
2936
const s = session();
@@ -58,6 +65,8 @@
5865
</div>
5966
</header>
6067

68+
<SigninBanner />
69+
6170
<nav>
6271
{#each nav as item}
6372
<a
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
<script lang="ts">
2+
// Per DESIGN.md §6.5 the captive-portal sign-in window must be
3+
// visible on EVERY screen, not just the WiFi pane — it's a
4+
// security-relevant state and easy to forget about. This banner
5+
// mounts in App.svelte above the main content and renders only
6+
// when the window is open.
7+
8+
import { net, closeSignin } from '../lib/netStore.svelte';
9+
import { ICON } from '../lib/icons';
10+
11+
const s = net();
12+
13+
function fmtRemaining(sec: number): string {
14+
const m = Math.floor(sec / 60);
15+
const r = Math.max(0, sec - m * 60);
16+
return m + 'm ' + r.toString().padStart(2, '0') + 's';
17+
}
18+
</script>
19+
20+
{#if s.signin?.state === 'open'}
21+
<div class="banner" role="status" aria-live="polite">
22+
<span class="icon">{ICON.warn}</span>
23+
<div class="text">
24+
<strong>Sign-in window open</strong>
25+
<span class="rem">— {fmtRemaining(s.signin.remaining_sec ?? 0)} remaining</span>
26+
<div class="detail">
27+
LAN→WAN allowed to
28+
<code>{(s.signin.portal_ips ?? []).join(', ') || ''}</code>
29+
on TCP <code>{(s.signin.allowed_ports ?? []).join(', ')}</code>.
30+
</div>
31+
</div>
32+
<button onclick={closeSignin} class="close">Close now</button>
33+
</div>
34+
{/if}
35+
36+
<style>
37+
.banner {
38+
display: grid;
39+
grid-template-columns: auto 1fr auto;
40+
gap: 12px;
41+
align-items: center;
42+
background: color-mix(in srgb, var(--accent-warn) 18%, var(--bg-elev));
43+
border: 1px solid var(--accent-warn);
44+
border-radius: var(--radius);
45+
padding: 10px 12px;
46+
margin-bottom: 12px;
47+
color: var(--fg);
48+
font-size: 13px;
49+
}
50+
.banner .icon { color: var(--accent-warn); font-size: 16px; }
51+
.text { line-height: 1.4; }
52+
.rem { color: var(--accent-warn); font-weight: 600; }
53+
.detail { color: var(--fg-dim); font-size: 12px; }
54+
code { background: var(--bg); padding: 1px 4px; border-radius: 3px; }
55+
.close {
56+
background: var(--bg-elev);
57+
border-color: var(--accent-warn);
58+
color: var(--accent-warn);
59+
}
60+
</style>
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
// Shared store that polls bubble-netd's /net/signin/status. Multiple
2+
// components subscribe (the global SigninBanner, the WiFi page's
3+
// captive-portal card, possibly future settings panels). DESIGN.md
4+
// §6.5 requires the open-window state to be visible on EVERY screen,
5+
// which is what this store + the banner mounted in App.svelte deliver.
6+
7+
import * as api from './api';
8+
import type { CaptiveStatus, SigninStatus } from './api';
9+
10+
interface NetState {
11+
signin: SigninStatus | null;
12+
captive: CaptiveStatus | null;
13+
// Last error string surfaced from any net call. Cleared on success.
14+
lastError: string | null;
15+
}
16+
17+
const state: NetState = $state({ signin: null, captive: null, lastError: null });
18+
19+
export function net(): NetState {
20+
return state;
21+
}
22+
23+
export async function refreshSignin(): Promise<void> {
24+
const r = await api.netSigninStatus();
25+
if (r.ok) {
26+
state.signin = r.data;
27+
state.lastError = null;
28+
} else {
29+
state.lastError = r.error.error;
30+
}
31+
}
32+
33+
export async function refreshCaptive(): Promise<void> {
34+
const r = await api.netCaptive();
35+
if (r.ok) {
36+
state.captive = r.data;
37+
}
38+
}
39+
40+
export async function openSignin(portalIPs: string[], durationSec?: number) {
41+
const r = await api.netSigninOpen(portalIPs, durationSec);
42+
await refreshSignin();
43+
return r;
44+
}
45+
46+
export async function closeSignin() {
47+
const r = await api.netSigninClose();
48+
await refreshSignin();
49+
return r;
50+
}
51+
52+
let pollHandle: ReturnType<typeof setInterval> | null = null;
53+
54+
// Start a low-frequency background poll. The banner subscribes to the
55+
// resulting state via Svelte runes. Idempotent.
56+
export function startPolling(intervalMs = 5000): () => void {
57+
if (pollHandle !== null) return () => {};
58+
void refreshSignin();
59+
pollHandle = setInterval(() => {
60+
void refreshSignin();
61+
}, intervalMs);
62+
return () => {
63+
if (pollHandle !== null) {
64+
clearInterval(pollHandle);
65+
pollHandle = null;
66+
}
67+
};
68+
}

frontend/src/routes/Wifi.svelte

Lines changed: 148 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
<script lang="ts">
22
import { onMount } from 'svelte';
33
import { rpc } from '../lib/rpc';
4+
import { net as netStore, refreshCaptive, refreshSignin, openSignin } from '../lib/netStore.svelte';
5+
import * as api from '../lib/api';
46
import { ICON } from '../lib/icons';
57
68
interface ScanResult {
@@ -14,7 +16,15 @@
1416
let loading = $state(false);
1517
let connecting = $state<string | null>(null);
1618
let connected = $state<string | null>(null);
17-
let captiveOpen = $state(false);
19+
let captiveProbing = $state(false);
20+
let opening = $state(false);
21+
let signinError = $state('');
22+
23+
// Strict-mode toggle from §6.5. Lives here for now; if/when a Settings
24+
// page lands it migrates there.
25+
let strict = $state(false);
26+
27+
const ns = netStore();
1828
1929
async function scan() {
2030
loading = true;
@@ -23,17 +33,47 @@
2333
if (r.ok) nets = r.data.results;
2434
}
2535
26-
async function connect(net: ScanResult) {
27-
connecting = net.ssid;
36+
async function probeCaptive() {
37+
captiveProbing = true;
38+
signinError = '';
39+
await refreshCaptive();
40+
captiveProbing = false;
41+
}
42+
43+
async function openWindow() {
44+
if (!ns.captive?.captive || (ns.captive.portal_ips ?? []).length === 0) {
45+
signinError = 'no portal IPs detected; rerun the captive probe';
46+
return;
47+
}
48+
opening = true;
49+
signinError = '';
50+
const r = await openSignin(ns.captive.portal_ips ?? [], 600);
51+
opening = false;
52+
if (!r.ok) signinError = r.error.error;
53+
}
54+
55+
async function toggleStrict() {
56+
strict = !strict;
57+
const r = await api.netSigninStrict(strict);
58+
if (!r.ok) {
59+
signinError = r.error.error;
60+
strict = !strict; // revert
61+
return;
62+
}
63+
await refreshSignin();
64+
}
65+
66+
async function connect(n: ScanResult) {
67+
connecting = n.ssid;
2868
const r = await rpc.call<{ connected: boolean }>('wireless', 'connect', {
29-
ssid: net.ssid,
30-
encryption: net.encryption,
69+
ssid: n.ssid,
70+
encryption: n.encryption,
3171
});
3272
connecting = null;
3373
if (r.ok && r.data.connected) {
34-
connected = net.ssid;
35-
// Mock: any open network is "probably captive."
36-
captiveOpen = net.encryption === 'open';
74+
connected = n.ssid;
75+
// After associating, immediately probe for a captive portal.
76+
void probeCaptive();
3777
}
3878
}
3979
@@ -44,7 +84,12 @@
4484
return '';
4585
}
4686
47-
onMount(scan);
87+
onMount(() => {
88+
void scan();
89+
void probeCaptive();
90+
void refreshSignin();
91+
if (ns.signin) strict = ns.signin.strict_mode;
92+
});
4893
</script>
4994

5095
<section>
@@ -61,17 +106,60 @@
61106
<div class="row">
62107
<span class="icon">{ICON.ok}</span>
63108
<strong>Connected to {connected}</strong>
109+
<button onclick={probeCaptive} disabled={captiveProbing} class="reprobe">
110+
<span class="icon" class:spin={captiveProbing}>{ICON.refresh}</span>
111+
recheck
112+
</button>
64113
</div>
65-
{#if captiveOpen}
66-
<p class="captive">
67-
<span class="icon">{ICON.warn}</span>
68-
Captive portal likely. Open the hotel login page to authenticate.
69-
</p>
70-
<button>Open hotel login</button>
114+
</div>
115+
{/if}
116+
117+
{#if ns.captive?.captive && ns.signin?.state !== 'open'}
118+
<div class="card captive">
119+
<div class="row">
120+
<span class="icon">{ICON.warn}</span>
121+
<strong>Hotel WiFi requires sign-in</strong>
122+
</div>
123+
<p class="hint">
124+
A captive portal is intercepting traffic. Detected at
125+
<code>{(ns.captive.portal_ips ?? []).join(', ') || 'unknown IP'}</code>.
126+
</p>
127+
<p class="detail">
128+
Opening the sign-in window adds a tightly-scoped firewall hole:
129+
LAN→WAN to those IPs only, on TCP 80/443, for 10 minutes. The
130+
kill switch stays in place for everything else.
131+
</p>
132+
<div class="actions">
133+
<button onclick={openWindow} disabled={opening || ns.signin?.strict_mode}>
134+
{opening ? 'opening…' : 'Open sign-in window (10 min)'}
135+
</button>
136+
{#if ns.signin?.strict_mode}
137+
<span class="dim">(disabled by strict mode)</span>
138+
{/if}
139+
</div>
140+
{#if signinError}
141+
<p class="err"><span class="icon">{ICON.err}</span> {signinError}</p>
71142
{/if}
72143
</div>
144+
{:else if ns.captive && !ns.captive.captive}
145+
<div class="card clean">
146+
<span class="icon">{ICON.ok}</span>
147+
Internet looks fine — no captive portal detected.
148+
</div>
73149
{/if}
74150

151+
<label class="toggle strict">
152+
<input type="checkbox" checked={ns.signin?.strict_mode ?? false} onchange={toggleStrict} />
153+
<span>
154+
<strong>Strict mode</strong>
155+
<small>
156+
Disable the captive-portal sign-in window entirely. With this on,
157+
captive portals must be cleared via a separate dedicated SSID;
158+
the kill switch is never relaxed. Recommended for high-threat trips.
159+
</small>
160+
</span>
161+
</label>
162+
75163
<ul class="list">
76164
{#each nets as net (net.bssid)}
77165
<li>
@@ -131,8 +219,52 @@
131219
display: grid;
132220
gap: 8px;
133221
}
222+
.card.captive {
223+
background: var(--bg-elev);
224+
border: 1px solid var(--accent-warn);
225+
border-radius: var(--radius);
226+
padding: 12px;
227+
margin-bottom: 12px;
228+
display: grid;
229+
gap: 8px;
230+
}
231+
.card.captive .icon { color: var(--accent-warn); }
232+
.card.clean {
233+
display: flex;
234+
align-items: center;
235+
gap: 8px;
236+
background: var(--bg-elev);
237+
border: 1px solid var(--accent-ok);
238+
border-radius: var(--radius);
239+
padding: 8px 12px;
240+
margin-bottom: 12px;
241+
color: var(--accent-ok);
242+
font-size: 13px;
243+
}
134244
.row { display: flex; align-items: center; gap: 8px; }
135-
.captive { color: var(--accent-warn); margin: 0; font-size: 13px; }
245+
.row > strong { flex: 1; }
246+
.hint { color: var(--fg-dim); font-size: 13px; margin: 0; line-height: 1.5; }
247+
.detail { color: var(--fg-faint); font-size: 12px; margin: 0; line-height: 1.5; }
248+
.err { color: var(--accent-err); font-size: 13px; margin: 0; }
249+
.reprobe { background: transparent; border: 1px solid var(--border); padding: 4px 8px; font-size: 12px; }
250+
.actions { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
251+
.dim { color: var(--fg-dim); font-size: 12px; }
252+
253+
.toggle.strict {
254+
display: grid;
255+
grid-template-columns: auto 1fr;
256+
gap: 12px;
257+
align-items: start;
258+
background: var(--bg-elev);
259+
border: 1px solid var(--border);
260+
border-radius: var(--radius);
261+
padding: 12px;
262+
margin-bottom: 12px;
263+
cursor: pointer;
264+
}
265+
.toggle.strict input { width: auto; margin-top: 4px; }
266+
.toggle.strict small { display: block; color: var(--fg-dim); margin-top: 2px; line-height: 1.5; }
267+
code { background: var(--bg); padding: 1px 4px; border-radius: 3px; }
136268
.spin { display: inline-block; animation: spin 1s linear infinite; }
137269
@keyframes spin { to { transform: rotate(360deg); } }
138270
</style>

0 commit comments

Comments
 (0)