mirror of
https://github.com/netbirdio/netbird.git
synced 2026-10-04 20:49:06 +02:00
add SSO session extend flow
Adds an end-to-end SSO session-extension feature: the management server publishes per-peer session deadlines on every Login/Sync, a new ExtendAuthSession RPC refreshes the deadline using a fresh JWT without tearing down the tunnel, and the daemon tracks the deadline locally so the UI can fire a T-10min warning toast with an interactive "Extend now" action.
This commit is contained in:
@@ -0,0 +1,126 @@
|
||||
//go:build !android && !ios && !freebsd && !js
|
||||
|
||||
package authsession
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/netbirdio/netbird/client/proto"
|
||||
)
|
||||
|
||||
// ExtendStartParams optionally pre-fills the IdP login form.
|
||||
type ExtendStartParams struct {
|
||||
// Hint is the OIDC login_hint, typically the user's email.
|
||||
Hint string `json:"hint"`
|
||||
}
|
||||
|
||||
// ExtendStartResult tells the UI what to open and how to match the
|
||||
// follow-up Wait call to the daemon's pending flow.
|
||||
type ExtendStartResult struct {
|
||||
VerificationURI string `json:"verificationUri"`
|
||||
VerificationURIComplete string `json:"verificationUriComplete"`
|
||||
UserCode string `json:"userCode"`
|
||||
DeviceCode string `json:"deviceCode"`
|
||||
ExpiresIn int64 `json:"expiresIn"`
|
||||
}
|
||||
|
||||
// ExtendWaitParams identifies the pending flow by the device/user code
|
||||
// the UI received from RequestExtend.
|
||||
type ExtendWaitParams struct {
|
||||
DeviceCode string `json:"deviceCode"`
|
||||
UserCode string `json:"userCode"`
|
||||
}
|
||||
|
||||
// ExtendResult carries the refreshed deadline. ExpiresAt is nil when the
|
||||
// management server reported the peer is not eligible for session
|
||||
// extension.
|
||||
type ExtendResult struct {
|
||||
ExpiresAt *time.Time `json:"sessionExpiresAt,omitempty"`
|
||||
}
|
||||
|
||||
// DaemonConn yields a lazy daemon gRPC client. Mirrors services.DaemonConn
|
||||
// in the Wails services package; duplicated here so the Session can be
|
||||
// owned by authsession without an import cycle.
|
||||
type DaemonConn interface {
|
||||
Client() (proto.DaemonServiceClient, error)
|
||||
}
|
||||
|
||||
// Session bundles the session-auth daemon RPCs the UI drives — the
|
||||
// interactive extend flow (RequestExtend + WaitExtend) and the Dismiss
|
||||
// hand-off. The tray uses it directly; the Wails-bound wrapper in
|
||||
// client/ui/services exposes only the subset the React frontend needs.
|
||||
type Session struct {
|
||||
conn DaemonConn
|
||||
}
|
||||
|
||||
// NewSession returns a Session backed by the shared daemon connection.
|
||||
func NewSession(conn DaemonConn) *Session {
|
||||
return &Session{conn: conn}
|
||||
}
|
||||
|
||||
// RequestExtend starts the SSO session-extension flow on the daemon and
|
||||
// returns the verification URI for the UI to open.
|
||||
func (s *Session) RequestExtend(ctx context.Context, p ExtendStartParams) (ExtendStartResult, error) {
|
||||
cli, err := s.conn.Client()
|
||||
if err != nil {
|
||||
return ExtendStartResult{}, err
|
||||
}
|
||||
|
||||
req := &proto.RequestExtendAuthSessionRequest{}
|
||||
if p.Hint != "" {
|
||||
h := p.Hint
|
||||
req.Hint = &h
|
||||
}
|
||||
|
||||
resp, err := cli.RequestExtendAuthSession(ctx, req)
|
||||
if err != nil {
|
||||
return ExtendStartResult{}, err
|
||||
}
|
||||
|
||||
return ExtendStartResult{
|
||||
VerificationURI: resp.GetVerificationURI(),
|
||||
VerificationURIComplete: resp.GetVerificationURIComplete(),
|
||||
UserCode: resp.GetUserCode(),
|
||||
DeviceCode: resp.GetDeviceCode(),
|
||||
ExpiresIn: resp.GetExpiresIn(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// WaitExtend blocks until the user completes the SSO flow started by
|
||||
// RequestExtend, then returns the new session deadline (or nil when the
|
||||
// management server reports the peer ineligible).
|
||||
func (s *Session) WaitExtend(ctx context.Context, p ExtendWaitParams) (ExtendResult, error) {
|
||||
cli, err := s.conn.Client()
|
||||
if err != nil {
|
||||
return ExtendResult{}, err
|
||||
}
|
||||
|
||||
resp, err := cli.WaitExtendAuthSession(ctx, &proto.WaitExtendAuthSessionRequest{
|
||||
DeviceCode: p.DeviceCode,
|
||||
UserCode: p.UserCode,
|
||||
})
|
||||
if err != nil {
|
||||
return ExtendResult{}, err
|
||||
}
|
||||
|
||||
out := ExtendResult{}
|
||||
if ts := resp.GetSessionExpiresAt(); ts.IsValid() && !ts.AsTime().IsZero() {
|
||||
t := ts.AsTime().UTC()
|
||||
out.ExpiresAt = &t
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// DismissWarning records the user's "Dismiss" click on the T-WarningLead
|
||||
// notification so the daemon suppresses the T-FinalWarningLead fallback
|
||||
// dialog for the current deadline. Best-effort: the daemon never reports
|
||||
// a "deadline not found" error — a stale or no-op call is silently swallowed.
|
||||
func (s *Session) DismissWarning(ctx context.Context) error {
|
||||
cli, err := s.conn.Client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = cli.DismissSessionWarning(ctx, &proto.DismissSessionWarningRequest{})
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
//go:build !android && !ios && !freebsd && !js
|
||||
|
||||
// Package authsession holds the UI-side domain logic for the SSO
|
||||
// session-extend feature. Wails service facades in
|
||||
// client/ui/services/session*.go are thin adapters around the types and
|
||||
// functions defined here; the parsing, request shapes, and constants
|
||||
// live in this package so future-us can reason about (and test) the
|
||||
// feature without dragging the Wails service surface around with it.
|
||||
package authsession
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/auth/sessionwatch"
|
||||
)
|
||||
|
||||
// Metadata keys the daemon attaches to session-warning SystemEvents.
|
||||
// Re-exported from sessionwatch (single source of truth on the daemon
|
||||
// side) so UI-side consumers don't have to import the daemon-internal
|
||||
// package directly.
|
||||
const (
|
||||
MetaWarning = sessionwatch.MetaSessionWarning
|
||||
MetaFinal = sessionwatch.MetaSessionFinal
|
||||
MetaExpiresAt = sessionwatch.MetaSessionExpiresAt
|
||||
MetaLeadMinutes = sessionwatch.MetaSessionLeadMinutes
|
||||
)
|
||||
|
||||
// Warning is the typed payload emitted on the session-warning Wails
|
||||
// events. The React side subscribes to "netbird:session:warning" and
|
||||
// "netbird:session:final-warning" and receives this shape.
|
||||
//
|
||||
// ExpiresAt is best-effort: when the metadata is missing or malformed
|
||||
// (e.g. an older daemon emits the event without the timestamp) it stays
|
||||
// zero — the UI can fall back to the Status snapshot.
|
||||
type Warning struct {
|
||||
// ExpiresAt is the absolute UTC deadline the warning was fired
|
||||
// against. The UI displays remaining time relative to its own clock.
|
||||
ExpiresAt time.Time `json:"sessionExpiresAt"`
|
||||
// LeadMinutes is the warning's configured lead time in minutes
|
||||
// (WarningLead for the T-10 event, FinalWarningLead for the T-2
|
||||
// event). Exposed so the UI can show "expires in ~N minutes" without
|
||||
// hardcoding either constant on its side.
|
||||
LeadMinutes int `json:"leadMinutes"`
|
||||
// Final is true on the T-FinalWarningLead fallback event and false
|
||||
// on the regular T-WarningLead notification. Exposed so a frontend
|
||||
// listener bound to the dedicated final-warning Wails event still
|
||||
// receives a payload it can self-describe (and so a tray that
|
||||
// happens to see both event streams can branch in one place).
|
||||
Final bool `json:"final"`
|
||||
}
|
||||
|
||||
// WarningFromMetadata parses the daemon's SystemEvent metadata into a
|
||||
// Warning payload. Returns (nil, false) when the event is not a
|
||||
// session-warning at all (the common case). When the metadata flag is
|
||||
// set but a field fails to parse, the field stays at its zero value and
|
||||
// the event is still surfaced — the UI gets to decide how to handle it.
|
||||
func WarningFromMetadata(meta map[string]string) (*Warning, bool) {
|
||||
if meta == nil || meta[MetaWarning] != "true" {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
out := &Warning{
|
||||
Final: meta[MetaFinal] == "true",
|
||||
}
|
||||
if raw := meta[MetaExpiresAt]; raw != "" {
|
||||
if t, err := sessionwatch.ParseExpiresAt(raw); err == nil {
|
||||
out.ExpiresAt = t
|
||||
}
|
||||
}
|
||||
if raw := meta[MetaLeadMinutes]; raw != "" {
|
||||
if n, err := sessionwatch.ParseLeadMinutes(raw); err == nil {
|
||||
out.LeadMinutes = n
|
||||
}
|
||||
}
|
||||
return out, true
|
||||
}
|
||||
|
||||
// ParseExpiresAt decodes a MetaExpiresAt metadata value to a UTC time.
|
||||
// Thin re-export of sessionwatch.ParseExpiresAt so UI-side call sites
|
||||
// (tray, frontend bindings) don't import the daemon-internal package.
|
||||
func ParseExpiresAt(s string) (time.Time, error) {
|
||||
return sessionwatch.ParseExpiresAt(s)
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
//go:build !android && !ios && !freebsd && !js
|
||||
|
||||
package authsession
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestWarningFromMetadata_NotASessionWarning(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
meta map[string]string
|
||||
}{
|
||||
{"nil metadata", nil},
|
||||
{"empty map", map[string]string{}},
|
||||
{"unrelated event", map[string]string{"new_version_available": "0.65.0"}},
|
||||
{"flag not 'true'", map[string]string{"session_warning": "1"}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if w, ok := WarningFromMetadata(tc.meta); ok {
|
||||
t.Fatalf("expected (nil, false), got (%+v, %v)", w, ok)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWarningFromMetadata_FullPayload(t *testing.T) {
|
||||
ts := "2026-05-18T13:30:00Z"
|
||||
meta := map[string]string{
|
||||
"session_warning": "true",
|
||||
"session_expires_at": ts,
|
||||
"lead_minutes": "10",
|
||||
}
|
||||
|
||||
got, ok := WarningFromMetadata(meta)
|
||||
if !ok {
|
||||
t.Fatalf("expected the warning to be recognised, got ok=false")
|
||||
}
|
||||
want, _ := time.Parse(time.RFC3339, ts)
|
||||
if !got.ExpiresAt.Equal(want.UTC()) {
|
||||
t.Errorf("ExpiresAt = %v, want %v", got.ExpiresAt, want.UTC())
|
||||
}
|
||||
if got.LeadMinutes != 10 {
|
||||
t.Errorf("LeadMinutes = %d, want 10", got.LeadMinutes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWarningFromMetadata_BadFieldsStillEmits(t *testing.T) {
|
||||
// Older or buggy daemon: the flag is set but the timestamp/lead are
|
||||
// missing or malformed. The UI should still get a warning so it can
|
||||
// at least surface "session expires soon"; field zero-values are fine.
|
||||
meta := map[string]string{
|
||||
"session_warning": "true",
|
||||
"session_expires_at": "not-a-timestamp",
|
||||
"lead_minutes": "abc",
|
||||
}
|
||||
|
||||
got, ok := WarningFromMetadata(meta)
|
||||
if !ok {
|
||||
t.Fatalf("warning should still be recognised even with malformed fields")
|
||||
}
|
||||
if !got.ExpiresAt.IsZero() {
|
||||
t.Errorf("malformed timestamp should leave field zero, got %v", got.ExpiresAt)
|
||||
}
|
||||
if got.LeadMinutes != 0 {
|
||||
t.Errorf("malformed lead_minutes should leave field 0, got %d", got.LeadMinutes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWarningFromMetadata_MissingFieldsStillEmits(t *testing.T) {
|
||||
// Only the flag is present (e.g. future-trimmed event). Still emit.
|
||||
meta := map[string]string{"session_warning": "true"}
|
||||
got, ok := WarningFromMetadata(meta)
|
||||
if !ok {
|
||||
t.Fatalf("warning should still be recognised when only flag is present")
|
||||
}
|
||||
if got.ExpiresAt.IsZero() != true || got.LeadMinutes != 0 {
|
||||
t.Errorf("missing fields should be zero-valued, got %+v", got)
|
||||
}
|
||||
}
|
||||
Generated
+1730
-1463
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,7 @@
|
||||
"tray.status.disconnected": "Getrennt",
|
||||
"tray.status.daemonUnavailable": "Nicht aktiv",
|
||||
"tray.status.error": "Fehler",
|
||||
"tray.session.expiresIn": "Sitzung: {remaining}",
|
||||
|
||||
"tray.menu.open": "NetBird öffnen",
|
||||
"tray.menu.connect": "Verbinden",
|
||||
@@ -31,6 +32,14 @@
|
||||
"notify.error.switchProfile": "Wechsel zu {profile} fehlgeschlagen",
|
||||
"notify.sessionExpired.title": "NetBird-Sitzung abgelaufen",
|
||||
"notify.sessionExpired.body": "Ihre NetBird-Sitzung ist abgelaufen. Bitte melden Sie sich erneut an.",
|
||||
"notify.sessionWarning.title": "Sitzung läuft bald ab",
|
||||
"notify.sessionWarning.body": "Ihre NetBird-Sitzung läuft in {remaining} ab. Klicken Sie auf Jetzt verlängern, um zu erneuern.",
|
||||
"notify.sessionWarning.bodyGeneric": "Ihre NetBird-Sitzung läuft bald ab. Klicken Sie auf Jetzt verlängern, um zu erneuern.",
|
||||
"notify.sessionWarning.extend": "Jetzt verlängern",
|
||||
"notify.sessionWarning.dismiss": "Verwerfen",
|
||||
"notify.sessionWarning.failed": "NetBird-Sitzung konnte nicht verlängert werden",
|
||||
"notify.sessionWarning.successTitle": "NetBird-Sitzung verlängert",
|
||||
"notify.sessionWarning.successBody": "Ihre Sitzung wurde erneuert.",
|
||||
|
||||
"common.cancel": "Abbrechen",
|
||||
"common.save": "Speichern",
|
||||
@@ -271,6 +280,7 @@
|
||||
"sessionAboutToExpire.stay": "Verbunden bleiben",
|
||||
"sessionAboutToExpire.logout": "Abmelden",
|
||||
"sessionAboutToExpire.expired": "Sitzung abgelaufen",
|
||||
"sessionAboutToExpire.extendFailedTitle": "Sitzungsverlängerung fehlgeschlagen",
|
||||
|
||||
"peers.search.placeholder": "Nach Peer-Name, DNS oder IP-Adresse suchen",
|
||||
"peers.filter.all": "Alle",
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"tray.status.disconnected": "Disconnected",
|
||||
"tray.status.daemonUnavailable": "Not running",
|
||||
"tray.status.error": "Error",
|
||||
"tray.session.expiresIn": "Session: {remaining}",
|
||||
|
||||
"tray.menu.open": "Open NetBird",
|
||||
"tray.menu.connect": "Connect",
|
||||
@@ -31,6 +32,14 @@
|
||||
"notify.error.switchProfile": "Failed to switch to {profile}",
|
||||
"notify.sessionExpired.title": "NetBird session expired",
|
||||
"notify.sessionExpired.body": "Your NetBird session has expired. Please log in again.",
|
||||
"notify.sessionWarning.title": "Session expires soon",
|
||||
"notify.sessionWarning.body": "Your NetBird session expires in {remaining}. Click Extend now to renew.",
|
||||
"notify.sessionWarning.bodyGeneric": "Your NetBird session is about to expire. Click Extend now to renew.",
|
||||
"notify.sessionWarning.extend": "Extend now",
|
||||
"notify.sessionWarning.dismiss": "Dismiss",
|
||||
"notify.sessionWarning.failed": "Failed to extend NetBird session",
|
||||
"notify.sessionWarning.successTitle": "NetBird session extended",
|
||||
"notify.sessionWarning.successBody": "Your session has been refreshed.",
|
||||
|
||||
"common.cancel": "Cancel",
|
||||
"common.save": "Save",
|
||||
@@ -292,6 +301,7 @@
|
||||
"sessionAboutToExpire.stay": "Stay connected",
|
||||
"sessionAboutToExpire.logout": "Logout",
|
||||
"sessionAboutToExpire.expired": "Session expired",
|
||||
"sessionAboutToExpire.extendFailedTitle": "Extend Session Failed",
|
||||
|
||||
"peers.search.placeholder": "Search by peer name, DNS or IP address",
|
||||
"peers.filter.all": "All",
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"tray.status.disconnected": "Lekapcsolva",
|
||||
"tray.status.daemonUnavailable": "Nem fut",
|
||||
"tray.status.error": "Hiba",
|
||||
"tray.session.expiresIn": "Munkamenet: {remaining}",
|
||||
|
||||
"tray.menu.open": "NetBird megnyitása",
|
||||
"tray.menu.connect": "Csatlakozás",
|
||||
@@ -31,6 +32,14 @@
|
||||
"notify.error.switchProfile": "Átváltás sikertelen erre: {profile}",
|
||||
"notify.sessionExpired.title": "NetBird munkamenet lejárt",
|
||||
"notify.sessionExpired.body": "A NetBird munkamenet lejárt. Kérjük, jelentkezzen be újra.",
|
||||
"notify.sessionWarning.title": "Munkamenet hamarosan lejár",
|
||||
"notify.sessionWarning.body": "A NetBird munkamenet {remaining} múlva lejár. Kattints a Meghosszabbítás gombra a megújításhoz.",
|
||||
"notify.sessionWarning.bodyGeneric": "A NetBird munkamenet hamarosan lejár. Kattints a Meghosszabbítás gombra a megújításhoz.",
|
||||
"notify.sessionWarning.extend": "Meghosszabbítás",
|
||||
"notify.sessionWarning.dismiss": "Elvetés",
|
||||
"notify.sessionWarning.failed": "A NetBird munkamenet meghosszabbítása sikertelen",
|
||||
"notify.sessionWarning.successTitle": "NetBird munkamenet meghosszabbítva",
|
||||
"notify.sessionWarning.successBody": "A munkamenet frissítve.",
|
||||
|
||||
"common.cancel": "Mégse",
|
||||
"common.save": "Mentés",
|
||||
@@ -271,6 +280,7 @@
|
||||
"sessionAboutToExpire.stay": "Maradjon csatlakoztatva",
|
||||
"sessionAboutToExpire.logout": "Kijelentkezés",
|
||||
"sessionAboutToExpire.expired": "Munkamenet lejárt",
|
||||
"sessionAboutToExpire.extendFailedTitle": "A munkamenet meghosszabbítása sikertelen",
|
||||
|
||||
"peers.search.placeholder": "Keresés társ neve, DNS vagy IP-cím alapján",
|
||||
"peers.filter.all": "Összes",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { Events } from "@wailsio/runtime";
|
||||
import { Dialogs } from "@wailsio/runtime";
|
||||
import { ClockIcon } from "lucide-react";
|
||||
import { Button } from "@/components/Button";
|
||||
import { ConfirmDialog } from "@/components/ConfirmDialog";
|
||||
@@ -9,10 +9,14 @@ 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 {
|
||||
Connection,
|
||||
Profiles as ProfilesSvc,
|
||||
Session,
|
||||
WindowManager,
|
||||
} from "@bindings/services";
|
||||
import { useAutoSizeWindow } from "@/lib/useAutoSizeWindow";
|
||||
|
||||
const EVENT_TRIGGER_LOGIN = "trigger-login";
|
||||
const DEFAULT_SECONDS = 360;
|
||||
const WINDOW_WIDTH = 360;
|
||||
|
||||
@@ -35,6 +39,7 @@ export default function SessionAboutToExpireDialog() {
|
||||
}, [params]);
|
||||
|
||||
const [remaining, setRemaining] = useState(initialSeconds);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const expired = remaining <= 0;
|
||||
|
||||
useEffect(() => {
|
||||
@@ -49,10 +54,38 @@ export default function SessionAboutToExpireDialog() {
|
||||
return () => window.clearInterval(id);
|
||||
}, [remaining]);
|
||||
|
||||
const stay = useCallback(() => {
|
||||
void Events.Emit(EVENT_TRIGGER_LOGIN);
|
||||
WindowManager.CloseSessionAboutToExpire().catch(console.error);
|
||||
}, []);
|
||||
// Mirrors tray.go::runExtendSession: starts the daemon SSO extend flow,
|
||||
// opens the browser for the user to sign in, blocks on the daemon until
|
||||
// the new deadline arrives. Tunnel stays up; success simply closes the
|
||||
// dialog, failure surfaces a native error dialog and leaves this one
|
||||
// open so the user can retry or logout.
|
||||
const stay = useCallback(async () => {
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const start = await Session.RequestExtend({});
|
||||
const uri = start.verificationUriComplete || start.verificationUri;
|
||||
if (uri) {
|
||||
try {
|
||||
await Connection.OpenURL(uri);
|
||||
} catch (e) {
|
||||
console.debug("OpenURL failed during extend", e);
|
||||
}
|
||||
}
|
||||
await Session.WaitExtend({
|
||||
deviceCode: start.deviceCode,
|
||||
userCode: start.userCode,
|
||||
});
|
||||
WindowManager.CloseSessionAboutToExpire().catch(console.error);
|
||||
} catch (e) {
|
||||
await Dialogs.Error({
|
||||
Title: t("sessionAboutToExpire.extendFailedTitle"),
|
||||
Message: e instanceof Error ? e.message : String(e),
|
||||
});
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, [busy, t]);
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
try {
|
||||
@@ -99,7 +132,7 @@ export default function SessionAboutToExpireDialog() {
|
||||
size={"md"}
|
||||
className={"w-full"}
|
||||
onClick={stay}
|
||||
disabled={expired}
|
||||
disabled={expired || busy}
|
||||
>
|
||||
{t("sessionAboutToExpire.stay")}
|
||||
</Button>
|
||||
@@ -108,6 +141,7 @@ export default function SessionAboutToExpireDialog() {
|
||||
size={"md"}
|
||||
className={"w-full"}
|
||||
onClick={logout}
|
||||
disabled={busy}
|
||||
>
|
||||
{t("sessionAboutToExpire.logout")}
|
||||
</Button>
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"github.com/wailsapp/wails/v3/pkg/events"
|
||||
"github.com/wailsapp/wails/v3/pkg/services/notifications"
|
||||
|
||||
"github.com/netbirdio/netbird/client/ui/authsession"
|
||||
"github.com/netbirdio/netbird/client/ui/i18n"
|
||||
"github.com/netbirdio/netbird/client/ui/preferences"
|
||||
"github.com/netbirdio/netbird/client/ui/services"
|
||||
@@ -60,6 +61,7 @@ func init() {
|
||||
application.RegisterEvent[services.Status](services.EventStatus)
|
||||
application.RegisterEvent[services.SystemEvent](services.EventSystem)
|
||||
application.RegisterEvent[services.ProfileRef](services.EventProfileChanged)
|
||||
application.RegisterEvent[authsession.Warning](services.EventSessionWarning)
|
||||
application.RegisterEvent[updater.State](updater.EventStateChanged)
|
||||
application.RegisterEvent[preferences.UIPreferences](preferences.EventPreferencesChanged)
|
||||
}
|
||||
@@ -155,6 +157,12 @@ func main() {
|
||||
localizer := NewLocalizer(bundle, prefStore)
|
||||
|
||||
app.RegisterService(application.NewService(connection))
|
||||
// authsession.Session owns the full extend + dismiss surface; the tray
|
||||
// drives the "Extend now" action from the T-10 OS notification through
|
||||
// this directly. The Wails-bound services.Session wraps only the subset
|
||||
// the React frontend calls, so the generated TS surface stays minimal.
|
||||
authSession := authsession.NewSession(conn)
|
||||
app.RegisterService(application.NewService(services.NewSession(authSession)))
|
||||
app.RegisterService(application.NewService(settings))
|
||||
app.RegisterService(application.NewService(services.NewNetworks(conn)))
|
||||
app.RegisterService(application.NewService(services.NewForwarding(conn)))
|
||||
@@ -215,6 +223,7 @@ func main() {
|
||||
Update: update,
|
||||
ProfileSwitcher: profileSwitcher,
|
||||
WindowManager: windowManager,
|
||||
Session: authSession,
|
||||
Localizer: localizer,
|
||||
})
|
||||
listenForShowSignal(context.Background(), tray)
|
||||
|
||||
+50
-28
@@ -15,6 +15,7 @@ import (
|
||||
"google.golang.org/grpc/status"
|
||||
|
||||
"github.com/netbirdio/netbird/client/proto"
|
||||
"github.com/netbirdio/netbird/client/ui/authsession"
|
||||
"github.com/netbirdio/netbird/client/ui/updater"
|
||||
)
|
||||
|
||||
@@ -34,6 +35,14 @@ const (
|
||||
// others without polling. The daemon itself does not emit a profile
|
||||
// event, so this is the only signal that closes the gap.
|
||||
EventProfileChanged = "netbird:profile:changed"
|
||||
// EventSessionWarning is emitted on every session-warning watcher
|
||||
// fire (T-WarningLead and T-FinalWarningLead) as a strongly-typed
|
||||
// sibling of EventSystem so React / tray subscribers don't have to
|
||||
// filter the firehose of EventSystem. Consumers branch on the
|
||||
// SessionWarning.Final flag to tell the interactive T-10 event apart
|
||||
// from the fallback T-2 event; the dialog auto-open lives in the
|
||||
// tray (Go side) so the frontend stays passive on this flow.
|
||||
EventSessionWarning = "netbird:session:warning"
|
||||
|
||||
// StatusDaemonUnavailable is the synthetic Status the UI emits when the
|
||||
// daemon's gRPC socket is unreachable (daemon not running, socket
|
||||
@@ -43,11 +52,11 @@ const (
|
||||
|
||||
// Daemon connection status strings — mirror internal.Status* in
|
||||
// client/internal/state.go.
|
||||
StatusConnected = "Connected"
|
||||
StatusConnecting = "Connecting"
|
||||
StatusIdle = "Idle"
|
||||
StatusNeedsLogin = "NeedsLogin"
|
||||
StatusLoginFailed = "LoginFailed"
|
||||
StatusConnected = "Connected"
|
||||
StatusConnecting = "Connecting"
|
||||
StatusIdle = "Idle"
|
||||
StatusNeedsLogin = "NeedsLogin"
|
||||
StatusLoginFailed = "LoginFailed"
|
||||
StatusSessionExpired = "SessionExpired"
|
||||
)
|
||||
|
||||
@@ -110,13 +119,19 @@ type LocalPeer struct {
|
||||
|
||||
// Status is the snapshot the frontend renders on the dashboard.
|
||||
type Status struct {
|
||||
Status string `json:"status"`
|
||||
DaemonVersion string `json:"daemonVersion"`
|
||||
Management PeerLink `json:"management"`
|
||||
Signal PeerLink `json:"signal"`
|
||||
Local LocalPeer `json:"local"`
|
||||
Peers []PeerStatus `json:"peers"`
|
||||
Status string `json:"status"`
|
||||
DaemonVersion string `json:"daemonVersion"`
|
||||
Management PeerLink `json:"management"`
|
||||
Signal PeerLink `json:"signal"`
|
||||
Local LocalPeer `json:"local"`
|
||||
Peers []PeerStatus `json:"peers"`
|
||||
Events []SystemEvent `json:"events"`
|
||||
// SessionExpiresAt is the absolute UTC instant at which the peer's
|
||||
// SSO session expires. nil when the peer is not SSO-tracked or login
|
||||
// expiration is disabled (either server-side off, or peer not
|
||||
// SSO-registered). The UI derives "warning active" from this value
|
||||
// plus its own clock.
|
||||
SessionExpiresAt *time.Time `json:"sessionExpiresAt,omitempty"`
|
||||
}
|
||||
|
||||
// Peers serves the dashboard data: one polled Status RPC and a long-running
|
||||
@@ -277,23 +292,6 @@ func (s *Peers) Get(ctx context.Context) (Status, error) {
|
||||
return statusFromProto(resp), nil
|
||||
}
|
||||
|
||||
// isDaemonUnreachable reports whether a gRPC stream error indicates the
|
||||
// daemon socket itself is not answering (process down, socket missing,
|
||||
// permission denied) versus the daemon responding with an application-level
|
||||
// error code. Only the former should flip the tray to "Not running" — a
|
||||
// daemon that returns FailedPrecondition (e.g. while it's retrying the
|
||||
// management connection) is alive and shouldn't be reported as down.
|
||||
func isDaemonUnreachable(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
st, ok := status.FromError(err)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
return st.Code() == codes.Unavailable
|
||||
}
|
||||
|
||||
// statusStreamLoop subscribes to the daemon's SubscribeStatus stream and
|
||||
// re-emits each FullStatus snapshot on the Wails event bus. The first
|
||||
// message is the current snapshot; subsequent messages fire on
|
||||
@@ -406,6 +404,9 @@ func (s *Peers) toastStreamLoop(ctx context.Context) {
|
||||
se := systemEventFromProto(ev)
|
||||
log.Infof("backend event: system severity=%s category=%s msg=%q", se.Severity, se.Category, se.UserMessage)
|
||||
s.emitter.Emit(EventSystem, se)
|
||||
if warn, ok := authsession.WarningFromMetadata(se.Metadata); ok {
|
||||
s.emitter.Emit(EventSessionWarning, warn)
|
||||
}
|
||||
if s.updater != nil {
|
||||
s.updater.OnSystemEvent(ev)
|
||||
}
|
||||
@@ -468,6 +469,10 @@ func statusFromProto(resp *proto.StatusResponse) Status {
|
||||
for _, e := range full.GetEvents() {
|
||||
st.Events = append(st.Events, systemEventFromProto(e))
|
||||
}
|
||||
if ts := resp.GetSessionExpiresAt(); ts.IsValid() && !ts.AsTime().IsZero() {
|
||||
t := ts.AsTime().UTC()
|
||||
st.SessionExpiresAt = &t
|
||||
}
|
||||
return st
|
||||
}
|
||||
|
||||
@@ -488,3 +493,20 @@ func systemEventFromProto(e *proto.SystemEvent) SystemEvent {
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// isDaemonUnreachable reports whether a gRPC stream error indicates the
|
||||
// daemon socket itself is not answering (process down, socket missing,
|
||||
// permission denied) versus the daemon responding with an application-level
|
||||
// error code. Only the former should flip the tray to "Not running" — a
|
||||
// daemon that returns FailedPrecondition (e.g. while it's retrying the
|
||||
// management connection) is alive and shouldn't be reported as down.
|
||||
func isDaemonUnreachable(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
st, ok := status.FromError(err)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
return st.Code() == codes.Unavailable
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
//go:build !android && !ios && !freebsd && !js
|
||||
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/netbirdio/netbird/client/ui/authsession"
|
||||
)
|
||||
|
||||
// Re-exports so frontend bindings stay on services.ExtendStart* /
|
||||
// services.ExtendWait* / services.ExtendResult without each call site
|
||||
// importing authsession.
|
||||
type (
|
||||
ExtendStartParams = authsession.ExtendStartParams
|
||||
ExtendStartResult = authsession.ExtendStartResult
|
||||
ExtendWaitParams = authsession.ExtendWaitParams
|
||||
ExtendResult = authsession.ExtendResult
|
||||
)
|
||||
|
||||
// Session is the Wails-bound wrapper around authsession.Session. It only
|
||||
// re-exposes the subset the React frontend actually calls
|
||||
// (SessionAboutToExpireDialog.tsx: RequestExtend + WaitExtend). The tray
|
||||
// uses authsession.Session directly, so methods that only the tray needs
|
||||
// (DismissWarning) are deliberately absent here — keeping the generated
|
||||
// TS surface minimal.
|
||||
type Session struct {
|
||||
inner *authsession.Session
|
||||
}
|
||||
|
||||
// NewSession returns the Wails-bound wrapper. The caller owns the inner
|
||||
// authsession.Session and may use it directly (e.g. the tray).
|
||||
func NewSession(inner *authsession.Session) *Session {
|
||||
return &Session{inner: inner}
|
||||
}
|
||||
|
||||
// RequestExtend starts the SSO session-extension flow on the daemon and
|
||||
// returns the verification URI for the UI to open.
|
||||
func (s *Session) RequestExtend(ctx context.Context, p ExtendStartParams) (ExtendStartResult, error) {
|
||||
return s.inner.RequestExtend(ctx, p)
|
||||
}
|
||||
|
||||
// WaitExtend blocks until the user completes the SSO flow started by
|
||||
// RequestExtend, then returns the new session deadline (or nil when the
|
||||
// management server reports the peer ineligible).
|
||||
func (s *Session) WaitExtend(ctx context.Context, p ExtendWaitParams) (ExtendResult, error) {
|
||||
return s.inner.WaitExtend(ctx, p)
|
||||
}
|
||||
+336
-3
@@ -9,12 +9,15 @@ import (
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/wailsapp/wails/v3/pkg/application"
|
||||
"github.com/wailsapp/wails/v3/pkg/events"
|
||||
"github.com/wailsapp/wails/v3/pkg/services/notifications"
|
||||
|
||||
nbstatus "github.com/netbirdio/netbird/client/status"
|
||||
"github.com/netbirdio/netbird/client/ui/authsession"
|
||||
"github.com/netbirdio/netbird/client/ui/i18n"
|
||||
"github.com/netbirdio/netbird/client/ui/services"
|
||||
"github.com/netbirdio/netbird/version"
|
||||
@@ -34,13 +37,29 @@ const (
|
||||
notifyIDEvent = "netbird-event-"
|
||||
notifyIDTrayError = "netbird-tray-error"
|
||||
notifyIDSessionExpired = "netbird-session-expired"
|
||||
notifyIDSessionWarning = "netbird-session-warning"
|
||||
|
||||
// notifyCategorySessionWarning groups the "Extend now" / "Dismiss"
|
||||
// actions on the T-10min OS notification. Registered once at tray
|
||||
// construction with the Wails notifications service; subsequent
|
||||
// SendNotificationWithActions calls reference it by ID.
|
||||
notifyCategorySessionWarning = "netbird-session-warning"
|
||||
notifyActionExtendNow = "extend-now"
|
||||
notifyActionDismiss = "dismiss"
|
||||
|
||||
statusError = "Error"
|
||||
|
||||
urlGitHubRepo = "https://github.com/netbirdio/netbird"
|
||||
urlGitHubReleases = "https://github.com/netbirdio/netbird/releases/latest"
|
||||
|
||||
// finalWarningCountdownSeconds is the countdown shown in the auto-opened
|
||||
// SessionAboutToExpire dialog. Mirrors sessionwatch.FinalWarningLead
|
||||
// (2 minutes); the values stay in sync by hand because the lead is fixed
|
||||
// for the initial rollout.
|
||||
finalWarningCountdownSeconds = 120
|
||||
)
|
||||
|
||||
|
||||
// Tray builds and updates the systray menu. It mirrors the layout of the Fyne
|
||||
// systray 1:1 and routes clicks back to the gRPC services. Dynamic state
|
||||
// (status icon, exit-node submenu) is driven by the netbird:status event.
|
||||
@@ -57,6 +76,12 @@ type TrayServices struct {
|
||||
Update *services.Update
|
||||
ProfileSwitcher *services.ProfileSwitcher
|
||||
WindowManager *services.WindowManager
|
||||
// Session drives the SSO session-extend flow invoked from the
|
||||
// "Extend now" action on the T-10min OS notification, plus the
|
||||
// Dismiss hand-off that suppresses the T-2 fallback dialog. Bound to
|
||||
// the authsession package directly because the Wails wrapper in
|
||||
// services only re-exposes the React-facing subset.
|
||||
Session *authsession.Session
|
||||
// Localizer is the tray's bridge to translations. Constructed in main
|
||||
// from i18n.Bundle + preferences.Store; the Wails-bound facades
|
||||
// (services.I18n, services.Preferences) are registered separately for
|
||||
@@ -75,8 +100,14 @@ type Tray struct {
|
||||
// language switch.
|
||||
loc *Localizer
|
||||
|
||||
menu *application.Menu
|
||||
statusItem *application.MenuItem
|
||||
menu *application.Menu
|
||||
statusItem *application.MenuItem
|
||||
// sessionExpiresItem displays the SSO session deadline as a humanised
|
||||
// remaining-time label ("Session: 47m"). Hidden when no deadline is
|
||||
// tracked (non-SSO peer or login-expiration disabled on the account).
|
||||
// Refreshed by applyStatus on every Status push and by a 1-minute
|
||||
// ticker between pushes so the countdown moves naturally.
|
||||
sessionExpiresItem *application.MenuItem
|
||||
upItem *application.MenuItem
|
||||
downItem *application.MenuItem
|
||||
exitNodeItem *application.MenuItem
|
||||
@@ -99,6 +130,16 @@ type Tray struct {
|
||||
activeProfile string
|
||||
activeUsername string
|
||||
switchCancel context.CancelFunc
|
||||
// sessionExpiresAt is the most recent deadline observed on a Status
|
||||
// snapshot. Used to skip a no-op label rewrite when the daemon repeats
|
||||
// the same value across rapid pushes. Guarded by mu.
|
||||
sessionExpiresAt time.Time
|
||||
// pendingConnectLogin is set when handleConnect kicks off an Up on an
|
||||
// idle daemon. The daemon will flip to NeedsLogin if the peer is
|
||||
// SSO-tracked and has no cached token; applyStatus consumes this flag
|
||||
// on that transition to automatically open the browser-login flow,
|
||||
// saving the user a second Connect click. Guarded by mu.
|
||||
pendingConnectLogin bool
|
||||
|
||||
// profileLoadMu serializes loadProfiles so the daemon-status-driven
|
||||
// refresh in applyStatus cannot race with the ApplicationStarted seed
|
||||
@@ -151,6 +192,14 @@ func NewTray(app *application.App, window *application.WebviewWindow, svc TraySe
|
||||
// nil-deref).
|
||||
app.Event.OnApplicationEvent(events.Common.ApplicationStarted, func(*application.ApplicationEvent) {
|
||||
go t.loadProfiles()
|
||||
// Notification-category registration must run after the Wails
|
||||
// notifications service Startup has populated wn.appName /
|
||||
// registry path on Windows; before app.Run() the category lookup
|
||||
// in SendNotificationWithActions silently falls back to a
|
||||
// gomb-nélküli notification (the Windows impl logs "Category not
|
||||
// found"). The macOS/Linux impls don't strictly require this
|
||||
// ordering, but running here is harmless for them.
|
||||
t.registerSessionWarningCategory()
|
||||
})
|
||||
|
||||
// Localizer fires this callback after it has already swapped its own
|
||||
@@ -185,6 +234,7 @@ func (t *Tray) reapplyMenuState() {
|
||||
lastStatus := t.lastStatus
|
||||
daemonVersion := t.lastDaemonVersion
|
||||
exitNodes := append([]string(nil), t.exitNodes...)
|
||||
sessionDeadline := t.sessionExpiresAt
|
||||
t.mu.Unlock()
|
||||
|
||||
daemonUnavailable := strings.EqualFold(lastStatus, services.StatusDaemonUnavailable)
|
||||
@@ -195,6 +245,15 @@ func (t *Tray) reapplyMenuState() {
|
||||
t.statusItem.SetEnabled(false)
|
||||
t.applyStatusIndicator(lastStatus)
|
||||
}
|
||||
if t.sessionExpiresItem != nil {
|
||||
if sessionDeadline.IsZero() {
|
||||
t.sessionExpiresItem.SetHidden(true)
|
||||
} else {
|
||||
remaining := nbstatus.FormatRemainingDuration(time.Until(sessionDeadline))
|
||||
t.sessionExpiresItem.SetLabel(t.loc.T("tray.session.expiresIn", "remaining", remaining))
|
||||
t.sessionExpiresItem.SetHidden(false)
|
||||
}
|
||||
}
|
||||
if t.upItem != nil {
|
||||
t.upItem.SetHidden(connected || connecting || daemonUnavailable)
|
||||
t.upItem.SetEnabled(!connected && !connecting && !daemonUnavailable)
|
||||
@@ -264,6 +323,14 @@ func (t *Tray) buildMenu() *application.Menu {
|
||||
SetEnabled(false).
|
||||
SetBitmap(iconMenuDotIdle)
|
||||
|
||||
// sessionExpiresItem sits directly below the status row so the
|
||||
// remaining-time label reads as a sub-line of "Connected" etc. Hidden
|
||||
// until applyStatus sees a non-zero SessionExpiresAt on the daemon
|
||||
// Status snapshot — peers without SSO tracking or with login expiry
|
||||
// disabled never reveal this row.
|
||||
t.sessionExpiresItem = menu.Add("").SetEnabled(false)
|
||||
t.sessionExpiresItem.SetHidden(true)
|
||||
|
||||
menu.AddSeparator()
|
||||
// The tray icon's left-click handler is intentionally unbound (see
|
||||
// NewTray for the rationale), so expose the window through an explicit
|
||||
@@ -362,12 +429,25 @@ func (t *Tray) handleConnect() {
|
||||
return
|
||||
}
|
||||
t.upItem.SetEnabled(false)
|
||||
// Arm the SSO auto-handoff: Up() is async and the daemon may flip to
|
||||
// NeedsLogin once it detects an SSO peer with no cached token. The
|
||||
// flag is consumed by applyStatus on that transition, which then
|
||||
// triggers the browser-login flow without the user having to click
|
||||
// Connect a second time. Cleared on any terminal state (Connected /
|
||||
// Idle / LoginFailed / DaemonUnavailable / SessionExpired) so a stale
|
||||
// flag can't hijack a future status push.
|
||||
t.mu.Lock()
|
||||
t.pendingConnectLogin = true
|
||||
t.mu.Unlock()
|
||||
go func() {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
if err := t.svc.Connection.Up(ctx, services.UpParams{}); err != nil {
|
||||
log.Errorf("connect: %v", err)
|
||||
t.notifyError(t.loc.T("notify.error.connect"))
|
||||
t.mu.Lock()
|
||||
t.pendingConnectLogin = false
|
||||
t.mu.Unlock()
|
||||
t.upItem.SetEnabled(true)
|
||||
}
|
||||
}()
|
||||
@@ -414,7 +494,14 @@ func (t *Tray) onStatusEvent(ev *application.CustomEvent) {
|
||||
// its own richer notification when EventUpdateState fires.
|
||||
func (t *Tray) onSystemEvent(ev *application.CustomEvent) {
|
||||
se, ok := ev.Data.(services.SystemEvent)
|
||||
if !ok || se.UserMessage == "" {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
// Session-warning events carry no UserMessage — the tray builds the
|
||||
// localised notification body locally from metadata. Every other
|
||||
// event needs a non-empty UserMessage to show anything meaningful.
|
||||
isSessionWarning := se.Metadata[authsession.MetaWarning] == "true"
|
||||
if !isSessionWarning && se.UserMessage == "" {
|
||||
return
|
||||
}
|
||||
if _, isUpdate := se.Metadata["new_version_available"]; isUpdate {
|
||||
@@ -438,6 +525,29 @@ func (t *Tray) onSystemEvent(ev *application.CustomEvent) {
|
||||
return
|
||||
}
|
||||
|
||||
// Session-warning events come in two flavours; detect via the stable
|
||||
// metadata flags rather than category/severity so a future reword on
|
||||
// the daemon side still routes here.
|
||||
// - T-WarningLead (MetaSessionWarning + no MetaSessionFinal) →
|
||||
// interactive "Extend now / Dismiss" OS notification. Title and
|
||||
// body are built locally from i18n + metadata so the text follows
|
||||
// the active UI language regardless of what the daemon (which has
|
||||
// no locale context) writes into UserMessage.
|
||||
// - T-FinalWarningLead (MetaSessionFinal=true) → auto-open the
|
||||
// SessionAboutToExpire dialog. No OS notification here; the
|
||||
// dialog is the last-chance reminder, doubling up would be noise.
|
||||
if se.Metadata != nil && se.Metadata[authsession.MetaWarning] == "true" {
|
||||
if se.Metadata[authsession.MetaFinal] == "true" {
|
||||
t.openSessionAboutToExpire()
|
||||
return
|
||||
}
|
||||
t.notifySessionWarning(
|
||||
t.loc.T("notify.sessionWarning.title"),
|
||||
t.buildSessionWarningBody(se.Metadata),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
body := se.UserMessage
|
||||
if id := se.Metadata["id"]; id != "" {
|
||||
body += fmt.Sprintf(" ID: %s", id)
|
||||
@@ -467,6 +577,28 @@ func (t *Tray) applyStatus(st services.Status) {
|
||||
// flag in onSessionExpire.
|
||||
sessionExpiredEnter := strings.EqualFold(st.Status, services.StatusSessionExpired) &&
|
||||
!strings.EqualFold(t.lastStatus, services.StatusSessionExpired)
|
||||
|
||||
// Consume the SSO auto-handoff flag armed by handleConnect. Trigger
|
||||
// the browser-login flow on a Connect → NeedsLogin transition so the
|
||||
// user doesn't need to click Connect a second time. Clear it on any
|
||||
// other terminal state — including Connecting bursts that resolve to
|
||||
// Connected / Idle / LoginFailed / DaemonUnavailable — so a stale
|
||||
// flag can't fire weeks later when the daemon happens to flip.
|
||||
triggerLogin := false
|
||||
if t.pendingConnectLogin {
|
||||
switch {
|
||||
case strings.EqualFold(st.Status, services.StatusNeedsLogin):
|
||||
triggerLogin = true
|
||||
t.pendingConnectLogin = false
|
||||
case strings.EqualFold(st.Status, services.StatusConnected),
|
||||
strings.EqualFold(st.Status, services.StatusIdle),
|
||||
strings.EqualFold(st.Status, services.StatusLoginFailed),
|
||||
strings.EqualFold(st.Status, services.StatusSessionExpired),
|
||||
strings.EqualFold(st.Status, services.StatusDaemonUnavailable):
|
||||
t.pendingConnectLogin = false
|
||||
}
|
||||
}
|
||||
|
||||
daemonVersionChanged := st.DaemonVersion != "" && st.DaemonVersion != t.lastDaemonVersion
|
||||
t.connected = connected
|
||||
t.lastStatus = st.Status
|
||||
@@ -479,6 +611,11 @@ func (t *Tray) applyStatus(st services.Status) {
|
||||
t.exitNodes = exitNodes
|
||||
t.mu.Unlock()
|
||||
|
||||
if triggerLogin {
|
||||
t.ShowWindow()
|
||||
t.app.Event.Emit(services.EventTriggerLogin)
|
||||
}
|
||||
|
||||
if iconChanged {
|
||||
t.applyIcon()
|
||||
daemonUnavailable := strings.EqualFold(st.Status, services.StatusDaemonUnavailable)
|
||||
@@ -547,6 +684,8 @@ func (t *Tray) applyStatus(st services.Status) {
|
||||
if sessionExpiredEnter {
|
||||
t.handleSessionExpired()
|
||||
}
|
||||
|
||||
t.applySessionExpiry(st.SessionExpiresAt, connected)
|
||||
}
|
||||
|
||||
// handleSessionExpired surfaces the SSO re-authentication path when the
|
||||
@@ -849,6 +988,40 @@ func (t *Tray) switchProfile(name string) {
|
||||
}()
|
||||
}
|
||||
|
||||
// applySessionExpiry refreshes the "Session: 47m" tray row from the latest
|
||||
// Status snapshot's SessionExpiresAt. Only shown when the tunnel is up:
|
||||
// in any other state (Idle after a Down, Connecting, NeedsLogin,
|
||||
// SessionExpired, LoginFailed, DaemonUnavailable, or mid profile-switch)
|
||||
// the deadline is meaningless and the row is hidden. The internal
|
||||
// sessionExpiresAt cache is cleared in the same path so reapplyMenuState
|
||||
// after a language switch doesn't resurrect a stale label.
|
||||
//
|
||||
// No per-minute ticker: between Status pushes the label may drift by a
|
||||
// few minutes, which is fine for a tray-menu status row that the user
|
||||
// opens on demand. The T-10min OS notification (driven by the daemon's
|
||||
// sessionwatch) does the time-critical signalling.
|
||||
func (t *Tray) applySessionExpiry(deadline *time.Time, connected bool) {
|
||||
var d time.Time
|
||||
if connected && deadline != nil {
|
||||
d = *deadline
|
||||
}
|
||||
|
||||
t.mu.Lock()
|
||||
t.sessionExpiresAt = d
|
||||
t.mu.Unlock()
|
||||
|
||||
if t.sessionExpiresItem == nil {
|
||||
return
|
||||
}
|
||||
if d.IsZero() {
|
||||
t.sessionExpiresItem.SetHidden(true)
|
||||
return
|
||||
}
|
||||
remaining := nbstatus.FormatRemainingDuration(time.Until(d))
|
||||
t.sessionExpiresItem.SetLabel(t.loc.T("tray.session.expiresIn", "remaining", remaining))
|
||||
t.sessionExpiresItem.SetHidden(false)
|
||||
}
|
||||
|
||||
// notify wraps the Wails notification service with the tray's standard
|
||||
// id-prefix scheme and swallows errors (notifications are best-effort).
|
||||
func (t *Tray) notify(title, body, id string) {
|
||||
@@ -864,6 +1037,166 @@ func (t *Tray) notify(title, body, id string) {
|
||||
}
|
||||
}
|
||||
|
||||
// registerSessionWarningCategory wires the OS notification category for the
|
||||
// T-10min SSO expiry warning. The category carries two actions ("Extend now"
|
||||
// and "Dismiss") and the global response handler so a click resolves back
|
||||
// into runExtendSession. Idempotent — called once from NewTray; errors are
|
||||
// logged and swallowed because the worst case is a plain text notification
|
||||
// without buttons.
|
||||
func (t *Tray) registerSessionWarningCategory() {
|
||||
if t.svc.Notifier == nil {
|
||||
return
|
||||
}
|
||||
if err := t.svc.Notifier.RegisterNotificationCategory(notifications.NotificationCategory{
|
||||
ID: notifyCategorySessionWarning,
|
||||
Actions: []notifications.NotificationAction{
|
||||
{ID: notifyActionExtendNow, Title: t.loc.T("notify.sessionWarning.extend")},
|
||||
{ID: notifyActionDismiss, Title: t.loc.T("notify.sessionWarning.dismiss")},
|
||||
},
|
||||
}); err != nil {
|
||||
log.Debugf("register session-warning notification category: %v", err)
|
||||
}
|
||||
t.svc.Notifier.OnNotificationResponse(func(result notifications.NotificationResult) {
|
||||
if result.Error != nil {
|
||||
log.Debugf("notification response error: %v", result.Error)
|
||||
return
|
||||
}
|
||||
if result.Response.CategoryID != notifyCategorySessionWarning {
|
||||
return
|
||||
}
|
||||
switch result.Response.ActionIdentifier {
|
||||
case notifyActionExtendNow, notifications.DefaultActionIdentifier:
|
||||
// DefaultActionIdentifier covers the body-click on platforms
|
||||
// that don't expose buttons separately (e.g. some minimal
|
||||
// Linux notification daemons fall back to a single click
|
||||
// area). Treat it as Extend so the user always has a path.
|
||||
go t.runExtendSession()
|
||||
case notifyActionDismiss:
|
||||
// Explicit user opt-out. Tell the daemon so the
|
||||
// T-FinalWarningLead fallback dialog stays closed for this
|
||||
// deadline; the regular watcher remains armed for the next
|
||||
// deadline value (e.g. after a successful extend elsewhere).
|
||||
go t.dismissSessionWarning()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// buildSessionWarningBody composes the localised body for the T-10min
|
||||
// notification from the daemon's metadata. The daemon does not have a
|
||||
// locale, so it ships a stable RFC3339 deadline ("session_expires_at")
|
||||
// and integer lead time ("lead_minutes") in metadata; the tray turns
|
||||
// them into a user-language sentence via the active i18n bundle.
|
||||
//
|
||||
// Falls back to a constant string when the metadata is missing or the
|
||||
// timestamp fails to parse — the user still sees the warning, just
|
||||
// without the remaining-time count.
|
||||
func (t *Tray) buildSessionWarningBody(meta map[string]string) string {
|
||||
if meta == nil {
|
||||
return t.loc.T("notify.sessionWarning.bodyGeneric")
|
||||
}
|
||||
raw := meta[authsession.MetaExpiresAt]
|
||||
if raw == "" {
|
||||
return t.loc.T("notify.sessionWarning.bodyGeneric")
|
||||
}
|
||||
deadline, err := authsession.ParseExpiresAt(raw)
|
||||
if err != nil {
|
||||
return t.loc.T("notify.sessionWarning.bodyGeneric")
|
||||
}
|
||||
remaining := nbstatus.FormatRemainingDuration(time.Until(deadline))
|
||||
return t.loc.T("notify.sessionWarning.body", "remaining", remaining)
|
||||
}
|
||||
|
||||
// notifySessionWarning sends the interactive T-10min OS notification. Falls
|
||||
// back to the plain `notify` helper if the Wails service doesn't expose the
|
||||
// with-actions variant (older platform impls, or a bare Notifier in tests).
|
||||
func (t *Tray) notifySessionWarning(title, body string) {
|
||||
if t.svc.Notifier == nil {
|
||||
return
|
||||
}
|
||||
err := t.svc.Notifier.SendNotificationWithActions(notifications.NotificationOptions{
|
||||
ID: notifyIDSessionWarning,
|
||||
Title: title,
|
||||
Body: body,
|
||||
CategoryID: notifyCategorySessionWarning,
|
||||
})
|
||||
if err != nil {
|
||||
log.Debugf("notify session-warning with actions: %v", err)
|
||||
// Fall back to a plain notification so the user at least gets
|
||||
// the warning text, even without buttons.
|
||||
t.notify(title, body, notifyIDSessionWarning)
|
||||
}
|
||||
}
|
||||
|
||||
// runExtendSession drives the daemon's RequestExtendAuthSession +
|
||||
// WaitExtendAuthSession pair when the user clicks "Extend now" on the
|
||||
// session-warning notification. Mirrors `doExtendSession` in
|
||||
// client/cmd/login.go but talks to the in-process Wails Session service
|
||||
// instead of opening a daemon gRPC channel from a CLI process. The
|
||||
// browser is opened via Connection.OpenURL (which honours $BROWSER on
|
||||
// Unix). Errors surface as plain notifyError calls — there is no foreground
|
||||
// UI flow here because the warning may fire while the main window is
|
||||
// closed.
|
||||
func (t *Tray) runExtendSession() {
|
||||
if t.svc.Session == nil || t.svc.Connection == nil {
|
||||
log.Debugf("session-warning: extend requested but services not wired")
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
start, err := t.svc.Session.RequestExtend(ctx, services.ExtendStartParams{})
|
||||
if err != nil {
|
||||
log.Warnf("session-warning: RequestExtend failed: %v", err)
|
||||
t.notifyError(t.loc.T("notify.sessionWarning.failed"))
|
||||
return
|
||||
}
|
||||
|
||||
uri := start.VerificationURIComplete
|
||||
if uri == "" {
|
||||
uri = start.VerificationURI
|
||||
}
|
||||
if uri != "" {
|
||||
if err := t.svc.Connection.OpenURL(uri); err != nil {
|
||||
log.Debugf("session-warning: opening verification URL: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := t.svc.Session.WaitExtend(ctx, services.ExtendWaitParams{
|
||||
DeviceCode: start.DeviceCode,
|
||||
UserCode: start.UserCode,
|
||||
}); err != nil {
|
||||
log.Warnf("session-warning: WaitExtend failed: %v", err)
|
||||
t.notifyError(t.loc.T("notify.sessionWarning.failed"))
|
||||
return
|
||||
}
|
||||
t.notify(t.loc.T("notify.sessionWarning.successTitle"), t.loc.T("notify.sessionWarning.successBody"), notifyIDSessionWarning)
|
||||
}
|
||||
|
||||
// dismissSessionWarning tells the daemon to silence the T-FinalWarningLead
|
||||
// fallback dialog for the current deadline. Best-effort: a failure only
|
||||
// means the dialog will still appear, so we log and move on.
|
||||
func (t *Tray) dismissSessionWarning() {
|
||||
if t.svc.Session == nil {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
if err := t.svc.Session.DismissWarning(ctx); err != nil {
|
||||
log.Debugf("session-warning: DismissWarning failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// openSessionAboutToExpire fires the auto-opened fallback dialog at
|
||||
// T-FinalWarningLead when the user did not dismiss the earlier T-10
|
||||
// notification. Idempotent on the WindowManager side (a second call
|
||||
// while the window is already open is a no-op).
|
||||
func (t *Tray) openSessionAboutToExpire() {
|
||||
if t.svc.WindowManager == nil {
|
||||
return
|
||||
}
|
||||
t.svc.WindowManager.OpenSessionAboutToExpire(finalWarningCountdownSeconds)
|
||||
}
|
||||
|
||||
// notifyError fires a generic "Error" notification for tray-driven action
|
||||
// failures. Each tray click site already logs the underlying error; this
|
||||
// adds the user-visible toast.
|
||||
|
||||
Reference in New Issue
Block a user