mirror of
https://github.com/netbirdio/netbird.git
synced 2026-05-08 01:39:55 +00:00
[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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user