diff --git a/client/server/server.go b/client/server/server.go index 82f683cde..22165fbcf 100644 --- a/client/server/server.go +++ b/client/server/server.go @@ -539,8 +539,6 @@ func (s *Server) Login(callerCtx context.Context, msg *proto.LoginRequest) (*pro return &proto.LoginResponse{}, nil } - state.Set(internal.StatusConnecting) - if msg.SetupKey == "" { hint := "" if msg.Hint != nil { @@ -592,6 +590,11 @@ func (s *Server) Login(callerCtx context.Context, msg *proto.LoginRequest) (*pro }, nil } + // Setup-key path: we are about to dial Management with the key, so the + // Connecting paint is meaningful here — unlike the SSO branch above, + // which returns NeedsLogin and parks on the browser leg. + state.Set(internal.StatusConnecting) + if loginStatus, err := s.loginAttempt(ctx, msg.SetupKey, ""); err != nil { state.Set(loginStatus) return nil, err @@ -717,10 +720,19 @@ func (s *Server) WaitSSOLogin(callerCtx context.Context, msg *proto.WaitSSOLogin s.mutex.Unlock() switch { case errors.Is(err, context.Canceled): - // External abort (profile switch, app quit, another - // WaitSSOLogin started). Not a login failure — let the - // top-level defer fall through to StatusIdle so the next - // flow starts from a clean state. + // External abort. If our caller cancelled (the client closed + // the browser-login popup, or the UI went away — callerCtx is + // done), clear the abandoned OAuth flow so a fresh Login starts + // a new device code instead of reusing this one. The entry + // NeedsLogin stays in place, so a reattaching client shows the + // login affordance. An internal abort (actCancel from a new + // Login/WaitSSOLogin, callerCtx still live) leaves the flow for + // the new owner — don't clobber it. + if callerCtx.Err() != nil { + s.mutex.Lock() + s.oauthAuthFlow = oauthAuthFlow{} + s.mutex.Unlock() + } case errors.Is(err, context.DeadlineExceeded): // OAuth device-code window expired with no user action. // Retryable — leave the daemon in NeedsLogin so the UI diff --git a/client/ui/frontend/src/modules/main/MainConnectionStatusSwitch.tsx b/client/ui/frontend/src/modules/main/MainConnectionStatusSwitch.tsx index b1f4fd393..18f36f45f 100644 --- a/client/ui/frontend/src/modules/main/MainConnectionStatusSwitch.tsx +++ b/client/ui/frontend/src/modules/main/MainConnectionStatusSwitch.tsx @@ -89,13 +89,14 @@ async function startLogin(): Promise { } if (cancelled) { - // Tell the daemon to drop the in-flight WaitSSOLogin so a - // future Login starts fresh; see services/connection.go:74. - try { - await Connection.Down(); - } catch (e) { - console.error(e); - } + // Cancel the in-flight WaitSSOLogin gRPC instead of a heavy + // Down. The daemon ties the wait to this call's context + // (server.go WaitSSOLogin), so cancelling ends the wait and + // clears the abandoned OAuth flow — a fresh Login then starts + // a new device code, with no Idle blink on the tray. Swallow + // the cancellation rejection on the abandoned promise. + waitPromise.cancel?.(); + void waitPromise.catch(() => {}); return; } } diff --git a/client/ui/services/connection.go b/client/ui/services/connection.go index 4499d3c42..95d98aa74 100644 --- a/client/ui/services/connection.go +++ b/client/ui/services/connection.go @@ -199,16 +199,13 @@ func (s *Connection) Login(ctx context.Context, p LoginParams) (LoginResult, err return LoginResult{}, err } - // Reset the daemon's connection loop before kicking off a new login. - // If a previous Login left a WaitSSOLogin pending (user closed the - // browser without completing the flow), the daemon stays parked on the - // old UserCode and replies with "invalid setup-key or no sso information - // provided" to a fresh Login. Calling Down first dislodges that state; - // we ignore the error since Down on an already-idle daemon is a no-op. - if _, derr := cli.Down(ctx, &proto.DownRequest{}); derr != nil { - // Down failed — likely because the daemon is already idle. Continue. - _ = derr - } + // No pre-Login Down: the daemon's Login dislodges a pending WaitSSOLogin + // itself (server.go cancels the in-flight wait via actCancel), and an + // abandoned browser leg is torn down by startLogin cancelling the + // WaitSSOLogin RPC, which the daemon reacts to by clearing the stale + // OAuth flow. A defensive Down here would only add a visible Idle blink + // to the tray during the SSO handoff (Connect/profile-switch → + // NeedsLogin → auto-login) for no gain. // Mirror the Fyne client's defaulting: when the frontend doesn't supply // profile / username, fall back to the daemon's active profile and the