[client] Close the session-expiration dialog only on renewal (#7337)

* [client] Close the session-expiration dialog only on an actual session renewal

The dialog auto-closed on any Connected status snapshot, but the daemon
emits Connected periodically regardless of session state, so the warning
popup disappeared on the next snapshot (~30s) with no chance to
re-authenticate. Close only when the snapshot's session deadline jumps
past the one the dialog was opened for, meaning the session was renewed
from another surface (tray action, CLI, main window).

* [client] Compare session renewals against the exact deadline in the expiration dialog

The dialog reconstructed its reference deadline from the relative seconds
URL parameter, which carries up to a second of truncation and mount
latency, forcing a renewal-detection margin wide enough to miss a renewal
made shortly after the previous login. Pass the absolute deadline (unix
ms) from both tray call sites - the extend flow's cached deadline and the
final warning's event metadata - so any forward jump in the snapshot
deadline closes the dialog; the seconds-derived fallback with a small
tolerance remains for an unknown deadline.

* [client] Derive the expiration dialog countdown from the deadline

The per-second decrement assumed the interval fires once a second, but
the webview's timers get suspended for tens of seconds under App Nap /
hidden-window throttling, leaving the displayed countdown behind the
wall clock by the suspended time. Recompute the remaining time from the
absolute deadline on every tick so the first tick after a suspension
shows the correct value.

* [client] Tolerate the warning deadline's second precision in the renewal check

The final-warning metadata formats the deadline as RFC3339 truncated to
whole seconds while the status snapshot keeps millisecond precision, so
an unchanged deadline could appear up to 999 ms newer than the exact URL
value and close the dialog on the first snapshot. Allow a sub-second
tolerance on the exact path; any real renewal jumps by at least seconds.
This commit is contained in:
Zoltan Papp
2026-08-31 10:32:36 +02:00
committed by GitHub
parent 945b0b6be2
commit 086d8ba507
4 changed files with 66 additions and 15 deletions

View File

@@ -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();
};

View File

@@ -292,11 +292,15 @@ func (s *WindowManager) CloseBrowserLogin() {
}
// OpenSessionExpiration shows the countdown warning on the cursor's display; seconds seeds
// the countdown. Singleton, destroyed on close.
func (s *WindowManager) OpenSessionExpiration(seconds int) {
// the countdown and deadlineUnixMilli (0 when unknown) is the absolute deadline the dialog
// compares renewal snapshots against. Singleton, destroyed on close.
func (s *WindowManager) OpenSessionExpiration(seconds int, deadlineUnixMilli int64) {
s.mu.Lock()
defer s.mu.Unlock()
startURL := "/#/dialog/session-expiration?seconds=" + strconv.Itoa(seconds)
if deadlineUnixMilli > 0 {
startURL += "&deadline=" + strconv.FormatInt(deadlineUnixMilli, 10)
}
if s.sessionExpiration == nil {
opts := DialogWindowOptions("session-expiration", s.title("window.title.sessionExpiration"), startURL, s.linuxIcon)
opts.Screen = s.getScreenBasedOnCursorPosition()

View File

@@ -76,7 +76,8 @@ func (t *Tray) onSystemEvent(ev *application.CustomEvent) {
if se.Metadata != nil && se.Metadata[authsession.MetaWarning] == "true" {
if se.Metadata[authsession.MetaFinal] == "true" {
t.openSessionExpiration()
deadline, _ := authsession.ParseExpiresAt(se.Metadata[authsession.MetaExpiresAt])
t.openSessionExpiration(deadline)
return
}
t.notifySessionWarning(

View File

@@ -284,12 +284,23 @@ func (t *Tray) dismissSessionWarning() {
}
// openSessionExpiration fires the fallback dialog when the earlier warning notification wasn't dismissed.
// Idempotent on the WindowManager side.
func (t *Tray) openSessionExpiration() {
// deadline is the absolute expiry from the warning event's metadata; when zero (older daemon,
// malformed metadata) the cached status-snapshot deadline fills in. Idempotent on the
// WindowManager side.
func (t *Tray) openSessionExpiration(deadline time.Time) {
if t.svc.WindowManager == nil {
return
}
t.svc.WindowManager.OpenSessionExpiration(finalWarningCountdownSeconds)
if deadline.IsZero() {
t.sessionMu.Lock()
deadline = t.sessionExpiresAt
t.sessionMu.Unlock()
}
var deadlineMs int64
if !deadline.IsZero() {
deadlineMs = deadline.UnixMilli()
}
t.svc.WindowManager.OpenSessionExpiration(finalWarningCountdownSeconds, deadlineMs)
}
// openSessionExtendFlow opens the SessionExpiration window seeded with the cached deadline's remaining time,
@@ -310,5 +321,5 @@ func (t *Tray) openSessionExtendFlow() {
if t.svc.WindowManager == nil {
return
}
t.svc.WindowManager.OpenSessionExpiration(seconds)
t.svc.WindowManager.OpenSessionExpiration(seconds, deadline.UnixMilli())
}