From bb2bf673a07d2ed41b3ef3d6ae86543856aa1ac4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zolt=C3=A1n=20Papp?= Date: Wed, 6 May 2026 17:48:47 +0200 Subject: [PATCH] [client/ui-wails] Wire up the SSO login flow end-to-end MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../client/ui-wails/services/connection.ts | 11 ++ client/ui-wails/frontend/src/App.tsx | 4 +- client/ui-wails/frontend/src/pages/Login.tsx | 108 ++++++++++++++++++ .../ui-wails/frontend/src/pages/LoginUrl.tsx | 3 +- client/ui-wails/frontend/src/pages/Status.tsx | 21 +++- client/ui-wails/services/connection.go | 62 ++++++++-- client/ui-wails/tray.go | 19 ++- 7 files changed, 213 insertions(+), 15 deletions(-) create mode 100644 client/ui-wails/frontend/src/pages/Login.tsx diff --git a/client/ui-wails/frontend/bindings/github.com/netbirdio/netbird/client/ui-wails/services/connection.ts b/client/ui-wails/frontend/bindings/github.com/netbirdio/netbird/client/ui-wails/services/connection.ts index bff04759e..d4d2dd761 100644 --- a/client/ui-wails/frontend/bindings/github.com/netbirdio/netbird/client/ui-wails/services/connection.ts +++ b/client/ui-wails/frontend/bindings/github.com/netbirdio/netbird/client/ui-wails/services/connection.ts @@ -28,6 +28,17 @@ export function Logout(p: $models.LogoutParams): $CancellablePromise { 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 { + return $Call.ByID(4267001345, url); +} + export function Up(p: $models.UpParams): $CancellablePromise { return $Call.ByID(1178388469, p); } diff --git a/client/ui-wails/frontend/src/App.tsx b/client/ui-wails/frontend/src/App.tsx index 6d8303ca5..2dcb4464a 100644 --- a/client/ui-wails/frontend/src/App.tsx +++ b/client/ui-wails/frontend/src/App.tsx @@ -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 ( } /> - } /> + } /> + } /> } /> }> } /> diff --git a/client/ui-wails/frontend/src/pages/Login.tsx b/client/ui-wails/frontend/src/pages/Login.tsx new file mode 100644 index 000000000..bd2c09926 --- /dev/null +++ b/client/ui-wails/frontend/src/pages/Login.tsx @@ -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("starting"); + const [verificationUri, setVerificationUri] = useState(""); + const [errorMsg, setErrorMsg] = useState(""); + 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 ( +
+ +

Login failed

+

{errorMsg}

+ +
+ ); + } + + if (phase === "browser") { + return ( +
+

Continue in your browser

+

+ A browser tab should have opened. Sign in there — this window will + continue automatically once you're done. +

+ {verificationUri && ( + + )} +

+ {verificationUri} +

+
+ + Waiting for sign-in… +
+
+ ); + } + + const message = + phase === "connecting" ? "Bringing the connection up…" : "Starting login…"; + return ( +
+ +

{message}

+
+ ); +} diff --git a/client/ui-wails/frontend/src/pages/LoginUrl.tsx b/client/ui-wails/frontend/src/pages/LoginUrl.tsx index 71c8e88a1..6841b92a4 100644 --- a/client/ui-wails/frontend/src/pages/LoginUrl.tsx +++ b/client/ui-wails/frontend/src/pages/LoginUrl.tsx @@ -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() {

Open the following URL to finish signing in.

- diff --git a/client/ui-wails/frontend/src/pages/Status.tsx b/client/ui-wails/frontend/src/pages/Status.tsx index ec0533568..cd40dc58a 100644 --- a/client/ui-wails/frontend/src/pages/Status.tsx +++ b/client/ui-wails/frontend/src/pages/Status.tsx @@ -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() {
- + {needsLogin ? ( + + ) : ( + + )} diff --git a/client/ui-wails/services/connection.go b/client/ui-wails/services/connection.go index 282bd04f7..0af68028e 100644 --- a/client/ui-wails/services/connection.go +++ b/client/ui-wails/services/connection.go @@ -4,6 +4,11 @@ package services import ( "context" + "fmt" + "os" + "os/exec" + "os/user" + "runtime" "github.com/netbirdio/netbird/client/proto" ) @@ -59,16 +64,38 @@ func (s *Connection) Login(ctx context.Context, p LoginParams) (LoginResult, err if err != nil { return LoginResult{}, err } + + // 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 + // condition so the daemon knows we can render an SSO browser flow. + profileName := p.ProfileName + username := p.Username + if profileName == "" { + if active, aerr := cli.GetActiveProfile(ctx, &proto.GetActiveProfileRequest{}); aerr == nil { + profileName = active.GetProfileName() + if username == "" { + username = active.GetUsername() + } + } + } + if username == "" { + if u, uerr := user.Current(); uerr == nil { + username = u.Username + } + } + req := &proto.LoginRequest{ - ManagementUrl: p.ManagementURL, - SetupKey: p.SetupKey, - Hostname: p.Hostname, + ManagementUrl: p.ManagementURL, + SetupKey: p.SetupKey, + Hostname: p.Hostname, + IsUnixDesktopClient: runtime.GOOS == "linux" || runtime.GOOS == "freebsd", } - if p.ProfileName != "" { - req.ProfileName = ptrStr(p.ProfileName) + if profileName != "" { + req.ProfileName = ptrStr(profileName) } - if p.Username != "" { - req.Username = ptrStr(p.Username) + if username != "" { + req.Username = ptrStr(username) } if p.PreSharedKey != "" { req.OptionalPreSharedKey = ptrStr(p.PreSharedKey) @@ -129,6 +156,27 @@ func (s *Connection) Down(ctx context.Context) error { return err } +// 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. +func (s *Connection) OpenURL(url string) error { + if browser := os.Getenv("BROWSER"); browser != "" { + return exec.Command(browser, url).Start() + } + switch runtime.GOOS { + case "windows": + return exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start() + case "darwin": + return exec.Command("open", url).Start() + case "linux", "freebsd": + return exec.Command("xdg-open", url).Start() + default: + return fmt.Errorf("unsupported platform") + } +} + func (s *Connection) Logout(ctx context.Context, p LogoutParams) error { cli, err := s.conn.Client() if err != nil { diff --git a/client/ui-wails/tray.go b/client/ui-wails/tray.go index eb4c18586..063a4c5ee 100644 --- a/client/ui-wails/tray.go +++ b/client/ui-wails/tray.go @@ -75,6 +75,10 @@ const ( // Daemon status string for an SSO session that has expired and needs // re-authentication. Mirrors internal.StatusSessionExpired. statusSessionExpired = "SessionExpired" + // statusNeedsLogin is what the daemon publishes before the user has + // completed an SSO authentication on this profile. Mirrors + // internal.StatusNeedsLogin. + statusNeedsLogin = "NeedsLogin" // External URLs. urlGitHubRepo = "https://github.com/netbirdio/netbird" @@ -182,7 +186,13 @@ func (t *Tray) ShowWindow() { func (t *Tray) buildMenu() *application.Menu { menu := application.NewMenu() - t.statusItem = menu.Add(menuStatusDisconnected).SetEnabled(false) + // statusItem doubles as the "Login" entry once the daemon reports + // NeedsLogin/SessionExpired — applyStatus toggles its enabled state and + // label. The click handler is harmless while disabled, so we wire it + // up unconditionally rather than swapping items at runtime. + t.statusItem = menu.Add(menuStatusDisconnected). + OnClick(func(*application.Context) { t.openRoute("/login") }). + SetEnabled(false) menu.AddSeparator() // On Linux the tray icon's left-click handler is intentionally unbound @@ -447,11 +457,16 @@ func (t *Tray) applyStatus(st services.Status) { if iconChanged { t.applyIcon() + needsLogin := strings.EqualFold(st.Status, statusNeedsLogin) || + strings.EqualFold(st.Status, statusSessionExpired) if t.statusItem != nil { + // When the daemon needs re-authentication the status row turns + // into the actionable Login entry — Connect would only fail. t.statusItem.SetLabel(st.Status) + t.statusItem.SetEnabled(needsLogin) } if t.upItem != nil { - t.upItem.SetEnabled(!connected) + t.upItem.SetEnabled(!connected && !needsLogin) } if t.downItem != nil { t.downItem.SetEnabled(connected)