diff --git a/client/ui/frontend/CLAUDE.md b/client/ui/frontend/CLAUDE.md index 44b809566..354c73fa2 100644 --- a/client/ui/frontend/CLAUDE.md +++ b/client/ui/frontend/CLAUDE.md @@ -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`) | | `*` | `` | `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. diff --git a/client/ui/frontend/package.json b/client/ui/frontend/package.json index 952de8ee0..a66854e50 100644 --- a/client/ui/frontend/package.json +++ b/client/ui/frontend/package.json @@ -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", diff --git a/client/ui/frontend/pnpm-lock.yaml b/client/ui/frontend/pnpm-lock.yaml index 4ce11154b..ac20a6457 100644 --- a/client/ui/frontend/pnpm-lock.yaml +++ b/client/ui/frontend/pnpm-lock.yaml @@ -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: diff --git a/client/ui/frontend/src/app.tsx b/client/ui/frontend/src/app.tsx index ae094d5c6..e3205998b 100644 --- a/client/ui/frontend/src/app.tsx +++ b/client/ui/frontend/src/app.tsx @@ -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() } /> - } /> + } /> } /> - } /> - } /> + } /> + } /> }> } /> diff --git a/client/ui/frontend/src/components/ConfirmDialog.tsx b/client/ui/frontend/src/components/ConfirmDialog.tsx new file mode 100644 index 000000000..6cfeca803 --- /dev/null +++ b/client/ui/frontend/src/components/ConfirmDialog.tsx @@ -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( + function ConfirmDialog({ children }, ref) { + return ( +
+
+ {children} +
+
+ ); + }, +); diff --git a/client/ui/frontend/src/components/DialogActions.tsx b/client/ui/frontend/src/components/DialogActions.tsx new file mode 100644 index 000000000..9c2659e9e --- /dev/null +++ b/client/ui/frontend/src/components/DialogActions.tsx @@ -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) => ( +
+ {children} +
+); diff --git a/client/ui/frontend/src/components/DialogDescription.tsx b/client/ui/frontend/src/components/DialogDescription.tsx new file mode 100644 index 000000000..0c5670fa2 --- /dev/null +++ b/client/ui/frontend/src/components/DialogDescription.tsx @@ -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) => ( +

{children}

+); diff --git a/client/ui/frontend/src/components/DialogHeading.tsx b/client/ui/frontend/src/components/DialogHeading.tsx new file mode 100644 index 000000000..7ce53b1c0 --- /dev/null +++ b/client/ui/frontend/src/components/DialogHeading.tsx @@ -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) => ( +

+ {children} +

+); diff --git a/client/ui/frontend/src/components/SquareIcon.tsx b/client/ui/frontend/src/components/SquareIcon.tsx new file mode 100644 index 000000000..ae1dfd69d --- /dev/null +++ b/client/ui/frontend/src/components/SquareIcon.tsx @@ -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; + iconSize?: number; + className?: string; +}; + +export const SquareIcon = ({ icon: Icon, iconSize = 20, className }: SquareIconProps) => ( +
+ +
+); diff --git a/client/ui/frontend/src/i18n/locales/de/common.json b/client/ui/frontend/src/i18n/locales/de/common.json index c57d77036..68edbfb2a 100644 --- a/client/ui/frontend/src/i18n/locales/de/common.json +++ b/client/ui/frontend/src/i18n/locales/de/common.json @@ -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", diff --git a/client/ui/frontend/src/i18n/locales/en/common.json b/client/ui/frontend/src/i18n/locales/en/common.json index 08cc3708a..e464a11b2 100644 --- a/client/ui/frontend/src/i18n/locales/en/common.json +++ b/client/ui/frontend/src/i18n/locales/en/common.json @@ -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", diff --git a/client/ui/frontend/src/i18n/locales/hu/common.json b/client/ui/frontend/src/i18n/locales/hu/common.json index 2c527791e..910f48b37 100644 --- a/client/ui/frontend/src/i18n/locales/hu/common.json +++ b/client/ui/frontend/src/i18n/locales/hu/common.json @@ -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", diff --git a/client/ui/frontend/src/layouts/ConnectionStatusSwitch.tsx b/client/ui/frontend/src/layouts/ConnectionStatusSwitch.tsx index 284dd0c59..7f1f5948f 100644 --- a/client/ui/frontend/src/layouts/ConnectionStatusSwitch.tsx +++ b/client/ui/frontend/src/layouts/ConnectionStatusSwitch.tsx @@ -66,8 +66,16 @@ async function startLogin(): Promise { 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((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; diff --git a/client/ui/frontend/src/lib/useAutoSizeWindow.ts b/client/ui/frontend/src/lib/useAutoSizeWindow.ts new file mode 100644 index 000000000..3bcfcddc9 --- /dev/null +++ b/client/ui/frontend/src/lib/useAutoSizeWindow.ts @@ -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(width: number) { + const ref = useRef(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; +} diff --git a/client/ui/frontend/src/modules/session/SessionAboutToExpire.tsx b/client/ui/frontend/src/modules/authentication/SessionAboutToExpireDialog.tsx similarity index 65% rename from client/ui/frontend/src/modules/session/SessionAboutToExpire.tsx rename to client/ui/frontend/src/modules/authentication/SessionAboutToExpireDialog.tsx index e2b7a681e..b0582557b 100644 --- a/client/ui/frontend/src/modules/session/SessionAboutToExpire.tsx +++ b/client/ui/frontend/src/modules/authentication/SessionAboutToExpireDialog.tsx @@ -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(WINDOW_WIDTH); const [params] = useSearchParams(); const initialSeconds = useMemo(() => { const raw = params.get("seconds"); @@ -66,53 +70,48 @@ export default function SessionAboutToExpire() { }, []); return ( -
-
- + + + +
+ + {expired + ? t("sessionAboutToExpire.expired") + : t("sessionAboutToExpire.title")} + + + {t("sessionAboutToExpire.description")} +
-

