Skip to content

Commit 089d442

Browse files
authored
[client] Display login popup on session expiration (#3955)
This PR implements a feature enhancement to display a login popup when the session expires. Key changes include updating flag handling and client construction to support a new login URL popup, revising login and notification handling logic to use the new popup, and updating status and server-side session state management accordingly.
1 parent 04a3765 commit 089d442

File tree

5 files changed

+124
-36
lines changed

5 files changed

+124
-36
lines changed

client/cmd/status.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,10 @@ func statusFunc(cmd *cobra.Command, args []string) error {
6969
return err
7070
}
7171

72-
if resp.GetStatus() == string(internal.StatusNeedsLogin) || resp.GetStatus() == string(internal.StatusLoginFailed) {
72+
status := resp.GetStatus()
73+
74+
if status == string(internal.StatusNeedsLogin) || status == string(internal.StatusLoginFailed) ||
75+
status == string(internal.StatusSessionExpired) {
7376
cmd.Printf("Daemon status: %s\n\n"+
7477
"Run UP command to log in with SSO (interactive login):\n\n"+
7578
" netbird up \n\n"+

client/internal/state.go

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,11 @@ type StatusType string
1010
const (
1111
StatusIdle StatusType = "Idle"
1212

13-
StatusConnecting StatusType = "Connecting"
14-
StatusConnected StatusType = "Connected"
15-
StatusNeedsLogin StatusType = "NeedsLogin"
16-
StatusLoginFailed StatusType = "LoginFailed"
13+
StatusConnecting StatusType = "Connecting"
14+
StatusConnected StatusType = "Connected"
15+
StatusNeedsLogin StatusType = "NeedsLogin"
16+
StatusLoginFailed StatusType = "LoginFailed"
17+
StatusSessionExpired StatusType = "SessionExpired"
1718
)
1819

1920
// CtxInitState setup context state into the context tree.

client/server/server.go

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"runtime"
99
"strconv"
1010
"sync"
11+
"sync/atomic"
1112
"time"
1213

1314
"github.com/cenkalti/backoff/v4"
@@ -66,6 +67,7 @@ type Server struct {
6667

6768
lastProbe time.Time
6869
persistNetworkMap bool
70+
isSessionActive atomic.Bool
6971
}
7072

7173
type oauthAuthFlow struct {
@@ -567,9 +569,6 @@ func (s *Server) WaitSSOLogin(callerCtx context.Context, msg *proto.WaitSSOLogin
567569

568570
tokenInfo, err := s.oauthAuthFlow.flow.WaitToken(waitCTX, flowInfo)
569571
if err != nil {
570-
if err == context.Canceled {
571-
return nil, nil //nolint:nilnil
572-
}
573572
s.mutex.Lock()
574573
s.oauthAuthFlow.expiresAt = time.Now()
575574
s.mutex.Unlock()
@@ -640,6 +639,7 @@ func (s *Server) Up(callerCtx context.Context, _ *proto.UpRequest) (*proto.UpRes
640639
for {
641640
select {
642641
case <-runningChan:
642+
s.isSessionActive.Store(true)
643643
return &proto.UpResponse{}, nil
644644
case <-callerCtx.Done():
645645
log.Debug("context done, stopping the wait for engine to become ready")
@@ -668,6 +668,7 @@ func (s *Server) Down(ctx context.Context, _ *proto.DownRequest) (*proto.DownRes
668668
log.Errorf("failed to shut down properly: %v", err)
669669
return nil, err
670670
}
671+
s.isSessionActive.Store(false)
671672

672673
state := internal.CtxGetState(s.rootCtx)
673674
state.Set(internal.StatusIdle)
@@ -694,6 +695,12 @@ func (s *Server) Status(
694695
return nil, err
695696
}
696697

698+
if status == internal.StatusNeedsLogin && s.isSessionActive.Load() {
699+
log.Debug("status requested while session is active, returning SessionExpired")
700+
status = internal.StatusSessionExpired
701+
s.isSessionActive.Store(false)
702+
}
703+
697704
statusResponse := proto.StatusResponse{Status: string(status), DaemonVersion: version.NetbirdVersion()}
698705

699706
s.statusRecorder.UpdateManagementAddress(s.config.ManagementURL.String())

client/ui/client_ui.go

Lines changed: 105 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,10 @@ import (
2020

2121
"fyne.io/fyne/v2"
2222
"fyne.io/fyne/v2/app"
23+
"fyne.io/fyne/v2/canvas"
24+
"fyne.io/fyne/v2/container"
2325
"fyne.io/fyne/v2/dialog"
26+
"fyne.io/fyne/v2/layout"
2427
"fyne.io/fyne/v2/theme"
2528
"fyne.io/fyne/v2/widget"
2629
"fyne.io/systray"
@@ -51,7 +54,7 @@ const (
5154
)
5255

5356
func main() {
54-
daemonAddr, showSettings, showNetworks, showDebug, errorMsg, saveLogsInFile := parseFlags()
57+
daemonAddr, showSettings, showNetworks, showLoginURL, showDebug, errorMsg, saveLogsInFile := parseFlags()
5558

5659
// Initialize file logging if needed.
5760
var logFile string
@@ -77,13 +80,13 @@ func main() {
7780
}
7881

7982
// Create the service client (this also builds the settings or networks UI if requested).
80-
client := newServiceClient(daemonAddr, logFile, a, showSettings, showNetworks, showDebug)
83+
client := newServiceClient(daemonAddr, logFile, a, showSettings, showNetworks, showLoginURL, showDebug)
8184

8285
// Watch for theme/settings changes to update the icon.
8386
go watchSettingsChanges(a, client)
8487

8588
// Run in window mode if any UI flag was set.
86-
if showSettings || showNetworks || showDebug {
89+
if showSettings || showNetworks || showDebug || showLoginURL {
8790
a.Run()
8891
return
8992
}
@@ -104,14 +107,15 @@ func main() {
104107
}
105108

106109
// parseFlags reads and returns all needed command-line flags.
107-
func parseFlags() (daemonAddr string, showSettings, showNetworks, showDebug bool, errorMsg string, saveLogsInFile bool) {
110+
func parseFlags() (daemonAddr string, showSettings, showNetworks, showLoginURL, showDebug bool, errorMsg string, saveLogsInFile bool) {
108111
defaultDaemonAddr := "unix:///var/run/netbird.sock"
109112
if runtime.GOOS == "windows" {
110113
defaultDaemonAddr = "tcp://127.0.0.1:41731"
111114
}
112115
flag.StringVar(&daemonAddr, "daemon-addr", defaultDaemonAddr, "Daemon service address to serve CLI requests [unix|tcp]://[path|host:port]")
113116
flag.BoolVar(&showSettings, "settings", false, "run settings window")
114117
flag.BoolVar(&showNetworks, "networks", false, "run networks window")
118+
flag.BoolVar(&showLoginURL, "login-url", false, "show login URL in a popup window")
115119
flag.BoolVar(&showDebug, "debug", false, "run debug window")
116120
flag.StringVar(&errorMsg, "error-msg", "", "displays an error message window")
117121
flag.BoolVar(&saveLogsInFile, "use-log-file", false, fmt.Sprintf("save logs in a file: %s/netbird-ui-PID.log", os.TempDir()))
@@ -253,6 +257,7 @@ type serviceClient struct {
253257
exitNodeStates []exitNodeState
254258
mExitNodeDeselectAll *systray.MenuItem
255259
logFile string
260+
wLoginURL fyne.Window
256261
}
257262

258263
type menuHandler struct {
@@ -263,7 +268,7 @@ type menuHandler struct {
263268
// newServiceClient instance constructor
264269
//
265270
// This constructor also builds the UI elements for the settings window.
266-
func newServiceClient(addr string, logFile string, a fyne.App, showSettings bool, showNetworks bool, showDebug bool) *serviceClient {
271+
func newServiceClient(addr string, logFile string, a fyne.App, showSettings bool, showNetworks bool, showLoginURL bool, showDebug bool) *serviceClient {
267272
ctx, cancel := context.WithCancel(context.Background())
268273
s := &serviceClient{
269274
ctx: ctx,
@@ -286,6 +291,8 @@ func newServiceClient(addr string, logFile string, a fyne.App, showSettings bool
286291
s.showSettingsUI()
287292
case showNetworks:
288293
s.showNetworksUI()
294+
case showLoginURL:
295+
s.showLoginURL()
289296
case showDebug:
290297
s.showDebugUI()
291298
}
@@ -445,36 +452,36 @@ func (s *serviceClient) getSettingsForm() *widget.Form {
445452
}
446453
}
447454

448-
func (s *serviceClient) login() error {
455+
func (s *serviceClient) login(openURL bool) (*proto.LoginResponse, error) {
449456
conn, err := s.getSrvClient(defaultFailTimeout)
450457
if err != nil {
451458
log.Errorf("get client: %v", err)
452-
return err
459+
return nil, err
453460
}
454461

455462
loginResp, err := conn.Login(s.ctx, &proto.LoginRequest{
456463
IsUnixDesktopClient: runtime.GOOS == "linux" || runtime.GOOS == "freebsd",
457464
})
458465
if err != nil {
459466
log.Errorf("login to management URL with: %v", err)
460-
return err
467+
return nil, err
461468
}
462469

463-
if loginResp.NeedsSSOLogin {
470+
if loginResp.NeedsSSOLogin && openURL {
464471
err = open.Run(loginResp.VerificationURIComplete)
465472
if err != nil {
466473
log.Errorf("opening the verification uri in the browser failed: %v", err)
467-
return err
474+
return nil, err
468475
}
469476

470477
_, err = conn.WaitSSOLogin(s.ctx, &proto.WaitSSOLoginRequest{UserCode: loginResp.UserCode})
471478
if err != nil {
472479
log.Errorf("waiting sso login failed with: %v", err)
473-
return err
480+
return nil, err
474481
}
475482
}
476483

477-
return nil
484+
return loginResp, nil
478485
}
479486

480487
func (s *serviceClient) menuUpClick() error {
@@ -486,7 +493,7 @@ func (s *serviceClient) menuUpClick() error {
486493
return err
487494
}
488495

489-
err = s.login()
496+
_, err = s.login(true)
490497
if err != nil {
491498
log.Errorf("login failed with: %v", err)
492499
return err
@@ -558,7 +565,7 @@ func (s *serviceClient) updateStatus() error {
558565
defer s.updateIndicationLock.Unlock()
559566

560567
// notify the user when the session has expired
561-
if status.Status == string(internal.StatusNeedsLogin) {
568+
if status.Status == string(internal.StatusSessionExpired) {
562569
s.onSessionExpire()
563570
}
564571

@@ -732,7 +739,6 @@ func (s *serviceClient) onTrayReady() {
732739
go s.eventHandler.listen(s.ctx)
733740
}
734741

735-
736742
func (s *serviceClient) attachOutput(cmd *exec.Cmd) *os.File {
737743
if s.logFile == "" {
738744
// attach child's streams to parent's streams
@@ -871,17 +877,9 @@ func (s *serviceClient) onUpdateAvailable() {
871877

872878
// onSessionExpire sends a notification to the user when the session expires.
873879
func (s *serviceClient) onSessionExpire() {
880+
s.sendNotification = true
874881
if s.sendNotification {
875-
title := "Connection session expired"
876-
if runtime.GOOS == "darwin" {
877-
title = "NetBird connection session expired"
878-
}
879-
s.app.SendNotification(
880-
fyne.NewNotification(
881-
title,
882-
"Please re-authenticate to connect to the network",
883-
),
884-
)
882+
s.eventHandler.runSelfCommand("login-url", "true")
885883
s.sendNotification = false
886884
}
887885
}
@@ -955,9 +953,9 @@ func (s *serviceClient) updateConfig() error {
955953
ServerSSHAllowed: &sshAllowed,
956954
RosenpassEnabled: &rosenpassEnabled,
957955
DisableAutoConnect: &disableAutoStart,
956+
DisableNotifications: &notificationsDisabled,
958957
LazyConnectionEnabled: &lazyConnectionEnabled,
959958
BlockInbound: &blockInbound,
960-
DisableNotifications: &notificationsDisabled,
961959
}
962960

963961
if err := s.restartClient(&loginRequest); err != nil {
@@ -991,6 +989,87 @@ func (s *serviceClient) restartClient(loginRequest *proto.LoginRequest) error {
991989
return nil
992990
}
993991

992+
// showLoginURL creates a borderless window styled like a pop-up in the top-right corner using s.wLoginURL.
993+
func (s *serviceClient) showLoginURL() {
994+
995+
resp, err := s.login(false)
996+
if err != nil {
997+
log.Errorf("failed to fetch login URL: %v", err)
998+
return
999+
}
1000+
verificationURL := resp.VerificationURIComplete
1001+
if verificationURL == "" {
1002+
verificationURL = resp.VerificationURI
1003+
}
1004+
1005+
if verificationURL == "" {
1006+
log.Error("no verification URL provided in the login response")
1007+
return
1008+
}
1009+
1010+
resIcon := fyne.NewStaticResource("netbird.png", iconAbout)
1011+
1012+
if s.wLoginURL == nil {
1013+
s.wLoginURL = s.app.NewWindow("NetBird Session Expired")
1014+
s.wLoginURL.Resize(fyne.NewSize(400, 200))
1015+
s.wLoginURL.SetIcon(resIcon)
1016+
}
1017+
// add a description label
1018+
label := widget.NewLabel("Your NetBird session has expired.\nPlease re-authenticate to continue using NetBird.")
1019+
1020+
btn := widget.NewButtonWithIcon("Re-authenticate", theme.ViewRefreshIcon(), func() {
1021+
1022+
conn, err := s.getSrvClient(defaultFailTimeout)
1023+
if err != nil {
1024+
log.Errorf("get client: %v", err)
1025+
return
1026+
}
1027+
1028+
if err := openURL(verificationURL); err != nil {
1029+
log.Errorf("failed to open login URL: %v", err)
1030+
return
1031+
}
1032+
1033+
_, err = conn.WaitSSOLogin(s.ctx, &proto.WaitSSOLoginRequest{UserCode: resp.UserCode})
1034+
if err != nil {
1035+
log.Errorf("Waiting sso login failed with: %v", err)
1036+
label.SetText("Waiting login failed, please create \na debug bundle in the settings and contact support.")
1037+
return
1038+
}
1039+
1040+
label.SetText("Re-authentication successful.\nReconnecting")
1041+
time.Sleep(300 * time.Millisecond)
1042+
_, err = conn.Up(s.ctx, &proto.UpRequest{})
1043+
if err != nil {
1044+
label.SetText("Reconnecting failed, please create \na debug bundle in the settings and contact support.")
1045+
log.Errorf("Reconnecting failed with: %v", err)
1046+
return
1047+
}
1048+
1049+
label.SetText("Connection successful.\nClosing this window.")
1050+
time.Sleep(time.Second)
1051+
1052+
s.wLoginURL.Close()
1053+
})
1054+
1055+
img := canvas.NewImageFromResource(resIcon)
1056+
img.FillMode = canvas.ImageFillContain
1057+
img.SetMinSize(fyne.NewSize(64, 64))
1058+
img.Resize(fyne.NewSize(64, 64))
1059+
1060+
// center the content vertically
1061+
content := container.NewVBox(
1062+
layout.NewSpacer(),
1063+
img,
1064+
label,
1065+
btn,
1066+
layout.NewSpacer(),
1067+
)
1068+
s.wLoginURL.SetContent(container.NewCenter(content))
1069+
1070+
s.wLoginURL.Show()
1071+
}
1072+
9941073
func openURL(url string) error {
9951074
var err error
9961075
switch runtime.GOOS {

client/ui/network.go

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -358,8 +358,6 @@ func (s *serviceClient) updateExitNodes() {
358358
} else {
359359
s.mExitNode.Disable()
360360
}
361-
362-
log.Debugf("Exit nodes updated: %d", len(s.mExitNodeItems))
363361
}
364362

365363
func (s *serviceClient) recreateExitNodeMenu(exitNodes []*proto.Network) {

0 commit comments

Comments
 (0)