REST + Channels-style UI for DM pairing (list pending/approved, approve, revoke, clear). Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
1 parent
b91ac044b9
commit
5870b73397
9 files changed
+785
No files matched your search
@@ -33,6 +33,8 @@ import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable data object ModelScreen : NavKey
|
||||
|
||||
@Serializable data object PairingScreen : NavKey
|
||||
|
||||
@Serializable data object LogsScreen : NavKey
|
||||
|
||||
@Serializable data object PluginsScreen : NavKey
|
||||
|
||||
@@ -12,6 +12,7 @@ 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
|
||||
import androidx.compose.material.icons.filled.EmojiEvents
|
||||
import androidx.compose.material.icons.filled.Extension
|
||||
import androidx.compose.material.icons.filled.Folder
|
||||
@@ -41,6 +42,7 @@ import com.m57.hermescontrol.ui.keys.KeysScreen as KeysScreenContent
|
||||
import com.m57.hermescontrol.ui.logs.LogsScreen as LogsScreenContent
|
||||
import com.m57.hermescontrol.ui.mcp.McpServersScreen as McpServersScreenContent
|
||||
import com.m57.hermescontrol.ui.model.ModelScreen as ModelScreenContent
|
||||
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
|
||||
@@ -144,6 +146,12 @@ object ScreenRegistry {
|
||||
Icons.Filled.Psychology,
|
||||
DrawerSection.CONFIGURE,
|
||||
) { sessionId, openDrawer -> ModelScreenContent(onOpenDrawer = openDrawer) },
|
||||
ScreenDefinition(
|
||||
PairingScreen,
|
||||
R.string.screen_pairing,
|
||||
Icons.Filled.Devices,
|
||||
DrawerSection.CONFIGURE,
|
||||
) { sessionId, openDrawer -> PairingScreenContent(onOpenDrawer = openDrawer) },
|
||||
ScreenDefinition(
|
||||
KeysScreen,
|
||||
R.string.screen_keys,
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
package com.m57.hermescontrol.data.model
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* DM pairing management (issue #776).
|
||||
*
|
||||
* Backend contract (hermes_cli/web_server.py + gateway/pairing.py):
|
||||
* - `GET /api/pairing` → `{pending: [...], approved: [...]}`
|
||||
* - Pending entries: `{platform, request_id, user_id, user_name, age_minutes}`
|
||||
* - Approved entries: `{platform, user_id, user_name, approved_at}`
|
||||
*
|
||||
* Codes are stored hashed server-side and never returned; pending requests are
|
||||
* approved by their `request_id` (the desktop dashboard does the same).
|
||||
*/
|
||||
@Serializable
|
||||
data class PairingResponse(
|
||||
val pending: List<PairingItem> = emptyList(),
|
||||
val approved: List<PairingItem> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class PairingItem(
|
||||
val platform: String,
|
||||
@SerialName("request_id") val requestId: String? = null,
|
||||
@SerialName("user_id") val userId: String? = null,
|
||||
@SerialName("user_name") val userName: String? = null,
|
||||
@SerialName("age_minutes") val ageMinutes: Int? = null,
|
||||
) {
|
||||
/** Stable key for LazyColumn + per-item action state. */
|
||||
fun key(): String = "${platform}_${requestId ?: userId ?: ""}"
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class PairingApproveRequest(
|
||||
val platform: String,
|
||||
@SerialName("request_id") val requestId: String? = null,
|
||||
val code: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class PairingRevokeRequest(
|
||||
val platform: String,
|
||||
@SerialName("user_id") val userId: String,
|
||||
)
|
||||
@@ -63,6 +63,9 @@ 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
|
||||
import com.m57.hermescontrol.data.model.PluginProvidersPutRequest
|
||||
import com.m57.hermescontrol.data.model.PluginsHubResponse
|
||||
import com.m57.hermescontrol.data.model.PortalResponse
|
||||
@@ -580,6 +583,28 @@ interface HermesApiService {
|
||||
@Path("pairing_id") pairingId: String,
|
||||
): Response<Unit>
|
||||
|
||||
// ── DM pairing management (issue #776) ──────────────────────────────
|
||||
// Approve/revoke messaging users who requested access via pairing
|
||||
// codes. Pending requests are approved by `request_id` (as returned by
|
||||
// GET /api/pairing); the `code` field is the one-time code a user
|
||||
// relays from their DM.
|
||||
|
||||
@GET("api/pairing")
|
||||
suspend fun getPairing(): Response<PairingResponse>
|
||||
|
||||
@POST("api/pairing/approve")
|
||||
suspend fun approvePairing(
|
||||
@Body body: PairingApproveRequest,
|
||||
): Response<Unit>
|
||||
|
||||
@POST("api/pairing/revoke")
|
||||
suspend fun revokePairing(
|
||||
@Body body: PairingRevokeRequest,
|
||||
): Response<Unit>
|
||||
|
||||
@POST("api/pairing/clear-pending")
|
||||
suspend fun clearPendingPairing(): 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
|
||||
|
||||
@@ -0,0 +1,337 @@
|
||||
package com.m57.hermescontrol.ui.pairing
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
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.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.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
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.PairingItem
|
||||
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.NavIcon
|
||||
import com.m57.hermescontrol.ui.common.SkeletonListState
|
||||
import com.m57.hermescontrol.ui.common.ToastEffect
|
||||
import com.m57.hermescontrol.ui.common.listContentPadding
|
||||
import com.m57.hermescontrol.ui.common.listItemSpacing
|
||||
|
||||
@Composable
|
||||
fun PairingScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
onOpenDrawer: (() -> Unit)? = null,
|
||||
viewModel: PairingViewModel = viewModel { PairingViewModel() },
|
||||
) {
|
||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.loadPairing()
|
||||
}
|
||||
|
||||
ToastEffect(toastMessage = state.toastMessage, onClearToast = viewModel::clearToast)
|
||||
|
||||
HermesScaffold(
|
||||
title = { Text(stringResource(R.string.screen_pairing)) },
|
||||
navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) },
|
||||
isRefreshing = state.isLoading,
|
||||
onRefresh = { viewModel.loadPairing() },
|
||||
modifier = modifier,
|
||||
) { paddingValues ->
|
||||
val pairing = state.pairing
|
||||
when {
|
||||
state.isLoading && pairing == null -> {
|
||||
SkeletonListState(modifier = Modifier.padding(paddingValues))
|
||||
}
|
||||
|
||||
state.errorMessage != null && pairing == null -> {
|
||||
ErrorState(
|
||||
message = state.errorMessage ?: "",
|
||||
onRetry = { viewModel.loadPairing() },
|
||||
modifier = Modifier.padding(paddingValues),
|
||||
)
|
||||
}
|
||||
|
||||
pairing == null ||
|
||||
(pairing.pending.isEmpty() && pairing.approved.isEmpty()) -> {
|
||||
EmptyState(
|
||||
title = stringResource(R.string.pairing_empty_title),
|
||||
subtitle = stringResource(R.string.pairing_empty_desc),
|
||||
modifier = Modifier.padding(paddingValues),
|
||||
)
|
||||
}
|
||||
|
||||
else -> {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = listContentPadding,
|
||||
verticalArrangement = listItemSpacing,
|
||||
) {
|
||||
if (pairing.pending.isNotEmpty()) {
|
||||
item(key = "pending_header") {
|
||||
PendingHeader(
|
||||
isClearing = state.actionKey == "clear",
|
||||
onClearAll = { viewModel.clearPending() },
|
||||
)
|
||||
}
|
||||
|
||||
items(pairing.pending, key = { it.key() }) { item ->
|
||||
PendingCard(
|
||||
item = item,
|
||||
isActing = state.actionKey == "approve:${item.requestId}",
|
||||
onApprove = {
|
||||
item.requestId?.let { viewModel.approvePairing(item.platform, it) }
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
item(key = "approved_header") {
|
||||
SectionHeader(text = stringResource(R.string.pairing_sec_approved))
|
||||
}
|
||||
|
||||
if (pairing.approved.isEmpty()) {
|
||||
item(key = "approved_empty") {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxWidth().padding(24.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.pairing_empty_approved),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
items(pairing.approved, key = { it.key() }) { item ->
|
||||
ApprovedCard(
|
||||
item = item,
|
||||
isActing = state.actionKey == "revoke:${item.platform}:${item.userId}",
|
||||
onRevoke = {
|
||||
item.userId?.let { viewModel.revokePairing(item.platform, it) }
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SectionHeader(text: String) {
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PendingHeader(
|
||||
isClearing: Boolean,
|
||||
onClearAll: () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
SectionHeader(text = stringResource(R.string.pairing_sec_pending))
|
||||
OutlinedButton(
|
||||
onClick = onClearAll,
|
||||
enabled = !isClearing,
|
||||
colors =
|
||||
ButtonDefaults.outlinedButtonColors(
|
||||
contentColor = MaterialTheme.colorScheme.error,
|
||||
),
|
||||
) {
|
||||
if (isClearing) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(14.dp),
|
||||
strokeWidth = 2.dp,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
}
|
||||
Text(
|
||||
text = stringResource(R.string.pairing_action_clear_all),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PendingCard(
|
||||
item: PairingItem,
|
||||
isActing: Boolean,
|
||||
onApprove: () -> Unit,
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
colors =
|
||||
CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.2f),
|
||||
),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(12.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = item.platform.uppercase(),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
item.userName?.takeIf { it.isNotBlank() }?.let { name ->
|
||||
Text(
|
||||
text = name,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = item.userId ?: stringResource(R.string.pairing_label_unknown_user),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
item.ageMinutes?.let { age ->
|
||||
Text(
|
||||
text = formatAge(age),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f),
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Button(
|
||||
onClick = onApprove,
|
||||
enabled = !isActing,
|
||||
) {
|
||||
if (isActing) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(14.dp),
|
||||
strokeWidth = 2.dp,
|
||||
color = MaterialTheme.colorScheme.onPrimary,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
}
|
||||
Text(
|
||||
text = stringResource(R.string.pairing_action_approve),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ApprovedCard(
|
||||
item: PairingItem,
|
||||
isActing: Boolean,
|
||||
onRevoke: () -> Unit,
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(12.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = item.platform.uppercase(),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
item.userName?.takeIf { it.isNotBlank() }?.let { name ->
|
||||
Text(
|
||||
text = name,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = item.userId ?: stringResource(R.string.pairing_label_unknown_user),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
OutlinedButton(
|
||||
onClick = onRevoke,
|
||||
enabled = !isActing,
|
||||
colors =
|
||||
ButtonDefaults.outlinedButtonColors(
|
||||
contentColor = MaterialTheme.colorScheme.error,
|
||||
),
|
||||
) {
|
||||
if (isActing) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(14.dp),
|
||||
strokeWidth = 2.dp,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
}
|
||||
Text(
|
||||
text = stringResource(R.string.pairing_action_revoke),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun formatAge(ageMinutes: Int): String {
|
||||
return if (ageMinutes < 60) {
|
||||
stringResource(R.string.pairing_label_request_age_m, ageMinutes)
|
||||
} else {
|
||||
stringResource(R.string.pairing_label_request_age_hm, ageMinutes / 60, ageMinutes % 60)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package com.m57.hermescontrol.ui.pairing
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.m57.hermescontrol.data.model.PairingApproveRequest
|
||||
import com.m57.hermescontrol.data.model.PairingResponse
|
||||
import com.m57.hermescontrol.data.model.PairingRevokeRequest
|
||||
import com.m57.hermescontrol.data.remote.ApiClient
|
||||
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.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
data class PairingUiState(
|
||||
val isLoading: Boolean = false,
|
||||
val pairing: PairingResponse? = null,
|
||||
val errorMessage: String? = null,
|
||||
val toastMessage: String? = null,
|
||||
/** Key of the item whose approve/revoke/clear action is in flight. */
|
||||
val actionKey: String? = null,
|
||||
)
|
||||
|
||||
class PairingViewModel :
|
||||
ViewModel(),
|
||||
ToastHost {
|
||||
private val _uiState = MutableStateFlow(PairingUiState())
|
||||
val uiState: StateFlow<PairingUiState> = _uiState.asStateFlow()
|
||||
|
||||
private var launchJob: Job? = null
|
||||
|
||||
fun loadPairing() {
|
||||
launchJob =
|
||||
safeLaunchLoad(
|
||||
currentJob = launchJob,
|
||||
apiCall = { safeApiCall { ApiClient.hermesApi.getPairing() } },
|
||||
onStart = {
|
||||
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
|
||||
},
|
||||
onSuccess = { data ->
|
||||
_uiState.update { it.copy(isLoading = false, pairing = data) }
|
||||
},
|
||||
onError = { errorMsg ->
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
errorMessage = "Failed to load pairing: $errorMsg",
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/** Approve a pending pairing request by its server-side `request_id`. */
|
||||
fun approvePairing(
|
||||
platform: String,
|
||||
requestId: String,
|
||||
) {
|
||||
runAction(
|
||||
actionKey = "approve:$requestId",
|
||||
onSuccess = { it.copy(toastMessage = "Pairing request approved") },
|
||||
apiCall = {
|
||||
safeApiCall {
|
||||
ApiClient.hermesApi.approvePairing(
|
||||
PairingApproveRequest(platform = platform, requestId = requestId),
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/** Remove a user from the approved whitelist. */
|
||||
fun revokePairing(
|
||||
platform: String,
|
||||
userId: String,
|
||||
) {
|
||||
runAction(
|
||||
actionKey = "revoke:$platform:$userId",
|
||||
onSuccess = { it.copy(toastMessage = "Access revoked for $userId") },
|
||||
apiCall = {
|
||||
safeApiCall {
|
||||
ApiClient.hermesApi.revokePairing(
|
||||
PairingRevokeRequest(platform = platform, userId = userId),
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/** Clear every pending pairing request. */
|
||||
fun clearPending() {
|
||||
runAction(
|
||||
actionKey = "clear",
|
||||
onSuccess = { it.copy(toastMessage = "Pending requests cleared") },
|
||||
apiCall = { safeApiCall { ApiClient.hermesApi.clearPendingPairing() } },
|
||||
)
|
||||
}
|
||||
|
||||
private fun runAction(
|
||||
actionKey: String,
|
||||
onSuccess: (PairingUiState) -> PairingUiState,
|
||||
apiCall: suspend () -> NetworkResult<Unit>,
|
||||
) {
|
||||
if (_uiState.value.actionKey != null) return
|
||||
_uiState.update { it.copy(actionKey = actionKey) }
|
||||
viewModelScope.launch {
|
||||
val result = withContext(Dispatchers.IO) { apiCall() }
|
||||
when (result) {
|
||||
is NetworkResult.Success -> {
|
||||
_uiState.update { onSuccess(it.copy(actionKey = null)) }
|
||||
loadPairing()
|
||||
}
|
||||
|
||||
is NetworkResult.Failure -> {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
actionKey = null,
|
||||
toastMessage = "Action failed: ${result.error.message}",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun clearToast() {
|
||||
_uiState.update { it.copy(toastMessage = null) }
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user