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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user