* 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
@@ -51,4 +51,6 @@ import kotlinx.serialization.Serializable
|
|||||||
|
|
||||||
@Serializable data object ProcessesScreen : NavKey
|
@Serializable data object ProcessesScreen : NavKey
|
||||||
|
|
||||||
|
@Serializable data object ProvidersScreen : NavKey
|
||||||
|
|
||||||
@Serializable data object AnalyticsScreen : 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.BarChart
|
||||||
import androidx.compose.material.icons.filled.Bolt
|
import androidx.compose.material.icons.filled.Bolt
|
||||||
import androidx.compose.material.icons.filled.Build
|
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.Code
|
||||||
import androidx.compose.material.icons.filled.Dashboard
|
import androidx.compose.material.icons.filled.Dashboard
|
||||||
import androidx.compose.material.icons.filled.Devices
|
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.plugins.PluginsScreen as PluginsScreenContent
|
||||||
import com.m57.hermescontrol.ui.process.ProcessesScreen as ProcessesScreenContent
|
import com.m57.hermescontrol.ui.process.ProcessesScreen as ProcessesScreenContent
|
||||||
import com.m57.hermescontrol.ui.profiles.ProfilesScreen as ProfilesScreenContent
|
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.sessions.SessionsScreen as HistoryScreenContent
|
||||||
import com.m57.hermescontrol.ui.settings.SettingsScreen as SettingsScreenContent
|
import com.m57.hermescontrol.ui.settings.SettingsScreen as SettingsScreenContent
|
||||||
import com.m57.hermescontrol.ui.skills.SkillsScreen as SkillsScreenContent
|
import com.m57.hermescontrol.ui.skills.SkillsScreen as SkillsScreenContent
|
||||||
@@ -157,6 +159,12 @@ object ScreenRegistry {
|
|||||||
Icons.AutoMirrored.Filled.ListAlt,
|
Icons.AutoMirrored.Filled.ListAlt,
|
||||||
DrawerSection.CONFIGURE,
|
DrawerSection.CONFIGURE,
|
||||||
) { sessionId, openDrawer -> ChannelsScreenContent(onOpenDrawer = openDrawer) },
|
) { sessionId, openDrawer -> ChannelsScreenContent(onOpenDrawer = openDrawer) },
|
||||||
|
ScreenDefinition(
|
||||||
|
ProvidersScreen,
|
||||||
|
R.string.screen_providers,
|
||||||
|
Icons.Filled.Cloud,
|
||||||
|
DrawerSection.CONFIGURE,
|
||||||
|
) { sessionId, openDrawer -> ProvidersScreenContent(onOpenDrawer = openDrawer) },
|
||||||
ScreenDefinition(
|
ScreenDefinition(
|
||||||
SystemScreen,
|
SystemScreen,
|
||||||
R.string.screen_system,
|
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.ModelAssignmentResponse
|
||||||
import com.m57.hermescontrol.data.model.ModelOptionsResponse
|
import com.m57.hermescontrol.data.model.ModelOptionsResponse
|
||||||
import com.m57.hermescontrol.data.model.ModelsAnalyticsResponse
|
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.PairingApproveRequest
|
||||||
import com.m57.hermescontrol.data.model.PairingResponse
|
import com.m57.hermescontrol.data.model.PairingResponse
|
||||||
import com.m57.hermescontrol.data.model.PairingRevokeRequest
|
import com.m57.hermescontrol.data.model.PairingRevokeRequest
|
||||||
@@ -557,6 +563,43 @@ interface HermesApiService {
|
|||||||
@Path("pairing_id") pairingId: String,
|
@Path("pairing_id") pairingId: String,
|
||||||
): Response<Unit>
|
): 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")
|
@GET("api/env")
|
||||||
suspend fun getEnvVars(): Response<Map<String, EnvVarConfig>>
|
suspend fun getEnvVars(): Response<Map<String, EnvVarConfig>>
|
||||||
|
|
||||||
|
|||||||
@@ -142,6 +142,20 @@ object HermesWsClient {
|
|||||||
/** Observable connection status */
|
/** Observable connection status */
|
||||||
val connectionStatus: StateFlow<ConnectionStatus> = _connectionStatus.asStateFlow()
|
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 {
|
init {
|
||||||
// Monitor network state to trigger immediate reconnect when network is restored
|
// Monitor network state to trigger immediate reconnect when network is restored
|
||||||
wsScope.launch {
|
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 ────────────────────────────────────────────────
|
// ── Connection helpers ────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -124,12 +124,15 @@ import com.m57.hermescontrol.R
|
|||||||
import com.m57.hermescontrol.data.model.Attachment
|
import com.m57.hermescontrol.data.model.Attachment
|
||||||
import com.m57.hermescontrol.data.ws.CommandCatalog
|
import com.m57.hermescontrol.data.ws.CommandCatalog
|
||||||
import com.m57.hermescontrol.data.ws.ConnectionStatus
|
import com.m57.hermescontrol.data.ws.ConnectionStatus
|
||||||
|
import com.m57.hermescontrol.data.ws.HermesWsClient
|
||||||
import com.m57.hermescontrol.notification.NotificationHelper
|
import com.m57.hermescontrol.notification.NotificationHelper
|
||||||
import com.m57.hermescontrol.theme.LocalHermesStatusColors
|
import com.m57.hermescontrol.theme.LocalHermesStatusColors
|
||||||
import com.m57.hermescontrol.theme.StatusRed
|
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.EmptyState
|
||||||
import com.m57.hermescontrol.ui.common.HermesScaffold
|
import com.m57.hermescontrol.ui.common.HermesScaffold
|
||||||
import com.m57.hermescontrol.ui.common.NavIcon
|
import com.m57.hermescontrol.ui.common.NavIcon
|
||||||
|
import com.m57.hermescontrol.ui.providers.ProvidersScreen
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.collectLatest
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
@@ -151,6 +154,7 @@ fun ChatScreen(
|
|||||||
) {
|
) {
|
||||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
val streamingState by viewModel.streamingState.collectAsStateWithLifecycle()
|
val streamingState by viewModel.streamingState.collectAsStateWithLifecycle()
|
||||||
|
val credentialWarning by HermesWsClient.credentialWarning.collectAsStateWithLifecycle()
|
||||||
val listState = rememberLazyListState()
|
val listState = rememberLazyListState()
|
||||||
var isOlderPagingArmed by remember(state.currentSessionId) { mutableStateOf(false) }
|
var isOlderPagingArmed by remember(state.currentSessionId) { mutableStateOf(false) }
|
||||||
|
|
||||||
@@ -403,6 +407,14 @@ fun ChatScreen(
|
|||||||
onReloginClick = { showReloginDialog = true },
|
onReloginClick = { showReloginDialog = true },
|
||||||
)
|
)
|
||||||
|
|
||||||
|
credentialWarning?.let { warning ->
|
||||||
|
CredentialWarningBanner(
|
||||||
|
warning = warning,
|
||||||
|
onFix = { NavigationController.navigateTo(com.m57.hermescontrol.ProvidersScreen) },
|
||||||
|
onDismiss = { HermesWsClient.clearCredentialWarning() },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
modifier =
|
modifier =
|
||||||
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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -72,6 +72,7 @@
|
|||||||
<string name="screen_achievements">Achievements</string>
|
<string name="screen_achievements">Achievements</string>
|
||||||
<string name="screen_history">History</string>
|
<string name="screen_history">History</string>
|
||||||
<string name="screen_processes">Processes</string>
|
<string name="screen_processes">Processes</string>
|
||||||
|
<string name="screen_providers">Providers</string>
|
||||||
<string name="screen_analytics">Analytics</string>
|
<string name="screen_analytics">Analytics</string>
|
||||||
|
|
||||||
<!-- Notifications -->
|
<!-- Notifications -->
|
||||||
@@ -845,6 +846,27 @@
|
|||||||
<string name="loading_state_subtitle_default">Loading…</string>
|
<string name="loading_state_subtitle_default">Loading…</string>
|
||||||
<string name="loading_state_subtitle_models">Fetching model providers…</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_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) -->
|
<!-- Processes Screen (issue #532) -->
|
||||||
<string name="processes_empty_title">No running processes</string>
|
<string name="processes_empty_title">No running processes</string>
|
||||||
|
|||||||
+222
-12
@@ -1,5 +1,6 @@
|
|||||||
package com.m57.hermescontrol.data.remote
|
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.SessionMessage
|
||||||
import com.m57.hermescontrol.data.model.StatusResponse
|
import com.m57.hermescontrol.data.model.StatusResponse
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
@@ -307,20 +308,44 @@ class HermesApiServiceMockWebServerTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun getSessionMessages_encodesSessionIdWithSlashes() =
|
fun getOAuthProviders_parsesList() =
|
||||||
runBlocking {
|
runBlocking {
|
||||||
val sessionId = "session/with/slashes"
|
|
||||||
mockServer.enqueue(
|
mockServer.enqueue(
|
||||||
MockResponse()
|
MockResponse()
|
||||||
.setResponseCode(200)
|
.setResponseCode(200)
|
||||||
.setBody(
|
.setBody(
|
||||||
"""
|
"""
|
||||||
{
|
{
|
||||||
"messages": [
|
"providers": [
|
||||||
{
|
{
|
||||||
"role": "user",
|
"id": "anthropic",
|
||||||
"content": "test",
|
"name": "Anthropic",
|
||||||
"timestamp": "1000"
|
"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)
|
assertTrue(response.isSuccessful)
|
||||||
|
val body = response.body()
|
||||||
|
assertNotNull(body)
|
||||||
|
assertEquals(2, body!!.providers.size)
|
||||||
|
|
||||||
// Verify the request path preserves slashes
|
val first = body.providers[0]
|
||||||
val request = mockServer.takeRequest()
|
assertEquals("anthropic", first.id)
|
||||||
assertTrue(
|
assertEquals("Anthropic", first.name)
|
||||||
"Slash-encoded session ID should appear in path",
|
assertEquals("pkce", first.flow)
|
||||||
request.path!!.contains("session/with/slashes"),
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user