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

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

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

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

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

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

---------

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

No files matched your search

@@ -51,4 +51,6 @@ import kotlinx.serialization.Serializable
@Serializable data object ProcessesScreen : NavKey
@Serializable data object ProvidersScreen : NavKey
@Serializable data object AnalyticsScreen : NavKey
@@ -8,6 +8,7 @@ import androidx.compose.material.icons.filled.AccountCircle
import androidx.compose.material.icons.filled.BarChart
import androidx.compose.material.icons.filled.Bolt
import androidx.compose.material.icons.filled.Build
import androidx.compose.material.icons.filled.Cloud
import androidx.compose.material.icons.filled.Code
import androidx.compose.material.icons.filled.Dashboard
import androidx.compose.material.icons.filled.Devices
@@ -40,6 +41,7 @@ import com.m57.hermescontrol.ui.pairing.PairingScreen as PairingScreenContent
import com.m57.hermescontrol.ui.plugins.PluginsScreen as PluginsScreenContent
import com.m57.hermescontrol.ui.process.ProcessesScreen as ProcessesScreenContent
import com.m57.hermescontrol.ui.profiles.ProfilesScreen as ProfilesScreenContent
import com.m57.hermescontrol.ui.providers.ProvidersScreen as ProvidersScreenContent
import com.m57.hermescontrol.ui.sessions.SessionsScreen as HistoryScreenContent
import com.m57.hermescontrol.ui.settings.SettingsScreen as SettingsScreenContent
import com.m57.hermescontrol.ui.skills.SkillsScreen as SkillsScreenContent
@@ -157,6 +159,12 @@ object ScreenRegistry {
Icons.AutoMirrored.Filled.ListAlt,
DrawerSection.CONFIGURE,
) { sessionId, openDrawer -> ChannelsScreenContent(onOpenDrawer = openDrawer) },
ScreenDefinition(
ProvidersScreen,
R.string.screen_providers,
Icons.Filled.Cloud,
DrawerSection.CONFIGURE,
) { sessionId, openDrawer -> ProvidersScreenContent(onOpenDrawer = openDrawer) },
ScreenDefinition(
SystemScreen,
R.string.screen_system,
@@ -0,0 +1,100 @@
package com.m57.hermescontrol.data.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* OAuth provider catalog entry returned by `GET /api/providers/oauth`.
*
* Wire schema (hermes_cli/web_server.py `list_oauth_providers`, ~:8415):
* id, name, flow ("pkce" | "device_code" | "external"), cli_command,
* docs_url, disconnect_hint, disconnect_command, disconnectable, status.
*
* `expires_at` in the status comes back as an ISO timestamp string.
*/
@Serializable
data class OAuthProvider(
val id: String,
val name: String,
val flow: String,
@SerialName("cli_command") val cliCommand: String = "",
@SerialName("docs_url") val docsUrl: String? = null,
@SerialName("disconnect_hint") val disconnectHint: String? = null,
@SerialName("disconnect_command") val disconnectCommand: String? = null,
val disconnectable: Boolean = true,
val status: OAuthProviderStatus = OAuthProviderStatus(),
)
@Serializable
data class OAuthProviderStatus(
@SerialName("logged_in") val loggedIn: Boolean = false,
val source: String? = null,
@SerialName("source_label") val sourceLabel: String? = null,
@SerialName("token_preview") val tokenPreview: String? = null,
@SerialName("expires_at") val expiresAt: String? = null,
@SerialName("has_refresh_token") val hasRefreshToken: Boolean? = null,
@SerialName("last_refresh") val lastRefresh: String? = null,
val error: String? = null,
)
@Serializable
data class OAuthProvidersResponse(
val providers: List<OAuthProvider> = emptyList(),
)
/**
* `POST /api/providers/oauth/{provider_id}/start`
*
* Discriminated by `flow`:
* - pkce: { session_id, flow:"pkce", auth_url, expires_in }
* - device_code: { session_id, flow:"device_code", user_code,
* verification_url, expires_in, poll_interval }
*
* `expires_in` is an int (seconds). We keep the union fields nullable so a
* single model parses both shapes.
*/
@Serializable
data class OAuthStartResponse(
@SerialName("session_id") val sessionId: String = "",
val flow: String = "",
@SerialName("auth_url") val authUrl: String? = null,
@SerialName("user_code") val userCode: String? = null,
@SerialName("verification_url") val verificationUrl: String? = null,
@SerialName("expires_in") val expiresIn: Int? = null,
@SerialName("poll_interval") val pollInterval: Int? = null,
)
@Serializable
data class OAuthSubmitRequest(
@SerialName("session_id") val sessionId: String,
val code: String,
)
/** `POST /api/providers/oauth/{provider_id}/submit` */
@Serializable
data class OAuthSubmitResponse(
val ok: Boolean = false,
val status: String? = null,
val message: String? = null,
)
/**
* `GET /api/providers/oauth/{provider_id}/poll/{session_id}`
*
* `expires_at` is a float epoch (web_server.py `time.time()`), kept as Double?.
*/
@Serializable
data class OAuthPollResponse(
@SerialName("session_id") val sessionId: String = "",
val status: String = "pending",
@SerialName("error_message") val errorMessage: String? = null,
@SerialName("expires_at") val expiresAt: Double? = null,
)
/** `DELETE /api/providers/oauth/sessions/{session_id}` */
@Serializable
data class OAuthCancelResponse(
val ok: Boolean = false,
@SerialName("session_id") val sessionId: String? = null,
val message: String? = null,
)
@@ -47,6 +47,12 @@ import com.m57.hermescontrol.data.model.ModelAssignmentRequest
import com.m57.hermescontrol.data.model.ModelAssignmentResponse
import com.m57.hermescontrol.data.model.ModelOptionsResponse
import com.m57.hermescontrol.data.model.ModelsAnalyticsResponse
import com.m57.hermescontrol.data.model.OAuthCancelResponse
import com.m57.hermescontrol.data.model.OAuthPollResponse
import com.m57.hermescontrol.data.model.OAuthProvidersResponse
import com.m57.hermescontrol.data.model.OAuthStartResponse
import com.m57.hermescontrol.data.model.OAuthSubmitRequest
import com.m57.hermescontrol.data.model.OAuthSubmitResponse
import com.m57.hermescontrol.data.model.PairingApproveRequest
import com.m57.hermescontrol.data.model.PairingResponse
import com.m57.hermescontrol.data.model.PairingRevokeRequest
@@ -557,6 +563,43 @@ interface HermesApiService {
@Path("pairing_id") pairingId: String,
): Response<Unit>
// ── OAuth provider management (issue #534) ──────────────────────────
// Auth: the dashboard gates /start, /submit, /disconnect, /cancel via
// `_require_token`, which accepts either `X-Hermes-Session-Token` OR the
// legacy `Authorization: Bearer <token>`. ApiClient already injects the
// Bearer token on every request, so no extra header code is needed here.
// (poll is tokenless — read-only session state.)
@GET("api/providers/oauth")
suspend fun getOAuthProviders(): Response<OAuthProvidersResponse>
@DELETE("api/providers/oauth/{provider_id}")
suspend fun disconnectOAuthProvider(
@Path("provider_id") providerId: String,
): Response<Unit>
@POST("api/providers/oauth/{provider_id}/start")
suspend fun startOAuthLogin(
@Path("provider_id") providerId: String,
): Response<OAuthStartResponse>
@POST("api/providers/oauth/{provider_id}/submit")
suspend fun submitOAuthCode(
@Path("provider_id") providerId: String,
@Body body: OAuthSubmitRequest,
): Response<OAuthSubmitResponse>
@GET("api/providers/oauth/{provider_id}/poll/{session_id}")
suspend fun pollOAuthSession(
@Path("provider_id") providerId: String,
@Path("session_id") sessionId: String,
): Response<OAuthPollResponse>
@HTTP(method = "DELETE", path = "api/providers/oauth/sessions/{session_id}", hasBody = false)
suspend fun cancelOAuthSession(
@Path("session_id") sessionId: String,
): Response<OAuthCancelResponse>
@GET("api/env")
suspend fun getEnvVars(): Response<Map<String, EnvVarConfig>>
@@ -142,6 +142,20 @@ object HermesWsClient {
/** Observable connection status */
val connectionStatus: StateFlow<ConnectionStatus> = _connectionStatus.asStateFlow()
// ── Credential warning (issue #534) ─────────────────────────────────
// Backend surfaces `credential_warning` in `gateway.ready` / `session.info`
// WS payloads (desktop `requestDesktopOnboarding`). Mobile has no equivalent
// at the auth layer, so we extract it here once, globally, and let any
// screen render a banner that deep-links to ProvidersScreen.
private val _credentialWarning = MutableStateFlow<String?>(null)
/** Non-null when the backend reports a credential warning to resolve. */
val credentialWarning: StateFlow<String?> = _credentialWarning.asStateFlow()
fun clearCredentialWarning() {
_credentialWarning.value = null
}
init {
// Monitor network state to trigger immediate reconnect when network is restored
wsScope.launch {
@@ -154,6 +168,21 @@ object HermesWsClient {
}
}
}
// Extract credential_warning from gateway.ready / session.info payloads.
wsScope.launch {
events.collect { event ->
val data: Map<String, Any?>? =
when (event) {
is WsEvent.GatewayReady -> event.data
is WsEvent.SessionInfo -> event.data
else -> null
}
val warning = data?.get("credential_warning") as? String
if (!warning.isNullOrBlank()) {
_credentialWarning.value = warning
}
}
}
}
// ── Connection helpers ────────────────────────────────────────────────
@@ -124,12 +124,15 @@ import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.model.Attachment
import com.m57.hermescontrol.data.ws.CommandCatalog
import com.m57.hermescontrol.data.ws.ConnectionStatus
import com.m57.hermescontrol.data.ws.HermesWsClient
import com.m57.hermescontrol.notification.NotificationHelper
import com.m57.hermescontrol.theme.LocalHermesStatusColors
import com.m57.hermescontrol.theme.StatusRed
import com.m57.hermescontrol.ui.common.CredentialWarningBanner
import com.m57.hermescontrol.ui.common.EmptyState
import com.m57.hermescontrol.ui.common.HermesScaffold
import com.m57.hermescontrol.ui.common.NavIcon
import com.m57.hermescontrol.ui.providers.ProvidersScreen
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.distinctUntilChanged
@@ -151,6 +154,7 @@ fun ChatScreen(
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
val streamingState by viewModel.streamingState.collectAsStateWithLifecycle()
val credentialWarning by HermesWsClient.credentialWarning.collectAsStateWithLifecycle()
val listState = rememberLazyListState()
var isOlderPagingArmed by remember(state.currentSessionId) { mutableStateOf(false) }
@@ -403,6 +407,14 @@ fun ChatScreen(
onReloginClick = { showReloginDialog = true },
)
credentialWarning?.let { warning ->
CredentialWarningBanner(
warning = warning,
onFix = { NavigationController.navigateTo(com.m57.hermescontrol.ProvidersScreen) },
onDismiss = { HermesWsClient.clearCredentialWarning() },
)
}
Box(
modifier =
Modifier
@@ -0,0 +1,65 @@
package com.m57.hermescontrol.ui.common
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.m57.hermescontrol.R
import com.m57.hermescontrol.theme.LocalHermesStatusColors
/**
* Banner shown when the backend reports a `credential_warning` (the mobile
* equivalent of the desktop `requestDesktopOnboarding` re-trigger, issue #534).
* Tapping "Fix" deep-links to ProvidersScreen; dismissing clears it globally.
*/
@Composable
fun CredentialWarningBanner(
warning: String,
onFix: () -> Unit,
onDismiss: () -> Unit,
) {
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Icon(
imageVector = Icons.Filled.Warning,
contentDescription = null,
tint = LocalHermesStatusColors.current.warning,
modifier = Modifier.padding(top = 2.dp),
)
Text(
text = warning,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
)
TextButton(onClick = onFix) {
Text(
text = stringResource(R.string.providers_action_fix),
style = MaterialTheme.typography.labelSmall,
)
}
TextButton(onClick = onDismiss) {
Text(
text = stringResource(R.string.action_dismiss),
style = MaterialTheme.typography.labelSmall,
)
}
}
}
@@ -0,0 +1,515 @@
package com.m57.hermescontrol.ui.providers
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Link
import androidx.compose.material.icons.filled.OpenInBrowser
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.model.OAuthProvider
import com.m57.hermescontrol.data.model.OAuthStartResponse
import com.m57.hermescontrol.theme.LocalHermesStatusColors
import com.m57.hermescontrol.ui.common.EmptyState
import com.m57.hermescontrol.ui.common.ErrorState
import com.m57.hermescontrol.ui.common.HermesScaffold
import com.m57.hermescontrol.ui.common.LoadingState
import com.m57.hermescontrol.ui.common.NavIcon
import com.m57.hermescontrol.ui.common.ToastEffect
import com.m57.hermescontrol.ui.common.listContentPadding
import com.m57.hermescontrol.ui.common.listItemSpacing
@Composable
fun ProvidersScreen(
modifier: Modifier = Modifier,
onOpenDrawer: (() -> Unit)? = null,
viewModel: ProvidersViewModel = viewModel { ProvidersViewModel() },
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
val context = LocalContext.current
LaunchedEffect(Unit) { viewModel.load() }
ToastEffect(toastMessage = state.toastMessage, onClearToast = viewModel::clearToast)
// Re-auth dialog
if (state.flowPhase != OAuthFlowPhase.IDLE && state.flowProvider != null) {
OAuthFlowDialog(
state = state,
onCodeChange = viewModel::onCodeInputChange,
onSubmitCode = viewModel::submitOAuthCode,
onCancel = viewModel::cancelOAuthFlow,
onDismissFlow = viewModel::dismissFlow,
onOpenBrowser = { url ->
try {
context.startActivity(
android.content.Intent(
android.content.Intent.ACTION_VIEW,
android.net.Uri.parse(url),
),
)
} catch (_: Exception) {
// No browser available — ignore; user can copy the URL.
}
},
)
}
HermesScaffold(
title = { Text(stringResource(R.string.screen_providers)) },
navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) },
isRefreshing = state.isLoading,
onRefresh = { viewModel.load() },
) { paddingValues ->
when {
state.isLoading && state.providers.isEmpty() -> {
LoadingState(
subtitle = stringResource(R.string.loading_state_subtitle_providers),
modifier = Modifier.padding(paddingValues),
)
}
state.errorMessage != null && state.providers.isEmpty() -> {
ErrorState(
message = state.errorMessage ?: "",
onRetry = { viewModel.load() },
modifier = Modifier.padding(paddingValues),
)
}
state.providers.isEmpty() -> {
EmptyState(
title = stringResource(R.string.providers_empty_title),
subtitle = stringResource(R.string.providers_empty_desc),
modifier = Modifier.padding(paddingValues),
)
}
else -> {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = listContentPadding,
verticalArrangement = listItemSpacing,
) {
items(state.providers, key = { it.id }) { provider ->
ProviderCard(
provider = provider,
isActing = state.actingId == provider.id,
flowPhase = state.flowPhase,
onConnect = { viewModel.startOAuthFlow(provider) },
onDisconnect = { viewModel.disconnectProvider(provider.id) },
)
}
}
}
}
}
}
@Composable
private fun ProviderCard(
provider: OAuthProvider,
isActing: Boolean,
flowPhase: OAuthFlowPhase,
onConnect: () -> Unit,
onDisconnect: () -> Unit,
) {
val loggedIn = provider.status.loggedIn
val isExternal = provider.flow == "external"
val flowActive = flowPhase != OAuthFlowPhase.IDLE && flowPhase != OAuthFlowPhase.DONE
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer),
) {
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 10.dp),
verticalAlignment = Alignment.Top,
) {
Icon(
imageVector = providerStatusIcon(provider),
contentDescription = null,
tint = providerStatusColor(provider),
modifier = Modifier.size(20.dp),
)
Spacer(modifier = Modifier.width(10.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = provider.name,
style = MaterialTheme.typography.titleSmall,
fontWeight = androidx.compose.ui.text.font.FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = providerStatusLabel(provider),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
provider.status.sourceLabel?.let { label ->
Spacer(modifier = Modifier.height(2.dp))
Text(
text = "via $label",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f),
)
}
Spacer(modifier = Modifier.height(8.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
if (flowActive) {
OutlinedButton(
onClick = { /* in-flight flow handled by dialog */ },
enabled = false,
) {
Text(
text = stringResource(R.string.providers_action_in_progress),
style = MaterialTheme.typography.labelSmall,
)
}
} else if (loggedIn) {
if (provider.disconnectable) {
OutlinedButton(
onClick = onDisconnect,
enabled = !isActing,
) {
Text(
text = stringResource(R.string.providers_action_disconnect),
style = MaterialTheme.typography.labelSmall,
)
}
} else {
provider.disconnectHint?.let { hint ->
Text(
text = hint,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
)
}
}
} else {
Button(
onClick = onConnect,
enabled = !isActing,
) {
Text(
text =
if (isExternal) {
stringResource(R.string.providers_action_how_to_connect)
} else {
stringResource(R.string.providers_action_connect)
},
style = MaterialTheme.typography.labelSmall,
)
}
}
}
}
}
}
}
// ── Re-auth flow dialog ─────────────────────────────────────────────────
@Composable
private fun OAuthFlowDialog(
state: ProvidersUiState,
onCodeChange: (String) -> Unit,
onSubmitCode: () -> Unit,
onCancel: () -> Unit,
onDismissFlow: () -> Unit,
onOpenBrowser: (String) -> Unit,
) {
val provider = state.flowProvider ?: return
val start = state.flowStart
AlertDialog(
onDismissRequest = onCancel,
confirmButton = {
when (state.flowPhase) {
OAuthFlowPhase.WAITING_CODE -> {
Button(onClick = onSubmitCode) {
Text(stringResource(R.string.providers_action_submit_code))
}
}
OAuthFlowPhase.DONE -> {
Button(onClick = onDismissFlow) {
Text(stringResource(R.string.action_done))
}
}
OAuthFlowPhase.ERROR -> {
Button(onClick = onDismissFlow) {
Text(stringResource(R.string.action_close))
}
}
OAuthFlowPhase.SHOW_CLI -> {
Button(onClick = onDismissFlow) {
Text(stringResource(R.string.action_close))
}
}
else -> {
TextButton(onClick = onCancel) {
Text(stringResource(R.string.action_cancel))
}
}
}
},
dismissButton = {
if (state.flowPhase == OAuthFlowPhase.WAITING_CODE) {
TextButton(onClick = onCancel) {
Text(stringResource(R.string.action_cancel))
}
} else {
Unit
}
},
title = { Text(provider.name, style = MaterialTheme.typography.titleLarge) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
when {
provider.flow == "external" -> {
ExternalFlowContent(provider = provider)
}
state.flowPhase == OAuthFlowPhase.WAITING_CODE && start?.authUrl != null -> {
PkceFlowContent(
authUrl = start.authUrl,
code = state.flowCodeInput,
onCodeChange = onCodeChange,
onOpenBrowser = onOpenBrowser,
error = state.flowErrorMessage,
)
}
state.flowPhase == OAuthFlowPhase.POLLING -> {
DeviceCodeFlowContent(
start = start,
status = state.flowStatus,
expiresIn = state.flowExpiresIn,
error = state.flowErrorMessage,
)
}
state.flowPhase == OAuthFlowPhase.DONE -> {
Text(
text = stringResource(R.string.providers_flow_success),
style = MaterialTheme.typography.bodyMedium,
)
}
state.flowErrorMessage != null -> {
Text(
text = state.flowErrorMessage!!,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodyMedium,
)
}
}
}
},
)
}
@Composable
private fun ExternalFlowContent(provider: OAuthProvider) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
text = stringResource(R.string.providers_external_hint),
style = MaterialTheme.typography.bodyMedium,
)
provider.cliCommand.takeIf { it.isNotBlank() }?.let { cmd ->
Surface(
color = LocalHermesStatusColors.current.infoContainer,
shape = RoundedCornerShape(4.dp),
) {
Text(
text = " $cmd",
style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
)
}
}
}
}
@Composable
private fun PkceFlowContent(
authUrl: String,
code: String,
onCodeChange: (String) -> Unit,
onOpenBrowser: (String) -> Unit,
error: String?,
) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Button(
onClick = { onOpenBrowser(authUrl) },
modifier = Modifier.fillMaxWidth(),
) {
Icon(
imageVector = Icons.Filled.OpenInBrowser,
contentDescription = null,
modifier = Modifier.size(16.dp),
)
Spacer(modifier = Modifier.width(6.dp))
Text(
text = stringResource(R.string.providers_action_open_browser),
style = MaterialTheme.typography.labelSmall,
)
}
Text(
text = stringResource(R.string.providers_pkce_paste_hint),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
OutlinedTextField(
value = code,
onValueChange = onCodeChange,
label = { Text(stringResource(R.string.providers_code_label)) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
isError = error != null,
visualTransformation = VisualTransformation.None,
)
error?.let {
Text(
text = it,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.labelSmall,
)
}
}
}
@Composable
private fun DeviceCodeFlowContent(
start: OAuthStartResponse?,
status: String,
expiresIn: String,
error: String?,
) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
text = stringResource(R.string.providers_device_code_hint),
style = MaterialTheme.typography.bodyMedium,
)
start?.userCode?.let { code ->
Surface(
color = LocalHermesStatusColors.current.infoContainer,
shape = RoundedCornerShape(4.dp),
) {
Text(
text = " $code",
style = MaterialTheme.typography.titleMedium.copy(fontFamily = FontFamily.Monospace),
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
)
}
}
start?.verificationUrl?.let { url ->
Text(
text = url,
style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
color = MaterialTheme.colorScheme.primary,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
if (expiresIn.isNotEmpty()) {
Text(
text = stringResource(R.string.providers_expires_in, expiresIn),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
val statusLabel = deviceStatusLabel(status)
if (statusLabel != null) {
Text(
text = statusLabel,
style = MaterialTheme.typography.bodySmall,
color =
if (status == "error") {
MaterialTheme.colorScheme.error
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
)
}
error?.let {
Text(
text = it,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.labelSmall,
)
}
}
}
// ── Status helpers ──────────────────────────────────────────────────────
private fun providerStatusIcon(provider: OAuthProvider): ImageVector =
when {
provider.status.loggedIn -> Icons.Filled.CheckCircle
provider.flow == "external" -> Icons.Filled.Link
else -> Icons.Filled.Warning
}
@Composable
private fun providerStatusColor(provider: OAuthProvider): Color =
when {
provider.status.loggedIn -> LocalHermesStatusColors.current.success
else -> LocalHermesStatusColors.current.warning
}
private fun providerStatusLabel(provider: OAuthProvider): String =
when {
provider.status.loggedIn -> "Connected"
provider.flow == "external" -> "External — connect via CLI"
else -> "Not connected"
}
private fun deviceStatusLabel(status: String): String? =
when (status) {
"pending" -> "Waiting for authorization…"
"approved" -> "Approved"
"denied" -> "Denied"
"expired" -> "Expired"
"error" -> "Error"
else -> null
}
@@ -0,0 +1,371 @@
package com.m57.hermescontrol.ui.providers
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.m57.hermescontrol.data.model.OAuthPollResponse
import com.m57.hermescontrol.data.model.OAuthProvider
import com.m57.hermescontrol.data.model.OAuthStartResponse
import com.m57.hermescontrol.data.model.OAuthSubmitRequest
import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.NetworkError
import com.m57.hermescontrol.data.remote.NetworkResult
import com.m57.hermescontrol.data.remote.safeApiCall
import com.m57.hermescontrol.ui.common.ToastHost
import com.m57.hermescontrol.ui.common.safeLaunchLoad
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
/** Re-auth flow phase shown in the OAuth dialog. */
enum class OAuthFlowPhase {
IDLE,
STARTING,
/** pkce: browser opened, waiting for the user to paste the callback code. */
WAITING_CODE,
/** device_code: user_code + verification_url shown, background poll running. */
POLLING,
/** terminal: success — dialog shows a confirmation + Done button. */
DONE,
/** terminal: error — dialog stays mounted so the user can read flowErrorMessage + Close. */
ERROR,
/** external provider: show the CLI setup command (no network call). */
SHOW_CLI,
}
data class ProvidersUiState(
val isLoading: Boolean = false,
val providers: List<OAuthProvider> = emptyList(),
val errorMessage: String? = null,
val toastMessage: String? = null,
val actingId: String? = null,
// ── Re-auth flow ──
val flowPhase: OAuthFlowPhase = OAuthFlowPhase.IDLE,
val flowProvider: OAuthProvider? = null,
val flowStart: OAuthStartResponse? = null,
val flowSessionId: String = "",
val flowProviderId: String = "",
val flowCodeInput: String = "",
val flowStatus: String = "pending",
val flowErrorMessage: String? = null,
val flowExpiresIn: String = "",
)
class ProvidersViewModel :
ViewModel(),
ToastHost {
private val _uiState = MutableStateFlow(ProvidersUiState())
val uiState: StateFlow<ProvidersUiState> = _uiState.asStateFlow()
private var pollJob: Job? = null
fun load() {
safeLaunchLoad(
apiCall = { safeApiCall { ApiClient.hermesApi.getOAuthProviders() } },
onStart = {
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
},
onSuccess = { data ->
_uiState.update {
it.copy(
isLoading = false,
providers = data?.providers.orEmpty(),
)
}
},
onError = { errorMsg ->
_uiState.update {
it.copy(
isLoading = false,
errorMessage = "Failed to load providers: $errorMsg",
)
}
},
)
}
/** Disconnect a provider whose credentials are app-managed. */
fun disconnectProvider(providerId: String) {
_uiState.update { it.copy(actingId = providerId) }
safeLaunchLoad(
apiCall = { safeApiCall { ApiClient.hermesApi.disconnectOAuthProvider(providerId) } },
onStart = {},
onSuccess = {
_uiState.update {
it.copy(
actingId = null,
toastMessage = "Disconnected",
)
}
load()
},
onError = { error ->
_uiState.update {
it.copy(
actingId = null,
toastMessage = "Disconnect failed: $error",
)
}
},
)
}
// ── Re-auth flow ────────────────────────────────────────────────────
fun startOAuthFlow(provider: OAuthProvider) {
// External providers can't be started from the app (backend returns 400 from
// /start — they must be configured via CLI). Show the CLI command directly instead
// of firing a request that will just 400 and snap the dialog shut.
if (provider.flow == "external") {
_uiState.update {
it.copy(
flowPhase = OAuthFlowPhase.SHOW_CLI,
flowProvider = provider,
flowProviderId = provider.id,
flowStart = null,
flowSessionId = "",
flowCodeInput = "",
flowStatus = "pending",
flowErrorMessage = null,
)
}
return
}
val phase = _uiState.value.flowPhase
if (phase == OAuthFlowPhase.STARTING || phase == OAuthFlowPhase.WAITING_CODE ||
phase == OAuthFlowPhase.POLLING
) {
return
}
_uiState.update {
it.copy(
flowPhase = OAuthFlowPhase.STARTING,
flowProvider = provider,
flowProviderId = provider.id,
flowStart = null,
flowSessionId = "",
flowCodeInput = "",
flowStatus = "pending",
flowErrorMessage = null,
)
}
viewModelScope.launch {
val result = safeApiCall { ApiClient.hermesApi.startOAuthLogin(provider.id) }
when (result) {
is NetworkResult.Success -> {
val start =
result.data ?: run {
_uiState.update {
it.copy(
flowPhase = OAuthFlowPhase.IDLE,
flowErrorMessage = "Empty start response",
)
}
return@launch
}
_uiState.update {
it.copy(
flowPhase =
when (start.flow) {
"pkce" -> OAuthFlowPhase.WAITING_CODE
"device_code" -> OAuthFlowPhase.POLLING
else -> OAuthFlowPhase.IDLE
},
flowStart = start,
flowSessionId = start.sessionId,
flowExpiresIn = formatExpiry(start.expiresIn),
)
}
if (start.flow == "device_code") {
pollSession(provider.id, start.sessionId)
}
}
is NetworkResult.Failure -> {
_uiState.update {
it.copy(
flowPhase = OAuthFlowPhase.IDLE,
flowErrorMessage = result.error.message,
)
}
}
}
}
}
fun onCodeInputChange(code: String) {
_uiState.update { it.copy(flowCodeInput = code) }
}
fun submitOAuthCode() {
val state = _uiState.value
val code = state.flowCodeInput.trim()
if (code.isEmpty()) {
_uiState.update { it.copy(flowErrorMessage = "Paste the authorization code first.") }
return
}
_uiState.update { it.copy(flowStatus = "pending", flowErrorMessage = null) }
viewModelScope.launch {
val result =
safeApiCall {
ApiClient.hermesApi.submitOAuthCode(
state.flowProviderId,
OAuthSubmitRequest(sessionId = state.flowSessionId, code = code),
)
}
when (result) {
is NetworkResult.Success -> {
val submit = result.data
if (submit?.ok == true) {
finishFlow("approved")
} else {
_uiState.update {
it.copy(
flowStatus = submit?.status ?: "error",
flowErrorMessage = submit?.message ?: "Submission rejected",
)
}
}
}
is NetworkResult.Failure -> {
_uiState.update {
it.copy(
flowStatus = "error",
flowErrorMessage = result.error.message,
)
}
}
}
}
}
private fun pollSession(
providerId: String,
sessionId: String,
) {
pollJob?.cancel()
pollJob =
viewModelScope.launch {
while (_uiState.value.flowPhase == OAuthFlowPhase.POLLING) {
delay((_uiState.value.flowStart?.pollInterval ?: 5).toLong() * 1000)
val result =
safeApiCall {
ApiClient.hermesApi.pollOAuthSession(providerId, sessionId)
}
when (result) {
is NetworkResult.Success -> {
val poll: OAuthPollResponse? = result.data
val status = poll?.status ?: "pending"
_uiState.update { it.copy(flowStatus = status) }
when (status) {
"approved" -> {
finishFlow("approved")
return@launch
}
"denied", "expired", "error" -> {
_uiState.update {
it.copy(
flowPhase = OAuthFlowPhase.ERROR,
flowStatus = status,
flowErrorMessage =
poll?.errorMessage
?: "OAuth flow ended ($status)",
)
}
return@launch
}
else -> { /* still pending — keep polling */ }
}
}
is NetworkResult.Failure -> {
// Tokenless endpoint; a transient failure shouldn't kill the flow.
// Continue polling unless it's a hard 4xx.
val code = (result.error as? NetworkError.Http)?.code ?: -1
if (code in 400..499) {
_uiState.update {
it.copy(
flowPhase = OAuthFlowPhase.ERROR,
flowErrorMessage = result.error.message,
)
}
return@launch
}
}
}
}
}
}
private fun finishFlow(status: String) {
pollJob?.cancel()
load()
// Stay on DONE so the dialog can show a success confirmation; the Screen's
// Done button calls dismissFlow() to reset to IDLE. (Setting IDLE here would
// conflate with DONE via StateFlow and unmount the dialog before the user sees it.)
_uiState.update {
it.copy(
flowPhase = OAuthFlowPhase.DONE,
flowStatus = status,
flowErrorMessage = null,
toastMessage = "OAuth connected",
)
}
}
/** Reset the re-auth flow to IDLE, clearing all flow-specific state. */
fun dismissFlow() {
pollJob?.cancel()
_uiState.update {
it.copy(
flowPhase = OAuthFlowPhase.IDLE,
flowProvider = null,
flowStart = null,
flowSessionId = "",
flowCodeInput = "",
flowStatus = "pending",
flowErrorMessage = null,
)
}
}
fun cancelOAuthFlow() {
val sessionId = _uiState.value.flowSessionId
pollJob?.cancel()
_uiState.update {
it.copy(
flowPhase = OAuthFlowPhase.IDLE,
flowStart = null,
flowSessionId = "",
flowCodeInput = "",
flowStatus = "pending",
flowErrorMessage = null,
)
}
if (sessionId.isNotEmpty()) {
viewModelScope.launch {
safeApiCall { ApiClient.hermesApi.cancelOAuthSession(sessionId) }
}
}
}
override fun clearToast() {
_uiState.update { it.copy(toastMessage = null) }
}
private fun formatExpiry(seconds: Int?): String {
if (seconds == null) return ""
val m = seconds / 60
val s = seconds % 60
return if (m > 0) "$m min ${s}s" else "${s}s"
}
}