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