// src/static/js/pages/wifi-ap.js — Wi-Fi Access Point leaf (spec §9.1).
// Dedicated ap_config key · STA uplink untouched · self-contained Save/Apply (/api/network/ap/*).
import { isAdvanced } from '../view-mode.js' ;
import { showFieldError , clearAllFieldErrors , setPageError } from '../validator.js' ;
import { icon } from '../icons.js' ;
import { showToast } from '../toast.js' ;
import { escapeHtml } from '../utils.js' ;
import { clearDirty } from '../page-dirty.js' ;
const _ DEFAULTS = { ap_enabled : false , ap_ssid : '' , ap_passphrase : '' , ap_band : 'auto' , ap_channel : 0 , ap_hidden : false } ;
let _ cfg = { ... _ DEFAULTS } ; // last loaded/saved config
let _ status = { } ; // live status (ap_enabled, ap0_up, hostapd_running, clients, client_details)
let _ countryPending = false ;
let _ statusTimer = null ;
// Fix #3b: track whether the backend has a stored PSK (from status.has_passphrase).
// null = not yet loaded (status not fetched); true/false = known.
// Used in _validate() to reject blank PSK when AP is being enabled on a fresh device.
let _ hasPassphrase = null ;
// v1.11.9 (review LOW concurrency): _save is async and bound to three buttons
// (ap-save / ap-apply / ap-save-apply) — guard against a double-apply re-entry.
let _ saving = false ;
function _ byteLen ( s ) { return new TextEncoder ( ) . encode ( s || '' ) . length ; }
function _ badge ( cls , text ) { return ` <span class="net-badge net-badge-- ${ cls } "> ${ escapeHtml ( text ) } </span> ` ; }
function _ fmtDbm ( v ) { return Number . isFinite ( v ) ? ` ${ v } dBm ` : 'Unknown' ; }
function _ fmtIdle ( v ) { return Number . isFinite ( v ) ? ` ${ v } ms ` : 'Unknown' ; }
function _ clientDetailsHtml ( ) {
const clients = Array . isArray ( _ status . client_details ) ? _ status . client_details : [ ] ;
if ( ! clients . length ) return '' ;
const rows = clients . map ( c => `
< tr >
< td class = "ap-client-table__mono" > $ { escapeHtml ( c . mac || 'unknown' ) } < / t d >
< td class = "ap-client-table__mono" > $ { escapeHtml ( c . ip || 'IP pending' ) } < / t d >
< td > $ { escapeHtml ( _ fmtDbm ( c . signal_dbm ) ) } < / t d >
< td > $ { escapeHtml ( _ fmtIdle ( c . inactive_ms ) ) } < / t d >
< / t r > ` ) . j o i n ( ' ' ) ;
return `
< div class = "data-table-wrapper ap-client-table" >
< table class = "data-table" >
< thead > < tr > < th > MAC < / t h > < t h > I P < / t h > < t h > S i g n a l < / t h > < t h > I d l e < / t h > < / t r > < / t h e a d >
< tbody > $ { rows } < / t b o d y >
< / t a b l e >
< / d i v > ` ;
}
async function _ api ( path , opts ) {
const res = await fetch ( path , opts ) ;
const body = await res . json ( ) . catch ( ( ) => ( { } ) ) ;
if ( ! res . ok ) throw new Error ( body . error || ` ${ path } : ${ res . status } ` ) ;
return body ;
}
function _ collect ( ) {
const g = id => document . getElementById ( id ) ;
const out = {
ap_enabled : g ( 'ap_enabled' ) ? . checked || false ,
ap_ssid : g ( 'ap_ssid' ) ? . value || '' ,
} ;
// v1.11.9 (review HIGH security UX): the backend STRIPS ap_passphrase from GET status,
// so the field renders blank. Only send ap_passphrase when the user actually typed a new
// one — a BLANK field OMITS the key so the server-side RMW merge keeps the stored PSK.
// (Sending ap_passphrase:"" would wipe the existing password.)
const psk = g ( 'ap_passphrase' ) ? . value || '' ;
if ( psk ) out . ap_passphrase = psk ;
// Advanced-only fields are collected only when rendered. In User mode (no DOM) they are
// omitted so the server-side config merge preserves the stored ap_band/ap_hidden.
const band = g ( 'ap_band' ) ; if ( band ) out . ap_band = band . value ;
const hidden = g ( 'ap_hidden' ) ; if ( hidden ) out . ap_hidden = hidden . checked ;
return out ;
}
function _ validate ( ) {
clearAllFieldErrors ( ) ;
const d = _ collect ( ) ; const errs = [ ] ;
if ( d . ap_enabled ) {
if ( ! d . ap_ssid || _ byteLen ( d . ap_ssid ) > 32 ) {
showFieldError ( document . getElementById ( 'ap_ssid' ) , 'Network name must be 1-32 bytes.' ) ; errs . push ( 'ssid' ) ;
}
// v1.11.9 (review HIGH security UX): a BLANK PSK field means "keep the existing
// stored password" (_collect omits ap_passphrase entirely). Only validate the
// 8-63 byte bound when the user actually typed a NEW passphrase.
// Fix #3b: on a fresh device (_hasPassphrase=false) a blank PSK has nothing to
// "keep" — the server-side RMW would store an empty PSK and fail apply-time
// validation. Reject proactively so the error is shown client-side immediately.
if ( 'ap_passphrase' in d ) {
const p = _ byteLen ( d . ap_passphrase ) ;
if ( p < 8 || p > 63 ) {
showFieldError ( document . getElementById ( 'ap_passphrase' ) , 'Password must be 8-63 bytes.' ) ; errs . push ( 'psk' ) ;
}
} else if ( _ hasPassphrase === false ) {
// Blank PSK + no stored PSK: require the user to set one now.
showFieldError ( document . getElementById ( 'ap_passphrase' ) , 'Wi-Fi AP requires a passphrase (8–63 characters).' ) ; errs . push ( 'psk' ) ;
}
}
setPageError ( 'wifi-ap' , errs . length > 0 ) ;
return errs . length === 0 ;
}
// Live status panel (matches net-apply dash-card / net-badge design language).
function _ statusHtml ( ) {
const adv = isAdvanced ( ) ;
if ( _ status . ap_enabled === undefined ) {
return ` <div class="dash-card"><div class="dash-row"><span class="dash-row__lbl">Status</span>
< span class = "dash-row__val" > $ { _ badge ( 'na' , 'loading…' ) } < / s p a n > < / d i v > < / d i v > ` ;
}
const running = _ status . ap_enabled && _ status . ap0_up && _ status . hostapd_running ;
const apBadge = running ? _ badge ( 'ok' , 'Running' ) : _ badge ( 'na' , 'Off' ) ;
const rows = [
` <div class="dash-row"><span class="dash-row__lbl">Access point</span><span class="dash-row__val"> ${ apBadge } </span></div> ` ,
` <div class="dash-row"><span class="dash-row__lbl"> ${ icon ( 'smartphone' , { size : 14 } )} Associated clients</span>
< span class = "dash-row__val" > $ { escapeHtml ( String ( _ status . clients ? ? 0 ) ) } < / s p a n > < / d i v > ` ,
] ;
if ( adv ) {
const hostapd = _ status . hostapd_running ? _ badge ( 'ok' , 'active' ) : _ badge ( 'na' , 'inactive' ) ;
rows . push ( ` <div class="dash-row"><span class="dash-row__lbl">hostapd</span><span class="dash-row__val"> ${ hostapd } </span></div> ` ) ;
}
if ( _ status . country_pending ) {
rows . push ( ` <div class="dash-row"><span class="dash-row__lbl">Regulatory domain</span>
< span class = "dash-row__val" > $ { _ badge ( 'warn' , 'Reboot required' ) } < / s p a n > < / d i v > ` ) ;
}
const refresh = adv
? ` <button class="btn btn--ghost btn--sm" id="ap-refresh" type="button"> ${ icon ( 'refresh-cw' , { size : 14 } )} Refresh</button> ` : '' ;
return ` <div class="dash-card"><h3 class="dash-card__title"> ${ icon ( 'activity' , { size : 16 } )} Status ${ refresh } </h3> ${ rows . join ( '' ) } ${ _ clientDetailsHtml ( ) } </div> ` ;
}
function _ render ( container ) {
const a = _ cfg , adv = isAdvanced ( ) ;
container . innerHTML = `
< div class = "page-header" > < h1 class = "page-header__title" > $ { icon ( 'radio-tower' , { size : 28 } ) } Wi - Fi Access Point < / h 1 >
< p class = "page-header__desc" > Broadcast this device as a Wi - Fi access point for local provisioning . Your < strong > STA uplink stays connected < / s t r o n g > — c l i e n t s c a n o n l y r e a c h t h e c o n f i g u r a t o r . < / p > < / d i v >
< div class = "card" >
$ { _ countryPending ? ` <div class="info-banner info-banner--warning"> ${ icon ( 'alert-triangle' , { size : 14 } )}
A country - code change is pending . Reboot the device before enabling the access point . < / d i v > ` : ' ' }
< label class = "radio-label" > < input type = "checkbox" id = "ap_enabled" $ { a . ap_enabled ? 'checked' : '' } $ { _ countryPending ? 'disabled' : '' } > Enable access point < / l a b e l >
< div class = "form-group" > < label class = "form-label" for = "ap_ssid" > Network name ( SSID ) < / l a b e l >
< input class = "form-input" id = "ap_ssid" maxlength = "32" value = "${escapeHtml(a.ap_ssid)}" placeholder = "e.g. Site-AP" > < / d i v >
< div class = "form-group" > < label class = "form-label" for = "ap_passphrase" > Password $ { icon ( 'lock' , { size : 12 } ) } ( WPA2 , 8 - 63 characters ) < / l a b e l >
< div class = "input-password" > < input class = "form-input" type = "password" id = "ap_passphrase" value = "" placeholder = "leave blank to keep current password" autocomplete = "new-password" >
< button class = "input-password__toggle" type = "button" aria - label = "Show password" > $ { icon ( 'eye' , { size : 14 } ) } < / b u t t o n > < / d i v > < / d i v >
$ { adv ? `
< div class = "form-row" >
< div class = "form-group" > < label class = "form-label" for = "ap_band" > Band < / l a b e l >
< select class = "form-select" id = "ap_band" >
< option value = "auto" $ { a . ap_band === 'auto' ? 'selected' : '' } > Auto ( follow STA channel , recommended ) < / o p t i o n >
< option value = "2g" $ { a . ap_band === '2g' ? 'selected' : '' } > 2.4 GHz ( experimental ) < / o p t i o n >
< option value = "5g" $ { a . ap_band === '5g' ? 'selected' : '' } > 5 GHz ( experimental ) < / o p t i o n > < / s e l e c t > < / d i v >
< div class = "form-group" > < label class = "radio-label" > < input type = "checkbox" id = "ap_hidden" $ { a . ap_hidden ? 'checked' : '' } > Hide SSID ( do not broadcast ) < / l a b e l > < / d i v >
< / d i v > ` : ' ' }
< div class = "dash-actions" >
$ { adv
? ` <button class="btn btn--outline" id="ap-save" type="button"> ${ icon ( 'save' , { size : 14 } )} Save</button>
< button class = "btn btn--primary" id = "ap-apply" type = "button" > $ { icon ( 'check-circle-2' , { size : 14 } ) } Save & amp ; Apply < / b u t t o n > `
: ` <button class="btn btn--primary" id="ap-save-apply" type="button"> ${ icon ( 'check-circle-2' , { size : 14 } )} Save & Apply</button> ` }
< / d i v >
< / d i v >
< div id = "ap-status" aria - live = "polite" > $ { _ statusHtml ( ) } < / d i v > ` ;
}
function _ bind ( container ) {
const t = container . querySelector ( '.input-password__toggle' ) ;
if ( t ) t . addEventListener ( 'click' , ( ) => {
const i = container . querySelector ( '#ap_passphrase' ) ;
if ( ! i ) return ;
const show = i . type === 'password' ;
i . type = show ? 'text' : 'password' ;
t . innerHTML = icon ( show ? 'eye-off' : 'eye' , { size : 14 } ) ;
t . setAttribute ( 'aria-label' , show ? 'Hide password' : 'Show password' ) ;
} ) ;
container . querySelector ( '#ap-save' ) ? . addEventListener ( 'click' , ( ) => _ save ( { apply : false } ) ) ;
container . querySelector ( '#ap-apply' ) ? . addEventListener ( 'click' , ( ) => _ save ( { apply : true } ) ) ;
container . querySelector ( '#ap-save-apply' ) ? . addEventListener ( 'click' , ( ) => _ save ( { apply : true } ) ) ;
container . querySelector ( '#ap-refresh' ) ? . addEventListener ( 'click' , ( ) => _ refreshStatus ( ) ) ;
}
async function _ saveConfig ( { toast = true } = { } ) {
if ( ! document . getElementById ( 'ap_enabled' ) ) {
throw new Error ( 'Wi-Fi AP page is not mounted.' ) ;
}
if ( ! _ validate ( ) ) {
throw new Error ( 'Check your input.' ) ;
}
const d = _ collect ( ) ;
await _ api ( 'api/network/ap/config' , { method : 'POST' , headers : { 'Content-Type' : 'application/json' } , body : JSON . stringify ( d ) } ) ;
_ cfg = { ... _ cfg , ... d } ;
clearDirty ( 'wifi-ap' ) ;
if ( toast ) showToast ( 'Access point settings saved.' , 'success' ) ;
}
async function _ save ( { apply } ) {
if ( _ saving ) return ; // in-flight guard: concurrent save/apply gestures → only one runs
_ saving = true ;
try {
await _ saveConfig ( { toast : ! apply } ) ;
if ( apply ) {
const r = await _ api ( 'api/network/ap/apply' , { method : 'POST' , headers : { 'Content-Type' : 'application/json' } , body : JSON . stringify ( { dry_run : false } ) } ) ;
if ( r . state !== 'COMMITTED' ) showToast ( 'Apply failed: ' + ( ( r . errors && r . errors [ 0 ] ) || 'validation failed' ) , 'error' ) ;
else showToast ( 'Access point settings applied.' , 'success' ) ;
}
await _ refreshStatus ( ) ;
} catch ( e ) { showToast ( 'Failed — ' + e . message , 'error' ) ; }
finally { _ saving = false ; }
}
async function _ refreshStatus ( ) {
try {
const s = await _ api ( 'api/network/ap/status' ) ;
_ status = s || { } ;
_ countryPending = ! ! s . country_pending ;
// Fix #3b: capture has_passphrase (boolean from backend, null = not yet known).
if ( typeof s . has_passphrase === 'boolean' ) _ hasPassphrase = s . has_passphrase ;
if ( s . config ) _ cfg = { ... _ DEFAULTS , ... s . config } ;
const el = document . getElementById ( 'ap-status' ) ;
if ( el ) {
el . innerHTML = _ statusHtml ( ) ;
// only the status region was replaced — re-bind just its Refresh button (form handlers persist)
el . querySelector ( '#ap-refresh' ) ? . addEventListener ( 'click' , ( ) => _ refreshStatus ( ) ) ;
}
} catch ( e ) { /* fail-soft: status poll failures stay silent */ }
}
const wifiApPage = {
render ( container ) { _ render ( container ) ; } ,
mount ( container ) {
_ bind ( container ) ;
// First entry: load saved config + live status, then re-render the form to reflect it.
_ refreshStatus ( ) . then ( ( ) => { _ render ( container ) ; _ bind ( container ) ; } ) ;
if ( isAdvanced ( ) ) _ statusTimer = setInterval ( _ refreshStatus , 10000 ) ;
} ,
destroy ( ) {
if ( _ statusTimer ) { clearInterval ( _ statusTimer ) ; _ statusTimer = null ; }
// v1.11.9 (review LOW correctness/honesty): reset live status so a revisit after an
// API failure shows loading/empty, not a stale "Running" panel. _cfg is intentionally
// NOT reset (resetting to defaults would flash a blank form before _refreshStatus).
_ status = { } ;
_ countryPending = false ;
_ saving = false ;
_ hasPassphrase = null ; // Fix #3b: reset so next mount re-fetches from backend
} ,
validate ( ) {
if ( ! document . getElementById ( 'ap_enabled' ) ) return [ ] ;
return _ validate ( ) ? [ ] : [ { page : 'wifi-ap' , message : 'Invalid Wi-Fi AP settings' } ] ;
} ,
async saveSelfContained ( ) {
await _ saveConfig ( { toast : false } ) ;
} ,
} ;
export default wifiApPage ;