feat: edit wifi networks in webui (#1743)

## Summary

**What is the goal of this PR?**
Add Wi-Fi network management to the Web UI settings page, similar to
existing OPDS server management, so users can view, add, edit, and
delete saved Wi-Fi credentials from the browser. Closes
https://github.com/crosspoint-reader/crosspoint-reader/issues/1544 and
https://github.com/crosspoint-reader/crosspoint-reader/discussions/607

**What changes are included?**

- Added Wi-Fi API endpoints:
- GET /api/wifi Listing saved networks (without exposing plaintext
passwords)
  - POST /api/wifi Creating/updating networks
  - POST /api/wifi/delete Deleting networks by index

- Added a new Wi-Fi Networks management section web UI settings page

<img width="921" height="712" alt="image"
src="https://github.com/user-attachments/assets/42c20a63-f335-4389-8246-d38065d6b65a"
/>


---

### AI Usage

While CrossPoint doesn't have restrictions on AI tools in contributing,
please be transparent about their usage as it
helps set the right context for reviewers.

Did you use AI tools to help write this code? _**< YES >**_

---------

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
Arthur Tazhitdinov
2026-05-07 21:29:48 -05:00
committed by GitHub
co-authored by Copilot
parent 1e2f6e2d67
commit e8d7153d7f
4 changed files with 280 additions and 2 deletions
+140
View File
@@ -14,6 +14,7 @@
#include "OpdsServerStore.h"
#include "SettingsList.h"
#include "WebDAVHandler.h"
#include "WifiCredentialStore.h"
#include "html/FilesPageHtml.generated.h"
#include "html/HomePageHtml.generated.h"
#include "html/SettingsPageHtml.generated.h"
@@ -168,6 +169,11 @@ void CrossPointWebServer::begin() {
server->on("/api/opds", HTTP_POST, [this] { handlePostOpdsServer(); });
server->on("/api/opds/delete", HTTP_POST, [this] { handleDeleteOpdsServer(); });
// Wi-Fi credential endpoints
server->on("/api/wifi", HTTP_GET, [this] { handleGetWifiNetworks(); });
server->on("/api/wifi", HTTP_POST, [this] { handlePostWifiNetwork(); });
server->on("/api/wifi/delete", HTTP_POST, [this] { handleDeleteWifiNetwork(); });
server->onNotFound([this] { handleNotFound(); });
LOG_DBG("WEB", "[MEM] Free heap after route setup: %d bytes", ESP.getFreeHeap());
@@ -1368,6 +1374,140 @@ void CrossPointWebServer::handleDeleteOpdsServer() {
server->send(200, "text/plain", "OK");
}
// ---- Wi-Fi Credentials API ----
void CrossPointWebServer::handleGetWifiNetworks() const {
const auto& credentials = WIFI_STORE.getCredentials();
const std::string& lastConnectedSsid = WIFI_STORE.getLastConnectedSsid();
// Stream JSON array incrementally to avoid allocating the full response in memory
server->setContentLength(CONTENT_LENGTH_UNKNOWN);
server->send(200, "application/json", "");
server->sendContent("[");
char output[320];
constexpr size_t outputSize = sizeof(output);
JsonDocument doc;
for (size_t i = 0; i < credentials.size(); i++) {
doc.clear();
doc["index"] = i;
doc["ssid"] = credentials[i].ssid;
// Never expose Wi-Fi passwords over the API — only indicate whether one is set
doc["hasPassword"] = !credentials[i].password.empty();
doc["isLastConnected"] = credentials[i].ssid == lastConnectedSsid;
const size_t written = serializeJson(doc, output, outputSize);
if (written >= outputSize) continue;
if (i > 0) server->sendContent(",");
server->sendContent(output);
}
server->sendContent("]");
server->sendContent("");
LOG_DBG("WEB", "Served Wi-Fi credentials API (%zu network(s))", credentials.size());
}
void CrossPointWebServer::handlePostWifiNetwork() {
if (!server->hasArg("plain")) {
server->send(400, "text/plain", "Missing JSON body");
return;
}
const String body = server->arg("plain");
JsonDocument doc;
const DeserializationError err = deserializeJson(doc, body);
if (err) {
server->send(400, "text/plain", String("Invalid JSON: ") + err.c_str());
return;
}
std::string ssid = doc["ssid"] | std::string("");
if (ssid.empty()) {
server->send(400, "text/plain", "SSID is required");
return;
}
// The password field is optional in the JSON payload. When absent (vs. present but empty),
// preserve the existing password for updates. Empty passwords are valid for open networks.
bool hasPasswordField = doc["password"].is<const char*>() || doc["password"].is<std::string>();
std::string password = doc["password"] | std::string("");
if (doc["index"].is<int>()) {
int idx = doc["index"].as<int>();
const auto& credentials = WIFI_STORE.getCredentials();
if (idx < 0 || idx >= static_cast<int>(credentials.size())) {
server->send(400, "text/plain", "Invalid network index");
return;
}
const std::string oldSsid = credentials[static_cast<size_t>(idx)].ssid;
if (!hasPasswordField) {
password = credentials[static_cast<size_t>(idx)].password;
}
bool ok = true;
if (oldSsid != ssid) {
ok = WIFI_STORE.removeCredential(oldSsid) && WIFI_STORE.addCredential(ssid, password);
} else {
ok = WIFI_STORE.addCredential(ssid, password);
}
if (!ok) {
server->send(400, "text/plain", "Failed to update Wi-Fi network");
return;
}
LOG_DBG("WEB", "Updated Wi-Fi network at index %d (SSID: %s)", idx, ssid.c_str());
} else {
if (!WIFI_STORE.addCredential(ssid, password)) {
server->send(400, "text/plain", "Cannot add network (limit reached)");
return;
}
LOG_DBG("WEB", "Added Wi-Fi network: %s", ssid.c_str());
}
server->send(200, "text/plain", "OK");
}
// Uses POST (not HTTP DELETE) because ESP32 WebServer doesn't support DELETE with body.
void CrossPointWebServer::handleDeleteWifiNetwork() {
if (!server->hasArg("plain")) {
server->send(400, "text/plain", "Missing JSON body");
return;
}
const String body = server->arg("plain");
JsonDocument doc;
const DeserializationError err = deserializeJson(doc, body);
if (err) {
server->send(400, "text/plain", String("Invalid JSON: ") + err.c_str());
return;
}
if (!doc["index"].is<int>()) {
server->send(400, "text/plain", "Missing index");
return;
}
int idx = doc["index"].as<int>();
const auto& credentials = WIFI_STORE.getCredentials();
if (idx < 0 || idx >= static_cast<int>(credentials.size())) {
server->send(400, "text/plain", "Invalid network index");
return;
}
const std::string ssid = credentials[static_cast<size_t>(idx)].ssid;
if (!WIFI_STORE.removeCredential(ssid)) {
server->send(400, "text/plain", "Failed to delete Wi-Fi network");
return;
}
LOG_DBG("WEB", "Deleted Wi-Fi network at index %d (SSID: %s)", idx, ssid.c_str());
server->send(200, "text/plain", "OK");
}
// WebSocket callback trampoline
void CrossPointWebServer::wsEventCallback(uint8_t num, WStype_t type, uint8_t* payload, size_t length) {
if (wsInstance) {
+5
View File
@@ -112,4 +112,9 @@ class CrossPointWebServer {
void handleGetOpdsServers() const;
void handlePostOpdsServer();
void handleDeleteOpdsServer();
// Wi-Fi credential handlers
void handleGetWifiNetworks() const;
void handlePostWifiNetwork();
void handleDeleteWifiNetwork();
};
+115
View File
@@ -299,6 +299,7 @@
<button class="save-btn" id="saveBtn" onclick="saveSettings()">Save Settings</button>
</div>
<div id="wifi-container"></div>
<div id="opds-container"></div>
<div class="card">
@@ -480,6 +481,119 @@
loadSettings();
// --- Wi-Fi Network Management ---
// Renders an editable list of saved Wi-Fi networks using /api/wifi endpoints.
// Password fields are never pre-filled; when left blank during edit, existing
// passwords remain unchanged server-side.
let wifiNetworks = [];
function renderWifiNetwork(net, idx) {
const isNew = idx === -1;
const id = isNew ? 'new' : idx;
const lastConnected = net.isLastConnected
? '<div style="margin-top:8px;color:var(--label-color);font-size:0.9em;">Last connected network</div>'
: '';
return '<div class="opds-server" id="wifi-' + id + '">' +
'<div class="setting-row">' +
'<span class="setting-name">SSID</span>' +
'<span class="setting-control"><input type="text" id="wifi-ssid-' + id + '" value="' + escapeHtml(net.ssid || '') + '"></span>' +
'</div>' +
'<div class="setting-row">' +
'<span class="setting-name">Password</span>' +
'<span class="setting-control"><input type="password" id="wifi-pass-' + id + '" placeholder="' + (net.hasPassword ? '(unchanged)' : '') + '"></span>' +
'</div>' +
lastConnected +
'<div class="opds-actions">' +
'<button class="btn-small btn-save-server" onclick="saveWifiNetwork(' + idx + ')">Save</button>' +
(isNew ? '' : '<button class="btn-small btn-delete" onclick="deleteWifiNetwork(' + idx + ')">Delete</button>') +
'</div>' +
'</div>';
}
function renderWifiSection() {
const container = document.getElementById('wifi-container');
let html = '<div class="card"><h2>Wi-Fi Networks</h2>';
if (wifiNetworks.length === 0) {
html += '<p style="color:var(--label-color);text-align:center;">No Wi-Fi networks saved</p>';
} else {
wifiNetworks.forEach(function(net, idx) {
html += renderWifiNetwork(net, idx);
});
}
html += '<div style="margin-top:12px;text-align:center;">' +
'<button class="btn-small btn-add" onclick="addWifiNetwork()">+ Add Network</button>' +
'</div></div>';
container.innerHTML = html;
}
async function loadWifiNetworks() {
try {
const resp = await fetch('/api/wifi');
if (!resp.ok) throw new Error('Failed to load');
wifiNetworks = await resp.json();
renderWifiSection();
} catch (e) {
console.error('Wi-Fi load error:', e);
}
}
function addWifiNetwork() {
const container = document.getElementById('wifi-container');
const card = container.querySelector('.card');
const addBtn = card.querySelector('.btn-add').parentElement;
// Prevent multiple unsaved new-network forms at once (idx -1 -> id "new")
if (document.getElementById('wifi-new')) return;
addBtn.insertAdjacentHTML('beforebegin', renderWifiNetwork({ssid:'',hasPassword:false,isLastConnected:false}, -1));
}
async function saveWifiNetwork(idx) {
const id = idx === -1 ? 'new' : idx;
const ssid = document.getElementById('wifi-ssid-' + id).value.trim();
if (!ssid) {
showMessage('SSID is required.', true);
return;
}
const data = { ssid: ssid };
// Only include password when the user actually typed something; omitting it
// tells the server to preserve an existing password.
const pass = document.getElementById('wifi-pass-' + id).value;
if (pass) data.password = pass;
if (idx >= 0) data.index = idx;
try {
const resp = await fetch('/api/wifi', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(data)
});
if (!resp.ok) throw new Error(await resp.text());
showMessage('Wi-Fi network saved!', false);
await loadWifiNetworks();
} catch (e) {
showMessage('Error: ' + e.message, true);
}
}
async function deleteWifiNetwork(idx) {
if (!confirm('Delete this Wi-Fi network?')) return;
try {
const resp = await fetch('/api/wifi/delete', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({index: idx})
});
if (!resp.ok) throw new Error(await resp.text());
showMessage('Wi-Fi network deleted', false);
await loadWifiNetworks();
} catch (e) {
showMessage('Error: ' + e.message, true);
}
}
// --- OPDS Server Management ---
// Dynamically renders an editable list of OPDS servers, communicating with the
// /api/opds REST endpoints. Password fields are never pre-filled for security;
@@ -594,6 +708,7 @@
}
}
loadWifiNetworks();
loadOpdsServers();
</script>
</body>