diff --git a/client/ui-wails/frontend/src/pages/Login.tsx b/client/ui-wails/frontend/src/pages/Login.tsx index bd2c09926..439096f47 100644 --- a/client/ui-wails/frontend/src/pages/Login.tsx +++ b/client/ui-wails/frontend/src/pages/Login.tsx @@ -1,6 +1,6 @@ -import { useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { useNavigate } from "react-router-dom"; -import { ExternalLink, Loader2, AlertTriangle } from "lucide-react"; +import { ExternalLink, Loader2, AlertTriangle, X, RotateCcw } from "lucide-react"; import { Connection } from "../../bindings/github.com/netbirdio/netbird/client/ui-wails/services"; import { Button } from "../components/Button"; @@ -11,13 +11,17 @@ export default function Login() { const [phase, setPhase] = useState("starting"); const [verificationUri, setVerificationUri] = useState(""); const [errorMsg, setErrorMsg] = useState(""); - const startedRef = useRef(false); + // attempt is bumped every time the user asks for a fresh start, which + // re-arms the useEffect below so the daemon's Login RPC is dialed again. + const [attempt, setAttempt] = useState(0); + const cancelledRef = useRef(false); useEffect(() => { - if (startedRef.current) return; - startedRef.current = true; + cancelledRef.current = false; + setPhase("starting"); + setVerificationUri(""); + setErrorMsg(""); - let cancelled = false; (async () => { try { const result = await Connection.Login({ @@ -29,7 +33,7 @@ export default function Login() { hostname: "", hint: "", }); - if (cancelled) return; + if (cancelledRef.current) return; if (result.needsSsoLogin) { const uri = result.verificationUriComplete || result.verificationUri; @@ -41,24 +45,53 @@ export default function Login() { userCode: result.userCode, hostname: "", }); - if (cancelled) return; + if (cancelledRef.current) return; } setPhase("connecting"); await Connection.Up({ profileName: "", username: "" }); - if (cancelled) return; + if (cancelledRef.current) return; navigate("/", { replace: true }); } catch (e) { - if (cancelled) return; + if (cancelledRef.current) return; setErrorMsg(String(e)); setPhase("error"); } })(); return () => { - cancelled = true; + cancelledRef.current = true; }; + }, [navigate, attempt]); + + // restart aborts any in-flight wait by toggling the cancellation flag, + // tells the daemon to drop whatever it's holding (a stale WaitSSOLogin + // can wedge the daemon for a previous UserCode), and then bumps attempt + // so the effect re-runs with a clean slate. + const restart = useCallback(async () => { + cancelledRef.current = true; + try { + await Connection.Down(); + } catch (e) { + console.error(e); + } + setAttempt((n) => n + 1); + }, []); + + // Cancel must also tell the daemon to abandon the in-flight WaitSSOLogin. + // Without Down(), the daemon stays parked on the OAuth flow's UserCode + // forever; subsequent Login calls re-use the cached flow but the user has + // no way out. Down() triggers the daemon's actCancel(), which unblocks + // WaitSSOLogin with a context-canceled error so our promise settles. + const cancel = useCallback(async () => { + cancelledRef.current = true; + try { + await Connection.Down(); + } catch (e) { + console.error(e); + } + navigate("/", { replace: true }); }, [navigate]); if (phase === "error") { @@ -67,7 +100,14 @@ export default function Login() {

Login failed

{errorMsg}

- +
+ + +
); } @@ -93,6 +133,14 @@ export default function Login() { Waiting for sign-in… +
+ + +
); } @@ -103,6 +151,9 @@ export default function Login() {

{message}

+
); } diff --git a/client/ui-wails/frontend/src/pages/Status.tsx b/client/ui-wails/frontend/src/pages/Status.tsx index cd40dc58a..abeae0476 100644 --- a/client/ui-wails/frontend/src/pages/Status.tsx +++ b/client/ui-wails/frontend/src/pages/Status.tsx @@ -15,9 +15,13 @@ export default function Status() { const connected = connState === "Connected"; const connecting = connState === "Connecting"; // The daemon reports "NeedsLogin" on a fresh install or after a session - // expires. Show a Login button instead of the plain Connect button β€” Connect - // (Up) without a valid session would fail anyway. + // expires; "SessionExpired" once a previously good session lapses. In both + // cases Connect would fail without a fresh SSO login. const needsLogin = connState === "NeedsLogin" || connState === "SessionExpired"; + // Always offer Login while we aren't Connected β€” including Connecting, + // because a stuck Login on the daemon leaves us in Connecting forever and + // the user has no other way out. Disconnect is the manual unstick path. + const showLogin = !connected; const login = () => navigate("/login"); const connect = () => Connection.Up({ profileName: "", username: "" }).catch(console.error); @@ -45,6 +49,11 @@ export default function Status() { Connect )} + {showLogin && !needsLogin && ( + + )} diff --git a/client/ui-wails/services/connection.go b/client/ui-wails/services/connection.go index 0af68028e..6be9e153f 100644 --- a/client/ui-wails/services/connection.go +++ b/client/ui-wails/services/connection.go @@ -65,6 +65,17 @@ func (s *Connection) Login(ctx context.Context, p LoginParams) (LoginResult, err return LoginResult{}, err } + // Reset the daemon's connection loop before kicking off a new login. + // If a previous Login left a WaitSSOLogin pending (user closed the + // browser without completing the flow), the daemon stays parked on the + // old UserCode and replies with "invalid setup-key or no sso information + // provided" to a fresh Login. Calling Down first dislodges that state; + // we ignore the error since Down on an already-idle daemon is a no-op. + if _, derr := cli.Down(ctx, &proto.DownRequest{}); derr != nil { + // Down failed β€” likely because the daemon is already idle. Continue. + _ = derr + } + // Mirror the Fyne client's defaulting: when the frontend doesn't supply // profile / username, fall back to the daemon's active profile and the // current OS user. The flag matches the Fyne ui's IsUnixDesktopClient