feat(#776): pairing management screen (#791)

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:
M57andM57 authored and GitHub committed 2026-08-03 19:49:31 +03:00
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) }
}
}