Configure hotspot credentials from dashboard

This commit is contained in:
2026-07-31 20:41:16 +02:00
parent e52d2690ee
commit d1239d66af
19 changed files with 443 additions and 23 deletions
+85 -4
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useState, type FormEvent } from "react";
import {
ArrowClockwise,
CheckCircle,
@@ -232,6 +232,12 @@ function SettingsPage({ csrfToken }: { csrfToken: string }) {
const [requesting, setRequesting] = useState(false);
const [confirming, setConfirming] = useState(false);
const [networkAction, setNetworkAction] = useState<"retry-upstream" | "restore-hotspot" | null>(null);
const [hotspotSsid, setHotspotSsid] = useState("MG4-Companion");
const [hotspotPassword, setHotspotPassword] = useState("");
const [hotspotPasswordAgain, setHotspotPasswordAgain] = useState("");
const [ssidTouched, setSsidTouched] = useState(false);
const [confirmHotspot, setConfirmHotspot] = useState(false);
const [networkNotice, setNetworkNotice] = useState("");
const running = update?.state === "checking" || update?.state === "building";
const refreshUpdate = useCallback(async () => {
@@ -262,6 +268,10 @@ function SettingsPage({ csrfToken }: { csrfToken: string }) {
return () => window.clearInterval(interval);
}, [refreshNetwork, refreshUpdate]);
useEffect(() => {
if (!ssidTouched && network?.hotspotSsid) setHotspotSsid(network.hotspotSsid);
}, [network?.hotspotSsid, ssidTouched]);
async function startUpdate() {
setConfirming(false);
setRequesting(true);
@@ -292,6 +302,43 @@ function SettingsPage({ csrfToken }: { csrfToken: string }) {
}
}
function prepareHotspotConfiguration(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setNetworkError("");
setNetworkNotice("");
if (!/^[a-zA-Z0-9_. -]{1,32}$/.test(hotspotSsid.trim())) {
setNetworkError("Use 1-32 letters, numbers, spaces, dots, hyphens, or underscores for the hotspot name");
return;
}
if (hotspotPassword.length < 8 || hotspotPassword.length > 63 || !/^[\x20-\x7e]+$/.test(hotspotPassword)) {
setNetworkError("Use 8-63 printable characters for the hotspot password");
return;
}
if (hotspotPassword !== hotspotPasswordAgain) {
setNetworkError("The hotspot passwords do not match");
return;
}
setConfirmHotspot(true);
}
async function configureHotspot() {
setConfirmHotspot(false);
setRequesting(true);
setNetworkError("");
setNetworkNotice("");
try {
await post("/api/network/configuration", { ssid: hotspotSsid.trim(), password: hotspotPassword }, csrfToken);
setHotspotPassword("");
setHotspotPasswordAgain("");
setNetworkNotice("Hotspot configuration accepted. Reconnect using the new name and password if this page disconnects.");
window.setTimeout(() => void refreshNetwork(), 2_000);
} catch (caught) {
setNetworkError(caught instanceof ApiError ? caught.message : "The hotspot could not be configured");
} finally {
setRequesting(false);
}
}
const revision = update?.installedRevision?.slice(0, 12) ?? "Unavailable";
const statusLabel = !update ? "Loading" : update.state === "current" ? "Up to date" : update.state === "success" ? "Updated" : update.state === "failed" ? "Needs attention" : running ? "Updating" : "Ready";
const modeLabels: Record<NonNullable<NetworkStatus>["mode"], string> = {
@@ -333,16 +380,39 @@ function SettingsPage({ csrfToken }: { csrfToken: string }) {
</div>
{networkError && <div className="form-error" role="alert"><WarningCircle size={20} />{networkError}</div>}
{networkNotice && <div className="network-notice" role="status"><CheckCircle size={20} />{networkNotice}</div>}
{network?.lastFailure && <p className="network-failure"><WarningCircle size={18} /> Last issue: {network.lastFailure}</p>}
<div className="settings-actions">
<button className="primary-button" onClick={() => setNetworkAction("retry-upstream")} disabled={!network?.supported || requesting || network.mode === "probing_built_in"}>
<button className="primary-button" onClick={() => setNetworkAction("retry-upstream")} disabled={!network?.enabled || requesting || network.mode === "probing_built_in"}>
<ArrowClockwise size={22} /> Try upstream again
</button>
<button className="secondary-button text-button" onClick={() => setNetworkAction("restore-hotspot")} disabled={!network?.supported || requesting || network.mode === "offline_hotspot" || network.mode === "dual_radio"}>
<button className="secondary-button text-button" onClick={() => setNetworkAction("restore-hotspot")} disabled={!network?.enabled || requesting || network.mode === "offline_hotspot" || network.mode === "dual_radio"}>
<WifiHigh size={22} /> Restore hotspot
</button>
</div>
{!network?.supported && <p className="settings-note">Run the network setup helper on the Pi after creating a saved AWUS upstream profile. Automatic switching stays disabled until then.</p>}
<form className="hotspot-form" onSubmit={prepareHotspotConfiguration}>
<div className="hotspot-form-heading">
<div><h3>Hotspot credentials</h3><p>Changing these settings may disconnect devices currently using the car hotspot.</p></div>
{network?.hotspotSsid && <span>Current: {network.hotspotSsid}</span>}
</div>
<div className="hotspot-fields">
<label>
<span>Network name</span>
<input value={hotspotSsid} onChange={(event) => { setHotspotSsid(event.target.value); setSsidTouched(true); }} maxLength={32} autoComplete="off" required />
</label>
<label>
<span>New password</span>
<input type="password" value={hotspotPassword} onChange={(event) => setHotspotPassword(event.target.value)} minLength={8} maxLength={63} autoComplete="new-password" required />
</label>
<label>
<span>Repeat password</span>
<input type="password" value={hotspotPasswordAgain} onChange={(event) => setHotspotPasswordAgain(event.target.value)} minLength={8} maxLength={63} autoComplete="new-password" required />
</label>
<button className="secondary-button text-button" type="submit" disabled={requesting}>Save hotspot</button>
</div>
<p className="settings-note">The password is sent only to the local Pi configuration service and is stored by NetworkManager as a derived WPA key.</p>
</form>
</section>
<section className="settings-panel">
@@ -406,6 +476,17 @@ function SettingsPage({ csrfToken }: { csrfToken: string }) {
</div>
</div>
</div>}
{confirmHotspot && <div className="modal-backdrop" role="presentation" onMouseDown={() => setConfirmHotspot(false)}>
<div className="confirm-dialog" role="dialog" aria-modal="true" aria-labelledby="hotspot-dialog-title" onMouseDown={(event) => event.stopPropagation()}>
<h2 id="hotspot-dialog-title">Apply new hotspot credentials?</h2>
<p>The hotspot will become <strong>{hotspotSsid.trim()}</strong>. Connected devices may disconnect and must reconnect with the new password.</p>
<div className="dialog-actions">
<button className="secondary-button" onClick={() => setConfirmHotspot(false)}>Cancel</button>
<button className="primary-button" onClick={() => void configureHotspot()}>Apply settings</button>
</div>
</div>
</div>}
</div>
);
}