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>
);
}
+1
View File
@@ -76,6 +76,7 @@ export type NetworkStatus = {
probeDeadline: string | null;
lastFailure: string | null;
connectivity: "full" | "limited" | "portal" | "none" | "unknown";
hotspotSsid: string | null;
hotspot: NetworkAdapterStatus;
upstream: NetworkAdapterStatus;
};
+11
View File
@@ -116,7 +116,16 @@ button:disabled { opacity: 0.55; cursor: not-allowed; }
.mode-error strong, .mode-degraded strong { color: var(--danger); }
.network-failure { display: flex; align-items: flex-start; gap: 8px; margin: 0 0 16px; color: #ffc8bf; font-size: 0.82rem; line-height: 1.45; }
.network-failure svg { flex: none; }
.network-notice { display: flex; align-items: center; gap: 9px; margin: 0 0 16px; color: var(--accent); }
.text-button { width: auto; padding: 0 20px; font-size: inherit; }
.hotspot-form { margin-top: 26px; padding-top: 24px; border-top: 1px solid var(--line); }
.hotspot-form-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; }
.hotspot-form-heading h3 { margin: 0 0 6px; font-size: 1.05rem; }
.hotspot-form-heading p { margin: 0; color: var(--muted); font-size: 0.83rem; }
.hotspot-form-heading > span { color: var(--accent); font: 0.75rem/1.4 ui-monospace, "Cascadia Code", monospace; }
.hotspot-fields { display: grid; grid-template-columns: 1fr 1fr 1fr auto; align-items: end; gap: 12px; margin-top: 20px; }
.hotspot-fields label { display: grid; gap: 7px; color: var(--muted); font-size: 0.78rem; }
.hotspot-fields input { width: 100%; min-height: 52px; padding: 0 13px; color: var(--text); background: #10140f; border: 1px solid #465043; border-radius: 9px; }
.settings-title { display: flex; align-items: flex-start; gap: 18px; }
.settings-title h2, .compact-panel h2 { margin-bottom: 8px; font-size: 1.45rem; }
.settings-title p { max-width: 620px; margin: 0; color: var(--muted); line-height: 1.5; }
@@ -157,6 +166,7 @@ button:disabled { opacity: 0.55; cursor: not-allowed; }
.metrics-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.settings-layout { grid-template-columns: 1fr; }
.network-overview { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.hotspot-fields { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (max-width: 767px) {
@@ -181,6 +191,7 @@ button:disabled { opacity: 0.55; cursor: not-allowed; }
.metrics-grid, .system-strip { grid-template-columns: 1fr; }
.update-facts { grid-template-columns: 1fr; }
.network-overview { grid-template-columns: 1fr; }
.hotspot-fields { grid-template-columns: 1fr; }
.settings-actions, .dialog-actions { flex-direction: column; }
.settings-actions button, .dialog-actions button { width: 100%; }
.skeleton-grid { grid-template-columns: 1fr; }