[client/ui-wails] Make the SSO login flow recoverable from a stuck state

A pending WaitSSOLogin parks the daemon on an OAuth UserCode forever
once the user closes the browser without completing the flow. The
frontend can't unblock that on its own — it needs the daemon to fire
its own actCancel(). Three fixes work together:

- Login() now issues a Down() before kicking off the new flow so a
  previously-stuck WaitSSOLogin is unwedged before we ask the daemon
  for fresh OAuth info.
- The Login page's Cancel button calls Down() before navigating away,
  so abandoning the flow mid-browser actually settles the daemon's
  in-flight WaitSSOLogin instead of leaving it pinned.
- Status keeps the Login button visible whenever we aren't Connected
  (including Connecting), so a UI restart that finds the daemon stuck
  in Connecting still has a one-click recovery path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Zoltán Papp
2026-05-06 17:59:50 +02:00
parent bb2bf673a0
commit 05ee4e52b8
3 changed files with 85 additions and 14 deletions

View File

@@ -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<Phase>("starting");
const [verificationUri, setVerificationUri] = useState<string>("");
const [errorMsg, setErrorMsg] = useState<string>("");
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() {
<AlertTriangle className="h-8 w-8 text-red-500" strokeWidth={1.5} />
<h1 className="text-xl font-semibold">Login failed</h1>
<p className="max-w-sm break-words text-sm text-nb-gray-500">{errorMsg}</p>
<Button onClick={() => navigate("/", { replace: true })}>Back</Button>
<div className="flex gap-2">
<Button onClick={restart}>
<RotateCcw className="h-4 w-4" strokeWidth={1.5} /> Try again
</Button>
<Button variant="secondary" onClick={cancel}>
Back
</Button>
</div>
</div>
);
}
@@ -93,6 +133,14 @@ export default function Login() {
<Loader2 className="h-4 w-4 animate-spin" strokeWidth={1.5} />
Waiting for sign-in
</div>
<div className="flex gap-2 pt-2">
<Button variant="secondary" onClick={restart}>
<RotateCcw className="h-4 w-4" strokeWidth={1.5} /> Restart
</Button>
<Button variant="ghost" onClick={cancel}>
<X className="h-4 w-4" strokeWidth={1.5} /> Cancel
</Button>
</div>
</div>
);
}
@@ -103,6 +151,9 @@ export default function Login() {
<div className="flex h-full flex-col items-center justify-center gap-3 p-6 text-center">
<Loader2 className="h-8 w-8 animate-spin text-netbird" strokeWidth={1.5} />
<p className="text-sm text-nb-gray-500">{message}</p>
<Button variant="ghost" onClick={cancel}>
<X className="h-4 w-4" strokeWidth={1.5} /> Cancel
</Button>
</div>
);
}

View File

@@ -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() {
<Power className="h-4 w-4" strokeWidth={1.5} /> Connect
</Button>
)}
{showLogin && !needsLogin && (
<Button onClick={login} variant="secondary">
<LogIn className="h-4 w-4" strokeWidth={1.5} /> Login
</Button>
)}
<Button onClick={disconnect} variant="secondary" disabled={!connected}>
Disconnect
</Button>

View File

@@ -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