Просмотр исходного кода

Add manual WiFi entry, hotspot password, and forget confirmation

- Add "+" button to manually enter hidden network SSID and password
- Add hotspot password management (GET/POST /api/wifi/hotspot/password)
- Add confirmation dialog when forgetting saved networks with
  context-aware warnings about hotspot fallback

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
tuanchris 4 месяцев назад
Родитель
Сommit
a56cb08f23
3 измененных файлов с 280 добавлено и 25 удалено
  1. 207 25
      frontend/src/pages/WiFiSetupPage.tsx
  2. 54 0
      modules/wifi/manager.py
  3. 19 0
      modules/wifi/router.py

+ 207 - 25
frontend/src/pages/WiFiSetupPage.tsx

@@ -12,6 +12,7 @@ import {
   Dialog,
   DialogContent,
   DialogDescription,
+  DialogFooter,
   DialogHeader,
   DialogTitle,
 } from '@/components/ui/dialog'
@@ -55,6 +56,13 @@ export function WiFiSetupPage() {
   const [isScanning, setIsScanning] = useState(false)
   const [isConnecting, setIsConnecting] = useState(false)
   const [isRebooting, setIsRebooting] = useState(false)
+  const [isManualEntry, setIsManualEntry] = useState(false)
+  const [manualSsid, setManualSsid] = useState('')
+  const [forgetSsid, setForgetSsid] = useState<string | null>(null)
+  const [apPassword, setApPassword] = useState('')
+  const [apPasswordInput, setApPasswordInput] = useState('')
+  const [showApPassword, setShowApPassword] = useState(false)
+  const [isSavingApPassword, setIsSavingApPassword] = useState(false)
 
   const fetchStatus = useCallback(async () => {
     try {
@@ -74,6 +82,16 @@ export function WiFiSetupPage() {
     }
   }, [])
 
+  const fetchApPassword = useCallback(async () => {
+    try {
+      const data = await apiClient.get<{ password: string }>('/api/wifi/hotspot/password')
+      setApPassword(data.password)
+      setApPasswordInput(data.password)
+    } catch {
+      // Silently fail
+    }
+  }, [])
+
   const scanNetworks = useCallback(async () => {
     setIsScanning(true)
     try {
@@ -90,19 +108,21 @@ export function WiFiSetupPage() {
     fetchStatus()
     scanNetworks()
     fetchSaved()
-  }, [fetchStatus, scanNetworks, fetchSaved])
+    fetchApPassword()
+  }, [fetchStatus, scanNetworks, fetchSaved, fetchApPassword])
 
-  const needsPassword = selectedNetwork &&
+  const needsPassword = isManualEntry || (selectedNetwork &&
     selectedNetwork.security !== 'Open' &&
-    !selectedNetwork.saved
+    !selectedNetwork.saved)
 
   const handleConnect = async () => {
-    if (!selectedNetwork) return
+    const ssid = isManualEntry ? manualSsid.trim() : selectedNetwork?.ssid
+    if (!ssid) return
 
     setIsConnecting(true)
     try {
       const result = await apiClient.post<{ success: boolean; message: string }>('/api/wifi/connect', {
-        ssid: selectedNetwork.ssid,
+        ssid,
         password: needsPassword ? password : '',
       })
 
@@ -118,7 +138,10 @@ export function WiFiSetupPage() {
     }
   }
 
-  const handleForget = async (ssid: string) => {
+  const handleForget = async () => {
+    if (!forgetSsid) return
+    const ssid = forgetSsid
+    setForgetSsid(null)
     try {
       await apiClient.post('/api/wifi/forget', { ssid })
       toast.success(`Forgot '${ssid}'`)
@@ -129,6 +152,28 @@ export function WiFiSetupPage() {
     }
   }
 
+  const isForgetActive = forgetSsid === status?.ssid
+  const otherSavedCount = savedConnections.filter(c => c.ssid !== forgetSsid).length
+  const willStartHotspot = isForgetActive && otherSavedCount === 0
+
+  const handleSaveApPassword = async () => {
+    setIsSavingApPassword(true)
+    try {
+      const result = await apiClient.post<{ success: boolean; message: string }>('/api/wifi/hotspot/password', {
+        password: apPasswordInput,
+      })
+      if (result.success) {
+        setApPassword(apPasswordInput)
+        toast.success(result.message)
+      }
+    } catch (err) {
+      const message = err instanceof Error ? err.message : 'Failed to update password'
+      toast.error(message)
+    } finally {
+      setIsSavingApPassword(false)
+    }
+  }
+
   const openConnectDialog = (network: WiFiNetwork) => {
     setSelectedNetwork(network)
     setPassword('')
@@ -140,9 +185,19 @@ export function WiFiSetupPage() {
       setSelectedNetwork(null)
       setPassword('')
       setShowPassword(false)
+      setIsManualEntry(false)
+      setManualSsid('')
     }
   }
 
+  const openManualEntry = () => {
+    setIsManualEntry(true)
+    setManualSsid('')
+    setPassword('')
+    setShowPassword(false)
+    setSelectedNetwork({ ssid: '', signal: 0, security: 'Manual', saved: false, active: false })
+  }
+
   const isHotspotMode = status?.mode === 'hotspot'
 
   if (isRebooting) {
@@ -154,7 +209,7 @@ export function WiFiSetupPage() {
               <span className="material-icons text-5xl text-blue-500 animate-spin">refresh</span>
               <h2 className="text-xl font-semibold">Rebooting...</h2>
               <p className="text-muted-foreground">
-                The system is rebooting to connect to <strong>{selectedNetwork?.ssid}</strong>.
+                The system is rebooting to connect to <strong>{isManualEntry ? manualSsid : selectedNetwork?.ssid}</strong>.
               </p>
               <p className="text-sm text-muted-foreground">
                 Once connected, access Dune Weaver on your home network at:
@@ -243,17 +298,28 @@ export function WiFiSetupPage() {
                   : ''}
               </span>
             </div>
-            <Button
-              variant="ghost"
-              size="sm"
-              className="h-7 w-7 p-0"
-              onClick={scanNetworks}
-              disabled={isScanning}
-            >
-              <span className={`material-icons text-base ${isScanning ? 'animate-spin' : ''}`}>
-                refresh
-              </span>
-            </Button>
+            <div className="flex items-center gap-1">
+              <Button
+                variant="ghost"
+                size="sm"
+                className="h-7 w-7 p-0"
+                onClick={openManualEntry}
+                title="Add network manually"
+              >
+                <span className="material-icons text-base">add</span>
+              </Button>
+              <Button
+                variant="ghost"
+                size="sm"
+                className="h-7 w-7 p-0"
+                onClick={scanNetworks}
+                disabled={isScanning}
+              >
+                <span className={`material-icons text-base ${isScanning ? 'animate-spin' : ''}`}>
+                  refresh
+                </span>
+              </Button>
+            </div>
           </div>
           <div className="space-y-0.5">
             {networks.length === 0 && isScanning && (
@@ -314,7 +380,7 @@ export function WiFiSetupPage() {
                     variant="ghost"
                     size="sm"
                     className="h-7 w-7 p-0"
-                    onClick={() => handleForget(con.ssid)}
+                    onClick={() => setForgetSsid(con.ssid)}
                   >
                     <span className="material-icons text-sm text-destructive">delete</span>
                   </Button>
@@ -325,6 +391,62 @@ export function WiFiSetupPage() {
         </Card>
       )}
 
+      {/* Hotspot Password */}
+      <Card>
+        <CardContent className="pt-4 pb-3 px-4">
+          <div className="flex items-center gap-2 mb-2">
+            <span className="material-icons-outlined text-base text-muted-foreground">wifi_tethering</span>
+            <span className="font-semibold text-sm">Hotspot Password</span>
+            {!apPassword && (
+              <Badge variant="secondary" className="text-xs px-1.5 py-0">Open</Badge>
+            )}
+          </div>
+          <p className="text-xs text-muted-foreground mb-3">
+            Set a password for the Dune Weaver hotspot. Leave empty for an open network.
+          </p>
+          <div className="flex gap-2">
+            <div className="relative flex-1">
+              <Input
+                type={showApPassword ? 'text' : 'password'}
+                value={apPasswordInput}
+                onChange={(e) => setApPasswordInput(e.target.value)}
+                placeholder="No password (open)"
+                className="pr-8 h-8 text-sm"
+                onKeyDown={(e) => {
+                  if (e.key === 'Enter' && apPasswordInput !== apPassword) handleSaveApPassword()
+                }}
+              />
+              <button
+                type="button"
+                onClick={() => setShowApPassword(!showApPassword)}
+                className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
+              >
+                <span className="material-icons text-sm">
+                  {showApPassword ? 'visibility_off' : 'visibility'}
+                </span>
+              </button>
+            </div>
+            <Button
+              size="sm"
+              className="h-8"
+              onClick={handleSaveApPassword}
+              disabled={isSavingApPassword || apPasswordInput === apPassword || (apPasswordInput.length > 0 && apPasswordInput.length < 8)}
+            >
+              {isSavingApPassword ? (
+                <span className="material-icons text-sm animate-spin">refresh</span>
+              ) : (
+                'Save'
+              )}
+            </Button>
+          </div>
+          {apPasswordInput && apPasswordInput.length < 8 && apPasswordInput.length > 0 && (
+            <p className="text-xs text-destructive mt-1">
+              Password must be at least 8 characters
+            </p>
+          )}
+        </CardContent>
+      </Card>
+
       {/* Help */}
       {isHotspotMode && (
         <>
@@ -339,21 +461,81 @@ export function WiFiSetupPage() {
         </>
       )}
 
+      {/* Forget Confirmation Dialog */}
+      <Dialog open={forgetSsid !== null} onOpenChange={(open) => { if (!open) setForgetSsid(null) }}>
+        <DialogContent className="sm:max-w-md">
+          <DialogHeader>
+            <DialogTitle>Forget '{forgetSsid}'?</DialogTitle>
+            <DialogDescription>
+              {willStartHotspot ? (
+                <>
+                  This is your only saved network. Forgetting it will start the
+                  {' '}<strong>Dune Weaver</strong> hotspot. Connect to the Dune Weaver WiFi to access this page again.
+                </>
+              ) : isForgetActive ? (
+                <>
+                  You are currently connected to this network. The system will
+                  try the next saved network, or start the hotspot if none are in range.
+                </>
+              ) : (
+                'The saved credentials for this network will be removed.'
+              )}
+            </DialogDescription>
+          </DialogHeader>
+          <DialogFooter className="flex-row gap-2 sm:justify-end">
+            <Button variant="outline" onClick={() => setForgetSsid(null)}>
+              Cancel
+            </Button>
+            <Button variant="destructive" onClick={handleForget}>
+              Forget
+            </Button>
+          </DialogFooter>
+        </DialogContent>
+      </Dialog>
+
       {/* Connect Dialog */}
       <Dialog open={selectedNetwork !== null} onOpenChange={(open) => { if (!open) closeDialog() }}>
         <DialogContent className="sm:max-w-md">
           <DialogHeader>
             <DialogTitle className="flex items-center gap-2">
-              {selectedNetwork && <SignalIcon signal={selectedNetwork.signal} />}
-              {selectedNetwork?.ssid}
+              {isManualEntry ? (
+                <>
+                  <span className="material-icons text-lg text-muted-foreground">add_circle_outline</span>
+                  Add Network
+                </>
+              ) : (
+                <>
+                  {selectedNetwork && <SignalIcon signal={selectedNetwork.signal} />}
+                  {selectedNetwork?.ssid}
+                </>
+              )}
             </DialogTitle>
             <DialogDescription>
-              {selectedNetwork?.security !== 'Open' ? 'Secured network' : 'Open network'}
-              {selectedNetwork?.saved && ' · Saved'}
+              {isManualEntry
+                ? 'Enter the network name and password'
+                : <>
+                    {selectedNetwork?.security !== 'Open' ? 'Secured network' : 'Open network'}
+                    {selectedNetwork?.saved && ' · Saved'}
+                  </>
+              }
             </DialogDescription>
           </DialogHeader>
 
           <div className="space-y-4">
+            {isManualEntry && (
+              <div className="space-y-2">
+                <Label htmlFor="wifi-ssid">Network Name (SSID)</Label>
+                <Input
+                  id="wifi-ssid"
+                  type="text"
+                  value={manualSsid}
+                  onChange={(e) => setManualSsid(e.target.value)}
+                  placeholder="Enter network name"
+                  autoFocus
+                />
+              </div>
+            )}
+
             {needsPassword && (
               <div className="space-y-2">
                 <Label htmlFor="wifi-password">Password</Label>
@@ -364,7 +546,7 @@ export function WiFiSetupPage() {
                     value={password}
                     onChange={(e) => setPassword(e.target.value)}
                     placeholder="Enter WiFi password"
-                    autoFocus
+                    autoFocus={!isManualEntry}
                     onKeyDown={(e) => {
                       if (e.key === 'Enter' && password) handleConnect()
                     }}
@@ -385,7 +567,7 @@ export function WiFiSetupPage() {
             <Button
               className="w-full"
               onClick={handleConnect}
-              disabled={isConnecting || (!!needsPassword && !password)}
+              disabled={isConnecting || (isManualEntry && !manualSsid.trim()) || (!!needsPassword && !password)}
             >
               {isConnecting ? (
                 <>

+ 54 - 0
modules/wifi/manager.py

@@ -359,6 +359,60 @@ def forget_network(ssid: str) -> dict:
         return {"success": False, "message": str(e)}
 
 
+def get_hotspot_password() -> dict:
+    """Get the current hotspot password (empty string if open network)."""
+    try:
+        output = run_nmcli("-s", "-t", "-f", "802-11-wireless-security.psk",
+                           "con", "show", HOTSPOT_CON_NAME)
+        for line in output.strip().splitlines():
+            if "802-11-wireless-security.psk" in line:
+                psk = line.split(":", 1)[1] if ":" in line else ""
+                return {"password": psk}
+        return {"password": ""}
+    except Exception as e:
+        logger.error(f"Error getting hotspot password: {e}")
+        return {"password": ""}
+
+
+def set_hotspot_password(password: str) -> dict:
+    """Set or remove the hotspot password.
+
+    If password is non-empty, enables WPA-PSK. If empty, removes security
+    (open network). Restarts the hotspot if it's currently active.
+    """
+    try:
+        if password:
+            if len(password) < 8:
+                return {"success": False, "message": "Password must be at least 8 characters"}
+            result = run_nmcli_check(
+                "con", "modify", HOTSPOT_CON_NAME,
+                "wifi-sec.key-mgmt", "wpa-psk",
+                "wifi-sec.psk", password,
+            )
+        else:
+            # Remove security settings entirely to make it an open network
+            result = run_nmcli_check(
+                "con", "modify", HOTSPOT_CON_NAME,
+                "remove", "802-11-wireless-security",
+            )
+
+        if result.returncode != 0:
+            error_msg = result.stderr.strip() or "Failed to update hotspot password"
+            return {"success": False, "message": error_msg}
+
+        # Restart hotspot if currently active so changes take effect
+        if get_wifi_mode() == "hotspot":
+            run_nmcli_check("con", "down", HOTSPOT_CON_NAME, timeout=10)
+            run_nmcli_check("con", "up", HOTSPOT_CON_NAME, timeout=10)
+
+        msg = "Hotspot password updated" if password else "Hotspot password removed (open network)"
+        logger.info(msg)
+        return {"success": True, "message": msg}
+    except Exception as e:
+        logger.error(f"Error setting hotspot password: {e}")
+        return {"success": False, "message": str(e)}
+
+
 def _trigger_autohotspot():
     """Trigger an immediate autohotspot check.
 

+ 19 - 0
modules/wifi/router.py

@@ -27,6 +27,10 @@ class WiFiForgetRequest(BaseModel):
     ssid: str
 
 
+class HotspotPasswordRequest(BaseModel):
+    password: Optional[str] = ""
+
+
 @router.get("/status")
 async def wifi_status():
     """Get current WiFi mode, connection, and IP."""
@@ -69,6 +73,21 @@ async def wifi_saved():
     return manager.get_saved_connections()
 
 
+@router.get("/hotspot/password")
+async def get_hotspot_password():
+    """Get the current hotspot password."""
+    return manager.get_hotspot_password()
+
+
+@router.post("/hotspot/password")
+async def set_hotspot_password(req: HotspotPasswordRequest):
+    """Set or remove the hotspot password."""
+    result = manager.set_hotspot_password(req.password or "")
+    if not result["success"]:
+        raise HTTPException(status_code=400, detail=result["message"])
+    return result
+
+
 # --- Captive portal detection endpoints ---
 # These handle the well-known URLs that phones/tablets probe to detect captive portals.
 # In hotspot mode, DNS redirects all domains to the Pi, so these probes arrive here.