- {expired - ? t("sessionAboutToExpire.expired") - : t("sessionAboutToExpire.title")} -

-

- {t("sessionAboutToExpire.description")} -

+
{formatMMSS(remaining)}
-
- + + -
-
+ + + ); } diff --git a/client/ui/frontend/src/modules/authentication/SessionExpiredDialog.tsx b/client/ui/frontend/src/modules/authentication/SessionExpiredDialog.tsx new file mode 100644 index 000000000..42269d964 --- /dev/null +++ b/client/ui/frontend/src/modules/authentication/SessionExpiredDialog.tsx @@ -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(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 ( + + + +
+ {t("sessionExpired.title")} + {t("sessionExpired.description")} +
+ + + + + +
+ ); +} diff --git a/client/ui/frontend/src/modules/authentication/WaitingForBrowserDialog.tsx b/client/ui/frontend/src/modules/authentication/WaitingForBrowserDialog.tsx new file mode 100644 index 000000000..b78f7a332 --- /dev/null +++ b/client/ui/frontend/src/modules/authentication/WaitingForBrowserDialog.tsx @@ -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(WINDOW_WIDTH); + + const tryAgain = useCallback(() => { + if (!uri) return; + Connection.OpenURL(uri).catch(console.error); + }, [uri]); + + const cancel = useCallback(() => { + void Events.Emit(EVENT_CANCEL); + }, []); + + return ( + + + +
+ + {t("browserLogin.title")} + + + {t("browserLogin.notSeeing")}{" "} + + +
+ + + + +
+ ); +} diff --git a/client/ui/frontend/src/modules/session/SessionExpired.tsx b/client/ui/frontend/src/modules/session/SessionExpired.tsx deleted file mode 100644 index f117ea0fe..000000000 --- a/client/ui/frontend/src/modules/session/SessionExpired.tsx +++ /dev/null @@ -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 ( -
-
- -
-

- {t("sessionExpired.title")} -

-

- {t("sessionExpired.description")} -

-
- - -
-
- ); -} diff --git a/client/ui/frontend/src/pages/BrowserLogin.tsx b/client/ui/frontend/src/pages/BrowserLogin.tsx deleted file mode 100644 index 4c6c3ba41..000000000 --- a/client/ui/frontend/src/pages/BrowserLogin.tsx +++ /dev/null @@ -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 ( -
- NetBird -

{t("browserLogin.title")}

-

{t("browserLogin.description")}

-
- - {t("browserLogin.waiting")} -
-

- {t("browserLogin.notSeeing")}{" "} - -

- -
- ); -} diff --git a/client/ui/main.go b/client/ui/main.go index 2629fd580..766b24705 100644 --- a/client/ui/main.go +++ b/client/ui/main.go @@ -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 diff --git a/client/ui/services/windowmanager.go b/client/ui/services/windowmanager.go index 1a97af9e7..c1b913151 100644 --- a/client/ui/services/windowmanager.go +++ b/client/ui/services/windowmanager.go @@ -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() } diff --git a/client/ui/tray.go b/client/ui/tray.go index 63281a9b8..ee8e11a4c 100644 --- a/client/ui/tray.go +++ b/client/ui/tray.go @@ -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 }