diff --git a/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt b/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt index c265098..972497e 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,16 @@ 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.PendingOAuthConnection +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 @@ -69,8 +69,6 @@ class CoderRemoteProvider( private var pollJob: Job? = null internal val lastEnvironments = mutableListOf() - private val settings = context.settingsStore.readOnly() - private val triggerSshConfig = Channel(Channel.CONFLATED) private val triggerProviderVisible = Channel(Channel.CONFLATED) private val dialogUi = DialogUi(context) @@ -113,6 +111,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 +269,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 +346,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 +370,22 @@ 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, + onConnect = onConnect.andThen(deferredLinkHandler(params, newUrl)) + .andThen { _, _ -> + coderHeaderPage.isBusy.update { false } + }, + onTokenRefreshed = ::onTokenRefreshed, ) + router.navigate(wizard) + context.popupPluginMainPage() } } catch (ex: Exception) { val textError = if (ex is APIResponseException) { @@ -403,7 +398,6 @@ class CoderRemoteProvider( textError ?: "" ) coderHeaderPage.isBusy.update { false } - context.envPageManager.showPluginEnvironmentsPage() } finally { firstRun = false } @@ -424,7 +418,17 @@ class CoderRemoteProvider( ) } - params["state"]?.takeIf { it == CoderSetupWizardContext.oauthSession?.state } + if (!router.hasActiveWizard) { + return context.logAndShowError( + FAILED_TO_HANDLE_OAUTH2_TITLE, + "OAuth2 callback arrived but the setup wizard is no longer active" + ) + } + val pendingOAuthConnection = router.pendingOAuthConnection ?: return context.logAndShowError( + FAILED_TO_HANDLE_OAUTH2_TITLE, + "OAuth2 callback arrived but no OAuth session was started" + ) + params["state"]?.takeIf { it == pendingOAuthConnection.session.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,29 @@ class CoderRemoteProvider( ) return } - exchangeOAuthCodeForToken(code, CoderSetupWizardContext.oauthSession!!) + exchangeOAuthCodeForToken(code, pendingOAuthConnection) } - private suspend fun exchangeOAuthCodeForToken(code: String, oauthSessionContext: CoderOAuthSessionContext) { + private suspend fun exchangeOAuthCodeForToken( + code: String, + pendingOAuthConnection: PendingOAuthConnection, + ) { try { context.logger.info("Handling OAuth callback...") + val oauthSessionContext = pendingOAuthConnection.session val tokenResponse = OAuth2Client(context).exchangeCode(oauthSessionContext, code) - CoderSetupWizardContext.oauthSession = oauthSessionContext.copy(tokenResponse = tokenResponse) - - CoderSetupWizardState.goToStep(WizardStep.CONNECT) + val wizard = CoderSetupWizardPage.connectStep( + context, settingsPage, visibilityState, + url = pendingOAuthConnection.url, + credentials = Credentials.OAuth(oauthSessionContext.copy(tokenResponse = tokenResponse)), + onConnect = onConnect, + onTokenRefreshed = ::onTokenRefreshed, + ) + router.navigate(wizard) + context.envPageManager.showPluginEnvironmentsPage(true) + context.ui.showUiPage(wizard) } catch (e: Exception) { context.logAndShowError("OAuth Error", "Exception during token exchange: ${e.message}", e) } @@ -520,77 +535,79 @@ 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 - } - } + // Show the setup wizard if one is already scheduled. + router.activePage?.let { return it } - // Login flow. - CoderSetupWizardState.goToFirstStep() - val setupWizardPage = - CoderCliSetupWizardPage( - context, - settingsPage, - visibilityState, + // Let the default workspace UI render if the HTTP client is initialized. + if (client != null) return null + + // Otherwise, schedule our own setup wizard. + return router.getOrCreate { buildSetupWizard() } + } + + /** + * 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 = autoSetupCredentials(url) ?: return CoderSetupWizardPage.deploymentUrlStep( + context, settingsPage, visibilityState, + onConnect = onConnect, + onTokenRefreshed = ::onTokenRefreshed, + ) + 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 } /** - * Auto-login only on first the firs run if there is a url & token configured or the auth - * should be done via certificates. + * Auto-login only on the first run when stored credentials or mTLS auth can be used. */ - private fun shouldDoAutoSetup(): Boolean = firstRun && (canAutoLogin() || !settings.requiresTokenAuth) + private fun shouldDoAutoSetup(): Boolean = firstRun && (canAutoLogin() || !context.settingsStore.requiresTokenAuth) + + fun canAutoLogin(): Boolean = autoSetupCredentials(context.deploymentUrl) != null - fun canAutoLogin(): Boolean = !context.secrets.apiTokenFor(context.deploymentUrl) - .isNullOrBlank() || context.secrets.oauthSessionFor(context.deploymentUrl.toString()) != null + private fun autoSetupCredentials(url: URL): Credentials? { + if (context.settingsStore.requiresMTlsAuth) return Credentials.MTls + + val tokenCredentials = context.secrets.apiTokenFor(url) + ?.takeIf { it.isNotBlank() } + ?.let { Credentials.Token(it) } + + if (!context.settingsStore.preferOAuth2IfAvailable) return tokenCredentials + + return context.secrets.oauthSessionFor(url.toString())?.let { + Credentials.OAuth(it.toSessionContext()) + } ?: tokenCredentials + } private suspend fun onTokenRefreshed(url: URL, oauthSessionCtx: CoderOAuthSessionContext) { oauthSessionCtx.tokenResponse?.accessToken?.let { cli?.login(it) } @@ -653,4 +670,4 @@ class CoderRemoteProvider( LoadableState.Loading } } -} \ No newline at end of file +} 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..563a0e23 100644 --- a/src/main/kotlin/com/coder/toolbox/views/CoderCliSetupWizardPage.kt +++ b/src/main/kotlin/com/coder/toolbox/views/CoderSetupWizardPage.kt @@ -4,7 +4,9 @@ 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.PendingOAuthConnection +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,28 +18,26 @@ 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, - initialAutoSetup: Boolean = false, - jumpToMainPageOnError: Boolean = false, + private var autoLogin: Boolean = false, onConnect: SuspendBiConsumer, onTokenRefreshed: (suspend (url: URL, oauthSessionCtx: CoderOAuthSessionContext) -> Unit)? = null ) : CoderPage(MutableStateFlow(context.i18n.ptrl("Setting up Coder")), false) { - private val shouldAutoSetup = MutableStateFlow(initialAutoSetup) + val model: WizardModel = WizardModel() 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, - shouldAutoLogin = shouldAutoSetup, - jumpToMainPageOnError = jumpToMainPageOnError, + model, visibilityState, - refreshWizard = this::displaySteps, + navigateBack = this::navigateBackFromConnect, onConnect = onConnect, onTokenRefreshed = onTokenRefreshed ) @@ -50,11 +50,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 +62,7 @@ class CoderCliSetupWizardPage( } private fun displaySteps() { - when (CoderSetupWizardState.currentStep()) { + when (model.currentStep()) { WizardStep.URL_REQUEST -> { fields.update { listOf(deploymentUrlStep.panel) @@ -111,25 +110,45 @@ class CoderCliSetupWizardPage( settingsAction, Action(context, "Back", closesPage = false, actionBlock = { connectStep.onBack() - shouldAutoSetup.update { - false - } displaySteps() }) ) } connectStep.onVisible() } + } + } - WizardStep.DONE -> { - context.logger.info("Closing the Setup Wizard") - stateCollectJob?.cancel() - context.ui.hideUiPage(this) - CoderSetupWizardState.goToFirstStep() - } + fun pendingOAuthConnection(): PendingOAuthConnection? { + val url = model.url ?: return null + val oauthSession = model.oauthSession ?: return null + return PendingOAuthConnection(url, oauthSession) + } + + private fun navigateBackFromConnect() { + if (autoLogin) { + autoLogin = false + model.clearFormData() + model.goToFirst() + return + } + if (context.settingsStore.requiresTokenAuth) { + model.goToPrevious() + } else { + model.goToFirst() } } + /** + * 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() } @@ -138,4 +157,42 @@ class CoderCliSetupWizardPage( * Show an error as a popup on this page. */ fun notify(message: String, ex: Throwable) = errorReporter.report(message, ex) + + + 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 connectStep( + context: CoderToolboxContext, + settingsPage: CoderSettingsPage, + visibilityState: StateFlow, + url: URL, + credentials: Credentials, + onConnect: SuspendBiConsumer, + onTokenRefreshed: (suspend (url: URL, oauthSessionCtx: CoderOAuthSessionContext) -> Unit)? = null, + ): CoderSetupWizardPage = CoderSetupWizardPage( + context, settingsPage, visibilityState, + autoLogin = true, + 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) + } + } } diff --git a/src/main/kotlin/com/coder/toolbox/views/ConnectStep.kt b/src/main/kotlin/com/coder/toolbox/views/ConnectStep.kt index f780627..03e4437 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 @@ -24,16 +23,16 @@ import kotlinx.coroutines.yield import java.net.URL private const val USER_HIT_THE_BACK_BUTTON = "User hit the back button" +private const val WIZARD_WAS_DISPOSED = "Wizard was disposed" /** * A page that connects a REST client and cli to Coder. */ class ConnectStep( private val context: CoderToolboxContext, - private val shouldAutoLogin: StateFlow, - private val jumpToMainPageOnError: Boolean, + private val model: WizardModel, visibilityState: StateFlow, - private val refreshWizard: () -> Unit, + private val navigateBack: () -> Unit, private val onConnect: SuspendBiConsumer, private val onTokenRefreshed: (suspend (url: URL, oauthSessionCtx: CoderOAuthSessionContext) -> Unit)? = null ) : WizardStep { @@ -54,7 +53,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!") } @@ -63,11 +62,11 @@ class ConnectStep( // Don't launch another connection attempt if one is already in progress. if (signInJob?.isActive == true) { - context.logger.info(">> ConnectStep: connection already in progress, skipping duplicate") + context.logger.info("Connection already in progress, skipping duplicate") 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 +74,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 +95,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,19 +141,20 @@ 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) { - errorReporter.report("Connection to $hostName was configured", ex) - handleNavigation() - refreshWizard() + // Back-button cancellation already navigates in onBack(), while + // dispose() must cancel without navigating. Treat these control-flow + // cancellations separately so we do not run navigateBack() twice. + if (ex.message != USER_HIT_THE_BACK_BUTTON && ex.message != WIZARD_WAS_DISPOSED) { + errorReporter.report("Failed to configure $hostName", ex) + navigateBack() } } catch (ex: Exception) { errorReporter.report("Failed to configure $hostName", ex) - handleNavigation() - refreshWizard() + navigateBack() } } @@ -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) { @@ -176,26 +176,6 @@ class ConnectStep( statusField.textState.update { context.i18n.pnotr(msg) } } - /** - * Handle navigation logic for both errors and back button - */ - private fun handleNavigation() { - if (shouldAutoLogin.value) { - CoderSetupWizardContext.reset() - if (jumpToMainPageOnError) { - context.popupPluginMainPage() - } else { - CoderSetupWizardState.goToFirstStep() - } - } else { - if (context.settingsStore.requiresTokenAuth) { - CoderSetupWizardState.goToPreviousStep() - } else { - CoderSetupWizardState.goToFirstStep() - } - } - } - override suspend fun onNext(): Boolean { return false } @@ -205,7 +185,17 @@ class ConnectStep( context.logger.info("Back button was pressed, cancelling in-progress connection setup...") signInJob?.cancel(CancellationException(USER_HIT_THE_BACK_BUTTON)) } finally { - handleNavigation() + navigateBack() } } + + /** + * 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(WIZARD_WAS_DISPOSED)) + 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..e7c4faa 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,34 +35,37 @@ class TokenStep( errorField.textState.update { context.i18n.pnotr("") } - if (CoderSetupWizardContext.hasUrl()) { - tokenField.textState.update { - context.secrets.apiTokenFor(CoderSetupWizardContext.url!!) ?: "" + model.url?.let { url -> + tokenField.contentState.update { + context.secrets.apiTokenFor(url) ?: "" } - } else { + (linkField.urlState as MutableStateFlow).update { + url.withPath("/login?redirect=%2Fcli-auth").toString() + } + } ?: run { errorField.textState.update { context.i18n.pnotr("URL not configure in the previous step. Please go back and provide a proper URL.") - return } - } - (linkField.urlState as MutableStateFlow).update { - CoderSetupWizardContext.url!!.withPath("/login?redirect=%2Fcli-auth")?.toString() ?: "" + (linkField.urlState as MutableStateFlow).update { + "" + } + return } } override suspend fun onNext(): Boolean { - val token = tokenField.textState.value + val token = tokenField.contentState.value if (token.isBlank()) { errorField.textState.update { context.i18n.ptrl("Token is required") } 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..a78b27c --- /dev/null +++ b/src/main/kotlin/com/coder/toolbox/views/state/PageRouter.kt @@ -0,0 +1,59 @@ +package com.coder.toolbox.views.state + +import com.coder.toolbox.views.CoderPage +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) + + private val activeWizard: CoderSetupWizardPage? + get() = (route.value as? PageRoute.Wizard)?.page + + val activePage: CoderPage? + get() = activeWizard + + val hasActiveWizard: Boolean + get() = activeWizard != null + + val pendingOAuthConnection: PendingOAuthConnection? + get() = activeWizard?.pendingOAuthConnection() + + /** + * Returns the page already on this route, or builds a new one and + * registers it. + */ + fun getOrCreate(build: () -> CoderSetupWizardPage): CoderPage { + (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 navigate(page: CoderSetupWizardPage) { + activeWizard?.dispose() + route.value = PageRoute.Wizard(page) + } + + fun clear() { + activeWizard?.dispose() + route.value = PageRoute.None + } +} 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..0104a5f --- /dev/null +++ b/src/main/kotlin/com/coder/toolbox/views/state/WizardModel.kt @@ -0,0 +1,72 @@ +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 +} + +data class PendingOAuthConnection( + val url: URL, + val session: CoderOAuthSessionContext +) diff --git a/src/test/kotlin/com/coder/toolbox/CoderRemoteProviderTest.kt b/src/test/kotlin/com/coder/toolbox/CoderRemoteProviderTest.kt index d5ba146..55d65b8 100644 --- a/src/test/kotlin/com/coder/toolbox/CoderRemoteProviderTest.kt +++ b/src/test/kotlin/com/coder/toolbox/CoderRemoteProviderTest.kt @@ -1,6 +1,7 @@ package com.coder.toolbox import com.coder.toolbox.cli.CoderCLIManager +import com.coder.toolbox.oauth.TokenEndpointAuthMethod import com.coder.toolbox.sdk.CoderRestClient import com.coder.toolbox.sdk.v2.models.Workspace import com.coder.toolbox.sdk.v2.models.WorkspaceAgent @@ -9,18 +10,25 @@ import com.coder.toolbox.sdk.v2.models.WorkspaceAgentStatus import com.coder.toolbox.sdk.v2.models.WorkspaceBuild import com.coder.toolbox.sdk.v2.models.WorkspaceResource import com.coder.toolbox.sdk.v2.models.WorkspaceStatus +import com.coder.toolbox.views.CoderSetupWizardPage +import com.coder.toolbox.views.state.StoredOAuthSession +import com.coder.toolbox.views.state.WizardStep import io.mockk.clearAllMocks import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every import io.mockk.mockk +import io.mockk.verify import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.Assertions.assertTrue +import java.net.URI import java.util.UUID import kotlin.test.AfterTest import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull import kotlin.test.assertSame class CoderRemoteProviderTest { @@ -227,6 +235,154 @@ class CoderRemoteProviderTest { coVerify(exactly = 1) { existingEnv.update(workspace, agent) } } + @Test + fun `given connected client when URI targets different deployment then scheduled wizard overrides client`() = + runTest { + // given + every { mockClient.url } returns URI("https://old.example.com").toURL() + every { mockContext.settingsStore.requiresMTlsAuth } returns false + every { mockContext.settingsStore.requiresTokenAuth } returns true + setPrivateField(remoteProvider, "client", mockClient) + setPrivateField(remoteProvider, "cli", mockCli) + + assertNull(remoteProvider.getOverrideUiPage()) + + // when + remoteProvider.handleUri( + URI("jetbrains://gateway/com.coder.toolbox?url=https%3A%2F%2Fnew.example.com&token=new-token") + ) + + // then + val overridePage = remoteProvider.getOverrideUiPage() + assertNotNull(overridePage) + assertTrue(overridePage is CoderSetupWizardPage) + verify { mockContext.popupPluginMainPage() } + } + + @Test + fun `given mTLS is required when auto setup has stored credentials then mTLS takes precedence`() { + // given + val url = URI("https://coder.example.com").toURL() + every { mockContext.deploymentUrl } returns url + every { mockContext.settingsStore.requiresMTlsAuth } returns true + every { mockContext.settingsStore.requiresTokenAuth } returns false + every { mockContext.settingsStore.preferOAuth2IfAvailable } returns true + every { mockContext.secrets.apiTokenFor(url) } returns "token" + every { mockContext.secrets.oauthSessionFor(url.toString()) } returns storedOAuthSession() + val provider = CoderRemoteProvider(mockContext) + + // when + val overridePage = provider.getOverrideUiPage() as CoderSetupWizardPage + + // then + assertEquals(WizardStep.CONNECT, overridePage.model.currentStep()) + assertNull(overridePage.model.token) + assertNull(overridePage.model.oauthSession) + } + + @Test + fun `given OAuth is preferred when auto setup has token and OAuth session then OAuth is used`() { + // given + val url = URI("https://coder.example.com").toURL() + every { mockContext.deploymentUrl } returns url + every { mockContext.settingsStore.requiresMTlsAuth } returns false + every { mockContext.settingsStore.requiresTokenAuth } returns true + every { mockContext.settingsStore.preferOAuth2IfAvailable } returns true + every { mockContext.secrets.apiTokenFor(url) } returns "token" + every { mockContext.secrets.oauthSessionFor(url.toString()) } returns storedOAuthSession() + val provider = CoderRemoteProvider(mockContext) + + // when + val overridePage = provider.getOverrideUiPage() as CoderSetupWizardPage + + // then + assertEquals(WizardStep.CONNECT, overridePage.model.currentStep()) + assertNull(overridePage.model.token) + assertNotNull(overridePage.model.oauthSession) + } + + @Test + fun `given OAuth is not preferred when auto setup has token and OAuth session then token is used`() { + // given + val url = URI("https://coder.example.com").toURL() + every { mockContext.deploymentUrl } returns url + every { mockContext.settingsStore.requiresMTlsAuth } returns false + every { mockContext.settingsStore.requiresTokenAuth } returns true + every { mockContext.settingsStore.preferOAuth2IfAvailable } returns false + every { mockContext.secrets.apiTokenFor(url) } returns "token" + every { mockContext.secrets.oauthSessionFor(url.toString()) } returns storedOAuthSession() + val provider = CoderRemoteProvider(mockContext) + + // when + val overridePage = provider.getOverrideUiPage() as CoderSetupWizardPage + + // then + assertEquals(WizardStep.CONNECT, overridePage.model.currentStep()) + assertEquals("token", overridePage.model.token) + assertNull(overridePage.model.oauthSession) + } + + @Test + fun `given OAuth is not preferred when auto setup has API token then token is used`() { + // given + val url = URI("https://coder.example.com").toURL() + every { mockContext.deploymentUrl } returns url + every { mockContext.settingsStore.requiresMTlsAuth } returns false + every { mockContext.settingsStore.requiresTokenAuth } returns true + every { mockContext.settingsStore.preferOAuth2IfAvailable } returns false + every { mockContext.secrets.apiTokenFor(url) } returns "api-token" + val provider = CoderRemoteProvider(mockContext) + + // when + val overridePage = provider.getOverrideUiPage() as CoderSetupWizardPage + + // then + assertEquals(WizardStep.CONNECT, overridePage.model.currentStep()) + assertEquals("api-token", overridePage.model.token) + assertNull(overridePage.model.oauthSession) + } + + @Test + fun `given OAuth is not preferred when auto setup has no API token then wizard starts at URL step`() { + // given + val url = URI("https://coder.example.com").toURL() + every { mockContext.deploymentUrl } returns url + every { mockContext.settingsStore.requiresMTlsAuth } returns false + every { mockContext.settingsStore.requiresTokenAuth } returns true + every { mockContext.settingsStore.preferOAuth2IfAvailable } returns false + every { mockContext.secrets.apiTokenFor(url) } returns null + val provider = CoderRemoteProvider(mockContext) + + // when + val overridePage = provider.getOverrideUiPage() as CoderSetupWizardPage + + // then + assertEquals(WizardStep.URL_REQUEST, overridePage.model.currentStep()) + assertNull(overridePage.model.token) + assertNull(overridePage.model.oauthSession) + } + + @Test + fun `given OAuth is not preferred when auto setup only has OAuth session then wizard starts at URL step`() { + // given + val url = URI("https://coder.example.com").toURL() + every { mockContext.deploymentUrl } returns url + every { mockContext.settingsStore.requiresMTlsAuth } returns false + every { mockContext.settingsStore.requiresTokenAuth } returns true + every { mockContext.settingsStore.preferOAuth2IfAvailable } returns false + every { mockContext.secrets.apiTokenFor(url) } returns null + every { mockContext.secrets.oauthSessionFor(url.toString()) } returns storedOAuthSession() + val provider = CoderRemoteProvider(mockContext) + + // when + val overridePage = provider.getOverrideUiPage() as CoderSetupWizardPage + + // then + assertEquals(WizardStep.URL_REQUEST, overridePage.model.currentStep()) + assertNull(overridePage.model.token) + assertNull(overridePage.model.oauthSession) + } + @Test fun `given no existing environment then one is created`() = runTest { // given @@ -371,4 +527,23 @@ class CoderRemoteProviderTest { every { this@mockk.outdated } returns false } } -} \ No newline at end of file + + private fun setPrivateField( + target: Any, + name: String, + value: Any, + ) { + target.javaClass.getDeclaredField(name).apply { + isAccessible = true + set(target, value) + } + } + + private fun storedOAuthSession(): StoredOAuthSession = StoredOAuthSession( + clientId = "client-id", + clientSecret = "client-secret", + refreshToken = "refresh-token", + tokenAuthMethod = TokenEndpointAuthMethod.CLIENT_SECRET_BASIC, + tokenEndpoint = "https://coder.example.com/oauth/token" + ) +} diff --git a/src/test/kotlin/com/coder/toolbox/views/state/PageRouterTest.kt b/src/test/kotlin/com/coder/toolbox/views/state/PageRouterTest.kt new file mode 100644 index 0000000..c0fd8db --- /dev/null +++ b/src/test/kotlin/com/coder/toolbox/views/state/PageRouterTest.kt @@ -0,0 +1,53 @@ +package com.coder.toolbox.views.state + +import com.coder.toolbox.oauth.TokenEndpointAuthMethod +import com.coder.toolbox.views.CoderSetupWizardPage +import io.mockk.every +import io.mockk.mockk +import java.net.URI +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class PageRouterTest { + + @Test + fun `given no active wizard when pending OAuth connection is requested then null is returned`() { + // given + val router = PageRouter() + + // when + val pendingOAuthConnection = router.pendingOAuthConnection + + // then + assertNull(pendingOAuthConnection) + } + + @Test + fun `given active wizard when pending OAuth connection is requested then wizard connection is returned`() { + // given + val pendingConnection = PendingOAuthConnection( + url = URI("https://coder.example.com").toURL(), + session = oauthSession() + ) + val wizard = mockk(relaxed = true) + every { wizard.pendingOAuthConnection() } returns pendingConnection + val router = PageRouter() + router.navigate(wizard) + + // when + val pendingOAuthConnection = router.pendingOAuthConnection + + // then + assertEquals(pendingConnection, pendingOAuthConnection) + } + + private fun oauthSession(): CoderOAuthSessionContext = CoderOAuthSessionContext( + clientId = "client-id", + clientSecret = "client-secret", + tokenCodeVerifier = "verifier", + state = "state", + tokenEndpoint = "https://coder.example.com/oauth/token", + tokenAuthMethod = TokenEndpointAuthMethod.CLIENT_SECRET_BASIC + ) +}