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

View File

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

View File

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