feat(#534): add OAuth provider management screen + credential_warning surfacing (#566)

* feat(#534): add OAuth provider management screen + credential_warning surfacing

* style(#534): fix ktlint import-ordering in ProvidersViewModel

* fix(#534): address agy review — terminal DONE/ERROR dialog states + PKCE browser launch

* fix(#534): add Done/Close dialog strings

* fix(#534): address agy review - terminal DONE/ERROR dialog states, real PKCE browser launch, clear flow state

---------

Co-authored-by: M57 <m57@hermes-control.app>
This commit is contained in:
M57andM57 authored and GitHub committed 2026-07-12 21:10:11 +03:00
1 parent ad87cd1054
commit 9e10c813d4
11 files changed
+1389 -12

No files matched your search

@@ -0,0 +1,100 @@
package com.m57.hermescontrol.data.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* OAuth provider catalog entry returned by `GET /api/providers/oauth`.
*
* Wire schema (hermes_cli/web_server.py `list_oauth_providers`, ~:8415):
* id, name, flow ("pkce" | "device_code" | "external"), cli_command,
* docs_url, disconnect_hint, disconnect_command, disconnectable, status.
*
* `expires_at` in the status comes back as an ISO timestamp string.
*/
@Serializable
data class OAuthProvider(
val id: String,
val name: String,
val flow: String,
@SerialName("cli_command") val cliCommand: String = "",
@SerialName("docs_url") val docsUrl: String? = null,
@SerialName("disconnect_hint") val disconnectHint: String? = null,
@SerialName("disconnect_command") val disconnectCommand: String? = null,
val disconnectable: Boolean = true,
val status: OAuthProviderStatus = OAuthProviderStatus(),
)
@Serializable
data class OAuthProviderStatus(
@SerialName("logged_in") val loggedIn: Boolean = false,
val source: String? = null,
@SerialName("source_label") val sourceLabel: String? = null,
@SerialName("token_preview") val tokenPreview: String? = null,
@SerialName("expires_at") val expiresAt: String? = null,
@SerialName("has_refresh_token") val hasRefreshToken: Boolean? = null,
@SerialName("last_refresh") val lastRefresh: String? = null,
val error: String? = null,
)
@Serializable
data class OAuthProvidersResponse(
val providers: List<OAuthProvider> = emptyList(),
)
/**
* `POST /api/providers/oauth/{provider_id}/start`
*
* Discriminated by `flow`:
* - pkce: { session_id, flow:"pkce", auth_url, expires_in }
* - device_code: { session_id, flow:"device_code", user_code,
* verification_url, expires_in, poll_interval }
*
* `expires_in` is an int (seconds). We keep the union fields nullable so a
* single model parses both shapes.
*/
@Serializable
data class OAuthStartResponse(
@SerialName("session_id") val sessionId: String = "",
val flow: String = "",
@SerialName("auth_url") val authUrl: String? = null,
@SerialName("user_code") val userCode: String? = null,
@SerialName("verification_url") val verificationUrl: String? = null,
@SerialName("expires_in") val expiresIn: Int? = null,
@SerialName("poll_interval") val pollInterval: Int? = null,
)
@Serializable
data class OAuthSubmitRequest(
@SerialName("session_id") val sessionId: String,
val code: String,
)
/** `POST /api/providers/oauth/{provider_id}/submit` */
@Serializable
data class OAuthSubmitResponse(
val ok: Boolean = false,
val status: String? = null,
val message: String? = null,
)
/**
* `GET /api/providers/oauth/{provider_id}/poll/{session_id}`
*
* `expires_at` is a float epoch (web_server.py `time.time()`), kept as Double?.
*/
@Serializable
data class OAuthPollResponse(
@SerialName("session_id") val sessionId: String = "",
val status: String = "pending",
@SerialName("error_message") val errorMessage: String? = null,
@SerialName("expires_at") val expiresAt: Double? = null,
)
/** `DELETE /api/providers/oauth/sessions/{session_id}` */
@Serializable
data class OAuthCancelResponse(
val ok: Boolean = false,
@SerialName("session_id") val sessionId: String? = null,
val message: String? = null,
)
@@ -47,6 +47,12 @@ import com.m57.hermescontrol.data.model.ModelAssignmentRequest
import com.m57.hermescontrol.data.model.ModelAssignmentResponse
import com.m57.hermescontrol.data.model.ModelOptionsResponse
import com.m57.hermescontrol.data.model.ModelsAnalyticsResponse
import com.m57.hermescontrol.data.model.OAuthCancelResponse
import com.m57.hermescontrol.data.model.OAuthPollResponse
import com.m57.hermescontrol.data.model.OAuthProvidersResponse
import com.m57.hermescontrol.data.model.OAuthStartResponse
import com.m57.hermescontrol.data.model.OAuthSubmitRequest
import com.m57.hermescontrol.data.model.OAuthSubmitResponse
import com.m57.hermescontrol.data.model.PairingApproveRequest
import com.m57.hermescontrol.data.model.PairingResponse
import com.m57.hermescontrol.data.model.PairingRevokeRequest
@@ -557,6 +563,43 @@ interface HermesApiService {
@Path("pairing_id") pairingId: String,
): Response<Unit>
// ── OAuth provider management (issue #534) ──────────────────────────
// Auth: the dashboard gates /start, /submit, /disconnect, /cancel via
// `_require_token`, which accepts either `X-Hermes-Session-Token` OR the
// legacy `Authorization: Bearer <token>`. ApiClient already injects the
// Bearer token on every request, so no extra header code is needed here.
// (poll is tokenless — read-only session state.)
@GET("api/providers/oauth")
suspend fun getOAuthProviders(): Response<OAuthProvidersResponse>
@DELETE("api/providers/oauth/{provider_id}")
suspend fun disconnectOAuthProvider(
@Path("provider_id") providerId: String,
): Response<Unit>
@POST("api/providers/oauth/{provider_id}/start")
suspend fun startOAuthLogin(
@Path("provider_id") providerId: String,
): Response<OAuthStartResponse>
@POST("api/providers/oauth/{provider_id}/submit")
suspend fun submitOAuthCode(
@Path("provider_id") providerId: String,
@Body body: OAuthSubmitRequest,
): Response<OAuthSubmitResponse>
@GET("api/providers/oauth/{provider_id}/poll/{session_id}")
suspend fun pollOAuthSession(
@Path("provider_id") providerId: String,
@Path("session_id") sessionId: String,
): Response<OAuthPollResponse>
@HTTP(method = "DELETE", path = "api/providers/oauth/sessions/{session_id}", hasBody = false)
suspend fun cancelOAuthSession(
@Path("session_id") sessionId: String,
): Response<OAuthCancelResponse>
@GET("api/env")
suspend fun getEnvVars(): Response<Map<String, EnvVarConfig>>
@@ -142,6 +142,20 @@ object HermesWsClient {
/** Observable connection status */
val connectionStatus: StateFlow<ConnectionStatus> = _connectionStatus.asStateFlow()
// ── Credential warning (issue #534) ─────────────────────────────────
// Backend surfaces `credential_warning` in `gateway.ready` / `session.info`
// WS payloads (desktop `requestDesktopOnboarding`). Mobile has no equivalent
// at the auth layer, so we extract it here once, globally, and let any
// screen render a banner that deep-links to ProvidersScreen.
private val _credentialWarning = MutableStateFlow<String?>(null)
/** Non-null when the backend reports a credential warning to resolve. */
val credentialWarning: StateFlow<String?> = _credentialWarning.asStateFlow()
fun clearCredentialWarning() {
_credentialWarning.value = null
}
init {
// Monitor network state to trigger immediate reconnect when network is restored
wsScope.launch {
@@ -154,6 +168,21 @@ object HermesWsClient {
}
}
}
// Extract credential_warning from gateway.ready / session.info payloads.
wsScope.launch {
events.collect { event ->
val data: Map<String, Any?>? =
when (event) {
is WsEvent.GatewayReady -> event.data
is WsEvent.SessionInfo -> event.data
else -> null
}
val warning = data?.get("credential_warning") as? String
if (!warning.isNullOrBlank()) {
_credentialWarning.value = warning
}
}
}
}
// ── Connection helpers ────────────────────────────────────────────────