Skip to content

Commit 4c68440

Browse files
authored
fix: token input screen is closed after switching between Toolbox and browser (#72)
- rough draft to fix UI state management in the authentication flow which today has 3 pages. If user closes Toolbox in any of these three pages (for example to go and copy the token from a browser), then when it comes back in Toolbox does not remember which was the last visible UiPage. - until JetBrains improves Toolbox state management, we can work around the problem by having only one UiPage with three "steps" in it, similar to a wizard. With this approach we can have complete control over the state of the page. - to be noted that I've also looked over two other approaches. The first idea was to manage the stat ourselves, but that didn’t work out as Toolbox doesn’t clearly tell us when the user clicks the Back button vs. when they close the window. So we can’t reliably figure out which page to show when it reopens. - another option was changing the auth flow entirely and adding custom redirect URLs for Toolbox plugins. But that would only work with certain Coder versions, which might not be ideal. - resolves #45
1 parent 353d8bf commit 4c68440

21 files changed

+544
-436
lines changed

Diff for: CHANGELOG.md

+4
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22

33
## Unreleased
44

5+
### Fixed
6+
7+
- Toolbox remembers the authentication page that was last visible on the screen
8+
59
## 0.1.2 - 2025-04-04
610

711
### Fixed

Diff for: gradle.properties

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
1-
version=0.1.2
1+
version=0.1.3
22
group=com.coder.toolbox
33
name=coder-toolbox

Diff for: src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt

+17-8
Original file line numberDiff line numberDiff line change
@@ -74,26 +74,35 @@ class CoderRemoteEnvironment(
7474
if (wsRawStatus.canStart()) {
7575
if (workspace.outdated) {
7676
actions.add(Action(context.i18n.ptrl("Update and start")) {
77-
val build = client.updateWorkspace(workspace)
78-
update(workspace.copy(latestBuild = build), agent)
77+
context.cs.launch {
78+
val build = client.updateWorkspace(workspace)
79+
update(workspace.copy(latestBuild = build), agent)
80+
}
7981
})
8082
} else {
8183
actions.add(Action(context.i18n.ptrl("Start")) {
82-
val build = client.startWorkspace(workspace)
83-
update(workspace.copy(latestBuild = build), agent)
84+
context.cs.launch {
85+
val build = client.startWorkspace(workspace)
86+
update(workspace.copy(latestBuild = build), agent)
87+
88+
}
8489
})
8590
}
8691
}
8792
if (wsRawStatus.canStop()) {
8893
if (workspace.outdated) {
8994
actions.add(Action(context.i18n.ptrl("Update and restart")) {
90-
val build = client.updateWorkspace(workspace)
91-
update(workspace.copy(latestBuild = build), agent)
95+
context.cs.launch {
96+
val build = client.updateWorkspace(workspace)
97+
update(workspace.copy(latestBuild = build), agent)
98+
}
9299
})
93100
} else {
94101
actions.add(Action(context.i18n.ptrl("Stop")) {
95-
val build = client.stopWorkspace(workspace)
96-
update(workspace.copy(latestBuild = build), agent)
102+
context.cs.launch {
103+
val build = client.stopWorkspace(workspace)
104+
update(workspace.copy(latestBuild = build), agent)
105+
}
97106
})
98107
}
99108
}

Diff for: src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt

+17-72
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,14 @@ package com.coder.toolbox
33
import com.coder.toolbox.cli.CoderCLIManager
44
import com.coder.toolbox.sdk.CoderRestClient
55
import com.coder.toolbox.sdk.v2.models.WorkspaceStatus
6-
import com.coder.toolbox.settings.SettingSource
76
import com.coder.toolbox.util.CoderProtocolHandler
87
import com.coder.toolbox.util.DialogUi
98
import com.coder.toolbox.views.Action
9+
import com.coder.toolbox.views.AuthWizardPage
1010
import com.coder.toolbox.views.CoderSettingsPage
11-
import com.coder.toolbox.views.ConnectPage
1211
import com.coder.toolbox.views.NewEnvironmentPage
13-
import com.coder.toolbox.views.SignInPage
14-
import com.coder.toolbox.views.TokenPage
12+
import com.coder.toolbox.views.state.AuthWizardState
13+
import com.coder.toolbox.views.state.WizardStep
1514
import com.jetbrains.toolbox.api.core.ui.icons.SvgIcon
1615
import com.jetbrains.toolbox.api.core.ui.icons.SvgIcon.IconType
1716
import com.jetbrains.toolbox.api.core.util.LoadableState
@@ -32,7 +31,6 @@ import kotlinx.coroutines.selects.onTimeout
3231
import kotlinx.coroutines.selects.select
3332
import java.net.SocketTimeoutException
3433
import java.net.URI
35-
import java.net.URL
3634
import kotlin.coroutines.cancellation.CancellationException
3735
import kotlin.time.Duration.Companion.seconds
3836
import kotlin.time.TimeSource
@@ -67,7 +65,7 @@ class CoderRemoteProvider(
6765
// On the first load, automatically log in if we can.
6866
private var firstRun = true
6967
private val isInitialized: MutableStateFlow<Boolean> = MutableStateFlow(false)
70-
private var coderHeaderPage = NewEnvironmentPage(context, context.i18n.pnotr(getDeploymentURL()?.first ?: ""))
68+
private var coderHeaderPage = NewEnvironmentPage(context, context.i18n.pnotr(context.deploymentUrl?.first ?: ""))
7169
private val linkHandler = CoderProtocolHandler(context, dialogUi, isInitialized)
7270
override val environments: MutableStateFlow<LoadableState<List<RemoteProviderEnvironment>>> = MutableStateFlow(
7371
LoadableState.Value(emptyList())
@@ -177,7 +175,7 @@ class CoderRemoteProvider(
177175
private fun logout() {
178176
// Keep the URL and token to make it easy to log back in, but set
179177
// rememberMe to false so we do not try to automatically log in.
180-
context.secrets.rememberMe = "false"
178+
context.secrets.rememberMe = false
181179
close()
182180
}
183181

@@ -189,7 +187,7 @@ class CoderRemoteProvider(
189187
if (username != null) {
190188
return dropDownFactory(context.i18n.pnotr(username)) {
191189
logout()
192-
context.ui.showUiPage(getOverrideUiPage()!!)
190+
context.envPageManager.showPluginEnvironmentsPage()
193191
}
194192
}
195193
return null
@@ -215,6 +213,7 @@ class CoderRemoteProvider(
215213
environments.value = LoadableState.Value(emptyList())
216214
isInitialized.update { false }
217215
client = null
216+
AuthWizardState.resetSteps()
218217
}
219218

220219
override val svgIcon: SvgIcon =
@@ -293,7 +292,7 @@ class CoderRemoteProvider(
293292
/**
294293
* Return the sign-in page if we do not have a valid client.
295294
296-
* Otherwise return null, which causes Toolbox to display the environment
295+
* Otherwise, return null, which causes Toolbox to display the environment
297296
* list.
298297
*/
299298
override fun getOverrideUiPage(): UiPage? {
@@ -306,7 +305,8 @@ class CoderRemoteProvider(
306305
context.secrets.lastDeploymentURL.let { lastDeploymentURL ->
307306
if (autologin && lastDeploymentURL.isNotBlank() && (lastToken.isNotBlank() || !settings.requireTokenAuth)) {
308307
try {
309-
return createConnectPage(URL(lastDeploymentURL), lastToken)
308+
AuthWizardState.goToStep(WizardStep.LOGIN)
309+
return AuthWizardPage(context, true, ::onConnect)
310310
} catch (ex: Exception) {
311311
autologinEx = ex
312312
}
@@ -316,84 +316,29 @@ class CoderRemoteProvider(
316316
firstRun = false
317317

318318
// Login flow.
319-
val signInPage =
320-
SignInPage(context, getDeploymentURL()) { deploymentURL ->
321-
context.ui.showUiPage(
322-
TokenPage(
323-
context,
324-
deploymentURL,
325-
getToken(deploymentURL)
326-
) { selectedToken ->
327-
context.ui.showUiPage(createConnectPage(deploymentURL, selectedToken))
328-
},
329-
)
330-
}
331-
319+
val authWizard = AuthWizardPage(context, false, ::onConnect)
332320
// We might have tried and failed to automatically log in.
333-
autologinEx?.let { signInPage.notify("Error logging in", it) }
321+
autologinEx?.let { authWizard.notify("Error logging in", it) }
334322
// We might have navigated here due to a polling error.
335-
pollError?.let { signInPage.notify("Error fetching workspaces", it) }
323+
pollError?.let { authWizard.notify("Error fetching workspaces", it) }
336324

337-
return signInPage
325+
return authWizard
338326
}
339327
return null
340328
}
341329

342-
private fun shouldDoAutoLogin(): Boolean = firstRun && context.secrets.rememberMe == "true"
330+
private fun shouldDoAutoLogin(): Boolean = firstRun && context.secrets.rememberMe == true
343331

344-
/**
345-
* Create a connect page that starts polling and resets the UI on success.
346-
*/
347-
private fun createConnectPage(deploymentURL: URL, token: String?): ConnectPage = ConnectPage(
348-
context,
349-
deploymentURL,
350-
token,
351-
::goToEnvironmentsPage,
352-
) { client, cli ->
332+
private fun onConnect(client: CoderRestClient, cli: CoderCLIManager) {
353333
// Store the URL and token for use next time.
354334
context.secrets.lastDeploymentURL = client.url.toString()
355335
context.secrets.lastToken = client.token ?: ""
356336
// Currently we always remember, but this could be made an option.
357-
context.secrets.rememberMe = "true"
337+
context.secrets.rememberMe = true
358338
this.client = client
359339
pollError = null
360340
pollJob?.cancel()
361341
pollJob = poll(client, cli)
362342
goToEnvironmentsPage()
363343
}
364-
365-
/**
366-
* Try to find a token.
367-
*
368-
* Order of preference:
369-
*
370-
* 1. Last used token, if it was for this deployment.
371-
* 2. Token on disk for this deployment.
372-
* 3. Global token for Coder, if it matches the deployment.
373-
*/
374-
private fun getToken(deploymentURL: URL): Pair<String, SettingSource>? = context.secrets.lastToken.let {
375-
if (it.isNotBlank() && context.secrets.lastDeploymentURL == deploymentURL.toString()) {
376-
it to SettingSource.LAST_USED
377-
} else {
378-
settings.token(deploymentURL)
379-
}
380-
}
381-
382-
/**
383-
* Try to find a URL.
384-
*
385-
* In order of preference:
386-
*
387-
* 1. Last used URL.
388-
* 2. URL in settings.
389-
* 3. CODER_URL.
390-
* 4. URL in global cli config.
391-
*/
392-
private fun getDeploymentURL(): Pair<String, SettingSource>? = context.secrets.lastDeploymentURL.let {
393-
if (it.isNotBlank()) {
394-
it to SettingSource.LAST_USED
395-
} else {
396-
context.settingsStore.defaultURL()
397-
}
398-
}
399344
}

Diff for: src/main/kotlin/com/coder/toolbox/CoderToolboxContext.kt

+42-1
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
package com.coder.toolbox
22

3+
import com.coder.toolbox.settings.SettingSource
34
import com.coder.toolbox.store.CoderSecretsStore
45
import com.coder.toolbox.store.CoderSettingsStore
6+
import com.coder.toolbox.util.toURL
57
import com.jetbrains.toolbox.api.core.diagnostics.Logger
68
import com.jetbrains.toolbox.api.localization.LocalizableStringFactory
79
import com.jetbrains.toolbox.api.remoteDev.connection.ClientHelper
@@ -20,4 +22,43 @@ data class CoderToolboxContext(
2022
val i18n: LocalizableStringFactory,
2123
val settingsStore: CoderSettingsStore,
2224
val secrets: CoderSecretsStore
23-
)
25+
) {
26+
/**
27+
* Try to find a URL.
28+
*
29+
* In order of preference:
30+
*
31+
* 1. Last used URL.
32+
* 2. URL in settings.
33+
* 3. CODER_URL.
34+
* 4. URL in global cli config.
35+
*/
36+
val deploymentUrl: Pair<String, SettingSource>?
37+
get() = this.secrets.lastDeploymentURL.let {
38+
if (it.isNotBlank()) {
39+
it to SettingSource.LAST_USED
40+
} else {
41+
this.settingsStore.defaultURL()
42+
}
43+
}
44+
45+
/**
46+
* Try to find a token.
47+
*
48+
* Order of preference:
49+
*
50+
* 1. Last used token, if it was for this deployment.
51+
* 2. Token on disk for this deployment.
52+
* 3. Global token for Coder, if it matches the deployment.
53+
*/
54+
fun getToken(deploymentURL: String?): Pair<String, SettingSource>? = this.secrets.lastToken.let {
55+
if (it.isNotBlank() && this.secrets.lastDeploymentURL == deploymentURL) {
56+
it to SettingSource.LAST_USED
57+
} else {
58+
if (deploymentURL != null) {
59+
this.settingsStore.token(deploymentURL.toURL())
60+
} else null
61+
}
62+
}
63+
64+
}

0 commit comments

Comments
 (0)