@@ -4,7 +4,6 @@ import com.coder.toolbox.browser.browse
44import com.coder.toolbox.cli.CoderCLIManager
55import com.coder.toolbox.feed.IdeFeedManager
66import com.coder.toolbox.oauth.OAuth2Client
7- import com.coder.toolbox.oauth.OAuthTokenResponse
87import com.coder.toolbox.plugin.PluginManager
98import com.coder.toolbox.sdk.CoderRestClient
109import com.coder.toolbox.sdk.ex.APIResponseException
@@ -22,15 +21,16 @@ import com.coder.toolbox.util.url
2221import com.coder.toolbox.util.validateStrictWebUrl
2322import com.coder.toolbox.util.withPath
2423import com.coder.toolbox.views.Action
25- import com.coder.toolbox.views.CoderCliSetupWizardPage
2624import com.coder.toolbox.views.CoderDelimiter
2725import com.coder.toolbox.views.CoderSettingsPage
26+ import com.coder.toolbox.views.CoderSetupWizardPage
2827import com.coder.toolbox.views.NewEnvironmentPage
2928import com.coder.toolbox.views.SuspendBiConsumer
3029import com.coder.toolbox.views.state.CoderOAuthSessionContext
31- import com.coder.toolbox.views.state.CoderSetupWizardContext
32- import com.coder.toolbox.views.state.CoderSetupWizardState
33- import com.coder.toolbox.views.state.WizardStep
30+ import com.coder.toolbox.views.state.Credentials
31+ import com.coder.toolbox.views.state.PageRouter
32+ import com.coder.toolbox.views.state.PendingOAuthConnection
33+ import com.coder.toolbox.views.state.toSessionContext
3434import com.jetbrains.toolbox.api.core.ui.icons.SvgIcon
3535import com.jetbrains.toolbox.api.core.ui.icons.SvgIcon.IconType
3636import com.jetbrains.toolbox.api.core.util.LoadableState
@@ -69,8 +69,6 @@ class CoderRemoteProvider(
6969 private var pollJob: Job ? = null
7070 internal val lastEnvironments = mutableListOf<CoderRemoteEnvironment >()
7171
72- private val settings = context.settingsStore.readOnly()
73-
7472 private val triggerSshConfig = Channel <Boolean >(Channel .CONFLATED )
7573 private val triggerProviderVisible = Channel <Boolean >(Channel .CONFLATED )
7674 private val dialogUi = DialogUi (context)
@@ -113,6 +111,8 @@ class CoderRemoteProvider(
113111
114112 private val errorBuffer = mutableListOf<Throwable >()
115113
114+ private val router = PageRouter ()
115+
116116 /* *
117117 * With the provided client, start polling for workspaces. Every time a new
118118 * workspace is added, reconfigure SSH using the provided cli (including the
@@ -269,6 +269,7 @@ class CoderRemoteProvider(
269269 lastEnvironments.clear()
270270 environments.value = LoadableState .Value (emptyList())
271271 isInitialized.update { false }
272+ router.clear()
272273 context.logger.info(" Coder plugin is now closed" )
273274 }
274275
@@ -345,9 +346,6 @@ class CoderRemoteProvider(
345346 */
346347 override suspend fun handleUri (uri : URI ) {
347348 try {
348- // Obtain focus. This switches to the main plugin screen, even
349- // if last opened provider was not Coder
350- context.envPageManager.showPluginEnvironmentsPage()
351349 if (uri.toString().startsWith(" jetbrains://gateway/com.coder.toolbox/auth" )) {
352350 handleOAuthUri(uri)
353351 return
@@ -372,25 +370,22 @@ class CoderRemoteProvider(
372370 linkHandler.handle(params, newUrl, this .client!! , this .cli!! )
373371 coderHeaderPage.isBusy.update { false }
374372 } else {
375- // Different URL - we need a new connection.
376- // Chain the link handling after onConnect so it runs once the connection is established.
377- CoderSetupWizardContext .apply {
378- url = newUrl
379- token = newToken
380- }
381- CoderSetupWizardState .goToStep(WizardStep .CONNECT )
382- context.ui.showUiPage(
383- CoderCliSetupWizardPage (
384- context, settingsPage, visibilityState,
385- initialAutoSetup = true ,
386- jumpToMainPageOnError = true ,
387- onConnect = onConnect.andThen(deferredLinkHandler(params, newUrl))
388- .andThen { _, _ ->
389- coderHeaderPage.isBusy.update { false }
390- },
391- onTokenRefreshed = ::onTokenRefreshed
392- )
373+ // Different URL - we need a new connection. Tear down any
374+ // in-flight wizard, install a fresh one on the router, and let
375+ // showPluginEnvironmentsPage() pull it through getOverrideUiPage.
376+ val credentials = newToken?.let { Credentials .Token (it) } ? : Credentials .MTls
377+ val wizard = CoderSetupWizardPage .connectStep(
378+ context, settingsPage, visibilityState,
379+ url = newUrl,
380+ credentials = credentials,
381+ onConnect = onConnect.andThen(deferredLinkHandler(params, newUrl))
382+ .andThen { _, _ ->
383+ coderHeaderPage.isBusy.update { false }
384+ },
385+ onTokenRefreshed = ::onTokenRefreshed,
393386 )
387+ router.navigate(wizard)
388+ context.popupPluginMainPage()
394389 }
395390 } catch (ex: Exception ) {
396391 val textError = if (ex is APIResponseException ) {
@@ -403,7 +398,6 @@ class CoderRemoteProvider(
403398 textError ? : " "
404399 )
405400 coderHeaderPage.isBusy.update { false }
406- context.envPageManager.showPluginEnvironmentsPage()
407401 } finally {
408402 firstRun = false
409403 }
@@ -424,7 +418,17 @@ class CoderRemoteProvider(
424418 )
425419 }
426420
427- params[" state" ]?.takeIf { it == CoderSetupWizardContext .oauthSession?.state }
421+ if (! router.hasActiveWizard) {
422+ return context.logAndShowError(
423+ FAILED_TO_HANDLE_OAUTH2_TITLE ,
424+ " OAuth2 callback arrived but the setup wizard is no longer active"
425+ )
426+ }
427+ val pendingOAuthConnection = router.pendingOAuthConnection ? : return context.logAndShowError(
428+ FAILED_TO_HANDLE_OAUTH2_TITLE ,
429+ " OAuth2 callback arrived but no OAuth session was started"
430+ )
431+ params[" state" ]?.takeIf { it == pendingOAuthConnection.session.state }
428432 ? : return context.logAndShowError(
429433 FAILED_TO_HANDLE_OAUTH2_TITLE ,
430434 " 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(
442446 )
443447 return
444448 }
445- exchangeOAuthCodeForToken(code, CoderSetupWizardContext .oauthSession !! )
449+ exchangeOAuthCodeForToken(code, pendingOAuthConnection )
446450 }
447451
448- private suspend fun exchangeOAuthCodeForToken (code : String , oauthSessionContext : CoderOAuthSessionContext ) {
452+ private suspend fun exchangeOAuthCodeForToken (
453+ code : String ,
454+ pendingOAuthConnection : PendingOAuthConnection ,
455+ ) {
449456 try {
450457 context.logger.info(" Handling OAuth callback..." )
451458
459+ val oauthSessionContext = pendingOAuthConnection.session
452460 val tokenResponse = OAuth2Client (context).exchangeCode(oauthSessionContext, code)
453- CoderSetupWizardContext .oauthSession = oauthSessionContext.copy(tokenResponse = tokenResponse)
454-
455- CoderSetupWizardState .goToStep(WizardStep .CONNECT )
461+ val wizard = CoderSetupWizardPage .connectStep(
462+ context, settingsPage, visibilityState,
463+ url = pendingOAuthConnection.url,
464+ credentials = Credentials .OAuth (oauthSessionContext.copy(tokenResponse = tokenResponse)),
465+ onConnect = onConnect,
466+ onTokenRefreshed = ::onTokenRefreshed,
467+ )
468+ router.navigate(wizard)
456469
470+ context.envPageManager.showPluginEnvironmentsPage(true )
471+ context.ui.showUiPage(wizard)
457472 } catch (e: Exception ) {
458473 context.logAndShowError(" OAuth Error" , " Exception during token exchange: ${e.message} " , e)
459474 }
@@ -520,77 +535,79 @@ class CoderRemoteProvider(
520535 * list.
521536 */
522537 override fun getOverrideUiPage (): UiPage ? {
523- // Show the setup page if we have not configured the client yet.
524- if (client == null ) {
525- // When coming back to the application, initializeSession immediately.
526- if (shouldDoAutoSetup()) {
527- try {
528- val storedOAuthSession = context.secrets.oauthSessionFor(context.deploymentUrl.toString())
529- CoderSetupWizardContext .apply {
530- url = context.deploymentUrl
531- token = context.secrets.apiTokenFor(context.deploymentUrl)
532- if (storedOAuthSession != null ) {
533- oauthSession = CoderOAuthSessionContext (
534- clientId = storedOAuthSession.clientId,
535- clientSecret = storedOAuthSession.clientSecret,
536- tokenCodeVerifier = " " ,
537- state = " " ,
538- tokenEndpoint = storedOAuthSession.tokenEndpoint,
539- tokenAuthMethod = storedOAuthSession.tokenAuthMethod,
540- tokenResponse = OAuthTokenResponse (
541- accessToken = " " ,
542- tokenType = " " ,
543- expiresIn = null ,
544- refreshToken = storedOAuthSession.refreshToken,
545- scope = null
546- )
547- )
548- }
549- }
550- CoderSetupWizardState .goToStep(WizardStep .CONNECT )
551- return CoderCliSetupWizardPage (
552- context, settingsPage, visibilityState,
553- initialAutoSetup = true ,
554- jumpToMainPageOnError = false ,
555- onConnect = onConnect,
556- onTokenRefreshed = ::onTokenRefreshed
557- )
558- } catch (ex: Exception ) {
559- errorBuffer.add(ex)
560- } finally {
561- firstRun = false
562- }
563- }
538+ // Show the setup wizard if one is already scheduled.
539+ router.activePage?.let { return it }
564540
565- // Login flow.
566- CoderSetupWizardState .goToFirstStep()
567- val setupWizardPage =
568- CoderCliSetupWizardPage (
569- context,
570- settingsPage,
571- visibilityState,
541+ // Let the default workspace UI render if the HTTP client is initialized.
542+ if (client != null ) return null
543+
544+ // Otherwise, schedule our own setup wizard.
545+ return router.getOrCreate { buildSetupWizard() }
546+ }
547+
548+ /* *
549+ * Build the wizard for the current state. Called once per provider lifetime
550+ * (until [close] clears the router); subsequent visibility cycles reuse the
551+ * same instance, preserving any in-flight connect job.
552+ */
553+ private fun buildSetupWizard (): CoderSetupWizardPage {
554+ // When coming back to the application, initializeSession immediately.
555+ if (shouldDoAutoSetup()) {
556+ try {
557+ val url = context.deploymentUrl
558+ val credentials = autoSetupCredentials(url) ? : return CoderSetupWizardPage .deploymentUrlStep(
559+ context, settingsPage, visibilityState,
560+ onConnect = onConnect,
561+ onTokenRefreshed = ::onTokenRefreshed,
562+ )
563+ return CoderSetupWizardPage .connectStep(
564+ context, settingsPage, visibilityState,
565+ url = url,
566+ credentials = credentials,
572567 onConnect = onConnect,
573- onTokenRefreshed = ::onTokenRefreshed
568+ onTokenRefreshed = ::onTokenRefreshed,
574569 )
575- // We might have navigated here due to a polling error.
576- errorBuffer.forEach {
577- setupWizardPage.notify(" Error encountered" , it)
570+ } catch (ex: Exception ) {
571+ errorBuffer.add(ex)
572+ } finally {
573+ firstRun = false
578574 }
579- errorBuffer.clear()
580- // and now reset the errors, otherwise we show it every time on the screen
581- return setupWizardPage
582575 }
583- return null
576+
577+ // Login flow.
578+ val setupWizardPage = CoderSetupWizardPage .deploymentUrlStep(
579+ context, settingsPage, visibilityState,
580+ onConnect = onConnect,
581+ onTokenRefreshed = ::onTokenRefreshed,
582+ )
583+ // We might have navigated here due to a polling error.
584+ errorBuffer.forEach {
585+ setupWizardPage.notify(" Error encountered" , it)
586+ }
587+ errorBuffer.clear()
588+ return setupWizardPage
584589 }
585590
586591 /* *
587- * Auto-login only on first the firs run if there is a url & token configured or the auth
588- * should be done via certificates.
592+ * Auto-login only on the first run when stored credentials or mTLS auth can be used.
589593 */
590- private fun shouldDoAutoSetup (): Boolean = firstRun && (canAutoLogin() || ! settings.requiresTokenAuth)
594+ private fun shouldDoAutoSetup (): Boolean = firstRun && (canAutoLogin() || ! context.settingsStore.requiresTokenAuth)
595+
596+ fun canAutoLogin (): Boolean = autoSetupCredentials(context.deploymentUrl) != null
591597
592- fun canAutoLogin (): Boolean = ! context.secrets.apiTokenFor(context.deploymentUrl)
593- .isNullOrBlank() || context.secrets.oauthSessionFor(context.deploymentUrl.toString()) != null
598+ private fun autoSetupCredentials (url : URL ): Credentials ? {
599+ if (context.settingsStore.requiresMTlsAuth) return Credentials .MTls
600+
601+ val tokenCredentials = context.secrets.apiTokenFor(url)
602+ ?.takeIf { it.isNotBlank() }
603+ ?.let { Credentials .Token (it) }
604+
605+ if (! context.settingsStore.preferOAuth2IfAvailable) return tokenCredentials
606+
607+ return context.secrets.oauthSessionFor(url.toString())?.let {
608+ Credentials .OAuth (it.toSessionContext())
609+ } ? : tokenCredentials
610+ }
594611
595612 private suspend fun onTokenRefreshed (url : URL , oauthSessionCtx : CoderOAuthSessionContext ) {
596613 oauthSessionCtx.tokenResponse?.accessToken?.let { cli?.login(it) }
@@ -653,4 +670,4 @@ class CoderRemoteProvider(
653670 LoadableState .Loading
654671 }
655672 }
656- }
673+ }
0 commit comments