@@ -654,7 +654,7 @@ const ActionIconButton = ({
className={cn(
"inline-flex h-9 w-9 cursor-default items-center justify-center rounded-md outline-none",
"transition-colors duration-150",
- "focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
+ "focus-visible:ring-2 focus-visible:ring-nb-gray-50/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
variant === "danger"
? "text-nb-gray-400 hover:bg-red-500/10 hover:text-red-500"
: "text-nb-gray-400 hover:bg-nb-gray-900 hover:text-nb-gray-100",
diff --git a/client/ui/frontend/src/modules/session/SessionExpirationDialog.tsx b/client/ui/frontend/src/modules/session/SessionExpirationDialog.tsx
index ef8d6862f..e57040a7a 100644
--- a/client/ui/frontend/src/modules/session/SessionExpirationDialog.tsx
+++ b/client/ui/frontend/src/modules/session/SessionExpirationDialog.tsx
@@ -18,6 +18,11 @@ import { formatRemaining } from "@/lib/formatters";
const DEFAULT_SECONDS = 360;
const WINDOW_WIDTH = 360;
const SOON_THRESHOLD_SECONDS = 60 * 60;
+const DEADLINE_TOLERANCE_MS = 5 * 1000;
+// The final-warning deadline reaches the Go side as RFC3339 truncated to whole
+// seconds, while the status snapshot carries millisecond precision, so an
+// unchanged deadline can look up to 999 ms newer than the exact URL value.
+const EXACT_DEADLINE_TOLERANCE_MS = 999;
export default function SessionExpirationDialog() {
const { t } = useTranslation();
@@ -29,11 +34,19 @@ export default function SessionExpirationDialog() {
const n = Number.parseInt(raw, 10);
return Number.isFinite(n) && n > 0 ? n : DEFAULT_SECONDS;
}, [params]);
+ const initialDeadline = useMemo(() => {
+ const raw = params.get("deadline");
+ if (!raw) return null;
+ const n = Number.parseInt(raw, 10);
+ return Number.isFinite(n) && n > 0 ? n : null;
+ }, [params]);
const [remaining, setRemaining] = useState(initialSeconds);
const [busy, setBusy] = useState(false);
const busyRef = useRef(busy);
busyRef.current = busy;
+ const openedDeadlineRef = useRef(initialDeadline ?? Date.now() + initialSeconds * 1000);
+ const exactDeadlineRef = useRef(initialDeadline !== null);
const expired = remaining <= 0;
const expiredRef = useRef(expired);
expiredRef.current = expired;
@@ -45,23 +58,45 @@ export default function SessionExpirationDialog() {
useEffect(() => {
setRemaining(initialSeconds);
- }, [initialSeconds]);
+ openedDeadlineRef.current = initialDeadline ?? Date.now() + initialSeconds * 1000;
+ exactDeadlineRef.current = initialDeadline !== null;
+ }, [initialSeconds, initialDeadline]);
+ // Recompute from the absolute deadline instead of decrementing per tick: webview
+ // timers get suspended for tens of seconds (App Nap / hidden-window throttling),
+ // so a tick counter drifts behind the wall clock by the suspended time.
useEffect(() => {
const id = globalThis.setInterval(() => {
- setRemaining((s) => (s <= 1 ? 0 : s - 1));
+ setRemaining(Math.max(0, Math.ceil((openedDeadlineRef.current - Date.now()) / 1000)));
}, 1000);
return () => globalThis.clearInterval(id);
}, [initialSeconds]);
+ // Auto-close only when the session was actually renewed elsewhere (tray action, CLI,
+ // main window): the daemon keeps emitting Connected snapshots regardless of session
+ // state, so the signal is the deadline jumping past the one this dialog was opened for.
+ // With the exact deadline from the URL any jump past its sub-second precision loss
+ // counts; the seconds-derived fallback needs a wider tolerance for the Go-side
+ // truncation and mount latency.
// Don't auto-close while busy (aborts our WaitExtend) or expired (hides the state).
useEffect(() => {
- const off = Events.On("netbird:status", (ev: { data: { status?: string } }) => {
- if (busyRef.current || expiredRef.current) return;
- if (ev?.data?.status === "Connected") {
- WindowManager.CloseSessionExpiration().catch(console.error);
- }
- });
+ const off = Events.On(
+ "netbird:status",
+ (ev: { data: { status?: string; sessionExpiresAt?: string | null } }) => {
+ if (busyRef.current || expiredRef.current) return;
+ if (ev?.data?.status !== "Connected") return;
+ const raw = ev?.data?.sessionExpiresAt;
+ if (!raw) return;
+ const renewed = Date.parse(raw);
+ if (!Number.isFinite(renewed)) return;
+ const tolerance = exactDeadlineRef.current
+ ? EXACT_DEADLINE_TOLERANCE_MS
+ : DEADLINE_TOLERANCE_MS;
+ if (renewed - openedDeadlineRef.current > tolerance) {
+ WindowManager.CloseSessionExpiration().catch(console.error);
+ }
+ },
+ );
return () => {
off();
};
diff --git a/client/ui/frontend/src/modules/settings/SettingsAbout.tsx b/client/ui/frontend/src/modules/settings/SettingsAbout.tsx
index 8ba1009ba..c103d1d74 100644
--- a/client/ui/frontend/src/modules/settings/SettingsAbout.tsx
+++ b/client/ui/frontend/src/modules/settings/SettingsAbout.tsx
@@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next";
import { Browser } from "@wailsio/runtime";
import { BookOpen, MessageSquareText, MessagesSquare } from "lucide-react";
import netbirdFull from "@/assets/logos/netbird-full.svg";
+import netbirdFullLight from "@/assets/logos/netbird-full-light.svg";
// Brand glyphs from simpleicons.org (lucide deprecated its brand icons).
const GithubIcon = (props: SVGProps) => (
@@ -90,7 +91,16 @@ export function SettingsAbout() {
"mx-auto flex min-h-[calc(100vh-12rem)] max-w-2xl flex-col items-center justify-center gap-4"
}
>
-
+
+
|