update dialogs, hide main window on browser login, keep state as disconnected when needslogin

This commit is contained in:
Eduard Gert
2026-05-18 16:31:59 +02:00
parent 741ce8581d
commit 5b71a4f2ad
22 changed files with 444 additions and 197 deletions

View File

@@ -24,10 +24,10 @@ React 18 + TS 5.7 (`strict`, `noImplicitAny: false`) + Vite 6 + Tailwind 3 (`dar
|---|---|---|---|
| `/` | `Main` | `AppLayout` | Main window default route |
| `/quick` | `QuickActions` | none | Standalone — **prototype**, not currently invoked by the Go side |
| `/browser-login` | `BrowserLogin` | none | Auxiliary window (Go `WindowManager.OpenBrowserLogin`) |
| `/browser-login` | `WaitingForBrowserDialog` (modules/authentication) | none | Auxiliary window (Go `WindowManager.OpenBrowserLogin`) |
| `/update` | `Update` (pages) | none | Main window during enforced-update install |
| `/session-expired` | `SessionExpired` (modules/session) | none | Auxiliary window (Go `WindowManager.OpenSessionExpired`, always-on-top) |
| `/session-about-to-expire` | `SessionAboutToExpire` (modules/session) | none | Auxiliary window (Go `WindowManager.OpenSessionAboutToExpire(seconds)`, always-on-top, mm:ss countdown via `?seconds=`) |
| `/session-expired` | `SessionExpiredDialog` (modules/authentication) | none | Auxiliary window (Go `WindowManager.OpenSessionExpired`, always-on-top) |
| `/session-about-to-expire` | `SessionAboutToExpireDialog` (modules/authentication) | none | Auxiliary window (Go `WindowManager.OpenSessionAboutToExpire(seconds)`, always-on-top, mm:ss countdown via `?seconds=`) |
| `/settings` | `Settings` | `SettingsLayout` | Auxiliary window (Go `WindowManager.OpenSettings`) |
| `*` | `<Navigate to="/">` | `AppLayout` | Catch-all |
@@ -161,7 +161,7 @@ Defined in `tailwind.config.ts`. `nb-gray` is the neutral palette (background =
- **`screens/Profiles.tsx`** still imports bindings via the deep relative path. It's the example of the preferred `ProfileSwitcher.SwitchActive` flow but otherwise pre-AppLayout.
- **`pages/Debug.tsx`** is the legacy debug-bundle screen. The polished flow is in `modules/settings/SettingsTroubleshooting.tsx` (via `useDebugBundle`). `pages/Debug.tsx` isn't currently routed.
- **`pages/Update.tsx`** and **`screens/Update.tsx`** are two different update pages. The route table points at `pages/Update.tsx` (the production one with the 15-minute timeout, daemon-down-grace, and error-mapping). The `screens/Update.tsx` is an older simpler variant.
- **`modules/session/SessionExpired.tsx`** and **`modules/session/SessionAboutToExpire.tsx`** are the always-on-top auxiliary windows. Today they're only triggered via the DEV-only "Development" tab in Settings (`SettingsDevelopment.tsx`) — a daemon-status hook (status `SessionExpired`, plus a future "about-to-expire" signal) will drive them later. Sign-in / Stay-connected emit `EventTriggerLogin` so the main window's `startLogin()` orchestrator handles the SSO flow; Logout uses `Connection.Logout({profileName, username})`.
- **`modules/authentication/SessionExpiredDialog.tsx`** and **`modules/authentication/SessionAboutToExpireDialog.tsx`** are the always-on-top auxiliary windows. Today they're only triggered via the DEV-only "Development" tab in Settings (`SettingsDevelopment.tsx`) — a daemon-status hook (status `SessionExpired`, plus a future "about-to-expire" signal) will drive them later. Sign-in / Stay-connected emit `EventTriggerLogin` so the main window's `startLogin()` orchestrator handles the SSO flow; Logout uses `Connection.Logout({profileName, username})`.
- **`screens/QuickActions.tsx`** is wired to `/quick` in the route table but nothing on the Go side currently navigates there.
- **`UpdateAvailableBanner`** is force-enabled via `FORCE_UPDATE_AVAILABLE = true` and additionally TODO-commented for the "only when management has auto updates enabled + force updates is disabled" case.
- **`lib/MainModuleContext.tsx`** is exported but unused. Candidate for deletion.

View File

@@ -32,7 +32,7 @@
"cmdk": "^1.1.1",
"framer-motion": "^12.38.0",
"i18next": "^26.2.0",
"lucide-react": "^0.469.0",
"lucide-react": "^0.535.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-i18next": "^17.0.8",

View File

@@ -60,8 +60,8 @@ dependencies:
specifier: ^26.2.0
version: 26.2.0(typescript@5.9.3)
lucide-react:
specifier: ^0.469.0
version: 0.469.0(react@18.3.1)
specifier: ^0.535.0
version: 0.535.0(react@18.3.1)
react:
specifier: ^18.3.1
version: 18.3.1
@@ -2096,8 +2096,8 @@ packages:
yallist: 3.1.1
dev: true
/lucide-react@0.469.0(react@18.3.1):
resolution: {integrity: sha512-28vvUnnKQ/dBwiCQtwJw7QauYnE7yd2Cyp4tTTJpvglX4EMpbflcdBgrgToX2j71B3YvugK/NH3BGUk+E/p/Fw==}
/lucide-react@0.535.0(react@18.3.1):
resolution: {integrity: sha512-2E3+YWGLpjZ8ejIYrdqxVjWMSMiRQHmU6xZYE9xA2SC5j2m0NeB4/acjhRdhxbfniBKoNEukDDQnmShTxwOQ4g==}
peerDependencies:
react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
dependencies:

View File

@@ -3,8 +3,8 @@ import ReactDOM from "react-dom/client";
import "./globals.css";
import { HashRouter, Navigate, Route, Routes } from "react-router-dom";
import QuickActions from "@/screens/QuickActions.tsx";
import SessionExpired from "@/modules/session/SessionExpired.tsx";
import SessionAboutToExpire from "@/modules/session/SessionAboutToExpire.tsx";
import SessionExpiredDialog from "@/modules/authentication/SessionExpiredDialog.tsx";
import SessionAboutToExpireDialog from "@/modules/authentication/SessionAboutToExpireDialog.tsx";
import Update from "@/screens/Update.tsx";
import { AppLayout } from "@/layouts/AppLayout.tsx";
import { SettingsLayout } from "@/layouts/SettingsLayout.tsx";
@@ -13,7 +13,7 @@ import { Settings } from "@/modules/settings/Settings.tsx";
import { SkeletonTheme } from "react-loading-skeleton";
import "react-loading-skeleton/dist/skeleton.css";
import { welcome } from "@/lib/welcome";
import BrowserLogin from "@/pages/BrowserLogin.tsx";
import WaitingForBrowserDialog from "@/modules/authentication/WaitingForBrowserDialog.tsx";
import { initI18n } from "@/lib/i18n";
welcome();
@@ -32,10 +32,10 @@ initI18n()
<HashRouter>
<Routes>
<Route path="/quick" element={<QuickActions />} />
<Route path="/browser-login" element={<BrowserLogin />} />
<Route path="/browser-login" element={<WaitingForBrowserDialog />} />
<Route path="/update" element={<Update />} />
<Route path="/session-expired" element={<SessionExpired />} />
<Route path="/session-about-to-expire" element={<SessionAboutToExpire />} />
<Route path="/session-expired" element={<SessionExpiredDialog />} />
<Route path="/session-about-to-expire" element={<SessionAboutToExpireDialog />} />
<Route element={<SettingsLayout />}>
<Route path="settings" element={<Settings />} />
</Route>

View File

@@ -0,0 +1,29 @@
import { ReactNode, forwardRef } from "react";
// ConfirmDialog is the shared layout wrapper used by dialog-style window
// surfaces (SessionExpired, SessionAboutToExpire, …). Purely a layout
// primitive — callers compose the contents (SquareIcon, DialogHeading,
// DialogDescription, DialogActions) so each dialog can tweak its own
// internal structure without growing the ConfirmDialog API.
//
// Callers that mount the dialog inside its own Wails window pair this
// with useAutoSizeWindow by forwarding the returned ref onto the content
// wrapper so the window height tracks the rendered content.
type ConfirmDialogProps = {
children: ReactNode;
};
export const ConfirmDialog = forwardRef<HTMLDivElement, ConfirmDialogProps>(
function ConfirmDialog({ children }, ref) {
return (
<div className={"flex flex-col items-center justify-center"}>
<div
ref={ref}
className={"flex flex-col items-center gap-5 p-8 text-center"}
>
{children}
</div>
</div>
);
},
);

View File

@@ -0,0 +1,21 @@
import { ReactNode } from "react";
import { cn } from "@/lib/cn";
// DialogActions wraps a vertical stack of Buttons inside a dialog surface.
// The wails-no-draggable class lets the user click the buttons even when
// the dialog window itself is draggable from any background region.
type DialogActionsProps = {
children: ReactNode;
className?: string;
};
export const DialogActions = ({ children, className }: DialogActionsProps) => (
<div
className={cn(
"wails-no-draggable flex flex-col gap-3 w-full mx-auto",
className,
)}
>
{children}
</div>
);

View File

@@ -0,0 +1,13 @@
import { ReactNode } from "react";
import { cn } from "@/lib/cn";
// DialogDescription is the supporting description text rendered under a
// DialogHeading inside ConfirmDialog (and similar dialog surfaces).
type DialogDescriptionProps = {
children: ReactNode;
className?: string;
};
export const DialogDescription = ({ children, className }: DialogDescriptionProps) => (
<p className={cn("text-sm text-nb-gray-300", className)}>{children}</p>
);

View File

@@ -0,0 +1,16 @@
import { ReactNode } from "react";
import { cn } from "@/lib/cn";
// DialogHeading is the title text used inside ConfirmDialog (and any other
// dialog-style surface with the same shape). Pair with DialogDescription
// for the standard title/description stack.
type DialogHeadingProps = {
children: ReactNode;
className?: string;
};
export const DialogHeading = ({ children, className }: DialogHeadingProps) => (
<p className={cn("text-base font-semibold text-nb-gray-50", className)}>
{children}
</p>
);

View File

@@ -0,0 +1,23 @@
import { ComponentType } from "react";
import { LucideProps } from "lucide-react";
import { cn } from "@/lib/cn";
// SquareIcon is the rounded-square icon tile used by dialog-style surfaces
// (ConfirmDialog, etc.). Renders a bordered dark tile with the provided
// lucide icon centered inside.
type SquareIconProps = {
icon: ComponentType<LucideProps>;
iconSize?: number;
className?: string;
};
export const SquareIcon = ({ icon: Icon, iconSize = 20, className }: SquareIconProps) => (
<div
className={cn(
"h-11 w-11 rounded-xl flex items-center justify-center bg-nb-gray-920 border border-nb-gray-900 text-white",
className,
)}
>
<Icon size={iconSize} />
</div>
);

View File

@@ -258,8 +258,6 @@
"update.page.failed": "Update fehlgeschlagen",
"browserLogin.title": "Setzen Sie den Anmeldevorgang im Browser fort",
"browserLogin.description": "Bitte schließen Sie die Kontoauthentifizierung im Browser-Tab ab und fahren Sie dort fort.",
"browserLogin.waiting": "Warten auf Anmeldung…",
"browserLogin.notSeeing": "Sehen Sie den Browser-Tab nicht?",
"browserLogin.tryAgain": "Erneut versuchen",

View File

@@ -258,8 +258,6 @@
"update.page.failed": "Update failed",
"browserLogin.title": "Continue in your browser to complete the login",
"browserLogin.description": "Please complete the account authentication process in the browser tab and continue from there.",
"browserLogin.waiting": "Waiting for sign-in…",
"browserLogin.notSeeing": "Not seeing the browser tab?",
"browserLogin.tryAgain": "Try again",

View File

@@ -258,8 +258,6 @@
"update.page.failed": "Frissítés sikertelen",
"browserLogin.title": "Folytassa a böngészőben a bejelentkezés befejezéséhez",
"browserLogin.description": "Kérjük, fejezze be a fiókhitelesítést a böngésző fülén, és folytassa onnan.",
"browserLogin.waiting": "Várakozás a bejelentkezésre…",
"browserLogin.notSeeing": "Nem látja a böngésző fülét?",
"browserLogin.tryAgain": "Próbálja újra",

View File

@@ -66,8 +66,16 @@ async function startLogin(): Promise<void> {
if (result.needsSsoLogin) {
const uri = result.verificationUriComplete || result.verificationUri;
if (uri) {
// Open the in-app sign-in popup first so it's already on
// screen when the system browser steals focus; otherwise
// the browser lands on top and the user has to dig the
// NetBird window back out.
try {
await WindowManager.OpenBrowserLogin(uri);
} catch (e) {
console.error(e);
}
Connection.OpenURL(uri).catch(console.error);
WindowManager.OpenBrowserLogin(uri).catch(console.error);
}
const cancelPromise = new Promise<void>((resolve) => {
@@ -166,17 +174,17 @@ export const ConnectionStatusSwitch = () => {
case "Connected":
return ConnectionState.Connected;
case "Connecting":
case "NeedsLogin":
// NeedsLogin is mid-SSO: the auto-chain in this component
// (and the tray's trigger-login emitter) flips the browser
// login window open as soon as the daemon reports it, so
// the switch should keep painting "Connecting" rather than
// dropping back to Disconnected while the user signs in.
return ConnectionState.Connecting;
case "Idle":
case "NeedsLogin":
case "LoginFailed":
case "SessionExpired":
case "DaemonUnavailable":
// NeedsLogin / SessionExpired without an in-flight user
// action read as Disconnected — the switch only flips to
// Connecting once the user (or the tray's trigger-login)
// kicks off the SSO flow, which sets action = "logging-in"
// and is handled by the guard above.
return ConnectionState.Disconnected;
default:
return ConnectionState.Disconnected;

View File

@@ -0,0 +1,41 @@
import { useLayoutEffect, useRef } from "react";
import { Window } from "@wailsio/runtime";
// useAutoSizeWindow resizes the current Wails window so its height matches
// the measured height of the content element the returned ref is attached
// to. Width stays fixed (Wails has no "fit-content-width" notion and the
// dialog-style session windows want a stable horizontal footprint).
//
// On first measurement the hook also calls Window.Show()/Focus() — the
// Go-side opens the window with Hidden: true so the user never sees the
// initial placeholder size snap to the measured size. Subsequent
// measurements (content changes after mount) only adjust the size.
//
// Re-measures via ResizeObserver so adding/removing content (e.g. the
// SessionAboutToExpire title swapping at countdown zero) keeps the chrome
// tight to the content with no scrollbar.
export function useAutoSizeWindow<T extends HTMLElement>(width: number) {
const ref = useRef<T | null>(null);
useLayoutEffect(() => {
const el = ref.current;
if (!el) return;
let shown = false;
const apply = () => {
const h = Math.ceil(el.getBoundingClientRect().height);
if (h <= 0) return;
void Window.SetSize(width, h)
.then(() => {
if (shown) return;
shown = true;
void Window.Show().catch(() => {});
void Window.Focus().catch(() => {});
})
.catch(() => {});
};
apply();
const ro = new ResizeObserver(apply);
ro.observe(el);
return () => ro.disconnect();
}, [width]);
return ref;
}

View File

@@ -4,14 +4,17 @@ import { useSearchParams } from "react-router-dom";
import { Events } from "@wailsio/runtime";
import { ClockIcon } from "lucide-react";
import { Button } from "@/components/Button";
import {
Connection,
Profiles as ProfilesSvc,
WindowManager,
} from "@bindings/services";
import { ConfirmDialog } from "@/components/ConfirmDialog";
import { DialogActions } from "@/components/DialogActions";
import { DialogDescription } from "@/components/DialogDescription";
import { DialogHeading } from "@/components/DialogHeading";
import { SquareIcon } from "@/components/SquareIcon";
import { Connection, Profiles as ProfilesSvc, WindowManager } from "@bindings/services";
import { useAutoSizeWindow } from "@/lib/useAutoSizeWindow";
const EVENT_TRIGGER_LOGIN = "trigger-login";
const DEFAULT_SECONDS = 360;
const WINDOW_WIDTH = 360;
function formatMMSS(seconds: number): string {
const s = Math.max(0, seconds | 0);
@@ -20,8 +23,9 @@ function formatMMSS(seconds: number): string {
return `${String(m).padStart(2, "0")}:${String(r).padStart(2, "0")}`;
}
export default function SessionAboutToExpire() {
export default function SessionAboutToExpireDialog() {
const { t } = useTranslation();
const contentRef = useAutoSizeWindow<HTMLDivElement>(WINDOW_WIDTH);
const [params] = useSearchParams();
const initialSeconds = useMemo(() => {
const raw = params.get("seconds");
@@ -66,53 +70,48 @@ export default function SessionAboutToExpire() {
}, []);
return (
<div
className={
"h-screen w-full flex flex-col items-center justify-center text-center px-6 py-8 bg-nb-gray-950"
}
>
<div
className={
"h-12 w-12 rounded-full flex items-center justify-center bg-nb-gray-900 text-netbird mb-4"
}
>
<ClockIcon size={22} />
<ConfirmDialog ref={contentRef}>
<SquareIcon icon={ClockIcon} className={"mt-4"} />
<div className={"flex flex-col items-center gap-1"}>
<DialogHeading>
{expired
? t("sessionAboutToExpire.expired")
: t("sessionAboutToExpire.title")}
</DialogHeading>
<DialogDescription>
{t("sessionAboutToExpire.description")}
</DialogDescription>
</div>
<h1 className={"text-base font-semibold text-nb-gray-100"}>
{expired
? t("sessionAboutToExpire.expired")
: t("sessionAboutToExpire.title")}
</h1>
<p className={"text-xs text-nb-gray-400 mt-1.5 max-w-[20rem] leading-snug"}>
{t("sessionAboutToExpire.description")}
</p>
<div
className={
"mt-5 font-mono text-3xl tabular-nums text-nb-gray-100 tracking-wider"
"font-mono font-semibold text-2xl tabular-nums text-nb-gray-50 tracking-wider"
}
aria-live={"polite"}
>
{formatMMSS(remaining)}
</div>
<div className={"flex gap-2 mt-5 w-full max-w-[18rem]"}>
<Button
variant={"secondary"}
size={"xs"}
className={"flex-1"}
onClick={logout}
>
{t("sessionAboutToExpire.logout")}
</Button>
<DialogActions>
<Button
variant={"primary"}
size={"xs"}
className={"flex-1"}
size={"md"}
className={"w-full"}
onClick={stay}
disabled={expired}
>
{t("sessionAboutToExpire.stay")}
</Button>
</div>
</div>
<Button
variant={"secondary"}
size={"md"}
className={"w-full"}
onClick={logout}
>
{t("sessionAboutToExpire.logout")}
</Button>
</DialogActions>
</ConfirmDialog>
);
}

View File

@@ -0,0 +1,49 @@
import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import { Events } from "@wailsio/runtime";
import { AlertCircleIcon } from "lucide-react";
import { Button } from "@/components/Button";
import { ConfirmDialog } from "@/components/ConfirmDialog";
import { DialogActions } from "@/components/DialogActions";
import { DialogDescription } from "@/components/DialogDescription";
import { DialogHeading } from "@/components/DialogHeading";
import { SquareIcon } from "@/components/SquareIcon";
import { WindowManager } from "@bindings/services";
import { useAutoSizeWindow } from "@/lib/useAutoSizeWindow";
const EVENT_TRIGGER_LOGIN = "trigger-login";
const WINDOW_WIDTH = 360;
export default function SessionExpiredDialog() {
const { t } = useTranslation();
const contentRef = useAutoSizeWindow<HTMLDivElement>(WINDOW_WIDTH);
const signIn = useCallback(() => {
void Events.Emit(EVENT_TRIGGER_LOGIN);
WindowManager.CloseSessionExpired().catch(console.error);
}, []);
const later = useCallback(() => {
WindowManager.CloseSessionExpired().catch(console.error);
}, []);
return (
<ConfirmDialog ref={contentRef}>
<SquareIcon icon={AlertCircleIcon} className={"mt-4"} />
<div className={"flex flex-col items-center gap-1"}>
<DialogHeading>{t("sessionExpired.title")}</DialogHeading>
<DialogDescription>{t("sessionExpired.description")}</DialogDescription>
</div>
<DialogActions>
<Button variant={"primary"} size={"md"} className={"w-full"} onClick={signIn}>
{t("sessionExpired.signIn")}
</Button>
<Button variant={"secondary"} size={"md"} className={"w-full"} onClick={later}>
{t("sessionExpired.later")}
</Button>
</DialogActions>
</ConfirmDialog>
);
}

View File

@@ -0,0 +1,71 @@
import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import { useSearchParams } from "react-router-dom";
import { Events } from "@wailsio/runtime";
import { Loader2 } from "lucide-react";
import { Connection } from "@bindings/services";
import { Button } from "@/components/Button";
import { ConfirmDialog } from "@/components/ConfirmDialog";
import { DialogActions } from "@/components/DialogActions";
import { DialogDescription } from "@/components/DialogDescription";
import { DialogHeading } from "@/components/DialogHeading";
import { SquareIcon } from "@/components/SquareIcon";
import { useAutoSizeWindow } from "@/lib/useAutoSizeWindow";
const EVENT_CANCEL = "browser-login:cancel";
const WINDOW_WIDTH = 360;
export default function WaitingForBrowserDialog() {
const { t } = useTranslation();
const [params] = useSearchParams();
const uri = params.get("uri") ?? "";
const contentRef = useAutoSizeWindow<HTMLDivElement>(WINDOW_WIDTH);
const tryAgain = useCallback(() => {
if (!uri) return;
Connection.OpenURL(uri).catch(console.error);
}, [uri]);
const cancel = useCallback(() => {
void Events.Emit(EVENT_CANCEL);
}, []);
return (
<ConfirmDialog ref={contentRef}>
<SquareIcon
icon={Loader2}
className={"mt-4 [&_svg]:animate-spin"}
/>
<div className={"flex flex-col items-center gap-2"}>
<DialogHeading className={"text-balance"}>
{t("browserLogin.title")}
</DialogHeading>
<DialogDescription>
{t("browserLogin.notSeeing")}{" "}
<button
type={"button"}
onClick={tryAgain}
disabled={!uri}
className={
"wails-no-draggable text-netbird hover:underline disabled:opacity-40 disabled:cursor-not-allowed"
}
>
{t("browserLogin.tryAgain")}
</button>
</DialogDescription>
</div>
<DialogActions>
<Button
variant={"secondary"}
size={"md"}
className={"w-full"}
onClick={cancel}
>
{t("common.cancel")}
</Button>
</DialogActions>
</ConfirmDialog>
);
}

View File

@@ -1,61 +0,0 @@
import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import { Events } from "@wailsio/runtime";
import { ShieldAlertIcon } from "lucide-react";
import { Button } from "@/components/Button";
import { WindowManager } from "@bindings/services";
const EVENT_TRIGGER_LOGIN = "trigger-login";
export default function SessionExpired() {
const { t } = useTranslation();
const signIn = useCallback(() => {
void Events.Emit(EVENT_TRIGGER_LOGIN);
WindowManager.CloseSessionExpired().catch(console.error);
}, []);
const later = useCallback(() => {
WindowManager.CloseSessionExpired().catch(console.error);
}, []);
return (
<div
className={
"h-screen w-full flex flex-col items-center justify-center text-center px-6 py-8 bg-nb-gray-950"
}
>
<div
className={
"h-12 w-12 rounded-full flex items-center justify-center bg-nb-gray-900 text-netbird mb-4"
}
>
<ShieldAlertIcon size={22} />
</div>
<h1 className={"text-base font-semibold text-nb-gray-100"}>
{t("sessionExpired.title")}
</h1>
<p className={"text-xs text-nb-gray-400 mt-1.5 max-w-[20rem] leading-snug"}>
{t("sessionExpired.description")}
</p>
<div className={"flex gap-2 mt-5 w-full max-w-[18rem]"}>
<Button
variant={"secondary"}
size={"xs"}
className={"flex-1"}
onClick={later}
>
{t("sessionExpired.later")}
</Button>
<Button
variant={"primary"}
size={"xs"}
className={"flex-1"}
onClick={signIn}
>
{t("sessionExpired.signIn")}
</Button>
</div>
</div>
);
}

View File

@@ -1,51 +0,0 @@
import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import { useSearchParams } from "react-router-dom";
import { Events } from "@wailsio/runtime";
import { Loader2 } from "lucide-react";
import { Connection } from "@bindings/services";
import { Button } from "../components/Button";
import netbirdFull from "@/assets/logos/netbird-full.svg";
const EVENT_CANCEL = "browser-login:cancel";
export default function BrowserLogin() {
const { t } = useTranslation();
const [params] = useSearchParams();
const uri = params.get("uri") ?? "";
const tryAgain = useCallback(() => {
if (!uri) return;
Connection.OpenURL(uri).catch(console.error);
}, [uri]);
const cancel = useCallback(() => {
void Events.Emit(EVENT_CANCEL);
}, []);
return (
<div className="flex h-screen flex-col items-center justify-center gap-3 p-8 text-center">
<img src={netbirdFull} alt="NetBird" className="mb-2 h-9" />
<h1 className="text-lg font-semibold text-white">{t("browserLogin.title")}</h1>
<p className="max-w-sm text-sm text-nb-gray-400">{t("browserLogin.description")}</p>
<div className="flex items-center gap-2 text-sm text-nb-gray-400">
<Loader2 className="h-4 w-4 animate-spin" strokeWidth={1.5} />
{t("browserLogin.waiting")}
</div>
<p className="text-sm text-nb-gray-400">
{t("browserLogin.notSeeing")}{" "}
<button
type="button"
onClick={tryAgain}
disabled={!uri}
className="text-netbird hover:underline disabled:opacity-40 disabled:cursor-not-allowed"
>
{t("browserLogin.tryAgain")}
</button>
</p>
<Button variant="secondary" onClick={cancel} className="mt-2">
{t("common.cancel")}
</Button>
</div>
);
}

View File

@@ -201,7 +201,7 @@ func main() {
// The settings and browser-login windows are created lazily and
// destroyed on close, so they don't linger as hidden windows that
// Wails's macOS dock-reopen handler would pop back up.
windowManager := services.NewWindowManager(app)
windowManager := services.NewWindowManager(app, window)
app.RegisterService(application.NewService(windowManager))
// Register an in-process StatusNotifierWatcher so the tray works on

View File

@@ -30,16 +30,26 @@ const EventBrowserLoginCancel = "browser-login:cancel"
// "Cleanup on close"). Destroying rather than hiding means the dock-reopen
// handler doesn't find a hidden window to resurrect.
type WindowManager struct {
app *application.App
settings *application.WebviewWindow
browserLogin *application.WebviewWindow
sessionExpired *application.WebviewWindow
sessionAboutToExpire *application.WebviewWindow
mu sync.Mutex
app *application.App
mainWindow *application.WebviewWindow
settings *application.WebviewWindow
browserLogin *application.WebviewWindow
sessionExpired *application.WebviewWindow
sessionAboutToExpire *application.WebviewWindow
// hiddenForLogin remembers windows that were visible when the
// BrowserLogin popup opened. They were Hide()n to keep focus on the
// SSO flow without resorting to AlwaysOnTop, and are restored when
// the BrowserLogin window closes (success or cancel).
hiddenForLogin []application.Window
mu sync.Mutex
}
func NewWindowManager(app *application.App) *WindowManager {
return &WindowManager{app: app}
// NewWindowManager wires the manager to the main app. `mainWindow` is the
// up-front-created webview the user interacts with from the tray — used to
// pick the BrowserLogin window's display so the sign-in popup follows the
// user onto the screen they're already looking at.
func NewWindowManager(app *application.App, mainWindow *application.WebviewWindow) *WindowManager {
return &WindowManager{app: app, mainWindow: mainWindow}
}
// OpenSettings shows the settings window, creating it on first use (and
@@ -87,12 +97,32 @@ func (s *WindowManager) OpenBrowserLogin(uri string) {
if uri != "" {
startURL = "/#/browser-login?uri=" + url.QueryEscape(uri)
}
s.hideOtherWindowsLocked("browser-login")
// Prefer the screen the main window is on so the sign-in popup
// shows up where the user is already looking on multi-monitor
// setups. Falls back to OS-default centering if the main window
// has no resolvable screen yet.
var screen *application.Screen
if s.mainWindow != nil {
if sc, err := s.mainWindow.GetScreen(); err == nil {
screen = sc
}
}
s.browserLogin = s.app.Window.NewWithOptions(application.WebviewWindowOptions{
Name: "browser-login",
Title: "NetBird Sign-in",
Width: 460,
Height: 440,
Width: 360,
Height: 320,
DisableResize: true,
// Hidden so the React side can measure its content via
// useAutoSizeWindow and call Window.SetSize + Show before the
// user sees the placeholder snapping to the measured height,
// matching the Session* windows.
Hidden: true,
// WindowCentered + Screen centers on the chosen display's
// WorkArea (see WebviewWindowOptions.Screen docs).
InitialPosition: application.WindowCentered,
Screen: screen,
MinimiseButtonState: application.ButtonHidden,
MaximiseButtonState: application.ButtonHidden,
CloseButtonState: application.ButtonEnabled,
@@ -113,15 +143,60 @@ func (s *WindowManager) OpenBrowserLogin(uri string) {
s.app.Event.Emit(EventBrowserLoginCancel)
s.mu.Lock()
s.browserLogin = nil
s.restoreHiddenWindowsLocked()
s.mu.Unlock()
})
} else if uri != "" {
// First open: window is Hidden, the React side auto-sizes via
// useAutoSizeWindow and calls Window.Show/Focus once content is
// measured. Returning here avoids the snap from placeholder to
// measured height.
return
}
if uri != "" {
s.browserLogin.SetURL("/#/browser-login?uri=" + url.QueryEscape(uri))
}
s.browserLogin.Show()
s.browserLogin.Focus()
}
// hideOtherWindowsLocked hides every currently visible window except the one
// named `keepName` and remembers them in hiddenForLogin so they can be
// restored when the BrowserLogin flow ends. Caller must hold s.mu.
func (s *WindowManager) hideOtherWindowsLocked(keepName string) {
for _, w := range s.app.Window.GetAll() {
if w == nil || w.Name() == keepName {
continue
}
if !w.IsVisible() {
continue
}
w.Hide()
s.hiddenForLogin = append(s.hiddenForLogin, w)
}
}
// restoreHiddenWindowsLocked re-shows every window that was hidden by
// hideOtherWindowsLocked. Caller must hold s.mu.
func (s *WindowManager) restoreHiddenWindowsLocked() {
for _, w := range s.hiddenForLogin {
if w == nil {
continue
}
w.Show()
}
s.hiddenForLogin = nil
}
// BrowserLoginWindow returns the live SSO popup window, or nil if no SSO
// flow is in progress. While it is non-nil it should be treated as the
// app's focal window — tray "Open" and dock/taskbar activation hand off
// to it instead of the (currently hidden) main window.
func (s *WindowManager) BrowserLoginWindow() *application.WebviewWindow {
s.mu.Lock()
defer s.mu.Unlock()
return s.browserLogin
}
// CloseBrowserLogin destroys the SSO popup window if it exists. Called from
// startLogin() when the flow completes or cancels programmatically.
func (s *WindowManager) CloseBrowserLogin() {
@@ -136,6 +211,12 @@ func (s *WindowManager) CloseBrowserLogin() {
// OpenSessionExpired shows the "session expired" prompt window above all
// other application windows. Singleton — destroyed on close.
//
// The window is created Hidden so the React side can measure its content
// and call Window.SetSize + Window.Show before the user sees the chrome —
// otherwise the user would briefly see the 360x320 placeholder snapping to
// the measured height. Re-opens (singleton already alive) Show/Focus
// directly here.
func (s *WindowManager) OpenSessionExpired() {
s.mu.Lock()
defer s.mu.Unlock()
@@ -143,10 +224,11 @@ func (s *WindowManager) OpenSessionExpired() {
s.sessionExpired = s.app.Window.NewWithOptions(application.WebviewWindowOptions{
Name: "session-expired",
Title: "NetBird",
Width: 460,
Height: 380,
Width: 360,
Height: 320,
DisableResize: true,
AlwaysOnTop: true,
Hidden: true,
MinimiseButtonState: application.ButtonHidden,
MaximiseButtonState: application.ButtonHidden,
CloseButtonState: application.ButtonEnabled,
@@ -164,6 +246,7 @@ func (s *WindowManager) OpenSessionExpired() {
s.sessionExpired = nil
s.mu.Unlock()
})
return
}
s.sessionExpired.Show()
s.sessionExpired.Focus()
@@ -183,6 +266,8 @@ func (s *WindowManager) CloseSessionExpired() {
// OpenSessionAboutToExpire shows the countdown warning window above all
// other application windows. `seconds` seeds the initial countdown value
// rendered as mm:ss in the React layer. Singleton — destroyed on close.
// Window is created Hidden so the React side can auto-size before paint
// (see OpenSessionExpired comment).
func (s *WindowManager) OpenSessionAboutToExpire(seconds int) {
s.mu.Lock()
defer s.mu.Unlock()
@@ -191,10 +276,11 @@ func (s *WindowManager) OpenSessionAboutToExpire(seconds int) {
s.sessionAboutToExpire = s.app.Window.NewWithOptions(application.WebviewWindowOptions{
Name: "session-about-to-expire",
Title: "NetBird",
Width: 460,
Height: 380,
Width: 360,
Height: 320,
DisableResize: true,
AlwaysOnTop: true,
Hidden: true,
MinimiseButtonState: application.ButtonHidden,
MaximiseButtonState: application.ButtonHidden,
CloseButtonState: application.ButtonEnabled,
@@ -212,9 +298,9 @@ func (s *WindowManager) OpenSessionAboutToExpire(seconds int) {
s.sessionAboutToExpire = nil
s.mu.Unlock()
})
} else {
s.sessionAboutToExpire.SetURL(startURL)
return
}
s.sessionAboutToExpire.SetURL(startURL)
s.sessionAboutToExpire.Show()
s.sessionAboutToExpire.Focus()
}

View File

@@ -236,6 +236,15 @@ func (t *Tray) reapplyMenuState() {
// active app. Focus() additionally calls activateIgnoringOtherApps:YES on
// macOS and SetForegroundWindow on Windows.
func (t *Tray) ShowWindow() {
// While an SSO flow is in progress the BrowserLogin popup is the focal
// window — the main window was hidden by WindowManager so the user
// stays on the sign-in surface. Tray "Open" / SIGUSR1 / dock-reopen
// should bring that window forward, not resurrect the main one mid-flow.
if w := t.svc.WindowManager.BrowserLoginWindow(); w != nil {
w.Show()
w.Focus()
return
}
if t.window == nil {
return
}