Skip to content
Merged
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## Unreleased

### Fixed

- Toolbox remembers the authentication page that was last visible on the screen

## 0.1.2 - 2025-04-04

### Fixed
Expand Down
2 changes: 1 addition & 1 deletion gradle.properties
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
version=0.1.2
version=0.1.3
group=com.coder.toolbox
name=coder-toolbox
62 changes: 13 additions & 49 deletions src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,11 @@ import com.coder.toolbox.settings.SettingSource
import com.coder.toolbox.util.CoderProtocolHandler
import com.coder.toolbox.util.DialogUi
import com.coder.toolbox.views.Action
import com.coder.toolbox.views.AuthWizardPage
import com.coder.toolbox.views.CoderSettingsPage
import com.coder.toolbox.views.ConnectPage
import com.coder.toolbox.views.NewEnvironmentPage
import com.coder.toolbox.views.SignInPage
import com.coder.toolbox.views.TokenPage
import com.coder.toolbox.views.state.AuthWizardState
import com.coder.toolbox.views.state.WizardStep
import com.jetbrains.toolbox.api.core.ui.icons.SvgIcon
import com.jetbrains.toolbox.api.core.ui.icons.SvgIcon.IconType
import com.jetbrains.toolbox.api.core.util.LoadableState
Expand Down Expand Up @@ -67,7 +67,7 @@ class CoderRemoteProvider(
// On the first load, automatically log in if we can.
private var firstRun = true
private val isInitialized: MutableStateFlow<Boolean> = MutableStateFlow(false)
private var coderHeaderPage = NewEnvironmentPage(context, context.i18n.pnotr(getDeploymentURL()?.first ?: ""))
private var coderHeaderPage = NewEnvironmentPage(context, context.i18n.pnotr(context.deploymentUrl?.first ?: ""))
private val linkHandler = CoderProtocolHandler(context, dialogUi, isInitialized)
override val environments: MutableStateFlow<LoadableState<List<RemoteProviderEnvironment>>> = MutableStateFlow(
LoadableState.Value(emptyList())
Expand Down Expand Up @@ -189,7 +189,7 @@ class CoderRemoteProvider(
if (username != null) {
return dropDownFactory(context.i18n.pnotr(username)) {
logout()
context.ui.showUiPage(getOverrideUiPage()!!)
context.envPageManager.showPluginEnvironmentsPage()
}
}
return null
Expand All @@ -215,6 +215,7 @@ class CoderRemoteProvider(
environments.value = LoadableState.Value(emptyList())
isInitialized.update { false }
client = null
AuthWizardState.resetSteps()
}

override val svgIcon: SvgIcon =
Expand Down Expand Up @@ -306,7 +307,8 @@ class CoderRemoteProvider(
context.secrets.lastDeploymentURL.let { lastDeploymentURL ->
if (autologin && lastDeploymentURL.isNotBlank() && (lastToken.isNotBlank() || !settings.requireTokenAuth)) {
try {
return createConnectPage(URL(lastDeploymentURL), lastToken)
AuthWizardState.goToStep(WizardStep.LOGIN)
return AuthWizardPage(context, true, ::onConnect)
} catch (ex: Exception) {
autologinEx = ex
}
Expand All @@ -316,40 +318,20 @@ class CoderRemoteProvider(
firstRun = false

// Login flow.
val signInPage =
SignInPage(context, getDeploymentURL()) { deploymentURL ->
context.ui.showUiPage(
TokenPage(
context,
deploymentURL,
getToken(deploymentURL)
) { selectedToken ->
context.ui.showUiPage(createConnectPage(deploymentURL, selectedToken))
},
)
}

val authWizard = AuthWizardPage(context, false, ::onConnect)
// We might have tried and failed to automatically log in.
autologinEx?.let { signInPage.notify("Error logging in", it) }
autologinEx?.let { authWizard.notify("Error logging in", it) }
// We might have navigated here due to a polling error.
pollError?.let { signInPage.notify("Error fetching workspaces", it) }
pollError?.let { authWizard.notify("Error fetching workspaces", it) }

return signInPage
return authWizard
}
return null
}

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

/**
* Create a connect page that starts polling and resets the UI on success.
*/
private fun createConnectPage(deploymentURL: URL, token: String?): ConnectPage = ConnectPage(
context,
deploymentURL,
token,
::goToEnvironmentsPage,
) { client, cli ->
private fun onConnect(client: CoderRestClient, cli: CoderCLIManager) {
// Store the URL and token for use next time.
context.secrets.lastDeploymentURL = client.url.toString()
context.secrets.lastToken = client.token ?: ""
Expand Down Expand Up @@ -378,22 +360,4 @@ class CoderRemoteProvider(
settings.token(deploymentURL)
}
}

/**
* Try to find a URL.
*
* In order of preference:
*
* 1. Last used URL.
* 2. URL in settings.
* 3. CODER_URL.
* 4. URL in global cli config.
*/
private fun getDeploymentURL(): Pair<String, SettingSource>? = context.secrets.lastDeploymentURL.let {
if (it.isNotBlank()) {
it to SettingSource.LAST_USED
} else {
context.settingsStore.defaultURL()
}
}
}
42 changes: 41 additions & 1 deletion src/main/kotlin/com/coder/toolbox/CoderToolboxContext.kt
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
package com.coder.toolbox

import com.coder.toolbox.settings.SettingSource
import com.coder.toolbox.store.CoderSecretsStore
import com.coder.toolbox.store.CoderSettingsStore
import com.coder.toolbox.util.toURL
import com.jetbrains.toolbox.api.core.diagnostics.Logger
import com.jetbrains.toolbox.api.localization.LocalizableStringFactory
import com.jetbrains.toolbox.api.remoteDev.connection.ClientHelper
Expand All @@ -20,4 +22,42 @@ data class CoderToolboxContext(
val i18n: LocalizableStringFactory,
val settingsStore: CoderSettingsStore,
val secrets: CoderSecretsStore
)
) {
/**
* Try to find a URL.
*
* In order of preference:
*
* 1. Last used URL.
* 2. URL in settings.
* 3. CODER_URL.
* 4. URL in global cli config.
*/
val deploymentUrl: Pair<String, SettingSource>? = this.secrets.lastDeploymentURL.let {
if (it.isNotBlank()) {
it to SettingSource.LAST_USED
} else {
this.settingsStore.defaultURL()
}
}

/**
* Try to find a token.
*
* Order of preference:
*
* 1. Last used token, if it was for this deployment.
* 2. Token on disk for this deployment.
* 3. Global token for Coder, if it matches the deployment.
*/
fun getToken(deploymentURL: String?): Pair<String, SettingSource>? = this.secrets.lastToken.let {
if (it.isNotBlank() && this.secrets.lastDeploymentURL == deploymentURL) {
it to SettingSource.LAST_USED
} else {
if (deploymentURL != null) {
this.settingsStore.token(deploymentURL.toURL())
} else null
}
}

}
2 changes: 1 addition & 1 deletion src/main/kotlin/com/coder/toolbox/util/URLExtensions.kt
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import java.net.IDN
import java.net.URI
import java.net.URL

fun String.toURL(): URL = URL(this)
fun String.toURL(): URL = URI.create(this).toURL()

fun URL.withPath(path: String): URL = URL(
this.protocol,
Expand Down
94 changes: 94 additions & 0 deletions src/main/kotlin/com/coder/toolbox/views/AuthWizardPage.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
package com.coder.toolbox.views

import com.coder.toolbox.CoderToolboxContext
import com.coder.toolbox.cli.CoderCLIManager
import com.coder.toolbox.sdk.CoderRestClient
import com.coder.toolbox.views.state.AuthWizardState
import com.coder.toolbox.views.state.WizardStep
import com.jetbrains.toolbox.api.ui.actions.RunnableActionDescription
import com.jetbrains.toolbox.api.ui.components.UiField
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update

class AuthWizardPage(
private val context: CoderToolboxContext,
private val isAutoLogin: Boolean = false,
onConnect: (
client: CoderRestClient,
cli: CoderCLIManager,
) -> Unit,
) : CoderPage(context, context.i18n.ptrl("Authenticate to Coder")) {
private val signInStep = SignInStep(context)
private val tokenStep = TokenStep(context)
private val connectStep = ConnectStep(context, this::notify, onConnect)


/**
* Fields for this page, displayed in order.
*/
override val fields: MutableStateFlow<List<UiField>> = MutableStateFlow(emptyList())
override val actionButtons: MutableStateFlow<List<RunnableActionDescription>> = MutableStateFlow(emptyList())

override fun beforeShow() {
displaySteps()
}

private fun displaySteps() {
when (AuthWizardState.currentStep()) {
WizardStep.URL_REQUEST -> {
fields.update {
listOf(signInStep.panel)
}
actionButtons.update {
listOf(
Action(context.i18n.ptrl("Sign In"), closesPage = false, actionBlock = {
if (signInStep.onNext()) {
displaySteps()
}
})
)
}
signInStep.onVisible()
}

WizardStep.TOKEN_REQUEST -> {
fields.update {
listOf(tokenStep.panel)
}
actionButtons.update {
listOf(
Action(context.i18n.ptrl("Back"), closesPage = false, actionBlock = {
tokenStep.onBack()
displaySteps()
}),
Action(context.i18n.ptrl("Connect"), closesPage = false, actionBlock = {
if (tokenStep.onNext()) {
displaySteps()
}
})
)
}
tokenStep.onVisible()
}

WizardStep.LOGIN -> {
fields.update {
listOf(connectStep.panel)
}
actionButtons.update {
listOf(
Action(context.i18n.ptrl("Back"), closesPage = false, actionBlock = {
if (isAutoLogin) {
AuthWizardState.resetSteps()
} else {
connectStep.onBack()
}
displaySteps()
})
)
}
connectStep.onVisible()
}
}
}
}
20 changes: 0 additions & 20 deletions src/main/kotlin/com/coder/toolbox/views/CoderPage.kt
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,7 @@ import com.jetbrains.toolbox.api.core.ui.icons.SvgIcon
import com.jetbrains.toolbox.api.core.ui.icons.SvgIcon.IconType
import com.jetbrains.toolbox.api.localization.LocalizableString
import com.jetbrains.toolbox.api.ui.actions.RunnableActionDescription
import com.jetbrains.toolbox.api.ui.components.UiField
import com.jetbrains.toolbox.api.ui.components.UiPage
import com.jetbrains.toolbox.api.ui.components.ValidationErrorField
import java.util.function.Consumer

/**
* Base page that handles the icon, displaying error notifications, and
Expand All @@ -25,19 +22,10 @@ abstract class CoderPage(
title: LocalizableString,
showIcon: Boolean = true,
) : UiPage(title) {
/**
* An error to display on the page.
*
* The current assumption is you only have one field per page.
*/
protected var errorField: ValidationErrorField? = null

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

/** Let Toolbox know the fields should be updated. */
protected var listener: Consumer<UiField?>? = null

/** Stores errors until the notifier is attached. */
private var errorBuffer: MutableList<Throwable> = mutableListOf()

Expand Down Expand Up @@ -76,14 +64,6 @@ abstract class CoderPage(
errorBuffer.clear()
}
}

/**
* Set/unset the field error and update the form.
*/
protected fun updateError(error: String?) {
errorField = error?.let { ValidationErrorField(context.i18n.pnotr(error)) }
listener?.accept(null) // Make Toolbox get the fields again.
}
}

/**
Expand Down
Loading
Loading