[client/ui-wails] Wire up the SSO login flow end-to-end

Mirror the Fyne client's login path: the daemon Login RPC now defaults
ProfileName/Username from GetActiveProfile + the OS user and sets
IsUnixDesktopClient on Linux/FreeBSD so the daemon picks the SSO
browser flow. A new OpenURL service launches the user's default
browser via xdg-open / open / rundll32 (Fyne's openURL helper) — the
embedded WebKit's window.open silently fails for external URLs.

The frontend gains a Login page that drives the full Login →
window.open via OpenURL → WaitSSOLogin → Up sequence with progress
states. Status surfaces a Login button while the daemon reports
NeedsLogin/SessionExpired, and the tray's status row stops being a
purely-decorative label: it becomes a clickable Login entry whenever
re-authentication is required.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Zoltán Papp
2026-05-06 17:48:47 +02:00
parent 91c745e5e8
commit bb2bf673a0
7 changed files with 213 additions and 15 deletions

View File

@@ -28,6 +28,17 @@ export function Logout(p: $models.LogoutParams): $CancellablePromise<void> {
return $Call.ByID(4028053230, p);
}
/**
* OpenURL launches the user's preferred browser to display url. Mirrors the
* Fyne client's openURL helper so the SSO flow can pop the verification page
* the same way as the legacy UI — WebKitGTK's window.open is blocked by the
* embedded webview, and asking the user to copy/paste defeats the point of
* SSO. Honors $BROWSER first, then falls back to the platform default.
*/
export function OpenURL(url: string): $CancellablePromise<void> {
return $Call.ByID(4267001345, url);
}
export function Up(p: $models.UpParams): $CancellablePromise<void> {
return $Call.ByID(1178388469, p);
}

View File

@@ -9,13 +9,15 @@ import Debug from "./pages/Debug";
import Update from "./pages/Update";
import QuickActions from "./pages/QuickActions";
import LoginUrl from "./pages/LoginUrl";
import Login from "./pages/Login";
export default function App() {
return (
<HashRouter>
<Routes>
<Route path="/quick" element={<QuickActions />} />
<Route path="/login" element={<LoginUrl />} />
<Route path="/login" element={<Login />} />
<Route path="/login-url" element={<LoginUrl />} />
<Route path="/update" element={<Update />} />
<Route element={<Layout />}>
<Route index element={<Status />} />

View File

@@ -0,0 +1,108 @@
import { useEffect, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import { ExternalLink, Loader2, AlertTriangle } from "lucide-react";
import { Connection } from "../../bindings/github.com/netbirdio/netbird/client/ui-wails/services";
import { Button } from "../components/Button";
type Phase = "starting" | "browser" | "connecting" | "error";
export default function Login() {
const navigate = useNavigate();
const [phase, setPhase] = useState<Phase>("starting");
const [verificationUri, setVerificationUri] = useState<string>("");
const [errorMsg, setErrorMsg] = useState<string>("");
const startedRef = useRef(false);
useEffect(() => {
if (startedRef.current) return;
startedRef.current = true;
let cancelled = false;
(async () => {
try {
const result = await Connection.Login({
profileName: "",
username: "",
managementUrl: "",
setupKey: "",
preSharedKey: "",
hostname: "",
hint: "",
});
if (cancelled) return;
if (result.needsSsoLogin) {
const uri = result.verificationUriComplete || result.verificationUri;
setVerificationUri(uri);
setPhase("browser");
if (uri) Connection.OpenURL(uri).catch(console.error);
await Connection.WaitSSOLogin({
userCode: result.userCode,
hostname: "",
});
if (cancelled) return;
}
setPhase("connecting");
await Connection.Up({ profileName: "", username: "" });
if (cancelled) return;
navigate("/", { replace: true });
} catch (e) {
if (cancelled) return;
setErrorMsg(String(e));
setPhase("error");
}
})();
return () => {
cancelled = true;
};
}, [navigate]);
if (phase === "error") {
return (
<div className="flex h-full flex-col items-center justify-center gap-4 p-6 text-center">
<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>
);
}
if (phase === "browser") {
return (
<div className="flex h-full flex-col items-center justify-center gap-4 p-6 text-center">
<h1 className="text-xl font-semibold">Continue in your browser</h1>
<p className="max-w-sm text-sm text-nb-gray-500">
A browser tab should have opened. Sign in there this window will
continue automatically once you're done.
</p>
{verificationUri && (
<Button onClick={() => Connection.OpenURL(verificationUri).catch(console.error)}>
<ExternalLink className="h-4 w-4" strokeWidth={1.5} />
Reopen URL
</Button>
)}
<p className="max-w-sm break-all font-mono text-xs text-nb-gray-500">
{verificationUri}
</p>
<div className="flex items-center gap-2 text-sm text-nb-gray-500">
<Loader2 className="h-4 w-4 animate-spin" strokeWidth={1.5} />
Waiting for sign-in
</div>
</div>
);
}
const message =
phase === "connecting" ? "Bringing the connection up…" : "Starting login…";
return (
<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>
</div>
);
}

View File

@@ -1,5 +1,6 @@
import { useEffect, useState } from "react";
import { ExternalLink } from "lucide-react";
import { Connection } from "../../bindings/github.com/netbirdio/netbird/client/ui-wails/services";
import { Button } from "../components/Button";
export default function LoginUrl() {
@@ -24,7 +25,7 @@ export default function LoginUrl() {
<p className="max-w-sm text-sm text-nb-gray-500">
Open the following URL to finish signing in.
</p>
<Button onClick={() => window.open(url, "_blank")}>
<Button onClick={() => Connection.OpenURL(url).catch(console.error)}>
<ExternalLink className="h-4 w-4" strokeWidth={1.5} />
Open URL
</Button>

View File

@@ -1,4 +1,5 @@
import { CheckCircle2, Circle, Loader2, AlertTriangle, Power } from "lucide-react";
import { CheckCircle2, Circle, Loader2, AlertTriangle, Power, LogIn } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { useStatus } from "../hooks/useStatus";
import { Connection } from "../../bindings/github.com/netbirdio/netbird/client/ui-wails/services";
import type { SystemEvent } from "../../bindings/github.com/netbirdio/netbird/client/ui-wails/services/models.js";
@@ -8,11 +9,17 @@ import { cn } from "../lib/cn";
export default function Status() {
const { status, error } = useStatus();
const navigate = useNavigate();
const connState = status?.status ?? "Disconnected";
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.
const needsLogin = connState === "NeedsLogin" || connState === "SessionExpired";
const login = () => navigate("/login");
const connect = () => Connection.Up({ profileName: "", username: "" }).catch(console.error);
const disconnect = () => Connection.Down().catch(console.error);
@@ -29,9 +36,15 @@ export default function Status() {
</div>
</div>
<div className="flex gap-2">
<Button onClick={connect} disabled={connected || connecting}>
<Power className="h-4 w-4" strokeWidth={1.5} /> Connect
</Button>
{needsLogin ? (
<Button onClick={login}>
<LogIn className="h-4 w-4" strokeWidth={1.5} /> Login
</Button>
) : (
<Button onClick={connect} disabled={connected || connecting}>
<Power className="h-4 w-4" strokeWidth={1.5} /> Connect
</Button>
)}
<Button onClick={disconnect} variant="secondary" disabled={!connected}>
Disconnect
</Button>