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

@@ -51,4 +51,6 @@ import kotlinx.serialization.Serializable
@Serializable data object ProcessesScreen : NavKey
@Serializable data object ProvidersScreen : NavKey
@Serializable data object AnalyticsScreen : NavKey
@@ -8,6 +8,7 @@ import androidx.compose.material.icons.filled.AccountCircle
import androidx.compose.material.icons.filled.BarChart
import androidx.compose.material.icons.filled.Bolt
import androidx.compose.material.icons.filled.Build
import androidx.compose.material.icons.filled.Cloud
import androidx.compose.material.icons.filled.Code
import androidx.compose.material.icons.filled.Dashboard
import androidx.compose.material.icons.filled.Devices
@@ -40,6 +41,7 @@ import com.m57.hermescontrol.ui.pairing.PairingScreen as PairingScreenContent
import com.m57.hermescontrol.ui.plugins.PluginsScreen as PluginsScreenContent
import com.m57.hermescontrol.ui.process.ProcessesScreen as ProcessesScreenContent
import com.m57.hermescontrol.ui.profiles.ProfilesScreen as ProfilesScreenContent
import com.m57.hermescontrol.ui.providers.ProvidersScreen as ProvidersScreenContent
import com.m57.hermescontrol.ui.sessions.SessionsScreen as HistoryScreenContent
import com.m57.hermescontrol.ui.settings.SettingsScreen as SettingsScreenContent
import com.m57.hermescontrol.ui.skills.SkillsScreen as SkillsScreenContent
@@ -157,6 +159,12 @@ object ScreenRegistry {
Icons.AutoMirrored.Filled.ListAlt,
DrawerSection.CONFIGURE,
) { sessionId, openDrawer -> ChannelsScreenContent(onOpenDrawer = openDrawer) },
ScreenDefinition(
ProvidersScreen,
R.string.screen_providers,
Icons.Filled.Cloud,
DrawerSection.CONFIGURE,
) { sessionId, openDrawer -> ProvidersScreenContent(onOpenDrawer = openDrawer) },
ScreenDefinition(
SystemScreen,
R.string.screen_system,
@@ -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 ────────────────────────────────────────────────
@@ -124,12 +124,15 @@ import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.model.Attachment
import com.m57.hermescontrol.data.ws.CommandCatalog
import com.m57.hermescontrol.data.ws.ConnectionStatus
import com.m57.hermescontrol.data.ws.HermesWsClient
import com.m57.hermescontrol.notification.NotificationHelper
import com.m57.hermescontrol.theme.LocalHermesStatusColors
import com.m57.hermescontrol.theme.StatusRed
import com.m57.hermescontrol.ui.common.CredentialWarningBanner
import com.m57.hermescontrol.ui.common.EmptyState
import com.m57.hermescontrol.ui.common.HermesScaffold
import com.m57.hermescontrol.ui.common.NavIcon
import com.m57.hermescontrol.ui.providers.ProvidersScreen
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.distinctUntilChanged
@@ -151,6 +154,7 @@ fun ChatScreen(
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
val streamingState by viewModel.streamingState.collectAsStateWithLifecycle()
val credentialWarning by HermesWsClient.credentialWarning.collectAsStateWithLifecycle()
val listState = rememberLazyListState()
var isOlderPagingArmed by remember(state.currentSessionId) { mutableStateOf(false) }
@@ -403,6 +407,14 @@ fun ChatScreen(
onReloginClick = { showReloginDialog = true },
)
credentialWarning?.let { warning ->
CredentialWarningBanner(
warning = warning,
onFix = { NavigationController.navigateTo(com.m57.hermescontrol.ProvidersScreen) },
onDismiss = { HermesWsClient.clearCredentialWarning() },
)
}
Box(
modifier =
Modifier
@@ -0,0 +1,65 @@
package com.m57.hermescontrol.ui.common
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.m57.hermescontrol.R
import com.m57.hermescontrol.theme.LocalHermesStatusColors
/**
* Banner shown when the backend reports a `credential_warning` (the mobile
* equivalent of the desktop `requestDesktopOnboarding` re-trigger, issue #534).
* Tapping "Fix" deep-links to ProvidersScreen; dismissing clears it globally.
*/
@Composable
fun CredentialWarningBanner(
warning: String,
onFix: () -> Unit,
onDismiss: () -> Unit,
) {
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Icon(
imageVector = Icons.Filled.Warning,
contentDescription = null,
tint = LocalHermesStatusColors.current.warning,
modifier = Modifier.padding(top = 2.dp),
)
Text(
text = warning,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
)
TextButton(onClick = onFix) {
Text(
text = stringResource(R.string.providers_action_fix),
style = MaterialTheme.typography.labelSmall,
)
}
TextButton(onClick = onDismiss) {
Text(
text = stringResource(R.string.action_dismiss),
style = MaterialTheme.typography.labelSmall,
)
}
}
}
@@ -0,0 +1,515 @@
package com.m57.hermescontrol.ui.providers
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Link
import androidx.compose.material.icons.filled.OpenInBrowser
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.model.OAuthProvider
import com.m57.hermescontrol.data.model.OAuthStartResponse
import com.m57.hermescontrol.theme.LocalHermesStatusColors
import com.m57.hermescontrol.ui.common.EmptyState
import com.m57.hermescontrol.ui.common.ErrorState
import com.m57.hermescontrol.ui.common.HermesScaffold
import com.m57.hermescontrol.ui.common.LoadingState
import com.m57.hermescontrol.ui.common.NavIcon
import com.m57.hermescontrol.ui.common.ToastEffect
import com.m57.hermescontrol.ui.common.listContentPadding
import com.m57.hermescontrol.ui.common.listItemSpacing
@Composable
fun ProvidersScreen(
modifier: Modifier = Modifier,
onOpenDrawer: (() -> Unit)? = null,
viewModel: ProvidersViewModel = viewModel { ProvidersViewModel() },
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
val context = LocalContext.current
LaunchedEffect(Unit) { viewModel.load() }
ToastEffect(toastMessage = state.toastMessage, onClearToast = viewModel::clearToast)
// Re-auth dialog
if (state.flowPhase != OAuthFlowPhase.IDLE && state.flowProvider != null) {
OAuthFlowDialog(
state = state,
onCodeChange = viewModel::onCodeInputChange,
onSubmitCode = viewModel::submitOAuthCode,
onCancel = viewModel::cancelOAuthFlow,
onDismissFlow = viewModel::dismissFlow,
onOpenBrowser = { url ->
try {
context.startActivity(
android.content.Intent(
android.content.Intent.ACTION_VIEW,
android.net.Uri.parse(url),
),
)
} catch (_: Exception) {
// No browser available — ignore; user can copy the URL.
}
},
)
}
HermesScaffold(
title = { Text(stringResource(R.string.screen_providers)) },
navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) },
isRefreshing = state.isLoading,
onRefresh = { viewModel.load() },
) { paddingValues ->
when {
state.isLoading && state.providers.isEmpty() -> {
LoadingState(
subtitle = stringResource(R.string.loading_state_subtitle_providers),
modifier = Modifier.padding(paddingValues),
)
}
state.errorMessage != null && state.providers.isEmpty() -> {
ErrorState(
message = state.errorMessage ?: "",
onRetry = { viewModel.load() },
modifier = Modifier.padding(paddingValues),
)
}
state.providers.isEmpty() -> {
EmptyState(
title = stringResource(R.string.providers_empty_title),
subtitle = stringResource(R.string.providers_empty_desc),
modifier = Modifier.padding(paddingValues),
)
}
else -> {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = listContentPadding,
verticalArrangement = listItemSpacing,
) {
items(state.providers, key = { it.id }) { provider ->
ProviderCard(
provider = provider,
isActing = state.actingId == provider.id,
flowPhase = state.flowPhase,
onConnect = { viewModel.startOAuthFlow(provider) },
onDisconnect = { viewModel.disconnectProvider(provider.id) },
)
}
}
}
}
}
}
@Composable
private fun ProviderCard(
provider: OAuthProvider,
isActing: Boolean,
flowPhase: OAuthFlowPhase,
onConnect: () -> Unit,
onDisconnect: () -> Unit,
) {
val loggedIn = provider.status.loggedIn
val isExternal = provider.flow == "external"
val flowActive = flowPhase != OAuthFlowPhase.IDLE && flowPhase != OAuthFlowPhase.DONE
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer),
) {
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 10.dp),
verticalAlignment = Alignment.Top,
) {
Icon(
imageVector = providerStatusIcon(provider),
contentDescription = null,
tint = providerStatusColor(provider),
modifier = Modifier.size(20.dp),
)
Spacer(modifier = Modifier.width(10.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = provider.name,
style = MaterialTheme.typography.titleSmall,
fontWeight = androidx.compose.ui.text.font.FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = providerStatusLabel(provider),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
provider.status.sourceLabel?.let { label ->
Spacer(modifier = Modifier.height(2.dp))
Text(
text = "via $label",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f),
)
}
Spacer(modifier = Modifier.height(8.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
if (flowActive) {
OutlinedButton(
onClick = { /* in-flight flow handled by dialog */ },
enabled = false,
) {
Text(
text = stringResource(R.string.providers_action_in_progress),
style = MaterialTheme.typography.labelSmall,
)
}
} else if (loggedIn) {
if (provider.disconnectable) {
OutlinedButton(
onClick = onDisconnect,
enabled = !isActing,
) {
Text(
text = stringResource(R.string.providers_action_disconnect),
style = MaterialTheme.typography.labelSmall,
)
}
} else {
provider.disconnectHint?.let { hint ->
Text(
text = hint,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
)
}
}
} else {
Button(
onClick = onConnect,
enabled = !isActing,
) {
Text(
text =
if (isExternal) {
stringResource(R.string.providers_action_how_to_connect)
} else {
stringResource(R.string.providers_action_connect)
},
style = MaterialTheme.typography.labelSmall,
)
}
}
}
}
}
}
}
// ── Re-auth flow dialog ─────────────────────────────────────────────────
@Composable
private fun OAuthFlowDialog(
state: ProvidersUiState,
onCodeChange: (String) -> Unit,
onSubmitCode: () -> Unit,
onCancel: () -> Unit,
onDismissFlow: () -> Unit,
onOpenBrowser: (String) -> Unit,
) {
val provider = state.flowProvider ?: return
val start = state.flowStart
AlertDialog(
onDismissRequest = onCancel,
confirmButton = {
when (state.flowPhase) {
OAuthFlowPhase.WAITING_CODE -> {
Button(onClick = onSubmitCode) {
Text(stringResource(R.string.providers_action_submit_code))
}
}
OAuthFlowPhase.DONE -> {
Button(onClick = onDismissFlow) {
Text(stringResource(R.string.action_done))
}
}
OAuthFlowPhase.ERROR -> {
Button(onClick = onDismissFlow) {
Text(stringResource(R.string.action_close))
}
}
OAuthFlowPhase.SHOW_CLI -> {
Button(onClick = onDismissFlow) {
Text(stringResource(R.string.action_close))
}
}
else -> {
TextButton(onClick = onCancel) {
Text(stringResource(R.string.action_cancel))
}
}
}
},
dismissButton = {
if (state.flowPhase == OAuthFlowPhase.WAITING_CODE) {
TextButton(onClick = onCancel) {
Text(stringResource(R.string.action_cancel))
}
} else {
Unit
}
},
title = { Text(provider.name, style = MaterialTheme.typography.titleLarge) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
when {
provider.flow == "external" -> {
ExternalFlowContent(provider = provider)
}
state.flowPhase == OAuthFlowPhase.WAITING_CODE && start?.authUrl != null -> {
PkceFlowContent(
authUrl = start.authUrl,
code = state.flowCodeInput,
onCodeChange = onCodeChange,
onOpenBrowser = onOpenBrowser,
error = state.flowErrorMessage,
)
}
state.flowPhase == OAuthFlowPhase.POLLING -> {
DeviceCodeFlowContent(
start = start,
status = state.flowStatus,
expiresIn = state.flowExpiresIn,
error = state.flowErrorMessage,
)
}
state.flowPhase == OAuthFlowPhase.DONE -> {
Text(
text = stringResource(R.string.providers_flow_success),
style = MaterialTheme.typography.bodyMedium,
)
}
state.flowErrorMessage != null -> {
Text(
text = state.flowErrorMessage!!,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodyMedium,
)
}
}
}
},
)
}
@Composable
private fun ExternalFlowContent(provider: OAuthProvider) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
text = stringResource(R.string.providers_external_hint),
style = MaterialTheme.typography.bodyMedium,
)
provider.cliCommand.takeIf { it.isNotBlank() }?.let { cmd ->
Surface(
color = LocalHermesStatusColors.current.infoContainer,
shape = RoundedCornerShape(4.dp),
) {
Text(
text = " $cmd",
style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
)
}
}
}
}
@Composable
private fun PkceFlowContent(
authUrl: String,
code: String,
onCodeChange: (String) -> Unit,
onOpenBrowser: (String) -> Unit,
error: String?,
) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Button(
onClick = { onOpenBrowser(authUrl) },
modifier = Modifier.fillMaxWidth(),
) {
Icon(
imageVector = Icons.Filled.OpenInBrowser,
contentDescription = null,
modifier = Modifier.size(16.dp),
)
Spacer(modifier = Modifier.width(6.dp))
Text(
text = stringResource(R.string.providers_action_open_browser),
style = MaterialTheme.typography.labelSmall,
)
}
Text(
text = stringResource(R.string.providers_pkce_paste_hint),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
OutlinedTextField(
value = code,
onValueChange = onCodeChange,
label = { Text(stringResource(R.string.providers_code_label)) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
isError = error != null,
visualTransformation = VisualTransformation.None,
)
error?.let {
Text(
text = it,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.labelSmall,
)
}
}
}
@Composable
private fun DeviceCodeFlowContent(
start: OAuthStartResponse?,
status: String,
expiresIn: String,
error: String?,
) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
text = stringResource(R.string.providers_device_code_hint),
style = MaterialTheme.typography.bodyMedium,
)
start?.userCode?.let { code ->
Surface(
color = LocalHermesStatusColors.current.infoContainer,
shape = RoundedCornerShape(4.dp),
) {
Text(
text = " $code",
style = MaterialTheme.typography.titleMedium.copy(fontFamily = FontFamily.Monospace),
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
)
}
}
start?.verificationUrl?.let { url ->
Text(
text = url,
style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
color = MaterialTheme.colorScheme.primary,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
if (expiresIn.isNotEmpty()) {
Text(
text = stringResource(R.string.providers_expires_in, expiresIn),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
val statusLabel = deviceStatusLabel(status)
if (statusLabel != null) {
Text(
text = statusLabel,
style = MaterialTheme.typography.bodySmall,
color =
if (status == "error") {
MaterialTheme.colorScheme.error
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
)
}
error?.let {
Text(
text = it,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.labelSmall,
)
}
}
}
// ── Status helpers ──────────────────────────────────────────────────────
private fun providerStatusIcon(provider: OAuthProvider): ImageVector =
when {
provider.status.loggedIn -> Icons.Filled.CheckCircle
provider.flow == "external" -> Icons.Filled.Link
else -> Icons.Filled.Warning
}
@Composable
private fun providerStatusColor(provider: OAuthProvider): Color =
when {
provider.status.loggedIn -> LocalHermesStatusColors.current.success
else -> LocalHermesStatusColors.current.warning
}
private fun providerStatusLabel(provider: OAuthProvider): String =
when {
provider.status.loggedIn -> "Connected"
provider.flow == "external" -> "External — connect via CLI"
else -> "Not connected"
}
private fun deviceStatusLabel(status: String): String? =
when (status) {
"pending" -> "Waiting for authorization…"
"approved" -> "Approved"
"denied" -> "Denied"
"expired" -> "Expired"
"error" -> "Error"
else -> null
}
@@ -0,0 +1,371 @@
package com.m57.hermescontrol.ui.providers
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.m57.hermescontrol.data.model.OAuthPollResponse
import com.m57.hermescontrol.data.model.OAuthProvider
import com.m57.hermescontrol.data.model.OAuthStartResponse
import com.m57.hermescontrol.data.model.OAuthSubmitRequest
import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.NetworkError
import com.m57.hermescontrol.data.remote.NetworkResult
import com.m57.hermescontrol.data.remote.safeApiCall
import com.m57.hermescontrol.ui.common.ToastHost
import com.m57.hermescontrol.ui.common.safeLaunchLoad
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
/** Re-auth flow phase shown in the OAuth dialog. */
enum class OAuthFlowPhase {
IDLE,
STARTING,
/** pkce: browser opened, waiting for the user to paste the callback code. */
WAITING_CODE,
/** device_code: user_code + verification_url shown, background poll running. */
POLLING,
/** terminal: success — dialog shows a confirmation + Done button. */
DONE,
/** terminal: error — dialog stays mounted so the user can read flowErrorMessage + Close. */
ERROR,
/** external provider: show the CLI setup command (no network call). */
SHOW_CLI,
}
data class ProvidersUiState(
val isLoading: Boolean = false,
val providers: List<OAuthProvider> = emptyList(),
val errorMessage: String? = null,
val toastMessage: String? = null,
val actingId: String? = null,
// ── Re-auth flow ──
val flowPhase: OAuthFlowPhase = OAuthFlowPhase.IDLE,
val flowProvider: OAuthProvider? = null,
val flowStart: OAuthStartResponse? = null,
val flowSessionId: String = "",
val flowProviderId: String = "",
val flowCodeInput: String = "",
val flowStatus: String = "pending",
val flowErrorMessage: String? = null,
val flowExpiresIn: String = "",
)
class ProvidersViewModel :
ViewModel(),
ToastHost {
private val _uiState = MutableStateFlow(ProvidersUiState())
val uiState: StateFlow<ProvidersUiState> = _uiState.asStateFlow()
private var pollJob: Job? = null
fun load() {
safeLaunchLoad(
apiCall = { safeApiCall { ApiClient.hermesApi.getOAuthProviders() } },
onStart = {
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
},
onSuccess = { data ->
_uiState.update {
it.copy(
isLoading = false,
providers = data?.providers.orEmpty(),
)
}
},
onError = { errorMsg ->
_uiState.update {
it.copy(
isLoading = false,
errorMessage = "Failed to load providers: $errorMsg",
)
}
},
)
}
/** Disconnect a provider whose credentials are app-managed. */
fun disconnectProvider(providerId: String) {
_uiState.update { it.copy(actingId = providerId) }
safeLaunchLoad(
apiCall = { safeApiCall { ApiClient.hermesApi.disconnectOAuthProvider(providerId) } },
onStart = {},
onSuccess = {
_uiState.update {
it.copy(
actingId = null,
toastMessage = "Disconnected",
)
}
load()
},
onError = { error ->
_uiState.update {
it.copy(
actingId = null,
toastMessage = "Disconnect failed: $error",
)
}
},
)
}
// ── Re-auth flow ────────────────────────────────────────────────────
fun startOAuthFlow(provider: OAuthProvider) {
// External providers can't be started from the app (backend returns 400 from
// /start — they must be configured via CLI). Show the CLI command directly instead
// of firing a request that will just 400 and snap the dialog shut.
if (provider.flow == "external") {
_uiState.update {
it.copy(
flowPhase = OAuthFlowPhase.SHOW_CLI,
flowProvider = provider,
flowProviderId = provider.id,
flowStart = null,
flowSessionId = "",
flowCodeInput = "",
flowStatus = "pending",
flowErrorMessage = null,
)
}
return
}
val phase = _uiState.value.flowPhase
if (phase == OAuthFlowPhase.STARTING || phase == OAuthFlowPhase.WAITING_CODE ||
phase == OAuthFlowPhase.POLLING
) {
return
}
_uiState.update {
it.copy(
flowPhase = OAuthFlowPhase.STARTING,
flowProvider = provider,
flowProviderId = provider.id,
flowStart = null,
flowSessionId = "",
flowCodeInput = "",
flowStatus = "pending",
flowErrorMessage = null,
)
}
viewModelScope.launch {
val result = safeApiCall { ApiClient.hermesApi.startOAuthLogin(provider.id) }
when (result) {
is NetworkResult.Success -> {
val start =
result.data ?: run {
_uiState.update {
it.copy(
flowPhase = OAuthFlowPhase.IDLE,
flowErrorMessage = "Empty start response",
)
}
return@launch
}
_uiState.update {
it.copy(
flowPhase =
when (start.flow) {
"pkce" -> OAuthFlowPhase.WAITING_CODE
"device_code" -> OAuthFlowPhase.POLLING
else -> OAuthFlowPhase.IDLE
},
flowStart = start,
flowSessionId = start.sessionId,
flowExpiresIn = formatExpiry(start.expiresIn),
)
}
if (start.flow == "device_code") {
pollSession(provider.id, start.sessionId)
}
}
is NetworkResult.Failure -> {
_uiState.update {
it.copy(
flowPhase = OAuthFlowPhase.IDLE,
flowErrorMessage = result.error.message,
)
}
}
}
}
}
fun onCodeInputChange(code: String) {
_uiState.update { it.copy(flowCodeInput = code) }
}
fun submitOAuthCode() {
val state = _uiState.value
val code = state.flowCodeInput.trim()
if (code.isEmpty()) {
_uiState.update { it.copy(flowErrorMessage = "Paste the authorization code first.") }
return
}
_uiState.update { it.copy(flowStatus = "pending", flowErrorMessage = null) }
viewModelScope.launch {
val result =
safeApiCall {
ApiClient.hermesApi.submitOAuthCode(
state.flowProviderId,
OAuthSubmitRequest(sessionId = state.flowSessionId, code = code),
)
}
when (result) {
is NetworkResult.Success -> {
val submit = result.data
if (submit?.ok == true) {
finishFlow("approved")
} else {
_uiState.update {
it.copy(
flowStatus = submit?.status ?: "error",
flowErrorMessage = submit?.message ?: "Submission rejected",
)
}
}
}
is NetworkResult.Failure -> {
_uiState.update {
it.copy(
flowStatus = "error",
flowErrorMessage = result.error.message,
)
}
}
}
}
}
private fun pollSession(
providerId: String,
sessionId: String,
) {
pollJob?.cancel()
pollJob =
viewModelScope.launch {
while (_uiState.value.flowPhase == OAuthFlowPhase.POLLING) {
delay((_uiState.value.flowStart?.pollInterval ?: 5).toLong() * 1000)
val result =
safeApiCall {
ApiClient.hermesApi.pollOAuthSession(providerId, sessionId)
}
when (result) {
is NetworkResult.Success -> {
val poll: OAuthPollResponse? = result.data
val status = poll?.status ?: "pending"
_uiState.update { it.copy(flowStatus = status) }
when (status) {
"approved" -> {
finishFlow("approved")
return@launch
}
"denied", "expired", "error" -> {
_uiState.update {
it.copy(
flowPhase = OAuthFlowPhase.ERROR,
flowStatus = status,
flowErrorMessage =
poll?.errorMessage
?: "OAuth flow ended ($status)",
)
}
return@launch
}
else -> { /* still pending — keep polling */ }
}
}
is NetworkResult.Failure -> {
// Tokenless endpoint; a transient failure shouldn't kill the flow.
// Continue polling unless it's a hard 4xx.
val code = (result.error as? NetworkError.Http)?.code ?: -1
if (code in 400..499) {
_uiState.update {
it.copy(
flowPhase = OAuthFlowPhase.ERROR,
flowErrorMessage = result.error.message,
)
}
return@launch
}
}
}
}
}
}
private fun finishFlow(status: String) {
pollJob?.cancel()
load()
// Stay on DONE so the dialog can show a success confirmation; the Screen's
// Done button calls dismissFlow() to reset to IDLE. (Setting IDLE here would
// conflate with DONE via StateFlow and unmount the dialog before the user sees it.)
_uiState.update {
it.copy(
flowPhase = OAuthFlowPhase.DONE,
flowStatus = status,
flowErrorMessage = null,
toastMessage = "OAuth connected",
)
}
}
/** Reset the re-auth flow to IDLE, clearing all flow-specific state. */
fun dismissFlow() {
pollJob?.cancel()
_uiState.update {
it.copy(
flowPhase = OAuthFlowPhase.IDLE,
flowProvider = null,
flowStart = null,
flowSessionId = "",
flowCodeInput = "",
flowStatus = "pending",
flowErrorMessage = null,
)
}
}
fun cancelOAuthFlow() {
val sessionId = _uiState.value.flowSessionId
pollJob?.cancel()
_uiState.update {
it.copy(
flowPhase = OAuthFlowPhase.IDLE,
flowStart = null,
flowSessionId = "",
flowCodeInput = "",
flowStatus = "pending",
flowErrorMessage = null,
)
}
if (sessionId.isNotEmpty()) {
viewModelScope.launch {
safeApiCall { ApiClient.hermesApi.cancelOAuthSession(sessionId) }
}
}
}
override fun clearToast() {
_uiState.update { it.copy(toastMessage = null) }
}
private fun formatExpiry(seconds: Int?): String {
if (seconds == null) return ""
val m = seconds / 60
val s = seconds % 60
return if (m > 0) "$m min ${s}s" else "${s}s"
}
}
+22
View File
@@ -72,6 +72,7 @@
<string name="screen_achievements">Achievements</string>
<string name="screen_history">History</string>
<string name="screen_processes">Processes</string>
<string name="screen_providers">Providers</string>
<string name="screen_analytics">Analytics</string>
<!-- Notifications -->
@@ -845,6 +846,27 @@
<string name="loading_state_subtitle_default">Loading…</string>
<string name="loading_state_subtitle_models">Fetching model providers…</string>
<string name="loading_state_subtitle_channels">Loading channels…</string>
<string name="loading_state_subtitle_providers">Loading OAuth providers…</string>
<!-- Providers Screen (issue #534) -->
<string name="providers_empty_title">No OAuth providers</string>
<string name="providers_empty_desc">Configured OAuth providers will appear here. Connect an account to use it from the app.</string>
<string name="providers_action_connect">Connect</string>
<string name="providers_action_how_to_connect">How to connect</string>
<string name="providers_action_disconnect">Disconnect</string>
<string name="providers_action_in_progress">In progress…</string>
<string name="providers_action_submit_code">Submit code</string>
<string name="providers_action_open_browser">Open browser to authorize</string>
<string name="providers_external_hint">This provider is managed by an external CLI. Run the command below on the host, then refresh this list.</string>
<string name="providers_pkce_paste_hint">Authorize in the browser, then paste the authorization code (the part before any # separator) below.</string>
<string name="providers_code_label">Authorization code</string>
<string name="providers_device_code_hint">Enter this code at the verification URL on another device, then wait — this screen polls automatically.</string>
<string name="providers_expires_in">Expires in %1$s</string>
<string name="providers_flow_success">OAuth connected successfully.</string>
<string name="providers_action_fix">Fix</string>
<string name="action_dismiss">Dismiss</string>
<string name="action_done">Done</string>
<string name="action_close">Close</string>
<!-- Processes Screen (issue #532) -->
<string name="processes_empty_title">No running processes</string>
@@ -1,5 +1,6 @@
package com.m57.hermescontrol.data.remote
import com.m57.hermescontrol.data.model.OAuthSubmitRequest
import com.m57.hermescontrol.data.model.SessionMessage
import com.m57.hermescontrol.data.model.StatusResponse
import kotlinx.coroutines.runBlocking
@@ -307,20 +308,44 @@ class HermesApiServiceMockWebServerTest {
}
@Test
fun getSessionMessages_encodesSessionIdWithSlashes() =
fun getOAuthProviders_parsesList() =
runBlocking {
val sessionId = "session/with/slashes"
mockServer.enqueue(
MockResponse()
.setResponseCode(200)
.setBody(
"""
{
"messages": [
"providers": [
{
"role": "user",
"content": "test",
"timestamp": "1000"
"id": "anthropic",
"name": "Anthropic",
"flow": "pkce",
"cli_command": "",
"docs_url": "https://docs.anthropic.com",
"disconnectable": true,
"status": {
"logged_in": true,
"source": "hermes_pkce",
"source_label": "Hermes PKCE (~/.hermes/.anthropic_oauth.json)",
"token_preview": "sk-ant-••••a1b2",
"expires_at": "2026-08-01T12:00:00",
"has_refresh_token": true,
"last_refresh": "2026-07-10T12:00:00"
}
},
{
"id": "nous",
"name": "Nous",
"flow": "device_code",
"cli_command": "hermes auth add nous",
"docs_url": null,
"disconnectable": false,
"disconnect_hint": "Run `hermes auth remove nous` on the host.",
"status": {
"logged_in": false,
"error": null
}
}
]
}
@@ -328,14 +353,199 @@ class HermesApiServiceMockWebServerTest {
),
)
val response = api.getSessionMessages(sessionId)
val response = api.getOAuthProviders()
assertTrue(response.isSuccessful)
val body = response.body()
assertNotNull(body)
assertEquals(2, body!!.providers.size)
// Verify the request path preserves slashes
val request = mockServer.takeRequest()
assertTrue(
"Slash-encoded session ID should appear in path",
request.path!!.contains("session/with/slashes"),
val first = body.providers[0]
assertEquals("anthropic", first.id)
assertEquals("Anthropic", first.name)
assertEquals("pkce", first.flow)
assertEquals(true, first.status.loggedIn)
assertEquals("sk-ant-••••a1b2", first.status.tokenPreview)
assertEquals(true, first.status.hasRefreshToken)
val second = body.providers[1]
assertEquals("nous", second.id)
assertEquals("device_code", second.flow)
assertEquals(false, second.disconnectable)
assertEquals("Run `hermes auth remove nous` on the host.", second.disconnectHint)
assertEquals(false, second.status.loggedIn)
}
@Test
fun startOAuthLogin_pkce_parsesResponse() =
runBlocking {
mockServer.enqueue(
MockResponse()
.setResponseCode(200)
.setBody(
"""
{
"session_id": "sess-abc",
"flow": "pkce",
"auth_url": "https://claude.ai/oauth/authorize?code=true",
"expires_in": 600
}
""".trimIndent(),
),
)
val response = api.startOAuthLogin("anthropic")
assertTrue(response.isSuccessful)
val body = response.body()
assertNotNull(body)
assertEquals("sess-abc", body!!.sessionId)
assertEquals("pkce", body.flow)
assertEquals("https://claude.ai/oauth/authorize?code=true", body.authUrl)
assertEquals(600, body.expiresIn)
}
@Test
fun startOAuthLogin_deviceCode_parsesResponse() =
runBlocking {
mockServer.enqueue(
MockResponse()
.setResponseCode(200)
.setBody(
"""
{
"session_id": "sess-xyz",
"flow": "device_code",
"user_code": "ABCD-EFGH",
"verification_url": "https://nous.ai/device",
"expires_in": 900,
"poll_interval": 5
}
""".trimIndent(),
),
)
val response = api.startOAuthLogin("nous")
assertTrue(response.isSuccessful)
val body = response.body()
assertNotNull(body)
assertEquals("device_code", body!!.flow)
assertEquals("ABCD-EFGH", body.userCode)
assertEquals("https://nous.ai/device", body.verificationUrl)
assertEquals(5, body.pollInterval)
}
@Test
fun submitOAuthCode_parsesResponse() =
runBlocking {
mockServer.enqueue(
MockResponse()
.setResponseCode(200)
.setBody(
"""
{
"ok": true,
"status": "approved",
"message": "Tokens saved"
}
""".trimIndent(),
),
)
val response =
api.submitOAuthCode(
"anthropic",
OAuthSubmitRequest(sessionId = "sess-abc", code = "auth-code-123"),
)
assertTrue(response.isSuccessful)
val body = response.body()
assertNotNull(body)
assertEquals(true, body!!.ok)
assertEquals("approved", body.status)
}
@Test
fun pollOAuthSession_parsesResponse() =
runBlocking {
mockServer.enqueue(
MockResponse()
.setResponseCode(200)
.setBody(
"""
{
"session_id": "sess-xyz",
"status": "pending",
"error_message": null,
"expires_at": 1780583153.638556
}
""".trimIndent(),
),
)
val response = api.pollOAuthSession("nous", "sess-xyz")
assertTrue(response.isSuccessful)
val body = response.body()
assertNotNull(body)
assertEquals("pending", body!!.status)
// Backend sends expires_at as a float epoch (time.time()).
assertEquals(1780583153.638556, body.expiresAt ?: 0.0, 0.0001)
}
@Test
fun cancelOAuthSession_parsesResponse() =
runBlocking {
mockServer.enqueue(
MockResponse()
.setResponseCode(200)
.setBody(
"""
{
"ok": true,
"session_id": "sess-xyz"
}
""".trimIndent(),
),
)
val response = api.cancelOAuthSession("sess-xyz")
assertTrue(response.isSuccessful)
val body = response.body()
assertNotNull(body)
assertEquals(true, body!!.ok)
assertEquals("sess-xyz", body.sessionId)
}
@Test
fun disconnectOAuthProvider_sendsDeleteWithId() =
runBlocking {
mockServer.enqueue(
MockResponse().setResponseCode(200).setBody(""),
)
val response = api.disconnectOAuthProvider("anthropic")
assertTrue(response.isSuccessful)
val request = mockServer.takeRequest()
assertEquals("DELETE", request.method)
assertEquals("/api/providers/oauth/anthropic", request.path)
}
@Test
fun pollOAuthSession_sendsPathWithBothIds() =
runBlocking {
mockServer.enqueue(
MockResponse()
.setResponseCode(200)
.setBody(
"""
{
"session_id": "sess-xyz",
"status": "approved"
}
""".trimIndent(),
),
)
val response = api.pollOAuthSession("nous", "sess-xyz")
assertTrue(response.isSuccessful)
val request = mockServer.takeRequest()
assertEquals("/api/providers/oauth/nous/poll/sess-xyz", request.path)
}
}