Skip to content

fix: show login screen when token expires during workspace polling #83

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Apr 14, 2025
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -2,16 +2,21 @@

## Unreleased

### Fixed

- login screen is shown instead of an empty list of workspaces when token expired

## 0.1.4 - 2025-04-11

### Fixed

- SSH connection to a Workspace is no longer established only once
- authorization wizard automatically goes to a previous screen when an error is encountered during connection to Coder deployment
- SSH connection to a Workspace is no longer established only once
- authorization wizard automatically goes to a previous screen when an error is encountered during connection to Coder
deployment

### Changed

- action buttons on the token input step were swapped to achieve better keyboard navigation
- action buttons on the token input step were swapped to achieve better keyboard navigation
- URI `project_path` query parameter was renamed to `folder`

## 0.1.3 - 2025-04-09
28 changes: 18 additions & 10 deletions src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt
Original file line number Diff line number Diff line change
@@ -2,6 +2,7 @@ package com.coder.toolbox

import com.coder.toolbox.cli.CoderCLIManager
import com.coder.toolbox.sdk.CoderRestClient
import com.coder.toolbox.sdk.ex.APIResponseException
import com.coder.toolbox.sdk.v2.models.WorkspaceStatus
import com.coder.toolbox.util.CoderProtocolHandler
import com.coder.toolbox.util.DialogUi
@@ -60,7 +61,7 @@ class CoderRemoteProvider(
// If we have an error in the polling we store it here before going back to
// sign-in page, so we can display it there. This is mainly because there
// does not seem to be a mechanism to show errors on the environment list.
private var pollError: Exception? = null
private var errorBuffer = mutableListOf<Throwable>()

// On the first load, automatically log in if we can.
private var firstRun = true
@@ -141,14 +142,21 @@ class CoderRemoteProvider(
client.setupSession()
} else {
context.logger.error(ex, "workspace polling error encountered")
pollError = ex
errorBuffer.add(ex)
logout()
break
}
} catch (ex: APIResponseException) {
context.logger.error(ex, "error in contacting ${client.url} while polling the available workspaces")
errorBuffer.add(ex)
logout()
goToEnvironmentsPage()
break
} catch (ex: Exception) {
context.logger.error(ex, "workspace polling error encountered")
pollError = ex
errorBuffer.add(ex)
logout()
goToEnvironmentsPage()
break
}

