diff --git a/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt b/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt index c265098..aa023fc 100644 --- a/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt +++ b/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt @@ -4,7 +4,6 @@ import com.coder.toolbox.browser.browse import com.coder.toolbox.cli.CoderCLIManager import com.coder.toolbox.feed.IdeFeedManager import com.coder.toolbox.oauth.OAuth2Client -import com.coder.toolbox.oauth.OAuthTokenResponse import com.coder.toolbox.plugin.PluginManager import com.coder.toolbox.sdk.CoderRestClient import com.coder.toolbox.sdk.ex.APIResponseException @@ -22,15 +21,15 @@ import com.coder.toolbox.util.url import com.coder.toolbox.util.validateStrictWebUrl import com.coder.toolbox.util.withPath import com.coder.toolbox.views.Action -import com.coder.toolbox.views.CoderCliSetupWizardPage import com.coder.toolbox.views.CoderDelimiter import com.coder.toolbox.views.CoderSettingsPage +import com.coder.toolbox.views.CoderSetupWizardPage import com.coder.toolbox.views.NewEnvironmentPage import com.coder.toolbox.views.SuspendBiConsumer import com.coder.toolbox.views.state.CoderOAuthSessionContext -import com.coder.toolbox.views.state.CoderSetupWizardContext -import com.coder.toolbox.views.state.CoderSetupWizardState -import com.coder.toolbox.views.state.WizardStep +import com.coder.toolbox.views.state.Credentials +import com.coder.toolbox.views.state.PageRouter +import com.coder.toolbox.views.state.toSessionContext 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 @@ -113,6 +112,8 @@ class CoderRemoteProvider( private val errorBuffer = mutableListOf() + private val router = PageRouter() + /** * With the provided client, start polling for workspaces. Every time a new * workspace is added, reconfigure SSH using the provided cli (including the @@ -269,6 +270,7 @@ class CoderRemoteProvider( lastEnvironments.clear() environments.value = LoadableState.Value(emptyList()) isInitialized.update { false } + router.clear() context.logger.info("Coder plugin is now closed") } @@ -345,9 +347,6 @@ class CoderRemoteProvider( */ override suspend fun handleUri(uri: URI) { try { - // Obtain focus. This switches to the main plugin screen, even - // if last opened provider was not Coder - context.envPageManager.showPluginEnvironmentsPage() if (uri.toString().startsWith("jetbrains://gateway/com.coder.toolbox/auth")) { handleOAuthUri(uri) return @@ -372,25 +371,23 @@ class CoderRemoteProvider( linkHandler.handle(params, newUrl, this.client!!, this.cli!!) coderHeaderPage.isBusy.update { false } } else { - // Different URL - we need a new connection. - // Chain the link handling after onConnect so it runs once the connection is established. - CoderSetupWizardContext.apply { - url = newUrl - token = newToken - } - CoderSetupWizardState.goToStep(WizardStep.CONNECT) - context.ui.showUiPage( - CoderCliSetupWizardPage( - context, settingsPage, visibilityState, - initialAutoSetup = true, - jumpToMainPageOnError = true, - onConnect = onConnect.andThen(deferredLinkHandler(params, newUrl)) - .andThen { _, _ -> - coderHeaderPage.isBusy.update { false } - }, - onTokenRefreshed = ::onTokenRefreshed - ) + // Different URL - we need a new connection. Tear down any + // in-flight wizard, install a fresh one on the router, and let + // showPluginEnvironmentsPage() pull it through getOverrideUiPage. + val credentials = newToken?.let { Credentials.Token(it) } ?: Credentials.MTls + val wizard = CoderSetupWizardPage.connectStep( + context, settingsPage, visibilityState, + url = newUrl, + credentials = credentials, + jumpToMainPageOnError = true, + onConnect = onConnect.andThen(deferredLinkHandler(params, newUrl)) + .andThen { _, _ -> + coderHeaderPage.isBusy.update { false } + }, + onTokenRefreshed = ::onTokenRefreshed, ) + router.replaceWith(wizard) + context.envPageManager.showPluginEnvironmentsPage() } } catch (ex: Exception) { val textError = if (ex is APIResponseException) { @@ -403,7 +400,6 @@ class CoderRemoteProvider( textError ?: "" ) coderHeaderPage.isBusy.update { false } - context.envPageManager.showPluginEnvironmentsPage() } finally { firstRun = false } @@ -424,7 +420,15 @@ class CoderRemoteProvider( ) } - params["state"]?.takeIf { it == CoderSetupWizardContext.oauthSession?.state } + val activeWizard = router.activeWizard ?: return context.logAndShowError( + FAILED_TO_HANDLE_OAUTH2_TITLE, + "OAuth2 callback arrived but the setup wizard is no longer active" + ) + val activeOAuthSession = activeWizard.model.oauthSession ?: return context.logAndShowError( + FAILED_TO_HANDLE_OAUTH2_TITLE, + "OAuth2 callback arrived but no OAuth session was started" + ) + params["state"]?.takeIf { it == activeOAuthSession.state } ?: return context.logAndShowError( FAILED_TO_HANDLE_OAUTH2_TITLE, "Server responded back with an invalid state that does not match the initial authorization state sent to the server" @@ -442,18 +446,19 @@ class CoderRemoteProvider( ) return } - exchangeOAuthCodeForToken(code, CoderSetupWizardContext.oauthSession!!) + exchangeOAuthCodeForToken(code, activeOAuthSession, activeWizard) } - private suspend fun exchangeOAuthCodeForToken(code: String, oauthSessionContext: CoderOAuthSessionContext) { + private suspend fun exchangeOAuthCodeForToken( + code: String, + oauthSessionContext: CoderOAuthSessionContext, + wizard: CoderSetupWizardPage, + ) { try { context.logger.info("Handling OAuth callback...") val tokenResponse = OAuth2Client(context).exchangeCode(oauthSessionContext, code) - CoderSetupWizardContext.oauthSession = oauthSessionContext.copy(tokenResponse = tokenResponse) - - CoderSetupWizardState.goToStep(WizardStep.CONNECT) - + wizard.advanceToConnectWithOAuth(oauthSessionContext.copy(tokenResponse = tokenResponse)) } catch (e: Exception) { context.logAndShowError("OAuth Error", "Exception during token exchange: ${e.message}", e) } @@ -520,67 +525,51 @@ class CoderRemoteProvider( * list. */ override fun getOverrideUiPage(): UiPage? { - // Show the setup page if we have not configured the client yet. - if (client == null) { - // When coming back to the application, initializeSession immediately. - if (shouldDoAutoSetup()) { - try { - val storedOAuthSession = context.secrets.oauthSessionFor(context.deploymentUrl.toString()) - CoderSetupWizardContext.apply { - url = context.deploymentUrl - token = context.secrets.apiTokenFor(context.deploymentUrl) - if (storedOAuthSession != null) { - oauthSession = CoderOAuthSessionContext( - clientId = storedOAuthSession.clientId, - clientSecret = storedOAuthSession.clientSecret, - tokenCodeVerifier = "", - state = "", - tokenEndpoint = storedOAuthSession.tokenEndpoint, - tokenAuthMethod = storedOAuthSession.tokenAuthMethod, - tokenResponse = OAuthTokenResponse( - accessToken = "", - tokenType = "", - expiresIn = null, - refreshToken = storedOAuthSession.refreshToken, - scope = null - ) - ) - } - } - CoderSetupWizardState.goToStep(WizardStep.CONNECT) - return CoderCliSetupWizardPage( - context, settingsPage, visibilityState, - initialAutoSetup = true, - jumpToMainPageOnError = false, - onConnect = onConnect, - onTokenRefreshed = ::onTokenRefreshed - ) - } catch (ex: Exception) { - errorBuffer.add(ex) - } finally { - firstRun = false - } - } + if (client != null) return null + return router.getOrCreate { buildSetupWizard() } + } - // Login flow. - CoderSetupWizardState.goToFirstStep() - val setupWizardPage = - CoderCliSetupWizardPage( - context, - settingsPage, - visibilityState, + /** + * Build the wizard for the current state. Called once per provider lifetime + * (until [close] clears the router); subsequent visibility cycles reuse the + * same instance, preserving any in-flight connect job. + */ + private fun buildSetupWizard(): CoderSetupWizardPage { + // When coming back to the application, initializeSession immediately. + if (shouldDoAutoSetup()) { + try { + val url = context.deploymentUrl + val credentials = context.secrets.oauthSessionFor(url.toString())?.let { + Credentials.OAuth(it.toSessionContext()) + } ?: context.secrets.apiTokenFor(url)?.let { + Credentials.Token(it) + } ?: Credentials.MTls + return CoderSetupWizardPage.connectStep( + context, settingsPage, visibilityState, + url = url, + credentials = credentials, onConnect = onConnect, - onTokenRefreshed = ::onTokenRefreshed + onTokenRefreshed = ::onTokenRefreshed, ) - // We might have navigated here due to a polling error. - errorBuffer.forEach { - setupWizardPage.notify("Error encountered", it) + } catch (ex: Exception) { + errorBuffer.add(ex) + } finally { + firstRun = false } - errorBuffer.clear() - // and now reset the errors, otherwise we show it every time on the screen - return setupWizardPage } - return null + + // Login flow. + val setupWizardPage = CoderSetupWizardPage.deploymentUrlStep( + context, settingsPage, visibilityState, + onConnect = onConnect, + onTokenRefreshed = ::onTokenRefreshed, + ) + // We might have navigated here due to a polling error. + errorBuffer.forEach { + setupWizardPage.notify("Error encountered", it) + } + errorBuffer.clear() + return setupWizardPage } /** diff --git a/src/main/kotlin/com/coder/toolbox/cli/downloader/CoderDownloadService.kt b/src/main/kotlin/com/coder/toolbox/cli/downloader/CoderDownloadService.kt index da5e2f2..3836675 100644 --- a/src/main/kotlin/com/coder/toolbox/cli/downloader/CoderDownloadService.kt +++ b/src/main/kotlin/com/coder/toolbox/cli/downloader/CoderDownloadService.kt @@ -8,6 +8,7 @@ import com.coder.toolbox.util.getHeaders import com.coder.toolbox.util.getOS import com.coder.toolbox.util.sha1 import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay import kotlinx.coroutines.withContext import okhttp3.ResponseBody import retrofit2.Response @@ -24,6 +25,7 @@ import java.nio.file.StandardOpenOption import java.util.zip.GZIPInputStream import kotlin.io.path.name import kotlin.io.path.notExists +import kotlin.time.Duration.Companion.seconds private val SUPPORTED_BIN_MIME_TYPES = listOf( "application/octet-stream", @@ -73,6 +75,7 @@ class CoderDownloadService( } context.logger.info("Downloading binary to temporary $cliTempDst") response.saveToDisk(cliTempDst, showTextProgress, buildVersion)?.makeExecutable() + delay(10.seconds) DownloadResult.Downloaded(remoteBinaryURL, cliTempDst) } diff --git a/src/main/kotlin/com/coder/toolbox/views/CoderCliSetupWizardPage.kt b/src/main/kotlin/com/coder/toolbox/views/CoderSetupWizardPage.kt similarity index 57% rename from src/main/kotlin/com/coder/toolbox/views/CoderCliSetupWizardPage.kt rename to src/main/kotlin/com/coder/toolbox/views/CoderSetupWizardPage.kt index f4391cd..ff82353 100644 --- a/src/main/kotlin/com/coder/toolbox/views/CoderCliSetupWizardPage.kt +++ b/src/main/kotlin/com/coder/toolbox/views/CoderSetupWizardPage.kt @@ -4,7 +4,8 @@ import com.coder.toolbox.CoderToolboxContext import com.coder.toolbox.cli.CoderCLIManager import com.coder.toolbox.sdk.CoderRestClient import com.coder.toolbox.views.state.CoderOAuthSessionContext -import com.coder.toolbox.views.state.CoderSetupWizardState +import com.coder.toolbox.views.state.Credentials +import com.coder.toolbox.views.state.WizardModel import com.coder.toolbox.views.state.WizardStep import com.jetbrains.toolbox.api.remoteDev.ProviderVisibilityState import com.jetbrains.toolbox.api.ui.actions.RunnableActionDescription @@ -16,7 +17,7 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import java.net.URL -class CoderCliSetupWizardPage( +class CoderSetupWizardPage private constructor( private val context: CoderToolboxContext, private val settingsPage: CoderSettingsPage, visibilityState: StateFlow, @@ -25,15 +26,75 @@ class CoderCliSetupWizardPage( onConnect: SuspendBiConsumer, onTokenRefreshed: (suspend (url: URL, oauthSessionCtx: CoderOAuthSessionContext) -> Unit)? = null ) : CoderPage(MutableStateFlow(context.i18n.ptrl("Setting up Coder")), false) { + + companion object { + fun deploymentUrlStep( + context: CoderToolboxContext, + settingsPage: CoderSettingsPage, + visibilityState: StateFlow, + onConnect: SuspendBiConsumer, + onTokenRefreshed: (suspend (url: URL, oauthSessionCtx: CoderOAuthSessionContext) -> Unit)? = null, + ): CoderSetupWizardPage = CoderSetupWizardPage( + context, settingsPage, visibilityState, + onConnect = onConnect, + onTokenRefreshed = onTokenRefreshed, + ).apply { model.goToFirst() } + + fun tokenStep( + context: CoderToolboxContext, + settingsPage: CoderSettingsPage, + visibilityState: StateFlow, + url: URL, + onConnect: SuspendBiConsumer, + onTokenRefreshed: (suspend (url: URL, oauthSessionCtx: CoderOAuthSessionContext) -> Unit)? = null, + ): CoderSetupWizardPage = CoderSetupWizardPage( + context, settingsPage, visibilityState, + onConnect = onConnect, + onTokenRefreshed = onTokenRefreshed, + ).apply { + model.url = url + model.goTo(WizardStep.TOKEN_REQUEST) + } + + fun connectStep( + context: CoderToolboxContext, + settingsPage: CoderSettingsPage, + visibilityState: StateFlow, + url: URL, + credentials: Credentials, + initialAutoSetup: Boolean = true, + jumpToMainPageOnError: Boolean = false, + onConnect: SuspendBiConsumer, + onTokenRefreshed: (suspend (url: URL, oauthSessionCtx: CoderOAuthSessionContext) -> Unit)? = null, + ): CoderSetupWizardPage = CoderSetupWizardPage( + context, settingsPage, visibilityState, + initialAutoSetup = initialAutoSetup, + jumpToMainPageOnError = jumpToMainPageOnError, + onConnect = onConnect, + onTokenRefreshed = onTokenRefreshed, + ).apply { + model.url = url + when (credentials) { + is Credentials.MTls -> Unit + is Credentials.Token -> model.token = credentials.value + is Credentials.OAuth -> model.oauthSession = credentials.session + } + model.goTo(WizardStep.CONNECT) + } + } + + val model: WizardModel = WizardModel() + private val shouldAutoSetup = MutableStateFlow(initialAutoSetup) private val settingsAction = Action(context, "Settings") { context.ui.showUiPage(settingsPage) } - private val deploymentUrlStep = DeploymentUrlStep(context, visibilityState) - private val tokenStep = TokenStep(context) + private val deploymentUrlStep = DeploymentUrlStep(context, model, visibilityState) + private val tokenStep = TokenStep(context, model) private val connectStep = ConnectStep( context, + model, shouldAutoLogin = shouldAutoSetup, jumpToMainPageOnError = jumpToMainPageOnError, visibilityState, @@ -50,11 +111,10 @@ class CoderCliSetupWizardPage( override val fields: MutableStateFlow> = MutableStateFlow(emptyList()) override val actionButtons: MutableStateFlow> = MutableStateFlow(emptyList()) - override fun beforeShow() { stateCollectJob?.cancel() stateCollectJob = context.cs.launch { - CoderSetupWizardState.step.collect { step -> + model.step.collect { step -> context.logger.info("Wizard step changed to $step") displaySteps() } @@ -63,7 +123,7 @@ class CoderCliSetupWizardPage( } private fun displaySteps() { - when (CoderSetupWizardState.currentStep()) { + when (model.currentStep()) { WizardStep.URL_REQUEST -> { fields.update { listOf(deploymentUrlStep.panel) @@ -120,16 +180,24 @@ class CoderCliSetupWizardPage( } connectStep.onVisible() } - - WizardStep.DONE -> { - context.logger.info("Closing the Setup Wizard") - stateCollectJob?.cancel() - context.ui.hideUiPage(this) - CoderSetupWizardState.goToFirstStep() - } } } + fun advanceToConnectWithOAuth(oauthSession: CoderOAuthSessionContext) { + model.oauthSession = oauthSession + model.goTo(WizardStep.CONNECT) + } + + /** + * Cancels any in-flight work owned by this wizard. Called by the page router + * when the wizard is being replaced (e.g. by a deep link to a different + * deployment) so its connect job doesn't keep running and clobber the new one. + */ + fun dispose() { + stateCollectJob?.cancel() + connectStep.dispose() + } + override fun afterHide() { stateCollectJob?.cancel() } diff --git a/src/main/kotlin/com/coder/toolbox/views/ConnectStep.kt b/src/main/kotlin/com/coder/toolbox/views/ConnectStep.kt index f780627..681e0eb 100644 --- a/src/main/kotlin/com/coder/toolbox/views/ConnectStep.kt +++ b/src/main/kotlin/com/coder/toolbox/views/ConnectStep.kt @@ -7,8 +7,7 @@ import com.coder.toolbox.oauth.OAuth2Client import com.coder.toolbox.plugin.PluginManager import com.coder.toolbox.sdk.CoderRestClient import com.coder.toolbox.views.state.CoderOAuthSessionContext -import com.coder.toolbox.views.state.CoderSetupWizardContext -import com.coder.toolbox.views.state.CoderSetupWizardState +import com.coder.toolbox.views.state.WizardModel import com.jetbrains.toolbox.api.remoteDev.ProviderVisibilityState import com.jetbrains.toolbox.api.ui.components.LabelField import com.jetbrains.toolbox.api.ui.components.RowGroup @@ -30,6 +29,7 @@ private const val USER_HIT_THE_BACK_BUTTON = "User hit the back button" */ class ConnectStep( private val context: CoderToolboxContext, + private val model: WizardModel, private val shouldAutoLogin: StateFlow, private val jumpToMainPageOnError: Boolean, visibilityState: StateFlow, @@ -54,7 +54,7 @@ class ConnectStep( context.i18n.pnotr("") } - if (context.settingsStore.requiresTokenAuth && CoderSetupWizardContext.isNotReadyForAuth()) { + if (context.settingsStore.requiresTokenAuth && model.isNotReadyForAuth()) { errorField.textState.update { context.i18n.pnotr("URL and token were not properly configured. Please go back and provide a proper URL and token!") } @@ -67,7 +67,7 @@ class ConnectStep( return } - statusField.textState.update { context.i18n.pnotr("Connecting to ${CoderSetupWizardContext.url?.host ?: "unknown host"}...") } + statusField.textState.update { context.i18n.pnotr("Connecting to ${model.url?.host ?: "unknown host"}...") } connect() } @@ -75,13 +75,13 @@ class ConnectStep( * Try connecting to Coder with the provided URL and token. */ private fun connect() { - val url = CoderSetupWizardContext.url + val url = model.url if (url == null) { errorField.textState.update { context.i18n.ptrl("URL is required") } return } - if (context.settingsStore.requiresTokenAuth && !CoderSetupWizardContext.hasToken() && !CoderSetupWizardContext.hasOAuthSession()) { + if (context.settingsStore.requiresTokenAuth && !model.hasToken() && !model.hasOAuthSession()) { errorField.textState.update { context.i18n.ptrl("Token is required") } return } @@ -96,12 +96,12 @@ class ConnectStep( val connectionLogic: suspend CoroutineScope.() -> Unit = { try { var oauthSession: CoderOAuthSessionContext? = null - if (context.settingsStore.requiresTokenAuth && context.settingsStore.preferOAuth2IfAvailable && CoderSetupWizardContext.hasOAuthSession()) { + if (context.settingsStore.requiresTokenAuth && context.settingsStore.preferOAuth2IfAvailable && model.hasOAuthSession()) { refreshOAuthToken() - oauthSession = CoderSetupWizardContext.oauthSession!!.copy() + oauthSession = model.oauthSession!!.copy() } - val apiToken = if (context.settingsStore.requiresTokenAuth) CoderSetupWizardContext.token else null + val apiToken = if (context.settingsStore.requiresTokenAuth) model.token else null context.logger.info("Setting up the HTTP client...") val client = CoderRestClient( @@ -142,8 +142,8 @@ class ConnectStep( oauthSession?.let { session -> onTokenRefreshed?.invoke(client.url, session) } - CoderSetupWizardContext.reset() - CoderSetupWizardState.goToDone() + // The provider's onConnect ran close() which clears the router; combined + // with client now being non-null this drops the wizard from getOverrideUiPage. context.envPageManager.showPluginEnvironmentsPage() } catch (ex: CancellationException) { if (ex.message != USER_HIT_THE_BACK_BUTTON) { @@ -162,13 +162,13 @@ class ConnectStep( } private suspend fun refreshOAuthToken() { - val session = CoderSetupWizardContext.oauthSession ?: return + val session = model.oauthSession ?: return if (!session.tokenResponse?.accessToken.isNullOrBlank()) return logAndReportProgress("Refreshing OAuth token...") val tokenResponse = OAuth2Client(context).refreshToken(session) context.logger.info("Successfully refreshed access token") - CoderSetupWizardContext.oauthSession = session.copy(tokenResponse = tokenResponse) + model.oauthSession = session.copy(tokenResponse = tokenResponse) } private fun logAndReportProgress(msg: String) { @@ -181,17 +181,17 @@ class ConnectStep( */ private fun handleNavigation() { if (shouldAutoLogin.value) { - CoderSetupWizardContext.reset() + model.clearFormData() if (jumpToMainPageOnError) { context.popupPluginMainPage() } else { - CoderSetupWizardState.goToFirstStep() + model.goToFirst() } } else { if (context.settingsStore.requiresTokenAuth) { - CoderSetupWizardState.goToPreviousStep() + model.goToPrevious() } else { - CoderSetupWizardState.goToFirstStep() + model.goToFirst() } } } @@ -208,4 +208,14 @@ class ConnectStep( handleNavigation() } } + + /** + * Cancels any in-flight connection without navigating. Used when the wizard + * itself is being torn down by an external trigger (e.g. a deep link to a + * different deployment). + */ + fun dispose() { + signInJob?.cancel(CancellationException(USER_HIT_THE_BACK_BUTTON)) + signInJob = null + } } diff --git a/src/main/kotlin/com/coder/toolbox/views/DeploymentUrlStep.kt b/src/main/kotlin/com/coder/toolbox/views/DeploymentUrlStep.kt index 8351732..8efd7d5 100644 --- a/src/main/kotlin/com/coder/toolbox/views/DeploymentUrlStep.kt +++ b/src/main/kotlin/com/coder/toolbox/views/DeploymentUrlStep.kt @@ -11,8 +11,7 @@ import com.coder.toolbox.util.WebUrlValidationResult.Invalid import com.coder.toolbox.util.toURL import com.coder.toolbox.util.validateStrictWebUrl import com.coder.toolbox.views.state.CoderOAuthSessionContext -import com.coder.toolbox.views.state.CoderSetupWizardContext -import com.coder.toolbox.views.state.CoderSetupWizardState +import com.coder.toolbox.views.state.WizardModel import com.jetbrains.toolbox.api.remoteDev.ProviderVisibilityState import com.jetbrains.toolbox.api.ui.components.CheckboxField import com.jetbrains.toolbox.api.ui.components.LabelField @@ -40,6 +39,7 @@ private const val OAUTH2_SCOPE: String = */ class DeploymentUrlStep( private val context: CoderToolboxContext, + private val model: WizardModel, visibilityState: StateFlow, ) : WizardStep { @@ -94,20 +94,20 @@ class DeploymentUrlStep( } try { - CoderSetupWizardContext.url = validateRawUrl(rawUrl) + model.url = validateRawUrl(rawUrl) } catch (e: MalformedURLException) { errorReporter.report("URL is invalid", e) return false } if (context.settingsStore.requiresMTlsAuth) { - CoderSetupWizardState.goToLastStep() + model.goToLast() return true } if (context.settingsStore.requiresTokenAuth && context.settingsStore.preferOAuth2IfAvailable) { try { context.logger.info("Prefers OAuth2 authentication") - CoderSetupWizardContext.oauthSession = handleOAuth2(rawUrl) + model.oauthSession = handleOAuth2(rawUrl) return false } catch (e: Exception) { errorReporter.report("Failed to authenticate with OAuth2: ${e.message}", e) @@ -115,7 +115,7 @@ class DeploymentUrlStep( } } // if all else fails try the good old API token auth - CoderSetupWizardState.goToNextStep() + model.goToNext() return true } diff --git a/src/main/kotlin/com/coder/toolbox/views/TokenStep.kt b/src/main/kotlin/com/coder/toolbox/views/TokenStep.kt index b50cdec..d91266f 100644 --- a/src/main/kotlin/com/coder/toolbox/views/TokenStep.kt +++ b/src/main/kotlin/com/coder/toolbox/views/TokenStep.kt @@ -2,8 +2,7 @@ package com.coder.toolbox.views import com.coder.toolbox.CoderToolboxContext import com.coder.toolbox.util.withPath -import com.coder.toolbox.views.state.CoderSetupWizardContext -import com.coder.toolbox.views.state.CoderSetupWizardState +import com.coder.toolbox.views.state.WizardModel import com.jetbrains.toolbox.api.ui.components.LinkField import com.jetbrains.toolbox.api.ui.components.RowGroup import com.jetbrains.toolbox.api.ui.components.TextField @@ -20,6 +19,7 @@ import kotlinx.coroutines.flow.update */ class TokenStep( private val context: CoderToolboxContext, + private val model: WizardModel, ) : WizardStep { private val tokenField = TextField(context.i18n.ptrl("Token"), "", TextType.Password) private val linkField = LinkField(context.i18n.ptrl("Get a token"), "") @@ -35,9 +35,9 @@ class TokenStep( errorField.textState.update { context.i18n.pnotr("") } - if (CoderSetupWizardContext.hasUrl()) { + if (model.hasUrl()) { tokenField.textState.update { - context.secrets.apiTokenFor(CoderSetupWizardContext.url!!) ?: "" + context.secrets.apiTokenFor(model.url!!) ?: "" } } else { errorField.textState.update { @@ -46,7 +46,7 @@ class TokenStep( } } (linkField.urlState as MutableStateFlow).update { - CoderSetupWizardContext.url!!.withPath("/login?redirect=%2Fcli-auth")?.toString() ?: "" + model.url!!.withPath("/login?redirect=%2Fcli-auth")?.toString() ?: "" } } @@ -57,12 +57,12 @@ class TokenStep( return false } - CoderSetupWizardContext.token = token - CoderSetupWizardState.goToNextStep() + model.token = token + model.goToNext() return true } override fun onBack() { - CoderSetupWizardState.goToPreviousStep() + model.goToPrevious() } } diff --git a/src/main/kotlin/com/coder/toolbox/views/state/CoderSetupWizardContext.kt b/src/main/kotlin/com/coder/toolbox/views/state/CoderSetupWizardContext.kt deleted file mode 100644 index 56182b4..0000000 --- a/src/main/kotlin/com/coder/toolbox/views/state/CoderSetupWizardContext.kt +++ /dev/null @@ -1,78 +0,0 @@ -package com.coder.toolbox.views.state - -import com.coder.toolbox.oauth.OAuthTokenResponse -import com.coder.toolbox.oauth.TokenEndpointAuthMethod -import java.net.URL - -/** - * Singleton that holds Coder setup wizard context (URL and token) across multiple - * Toolbox window lifecycle events. - * - * This ensures that user input (URL and token) is not lost when the Toolbox - * window is temporarily closed or recreated. - */ -object CoderSetupWizardContext { - /** - * The currently entered URL. - */ - var url: URL? = null - - /** - * The token associated with the URL. - */ - var token: String? = null - - /** - * The OAuth session context. - */ - var oauthSession: CoderOAuthSessionContext? = null - - /** - * Returns true if a URL is currently set. - */ - fun hasUrl(): Boolean = url != null - - /** - * Returns true if a token is currently set. - */ - fun hasToken(): Boolean = !token.isNullOrBlank() - - /** - * Returns true if an OAuth access token is currently set. - */ - fun hasOAuthSession(): Boolean = oauthSession?.tokenResponse?.accessToken != null - - /** - * Returns true if URL or token is missing and auth is not yet possible. - */ - fun isNotReadyForAuth(): Boolean = !(hasUrl() && (hasToken() || hasOAuthSession())) - - /** - * Resets both URL and token to null. - */ - fun reset() { - url = null - token = null - oauthSession = null - } -} - -data class CoderOAuthSessionContext( - val clientId: String, - val clientSecret: String, - val tokenCodeVerifier: String, - val state: String, - val tokenEndpoint: String, - val tokenResponse: OAuthTokenResponse? = null, - val tokenAuthMethod: TokenEndpointAuthMethod -) - -data class StoredOAuthSession( - val clientId: String, - val clientSecret: String, - val refreshToken: String, - val tokenAuthMethod: TokenEndpointAuthMethod, - val tokenEndpoint: String -) - -fun CoderOAuthSessionContext?.hasRefreshToken(): Boolean = this?.tokenResponse?.refreshToken != null \ No newline at end of file diff --git a/src/main/kotlin/com/coder/toolbox/views/state/CoderSetupWizardState.kt b/src/main/kotlin/com/coder/toolbox/views/state/CoderSetupWizardState.kt deleted file mode 100644 index 81edd2a..0000000 --- a/src/main/kotlin/com/coder/toolbox/views/state/CoderSetupWizardState.kt +++ /dev/null @@ -1,47 +0,0 @@ -package com.coder.toolbox.views.state - -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow - -/** - * A singleton that maintains the state of the coder setup wizard across Toolbox window lifecycle events. - * - * This is used to persist the wizard's progress (i.e., current step) between visibility changes - * of the Toolbox window. Without this object, closing and reopening the window would reset the wizard - * to its initial state by creating a new instance. - */ -object CoderSetupWizardState { - private val currentStep = MutableStateFlow(WizardStep.URL_REQUEST) - val step: StateFlow = currentStep - - fun currentStep(): WizardStep = currentStep.value - - fun goToStep(step: WizardStep) { - currentStep.value = step - } - - fun goToNextStep() { - currentStep.value = WizardStep.entries.toTypedArray()[(currentStep.value.ordinal + 1) % WizardStep.entries.size] - } - - fun goToPreviousStep() { - val entries = WizardStep.entries.toTypedArray() - currentStep.value = entries[(currentStep.value.ordinal - 1 + entries.size) % entries.size] - } - - fun goToLastStep() { - currentStep.value = WizardStep.CONNECT - } - - fun goToFirstStep() { - currentStep.value = WizardStep.URL_REQUEST - } - - fun goToDone() { - currentStep.value = WizardStep.DONE - } -} - -enum class WizardStep { - URL_REQUEST, TOKEN_REQUEST, CONNECT, DONE; -} diff --git a/src/main/kotlin/com/coder/toolbox/views/state/OAuthSessionContext.kt b/src/main/kotlin/com/coder/toolbox/views/state/OAuthSessionContext.kt new file mode 100644 index 0000000..604ddc9 --- /dev/null +++ b/src/main/kotlin/com/coder/toolbox/views/state/OAuthSessionContext.kt @@ -0,0 +1,40 @@ +package com.coder.toolbox.views.state + +import com.coder.toolbox.oauth.OAuthTokenResponse +import com.coder.toolbox.oauth.TokenEndpointAuthMethod + +data class CoderOAuthSessionContext( + val clientId: String, + val clientSecret: String, + val tokenCodeVerifier: String, + val state: String, + val tokenEndpoint: String, + val tokenResponse: OAuthTokenResponse? = null, + val tokenAuthMethod: TokenEndpointAuthMethod +) + +data class StoredOAuthSession( + val clientId: String, + val clientSecret: String, + val refreshToken: String, + val tokenAuthMethod: TokenEndpointAuthMethod, + val tokenEndpoint: String +) + +fun CoderOAuthSessionContext?.hasRefreshToken(): Boolean = this?.tokenResponse?.refreshToken != null + +fun StoredOAuthSession.toSessionContext(): CoderOAuthSessionContext = CoderOAuthSessionContext( + clientId = clientId, + clientSecret = clientSecret, + tokenCodeVerifier = "", + state = "", + tokenEndpoint = tokenEndpoint, + tokenAuthMethod = tokenAuthMethod, + tokenResponse = OAuthTokenResponse( + accessToken = "", + tokenType = "", + expiresIn = null, + refreshToken = refreshToken, + scope = null + ) +) \ No newline at end of file diff --git a/src/main/kotlin/com/coder/toolbox/views/state/PageRouter.kt b/src/main/kotlin/com/coder/toolbox/views/state/PageRouter.kt new file mode 100644 index 0000000..6b33d88 --- /dev/null +++ b/src/main/kotlin/com/coder/toolbox/views/state/PageRouter.kt @@ -0,0 +1,47 @@ +package com.coder.toolbox.views.state + +import com.coder.toolbox.views.CoderSetupWizardPage +import kotlinx.coroutines.flow.MutableStateFlow + +/** + * The page that should currently be rendered in place of the environment list. + */ +sealed interface PageRoute { + object None : PageRoute + data class Wizard(val page: CoderSetupWizardPage) : PageRoute +} + +/** + * Holds the active [PageRoute]. The same page instance is returned across + * Toolbox visibility cycles so in-flight work (e.g. an ongoing connect) is + * preserved instead of being thrown away every time the window reopens. + */ +class PageRouter { + private val route = MutableStateFlow(PageRoute.None) + + val activeWizard: CoderSetupWizardPage? + get() = (route.value as? PageRoute.Wizard)?.page + + /** + * Returns the page already on this route, or builds a new one and + * registers it. + */ + fun getOrCreate(build: () -> CoderSetupWizardPage): CoderSetupWizardPage { + (route.value as? PageRoute.Wizard)?.let { return it.page } + return build().also { route.value = PageRoute.Wizard(it) } + } + + /** + * Replaces any active page with [page]. Used when an external trigger + * (e.g. a deep link to a different deployment) needs to forcibly install + * a new wizard. + */ + fun replaceWith(page: CoderSetupWizardPage) { + activeWizard?.dispose() + route.value = PageRoute.Wizard(page) + } + + fun clear() { + route.value = PageRoute.None + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/coder/toolbox/views/state/WizardModel.kt b/src/main/kotlin/com/coder/toolbox/views/state/WizardModel.kt new file mode 100644 index 0000000..a2326f8 --- /dev/null +++ b/src/main/kotlin/com/coder/toolbox/views/state/WizardModel.kt @@ -0,0 +1,67 @@ +package com.coder.toolbox.views.state + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import java.net.URL + +/** + * Per-wizard mutable state: current step, form values, and OAuth session. + * + * Owned by a single [com.coder.toolbox.views.CoderSetupWizardPage] instance + * and lives as long as that wizard does, so it survives Toolbox visibility + * cycles without leaking across wizard recreations. + */ +class WizardModel { + private val _step: MutableStateFlow = MutableStateFlow(WizardStep.URL_REQUEST) + val step: StateFlow = _step + + var url: URL? = null + var token: String? = null + var oauthSession: CoderOAuthSessionContext? = null + + fun currentStep(): WizardStep = _step.value + + fun goTo(step: WizardStep) { + _step.value = step + } + + fun goToNext() { + val entries = WizardStep.entries + _step.value = entries[(_step.value.ordinal + 1) % entries.size] + } + + fun goToPrevious() { + val entries = WizardStep.entries + _step.value = entries[(_step.value.ordinal - 1 + entries.size) % entries.size] + } + + fun goToFirst() { + _step.value = WizardStep.URL_REQUEST + } + + fun goToLast() { + _step.value = WizardStep.CONNECT + } + + fun hasUrl(): Boolean = url != null + fun hasToken(): Boolean = !token.isNullOrBlank() + fun hasOAuthSession(): Boolean = oauthSession?.tokenResponse?.accessToken != null + + fun isNotReadyForAuth(): Boolean = !(hasUrl() && (hasToken() || hasOAuthSession())) + + fun clearFormData() { + url = null + token = null + oauthSession = null + } +} + +enum class WizardStep { + URL_REQUEST, TOKEN_REQUEST, CONNECT; +} + +sealed interface Credentials { + object MTls : Credentials + data class Token(val value: String) : Credentials + data class OAuth(val session: CoderOAuthSessionContext) : Credentials +} \ No newline at end of file