Configure hotspot credentials from dashboard
This commit is contained in:
+85
-4
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user