* 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:
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 ────────────────────────────────────────────────
|
||||
|
||||
Reference in new issue
Block a user