Skip to content

Commit 5a2eb56

Browse files
committed
fix: token input screen is closed after switching between Toolbox and browser
- 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 5a2eb56

13 files changed

+412
-269
lines changed

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

src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt

+13-49
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,11 @@ import com.coder.toolbox.settings.SettingSource
77
import com.coder.toolbox.util.CoderProtocolHandler
88
import com.coder.toolbox.util.DialogUi
99
import com.coder.toolbox.views.Action
10+
import com.coder.toolbox.views.AuthWizardPage
1011
import com.coder.toolbox.views.CoderSettingsPage
11-
import com.coder.toolbox.views.ConnectPage
1212
import com.coder.toolbox.views.NewEnvironmentPage
13-
import com.coder.toolbox.views.SignInPage
14-
import com.coder.toolbox.views.TokenPage
13+
import com.coder.toolbox.views.state.AuthWizardState
14+
import com.coder.toolbox.views.state.WizardStep
1515
import com.jetbrains.toolbox.api.core.ui.icons.SvgIcon
1616
import com.jetbrains.toolbox.api.core.ui.icons.SvgIcon.IconType
1717
import com.jetbrains.toolbox.api.core.util.LoadableState
@@ -67,7 +67,7 @@ class CoderRemoteProvider(
6767
// On the first load, automatically log in if we can.
6868
private var firstRun = true
6969
private val isInitialized: MutableStateFlow<Boolean> = MutableStateFlow(false)
70-
private var coderHeaderPage = NewEnvironmentPage(context, context.i18n.pnotr(getDeploymentURL()?.first ?: ""))
70+
private var coderHeaderPage = NewEnvironmentPage(context, context.i18n.pnotr(context.deploymentUrl?.first ?: ""))
7171
private val linkHandler = CoderProtocolHandler(context, dialogUi, isInitialized)
7272
override val environments: MutableStateFlow<LoadableState<List<RemoteProviderEnvironment>>> = MutableStateFlow(
7373
LoadableState.Value(emptyList())
@@ -189,7 +189,7 @@ class CoderRemoteProvider(
189189
if (username != null) {
190190
return dropDownFactory(context.i18n.pnotr(username)) {
191191
logout()
192-
context.ui.showUiPage(getOverrideUiPage()!!)
192+
context.envPageManager.showPluginEnvironmentsPage()
193193
}
194194
}
195195
return null
@@ -215,6 +215,7 @@ class CoderRemoteProvider(
215215
environments.value = LoadableState.Value(emptyList())
216216
isInitialized.update { false }
217217
client = null
218+
AuthWizardState.resetSteps()
218219
}
219220

220221
override val svgIcon: SvgIcon =
@@ -306,7 +307,8 @@ class CoderRemoteProvider(
306307
context.secrets.lastDeploymentURL.let { lastDeploymentURL ->
307308
if (autologin && lastDeploymentURL.isNotBlank() && (lastToken.isNotBlank() || !settings.requireTokenAuth)) {
308309
try {
309-
return createConnectPage(URL(lastDeploymentURL), lastToken)
310+
AuthWizardState.goToStep(WizardStep.LOGIN)
311+
return AuthWizardPage(context, true, ::onConnect)
310312
} catch (ex: Exception) {
311313
autologinEx = ex
312314
}
@@ -316,40 +318,20 @@ class CoderRemoteProvider(
316318
firstRun = false
317319

318320
// 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-
321+
val authWizard = AuthWizardPage(context, false, ::onConnect)
332322
// We might have tried and failed to automatically log in.
333-
autologinEx?.let { signInPage.notify("Error logging in", it) }
323+
autologinEx?.let { authWizard.notify("Error logging in", it) }
334324
// We might have navigated here due to a polling error.
335-
pollError?.let { signInPage.notify("Error fetching workspaces", it) }
325+
pollError?.let { authWizard.notify("Error fetching workspaces", it) }
336326

337-
return signInPage
327+
return authWizard
338328
}
339329
return null
340330
}
341331

342332
private fun shouldDoAutoLogin(): Boolean = firstRun && context.secrets.rememberMe == "true"
343333

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 ->
334+
private fun onConnect(client: CoderRestClient, cli: CoderCLIManager) {
353335
// Store the URL and token for use next time.
354336
context.secrets.lastDeploymentURL = client.url.toString()
355337
context.secrets.lastToken = client.token ?: ""
@@ -378,22 +360,4 @@ class CoderRemoteProvider(
378360
settings.token(deploymentURL)
379361
}
380362
}
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-
}
399363
}

src/main/kotlin/com/coder/toolbox/CoderToolboxContext.kt

+41-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,42 @@ 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>? = this.secrets.lastDeploymentURL.let {
37+
if (it.isNotBlank()) {
38+
it to SettingSource.LAST_USED
39+
} else {
40+
this.settingsStore.defaultURL()
41+
}
42+
}
43+
44+
/**
45+
* Try to find a token.
46+
*
47+
* Order of preference:
48+
*
49+
* 1. Last used token, if it was for this deployment.
50+
* 2. Token on disk for this deployment.
51+
* 3. Global token for Coder, if it matches the deployment.
52+
*/
53+
fun getToken(deploymentURL: String?): Pair<String, SettingSource>? = this.secrets.lastToken.let {
54+
if (it.isNotBlank() && this.secrets.lastDeploymentURL == deploymentURL) {
55+
it to SettingSource.LAST_USED
56+
} else {
57+
if (deploymentURL != null) {
58+
this.settingsStore.token(deploymentURL.toURL())
59+
} else null
60+
}
61+
}
62+
63+
}

src/main/kotlin/com/coder/toolbox/util/URLExtensions.kt

+1-1
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import java.net.IDN
44
import java.net.URI
55
import java.net.URL
66

7-
fun String.toURL(): URL = URL(this)
7+
fun String.toURL(): URL = URI.create(this).toURL()
88

99
fun URL.withPath(path: String): URL = URL(
1010
this.protocol,
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
package com.coder.toolbox.views
2+
3+
import com.coder.toolbox.CoderToolboxContext
4+
import com.coder.toolbox.cli.CoderCLIManager
5+
import com.coder.toolbox.sdk.CoderRestClient
6+
import com.coder.toolbox.views.state.AuthWizardState
7+
import com.coder.toolbox.views.state.WizardStep
8+
import com.jetbrains.toolbox.api.ui.actions.RunnableActionDescription
9+
import com.jetbrains.toolbox.api.ui.components.UiField
10+
import kotlinx.coroutines.flow.MutableStateFlow
11+
import kotlinx.coroutines.flow.update
12+
13+
class AuthWizardPage(
14+
private val context: CoderToolboxContext,
15+
private val isAutoLogin: Boolean = false,
16+
onConnect: (
17+
client: CoderRestClient,
18+
cli: CoderCLIManager,
19+
) -> Unit,
20+
) : CoderPage(context, context.i18n.ptrl("Authenticate to Coder")) {
21+
private val signInStep = SignInStep(context)
22+
private val tokenStep = TokenStep(context)
23+
private val connectStep = ConnectStep(context, this::notify, onConnect)
24+
25+
26+
/**
27+
* Fields for this page, displayed in order.
28+
*/
29+
override val fields: MutableStateFlow<List<UiField>> = MutableStateFlow(emptyList())
30+
override val actionButtons: MutableStateFlow<List<RunnableActionDescription>> = MutableStateFlow(emptyList())
31+
32+
override fun beforeShow() {
33+
displaySteps()
34+
}
35+
36+
private fun displaySteps() {
37+
when (AuthWizardState.currentStep()) {
38+
WizardStep.URL_REQUEST -> {
39+
fields.update {
40+
listOf(signInStep.panel)
41+
}
42+
actionButtons.update {
43+
listOf(
44+
Action(context.i18n.ptrl("Sign In"), closesPage = false, actionBlock = {
45+
if (signInStep.onNext()) {
46+
displaySteps()
47+
}
48+
})
49+
)
50+
}
51+
signInStep.onVisible()
52+
}
53+
54+
WizardStep.TOKEN_REQUEST -> {
55+
fields.update {
56+
listOf(tokenStep.panel)
57+
}
58+
actionButtons.update {
59+
listOf(
60+
Action(context.i18n.ptrl("Back"), closesPage = false, actionBlock = {
61+
tokenStep.onBack()
62+
displaySteps()
63+
}),
64+
Action(context.i18n.ptrl("Connect"), closesPage = false, actionBlock = {
65+
if (tokenStep.onNext()) {
66+
displaySteps()
67+
}
68+
})
69+
)
70+
}
71+
tokenStep.onVisible()
72+
}
73+
74+
WizardStep.LOGIN -> {
75+
fields.update {
76+
listOf(connectStep.panel)
77+
}
78+
actionButtons.update {
79+
listOf(
80+
Action(context.i18n.ptrl("Back"), closesPage = false, actionBlock = {
81+
if (isAutoLogin) {
82+
AuthWizardState.resetSteps()
83+
} else {
84+
connectStep.onBack()
85+
}
86+
displaySteps()
87+
})
88+
)
89+
}
90+
connectStep.onVisible()
91+
}
92+
}
93+
}
94+
}

src/main/kotlin/com/coder/toolbox/views/CoderPage.kt

-20
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,7 @@ import com.jetbrains.toolbox.api.core.ui.icons.SvgIcon
55
import com.jetbrains.toolbox.api.core.ui.icons.SvgIcon.IconType
66
import com.jetbrains.toolbox.api.localization.LocalizableString
77
import com.jetbrains.toolbox.api.ui.actions.RunnableActionDescription
8-
import com.jetbrains.toolbox.api.ui.components.UiField
98
import com.jetbrains.toolbox.api.ui.components.UiPage
10-
import com.jetbrains.toolbox.api.ui.components.ValidationErrorField
11-
import java.util.function.Consumer
129

1310
/**
1411
* Base page that handles the icon, displaying error notifications, and
@@ -25,19 +22,10 @@ abstract class CoderPage(
2522
title: LocalizableString,
2623
showIcon: Boolean = true,
2724
) : UiPage(title) {
28-
/**
29-
* An error to display on the page.
30-
*
31-
* The current assumption is you only have one field per page.
32-
*/
33-
protected var errorField: ValidationErrorField? = null
3425

3526
/** Toolbox uses this to show notifications on the page. */
3627
private var notifier: ((Throwable) -> Unit)? = null
3728

38-
/** Let Toolbox know the fields should be updated. */
39-
protected var listener: Consumer<UiField?>? = null
40-
4129
/** Stores errors until the notifier is attached. */
4230
private var errorBuffer: MutableList<Throwable> = mutableListOf()
4331

@@ -76,14 +64,6 @@ abstract class CoderPage(
7664
errorBuffer.clear()
7765
}
7866
}
79-
80-
/**
81-
* Set/unset the field error and update the form.
82-
*/
83-
protected fun updateError(error: String?) {
84-
errorField = error?.let { ValidationErrorField(context.i18n.pnotr(error)) }
85-
listener?.accept(null) // Make Toolbox get the fields again.
86-
}
8767
}
8868

8969
/**

0 commit comments

Comments
 (0)