mirror of
https://github.com/netbirdio/netbird.git
synced 2026-05-07 09:19:59 +00:00
[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:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 />} />
|
||||
|
||||
108
client/ui-wails/frontend/src/pages/Login.tsx
Normal file
108
client/ui-wails/frontend/src/pages/Login.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user