@@ -300,15 +308,14 @@ class CoderRemoteProvider(
if (client == null) {
// When coming back to the application, authenticate immediately.
val autologin = shouldDoAutoLogin()
var autologinEx: Exception? = null
context.secrets.lastToken.let { lastToken ->
context.secrets.lastDeploymentURL.let { lastDeploymentURL ->
if (autologin && lastDeploymentURL.isNotBlank() && (lastToken.isNotBlank() || !settings.requireTokenAuth)) {
try {
AuthWizardState.goToStep(WizardStep.LOGIN)
return AuthWizardPage(context, true, ::onConnect)
} catch (ex: Exception) {
autologinEx = ex
errorBuffer.add(ex)
}
}
}
@@ -317,11 +324,12 @@ class CoderRemoteProvider(

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

errorBuffer.forEach {
authWizard.notify("Error encountered", it)
}
// and now reset the errors, otherwise we show it every time on the screen
errorBuffer.clear()
return authWizard
}
return null
@@ -336,7 +344,7 @@ class CoderRemoteProvider(
// Currently we always remember, but this could be made an option.
context.secrets.rememberMe = true
this.client = client
pollError = null
errorBuffer.clear()
pollJob?.cancel()
pollJob = poll(client, cli)
goToEnvironmentsPage()
80 changes: 69 additions & 11 deletions src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt
Original file line number Diff line number Diff line change
@@ -7,6 +7,7 @@ import com.coder.toolbox.sdk.convertors.OSConverter
import com.coder.toolbox.sdk.convertors.UUIDConverter
import com.coder.toolbox.sdk.ex.APIResponseException
import com.coder.toolbox.sdk.v2.CoderV2RestFacade
import com.coder.toolbox.sdk.v2.models.ApiErrorResponse
import com.coder.toolbox.sdk.v2.models.BuildInfo
import com.coder.toolbox.sdk.v2.models.CreateWorkspaceBuildRequest
import com.coder.toolbox.sdk.v2.models.Template
@@ -24,6 +25,7 @@ import com.coder.toolbox.util.getOS
import com.squareup.moshi.Moshi
import okhttp3.Credentials
import okhttp3.OkHttpClient
import retrofit2.Response
import retrofit2.Retrofit
import retrofit2.converter.moshi.MoshiConverterFactory
import java.net.HttpURLConnection
@@ -55,6 +57,7 @@ open class CoderRestClient(
private val pluginVersion: String = "development",
) {
private val settings = context.settingsStore.readOnly()
private lateinit var moshi: Moshi
private lateinit var httpClient: OkHttpClient
private lateinit var retroRestClient: CoderV2RestFacade

@@ -66,7 +69,7 @@ open class CoderRestClient(
}

fun setupSession() {
val moshi =
moshi =
Moshi.Builder()
.add(ArchConverter())
.add(InstantConverter())
@@ -152,7 +155,7 @@ open class CoderRestClient(
suspend fun me(): User {
val userResponse = retroRestClient.me()
if (!userResponse.isSuccessful) {
throw APIResponseException("authenticate", url, userResponse)
throw APIResponseException("authenticate", url, userResponse.code(), userResponse.parseErrorBody(moshi))
}

return userResponse.body()!!
@@ -165,7 +168,12 @@ open class CoderRestClient(
suspend fun workspaces(): List<Workspace> {
val workspacesResponse = retroRestClient.workspaces("owner:me")
if (!workspacesResponse.isSuccessful) {
throw APIResponseException("retrieve workspaces", url, workspacesResponse)
throw APIResponseException(
"retrieve workspaces",
url,
workspacesResponse.code(),
workspacesResponse.parseErrorBody(moshi)
)
}

return workspacesResponse.body()!!.workspaces
@@ -178,7 +186,12 @@ open class CoderRestClient(
suspend fun workspace(workspaceID: UUID): Workspace {
val workspacesResponse = retroRestClient.workspace(workspaceID)
if (!workspacesResponse.isSuccessful) {
throw APIResponseException("retrieve workspace", url, workspacesResponse)
throw APIResponseException(
"retrieve workspace",
url,
workspacesResponse.code(),
workspacesResponse.parseErrorBody(moshi)
)
}

return workspacesResponse.body()!!
@@ -209,15 +222,25 @@ open class CoderRestClient(
val resourcesResponse =
retroRestClient.templateVersionResources(workspace.latestBuild.templateVersionID)
if (!resourcesResponse.isSuccessful) {
throw APIResponseException("retrieve resources for ${workspace.name}", url, resourcesResponse)
throw APIResponseException(
"retrieve resources for ${workspace.name}",
url,
resourcesResponse.code(),
resourcesResponse.parseErrorBody(moshi)
)
}
return resourcesResponse.body()!!
}

suspend fun buildInfo(): BuildInfo {
val buildInfoResponse = retroRestClient.buildInfo()
if (!buildInfoResponse.isSuccessful) {
throw APIResponseException("retrieve build information", url, buildInfoResponse)
throw APIResponseException(
"retrieve build information",
url,
buildInfoResponse.code(),
buildInfoResponse.parseErrorBody(moshi)
)
}
return buildInfoResponse.body()!!
}
@@ -228,7 +251,12 @@ open class CoderRestClient(
private suspend fun template(templateID: UUID): Template {
val templateResponse = retroRestClient.template(templateID)
if (!templateResponse.isSuccessful) {
throw APIResponseException("retrieve template with ID $templateID", url, templateResponse)
throw APIResponseException(
"retrieve template with ID $templateID",
url,
templateResponse.code(),
templateResponse.parseErrorBody(moshi)
)
}
return templateResponse.body()!!
}
@@ -240,7 +268,12 @@ open class CoderRestClient(
val buildRequest = CreateWorkspaceBuildRequest(null, WorkspaceTransition.START)
val buildResponse = retroRestClient.createWorkspaceBuild(workspace.id, buildRequest)
if (buildResponse.code() != HttpURLConnection.HTTP_CREATED) {
throw APIResponseException("start workspace ${workspace.name}", url, buildResponse)
throw APIResponseException(
"start workspace ${workspace.name}",
url,
buildResponse.code(),
buildResponse.parseErrorBody(moshi)
)
}
return buildResponse.body()!!
}
@@ -251,7 +284,12 @@ open class CoderRestClient(
val buildRequest = CreateWorkspaceBuildRequest(null, WorkspaceTransition.STOP)
val buildResponse = retroRestClient.createWorkspaceBuild(workspace.id, buildRequest)
if (buildResponse.code() != HttpURLConnection.HTTP_CREATED) {
throw APIResponseException("stop workspace ${workspace.name}", url, buildResponse)
throw APIResponseException(
"stop workspace ${workspace.name}",
url,
buildResponse.code(),
buildResponse.parseErrorBody(moshi)
)
}
return buildResponse.body()!!
}
@@ -263,7 +301,12 @@ open class CoderRestClient(
val buildRequest = CreateWorkspaceBuildRequest(null, WorkspaceTransition.DELETE, false)
val buildResponse = retroRestClient.createWorkspaceBuild(workspace.id, buildRequest)
if (buildResponse.code() != HttpURLConnection.HTTP_CREATED) {
throw APIResponseException("delete workspace ${workspace.name}", url, buildResponse)
throw APIResponseException(
"delete workspace ${workspace.name}",
url,
buildResponse.code(),
buildResponse.parseErrorBody(moshi)
)
}
}

@@ -283,7 +326,12 @@ open class CoderRestClient(
CreateWorkspaceBuildRequest(template.activeVersionID, WorkspaceTransition.START)
val buildResponse = retroRestClient.createWorkspaceBuild(workspace.id, buildRequest)
if (buildResponse.code() != HttpURLConnection.HTTP_CREATED) {
throw APIResponseException("update workspace ${workspace.name}", url, buildResponse)
throw APIResponseException(
"update workspace ${workspace.name}",
url,
buildResponse.code(),
buildResponse.parseErrorBody(moshi)
)
}
return buildResponse.body()!!
}
@@ -296,3 +344,13 @@ open class CoderRestClient(
}
}
}

private fun Response<*>.parseErrorBody(moshi: Moshi): ApiErrorResponse? {
val errorBody = this.errorBody() ?: return null
return try {
val adapter = moshi.adapter(ApiErrorResponse::class.java)
adapter.fromJson(errorBody.string())
} catch (e: Exception) {
null
}
}
102 changes: 83 additions & 19 deletions src/main/kotlin/com/coder/toolbox/sdk/ex/APIResponseException.kt
Original file line number Diff line number Diff line change
@@ -1,26 +1,90 @@
package com.coder.toolbox.sdk.ex

import com.coder.toolbox.sdk.v2.models.ApiErrorResponse
import java.io.IOException
import java.net.HttpURLConnection
import java.net.URL

class APIResponseException(action: String, url: URL, res: retrofit2.Response<*>) :
IOException(
"Unable to $action: url=$url, code=${res.code()}, details=${
when (res.code()) {
HttpURLConnection.HTTP_NOT_FOUND -> "The requested resource could not be found"
else -> res.errorBody()?.charStream()?.use {
val text = it.readText()
// Be careful with the length because if you try to show a
// notification in Toolbox that is too large it crashes the
// application.
if (text.length > 500) {
"${text.substring(0, 500)}"
} else {
text
}
} ?: "no details provided"
}}",
) {
val isUnauthorized = res.code() == HttpURLConnection.HTTP_UNAUTHORIZED
class APIResponseException(action: String, url: URL, code: Int, errorResponse: ApiErrorResponse?) :
IOException(formatToPretty(action, url, code, errorResponse)) {


val isUnauthorized = HttpURLConnection.HTTP_UNAUTHORIZED == code

companion object {
private fun formatToPretty(
action: String,
url: URL,
code: Int,
errorResponse: ApiErrorResponse?,
): String {
return if (errorResponse == null) {
"Unable to $action: url=$url, code=$code, details=${HttpErrorStatusMapper.getMessage(code)}"
} else {
var msg = "Unable to $action: url=$url, code=$code, message=${errorResponse.message}"
if (errorResponse.detail?.isNotEmpty() == true) {
msg += ", reason=${errorResponse.detail}"
}

// Be careful with the length because if you try to show a
// notification in Toolbox that is too large it crashes the
// application.
if (msg.length > 500) {
msg = "${msg.substring(0, 500)}"
}
msg
}
}
}
}

private object HttpErrorStatusMapper {
private val errorStatusMap = mapOf(
// 4xx: Client Errors
400 to "Bad Request",
401 to "Unauthorized",
402 to "Payment Required",
403 to "Forbidden",
404 to "Not Found",
405 to "Method Not Allowed",
406 to "Not Acceptable",
407 to "Proxy Authentication Required",
408 to "Request Timeout",
409 to "Conflict",
410 to "Gone",
411 to "Length Required",
412 to "Precondition Failed",
413 to "Payload Too Large",
414 to "URI Too Long",
415 to "Unsupported Media Type",
416 to "Range Not Satisfiable",
417 to "Expectation Failed",
418 to "I'm a teapot",
421 to "Misdirected Request",
422 to "Unprocessable Entity",
423 to "Locked",
424 to "Failed Dependency",
425 to "Too Early",
426 to "Upgrade Required",
428 to "Precondition Required",
429 to "Too Many Requests",
431 to "Request Header Fields Too Large",
451 to "Unavailable For Legal Reasons",

// 5xx: Server Errors
500 to "Internal Server Error",
501 to "Not Implemented",
502 to "Bad Gateway",
503 to "Service Unavailable",
504 to "Gateway Timeout",
505 to "HTTP Version Not Supported",
506 to "Variant Also Negotiates",
507 to "Insufficient Storage",
508 to "Loop Detected",
510 to "Not Extended",
511 to "Network Authentication Required"
)

fun getMessage(code: Int): String =
errorStatusMap[code] ?: "Unknown Error Status"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.coder.toolbox.sdk.v2.models

import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass

@JsonClass(generateAdapter = true)
data class ApiErrorResponse(
@Json(name = "message") val message: String,
@Json(name = "detail") val detail: String?,
)
29 changes: 9 additions & 20 deletions src/main/kotlin/com/coder/toolbox/views/CoderPage.kt
Original file line number Diff line number Diff line change
@@ -6,6 +6,8 @@ import com.jetbrains.toolbox.api.core.ui.icons.SvgIcon.IconType
import com.jetbrains.toolbox.api.localization.LocalizableString
import com.jetbrains.toolbox.api.ui.actions.RunnableActionDescription
import com.jetbrains.toolbox.api.ui.components.UiPage
import kotlinx.coroutines.launch
import java.util.UUID

/**
* Base page that handles the icon, displaying error notifications, and
@@ -23,12 +25,6 @@ abstract class CoderPage(
showIcon: Boolean = true,
) : UiPage(title) {

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

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

/**
* Return the icon, if showing one.
*
@@ -48,20 +44,13 @@ abstract class CoderPage(
*/
fun notify(logPrefix: String, ex: Throwable) {
context.logger.error(ex, logPrefix)
// It is possible the error listener is not attached yet.
notifier?.let { it(ex) } ?: errorBuffer.add(ex)
}

/**
* Immediately notify any pending errors and store for later errors.
*/
override fun setActionErrorNotifier(notifier: ((Throwable) -> Unit)?) {
this.notifier = notifier
notifier?.let {
errorBuffer.forEach {
notifier(it)
}
errorBuffer.clear()
context.cs.launch {
context.ui.showSnackbar(
UUID.randomUUID().toString(),
context.i18n.pnotr(logPrefix),
context.i18n.pnotr(ex.message ?: ""),
context.i18n.ptrl("Dismiss")
)
}
}
}