diff --git a/app/src/main/java/com/m57/hermescontrol/NavigationKeys.kt b/app/src/main/java/com/m57/hermescontrol/NavigationKeys.kt index e6a9c0ee..2be587a4 100644 --- a/app/src/main/java/com/m57/hermescontrol/NavigationKeys.kt +++ b/app/src/main/java/com/m57/hermescontrol/NavigationKeys.kt @@ -51,4 +51,6 @@ import kotlinx.serialization.Serializable @Serializable data object ProcessesScreen : NavKey +@Serializable data object ProvidersScreen : NavKey + @Serializable data object AnalyticsScreen : NavKey diff --git a/app/src/main/java/com/m57/hermescontrol/ScreenRegistry.kt b/app/src/main/java/com/m57/hermescontrol/ScreenRegistry.kt index 18f60648..0ab1e9ef 100644 --- a/app/src/main/java/com/m57/hermescontrol/ScreenRegistry.kt +++ b/app/src/main/java/com/m57/hermescontrol/ScreenRegistry.kt @@ -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, diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/OAuthProvider.kt b/app/src/main/java/com/m57/hermescontrol/data/model/OAuthProvider.kt new file mode 100644 index 00000000..f5525b2e --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/model/OAuthProvider.kt @@ -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 = 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, +) diff --git a/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt b/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt index 22ca15bd..fa73b0ef 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt @@ -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 + // ── 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 `. 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 + + @DELETE("api/providers/oauth/{provider_id}") + suspend fun disconnectOAuthProvider( + @Path("provider_id") providerId: String, + ): Response + + @POST("api/providers/oauth/{provider_id}/start") + suspend fun startOAuthLogin( + @Path("provider_id") providerId: String, + ): Response + + @POST("api/providers/oauth/{provider_id}/submit") + suspend fun submitOAuthCode( + @Path("provider_id") providerId: String, + @Body body: OAuthSubmitRequest, + ): Response + + @GET("api/providers/oauth/{provider_id}/poll/{session_id}") + suspend fun pollOAuthSession( + @Path("provider_id") providerId: String, + @Path("session_id") sessionId: String, + ): Response + + @HTTP(method = "DELETE", path = "api/providers/oauth/sessions/{session_id}", hasBody = false) + suspend fun cancelOAuthSession( + @Path("session_id") sessionId: String, + ): Response + @GET("api/env") suspend fun getEnvVars(): Response> diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/HermesWsClient.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/HermesWsClient.kt index 8c659d3f..894c4492 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/HermesWsClient.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/HermesWsClient.kt @@ -142,6 +142,20 @@ object HermesWsClient { /** Observable connection status */ val connectionStatus: StateFlow = _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(null) + + /** Non-null when the backend reports a credential warning to resolve. */ + val credentialWarning: StateFlow = _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? = + 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 ──────────────────────────────────────────────── diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt index 86587f50..d2639d3f 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt @@ -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 diff --git a/app/src/main/java/com/m57/hermescontrol/ui/common/CredentialWarningBanner.kt b/app/src/main/java/com/m57/hermescontrol/ui/common/CredentialWarningBanner.kt new file mode 100644 index 00000000..fc3c75e5 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/common/CredentialWarningBanner.kt @@ -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, + ) + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/providers/ProvidersScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/providers/ProvidersScreen.kt new file mode 100644 index 00000000..a459536f --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/providers/ProvidersScreen.kt @@ -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 + } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/providers/ProvidersViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/providers/ProvidersViewModel.kt new file mode 100644 index 00000000..614d04b2 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/providers/ProvidersViewModel.kt @@ -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 = 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 = _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" + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 7dd5af03..ea00f92d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -72,6 +72,7 @@ Achievements History Processes + Providers Analytics @@ -845,6 +846,27 @@ Loading… Fetching model providers… Loading channels… + Loading OAuth providers… + + + No OAuth providers + Configured OAuth providers will appear here. Connect an account to use it from the app. + Connect + How to connect + Disconnect + In progress… + Submit code + Open browser to authorize + This provider is managed by an external CLI. Run the command below on the host, then refresh this list. + Authorize in the browser, then paste the authorization code (the part before any # separator) below. + Authorization code + Enter this code at the verification URL on another device, then wait — this screen polls automatically. + Expires in %1$s + OAuth connected successfully. + Fix + Dismiss + Done + Close No running processes diff --git a/app/src/test/java/com/m57/hermescontrol/data/remote/HermesApiServiceMockWebServerTest.kt b/app/src/test/java/com/m57/hermescontrol/data/remote/HermesApiServiceMockWebServerTest.kt index 124edadb..2fbb36d8 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/remote/HermesApiServiceMockWebServerTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/remote/HermesApiServiceMockWebServerTest.kt @@ -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) } }