From 69cd34d289d4999b9dc5e41f5595dc82a4b8c4e2 Mon Sep 17 00:00:00 2001 From: M57 <41645758+Hy4ri@users.noreply.github.com> Date: Sun, 13 Sep 2026 00:20:07 +0300 Subject: [PATCH] feat(vault): credential vault status and mid-turn auth prompt handling (#1090) (#1097) --- .../chat/components/VaultPromptCardsTest.kt | 120 ++++++ .../java/com/m57/hermescontrol/Navigation.kt | 6 + .../com/m57/hermescontrol/NavigationKeys.kt | 2 + .../hermescontrol/data/model/VaultModels.kt | 61 +++ .../m57/hermescontrol/data/ws/EventParser.kt | 37 ++ .../hermescontrol/data/ws/VaultRepository.kt | 91 ++++ .../com/m57/hermescontrol/data/ws/WsEvent.kt | 38 ++ .../m57/hermescontrol/data/ws/WsMethods.kt | 10 + .../notification/ChatNotificationService.kt | 7 + .../ui/chat/ChatCredentialPromptsDelegate.kt | 232 ++++++++++ .../m57/hermescontrol/ui/chat/ChatScreen.kt | 10 +- .../hermescontrol/ui/chat/ChatViewModel.kt | 69 +++ .../ui/chat/ChatWsEventReducer.kt | 15 + .../ui/chat/components/VaultPromptCards.kt | 394 +++++++++++++++++ .../ui/chat/fullbleed/FullBleedChatList.kt | 47 ++ .../ui/settings/SettingsScreen.kt | 8 + .../ui/settings/SettingsSubPages.kt | 57 +++ .../ui/settings/SettingsVaultViewModel.kt | 163 +++++++ .../ui/settings/components/VaultSection.kt | 400 ++++++++++++++++++ app/src/main/res/values/strings.xml | 36 ++ .../hermescontrol/data/ws/EventParserTest.kt | 145 +++++++ .../chat/ChatCredentialPromptsDelegateTest.kt | 186 +++++--- .../ui/settings/SettingsVaultViewModelTest.kt | 143 +++++++ 23 files changed, 2224 insertions(+), 53 deletions(-) create mode 100644 app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/VaultPromptCardsTest.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/data/model/VaultModels.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/data/ws/VaultRepository.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/components/VaultPromptCards.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsVaultViewModel.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/settings/components/VaultSection.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/ui/settings/SettingsVaultViewModelTest.kt diff --git a/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/VaultPromptCardsTest.kt b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/VaultPromptCardsTest.kt new file mode 100644 index 00000000..044a7a04 --- /dev/null +++ b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/VaultPromptCardsTest.kt @@ -0,0 +1,120 @@ +package com.m57.hermescontrol.ui.chat.components + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextInput +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.MediumTest +import com.m57.hermescontrol.ui.chat.VaultCodePromptUi +import com.m57.hermescontrol.ui.chat.VaultSaveLoginPromptUi +import com.m57.hermescontrol.ui.chat.VaultUnlockPromptUi +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +@MediumTest +class VaultPromptCardsTest { + @get:Rule + val composeTestRule = createComposeRule() + + @Test + fun testVaultUnlockCard_interaction() { + var confirmedPassword = "" + var dismissed = false + + composeTestRule.setContent { + VaultUnlockCard( + prompt = + VaultUnlockPromptUi( + requestId = "req-1", + sessionId = "sess-1", + backend = "onepassword", + displayName = "1Password", + ), + onConfirm = { confirmedPassword = it }, + onDismiss = { dismissed = true }, + ) + } + + composeTestRule.onNodeWithTag("vault_unlock_card").assertIsDisplayed() + composeTestRule.onNodeWithText("Unlock 1Password").assertIsDisplayed() + + composeTestRule.onNodeWithTag("vault_unlock_password_input").performTextInput("mypass123") + composeTestRule.onNodeWithTag("vault_unlock_confirm").performClick() + + assertEquals("mypass123", confirmedPassword) + + composeTestRule.onNodeWithTag("vault_unlock_dismiss").performClick() + assertEquals(true, dismissed) + } + + @Test + fun testVaultSaveLoginCard_interaction() { + var savedIdentifier = "" + var savedPassword = "" + var dismissed = false + + composeTestRule.setContent { + VaultSaveLoginCard( + prompt = + VaultSaveLoginPromptUi( + requestId = "req-save", + sessionId = "sess-1", + origin = "https://github.com/login", + site = "GitHub", + ), + onConfirm = { id, pass -> + savedIdentifier = id + savedPassword = pass + }, + onDismiss = { dismissed = true }, + ) + } + + composeTestRule.onNodeWithTag("vault_save_login_card").assertIsDisplayed() + composeTestRule.onNodeWithTag("vault_save_login_identifier_input").performTextInput("testuser@example.com") + composeTestRule.onNodeWithTag("vault_save_login_password_input").performTextInput("supersecret") + + composeTestRule.onNodeWithTag("vault_save_login_confirm").performClick() + assertEquals("testuser@example.com", savedIdentifier) + assertEquals("supersecret", savedPassword) + + composeTestRule.onNodeWithTag("vault_save_login_dismiss").performClick() + assertEquals(true, dismissed) + } + + @Test + fun testVaultCodeCard_interaction() { + var submittedCode = "" + var dismissed = false + + composeTestRule.setContent { + VaultCodeCard( + prompt = + VaultCodePromptUi( + requestId = "req-code", + sessionId = "sess-1", + site = "GitHub", + hint = "SMS verification to ***1234", + ), + onConfirm = { submittedCode = it }, + onDismiss = { dismissed = true }, + ) + } + + composeTestRule.onNodeWithTag("vault_code_card").assertIsDisplayed() + composeTestRule.onNodeWithText("SMS verification to ***1234").assertIsDisplayed() + + composeTestRule.onNodeWithTag("vault_code_input").performTextInput("654321") + composeTestRule.onNodeWithTag("vault_code_confirm").performClick() + assertEquals("654321", submittedCode) + + composeTestRule.onNodeWithTag("vault_code_dismiss").performClick() + assertEquals(true, dismissed) + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/Navigation.kt b/app/src/main/java/com/m57/hermescontrol/Navigation.kt index be5727ef..0f7d575d 100644 --- a/app/src/main/java/com/m57/hermescontrol/Navigation.kt +++ b/app/src/main/java/com/m57/hermescontrol/Navigation.kt @@ -62,6 +62,7 @@ import com.m57.hermescontrol.ui.settings.SettingsBehaviorPage import com.m57.hermescontrol.ui.settings.SettingsChatPage import com.m57.hermescontrol.ui.settings.SettingsConnectionPage import com.m57.hermescontrol.ui.settings.SettingsLanguagePage +import com.m57.hermescontrol.ui.settings.SettingsVaultPage import com.m57.hermescontrol.ui.settings.SettingsViewModel import com.m57.hermescontrol.ui.toolsets.ToolsetDetailScreen import kotlinx.coroutines.launch @@ -137,6 +138,11 @@ private fun appEntryProvider( viewModel = viewModel { SettingsViewModel() }, ) } + entry { + SettingsVaultPage( + onBack = { NavigationController.goBack() }, + ) + } entry { SettingsAboutPage( onBack = { NavigationController.goBack() }, diff --git a/app/src/main/java/com/m57/hermescontrol/NavigationKeys.kt b/app/src/main/java/com/m57/hermescontrol/NavigationKeys.kt index 1934804c..776be2da 100644 --- a/app/src/main/java/com/m57/hermescontrol/NavigationKeys.kt +++ b/app/src/main/java/com/m57/hermescontrol/NavigationKeys.kt @@ -87,4 +87,6 @@ import kotlinx.serialization.Serializable @Serializable data object SettingsBehavior : NavKey +@Serializable data object SettingsVault : NavKey + @Serializable data object SettingsAbout : NavKey diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/VaultModels.kt b/app/src/main/java/com/m57/hermescontrol/data/model/VaultModels.kt new file mode 100644 index 00000000..f03fc26b --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/model/VaultModels.kt @@ -0,0 +1,61 @@ +package com.m57.hermescontrol.data.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * Models for the Credential Vault RPC surface (issue #1090). + * + * Backend: `tui_gateway/methods_vault.py`. + * Sources represent local encrypted vault or external managers (1Password, Bitwarden). + */ + +@Serializable +data class VaultSource( + val name: String, + @SerialName("display_name") val displayName: String = name, + val enabled: Boolean = false, + @SerialName("needs_unlock") val needsUnlock: Boolean = false, + val unlocked: Boolean = false, + val installed: Boolean = false, +) + +@Serializable +data class VaultSourcesResponse( + val sources: List = emptyList(), +) + +@Serializable +data class VaultItem( + val id: String, + val backend: String = "local", + val kind: String = "login", + val label: String = "", + val origin: String? = null, + @SerialName("created_at") val createdAt: String? = null, + @SerialName("identifier_type") val identifierType: String? = null, + val identifier: String? = null, + @SerialName("has_otp") val hasOtp: Boolean = false, +) + +@Serializable +data class VaultListResponse( + val items: List = emptyList(), +) + +@Serializable +data class VaultSourceSetResponse( + val name: String? = null, + val enabled: Boolean? = null, +) + +@Serializable +data class VaultUnlockResponse( + val name: String? = null, + val unlocked: Boolean? = null, +) + +@Serializable +data class VaultLockResponse( + val locked: Boolean? = null, +) diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/EventParser.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/EventParser.kt index f2d680b3..22bdd752 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/EventParser.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/EventParser.kt @@ -341,6 +341,43 @@ object EventParser { WsEvent.SecretExpire(requestId, sessionId) } + // ── Credential Vault prompts (issue #1090) ────────────────── + "vault.unlock.request" -> { + val requestId = payload?.get("request_id") as? String + val backend = payload?.get("backend") as? String + val displayName = payload?.get("display_name") as? String + WsEvent.VaultUnlockRequest(requestId, sessionId, backend, displayName) + } + + "vault.unlock.expire" -> { + val requestId = payload?.get("request_id") as? String + WsEvent.VaultUnlockExpire(requestId, sessionId) + } + + "vault.save_login.request" -> { + val requestId = payload?.get("request_id") as? String + val origin = payload?.get("origin") as? String + val site = payload?.get("site") as? String + WsEvent.VaultSaveLoginRequest(requestId, sessionId, origin, site) + } + + "vault.save_login.expire" -> { + val requestId = payload?.get("request_id") as? String + WsEvent.VaultSaveLoginExpire(requestId, sessionId) + } + + "vault.code.request" -> { + val requestId = payload?.get("request_id") as? String + val site = payload?.get("site") as? String + val hint = payload?.get("hint") as? String + WsEvent.VaultCodeRequest(requestId, sessionId, site, hint) + } + + "vault.code.expire" -> { + val requestId = payload?.get("request_id") as? String + WsEvent.VaultCodeExpire(requestId, sessionId) + } + else -> { Log.w(TAG, "Unknown event type: $eventType") WsEvent.Unknown(rawJson) diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/VaultRepository.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/VaultRepository.kt new file mode 100644 index 00000000..56d7a6cc --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/VaultRepository.kt @@ -0,0 +1,91 @@ +package com.m57.hermescontrol.data.ws + +import com.m57.hermescontrol.data.model.VaultItem +import com.m57.hermescontrol.data.model.VaultListResponse +import com.m57.hermescontrol.data.model.VaultLockResponse +import com.m57.hermescontrol.data.model.VaultSource +import com.m57.hermescontrol.data.model.VaultSourceSetResponse +import com.m57.hermescontrol.data.model.VaultSourcesResponse +import com.m57.hermescontrol.data.model.VaultUnlockResponse +import com.m57.hermescontrol.data.remote.OkHttpProvider +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.serializer + +/** + * Repository for Credential Vault RPC operations over WebSocket (issue #1090). + */ +object VaultRepository { + private val json get() = OkHttpProvider.json + + @Suppress("UNCHECKED_CAST") + private inline fun decode(result: Any?): T? { + if (result == null) return null + val element: JsonElement = + when (result) { + is JsonElement -> result + is Map<*, *> -> anyToJsonElement(result) + is List<*> -> JsonArray(result.map { anyToJsonElement(it) }) + else -> JsonPrimitive(result.toString()) + } + return json.decodeFromJsonElement(serializer(), element) + } + + @Suppress("UNCHECKED_CAST") + private fun anyToJsonElement(value: Any?): JsonElement = + when (value) { + null -> JsonNull + is JsonElement -> value + is Map<*, *> -> JsonObject((value as Map).mapValues { (_, v) -> anyToJsonElement(v) }) + is List<*> -> JsonArray(value.map { anyToJsonElement(it) }) + is String -> JsonPrimitive(value) + is Boolean -> JsonPrimitive(value) + is Number -> JsonPrimitive(value) + else -> JsonPrimitive(value.toString()) + } + + suspend fun getSources(): List { + val result = HermesWsClient.request(WsMethods.VAULT_SOURCES).await() + return decode(result)?.sources ?: emptyList() + } + + suspend fun setSourceEnabled( + name: String, + enabled: Boolean, + ): Boolean { + val result = + HermesWsClient + .request( + WsMethods.VAULT_SOURCE_SET, + mapOf("name" to name, "enabled" to enabled), + ).await() + return decode(result)?.enabled == enabled + } + + suspend fun unlockSource( + name: String, + password: String, + ): Boolean { + val result = + HermesWsClient + .request( + WsMethods.VAULT_UNLOCK, + mapOf("name" to name, "password" to password), + ).await() + return decode(result)?.unlocked == true + } + + suspend fun lockSource(name: String? = null): Boolean { + val params = if (name != null) mapOf("name" to name) else emptyMap() + val result = HermesWsClient.request(WsMethods.VAULT_LOCK, params).await() + return decode(result)?.locked == true + } + + suspend fun listItems(): List { + val result = HermesWsClient.request(WsMethods.VAULT_LIST).await() + return decode(result)?.items ?: emptyList() + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/WsEvent.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/WsEvent.kt index 3282eef9..94dd2b25 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/WsEvent.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/WsEvent.kt @@ -303,6 +303,44 @@ sealed class WsEvent { val sessionId: String?, ) : WsEvent() + // ── Credential Vault prompts (issue #1090) ────────────────────────── + + data class VaultUnlockRequest( + val requestId: String?, + val sessionId: String? = null, + val backend: String? = null, + val displayName: String? = null, + ) : WsEvent() + + data class VaultUnlockExpire( + val requestId: String?, + val sessionId: String? = null, + ) : WsEvent() + + data class VaultSaveLoginRequest( + val requestId: String?, + val sessionId: String? = null, + val origin: String? = null, + val site: String? = null, + ) : WsEvent() + + data class VaultSaveLoginExpire( + val requestId: String?, + val sessionId: String? = null, + ) : WsEvent() + + data class VaultCodeRequest( + val requestId: String?, + val sessionId: String? = null, + val site: String? = null, + val hint: String? = null, + ) : WsEvent() + + data class VaultCodeExpire( + val requestId: String?, + val sessionId: String? = null, + ) : WsEvent() + // ── Gateway-level errors ─────────────────────────────────────────── /** diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/WsMethods.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/WsMethods.kt index e2d8a1da..586f146a 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/WsMethods.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/WsMethods.kt @@ -43,6 +43,16 @@ object WsMethods { const val SUDO_RESPOND = "sudo.respond" const val SECRET_RESPOND = "secret.respond" + // ── Credential Vault (issue #1090) ──────────────────────────────────── + const val VAULT_UNLOCK_RESPOND = "vault.unlock.respond" + const val VAULT_SAVE_LOGIN_RESPOND = "vault.save_login.respond" + const val VAULT_CODE_RESPOND = "vault.code.respond" + const val VAULT_LIST = "vault.list" + const val VAULT_SOURCES = "vault.sources" + const val VAULT_SOURCE_SET = "vault.source.set" + const val VAULT_UNLOCK = "vault.unlock" + const val VAULT_LOCK = "vault.lock" + // ── Heartbeat & Latency (issue #1017) ────────────────────────────────── /** Ultra-lightweight liveness check and round-trip latency measurement. */ diff --git a/app/src/main/java/com/m57/hermescontrol/notification/ChatNotificationService.kt b/app/src/main/java/com/m57/hermescontrol/notification/ChatNotificationService.kt index 5bfd9fda..ce7e79ab 100644 --- a/app/src/main/java/com/m57/hermescontrol/notification/ChatNotificationService.kt +++ b/app/src/main/java/com/m57/hermescontrol/notification/ChatNotificationService.kt @@ -140,6 +140,13 @@ class ChatNotificationService : Service() { showReplyNotification(preview.take(100), null) } + is WsEvent.VaultUnlockRequest, + is WsEvent.VaultSaveLoginRequest, + is WsEvent.VaultCodeRequest, + -> { + showReplyNotification(getString(R.string.notif_input_needed), null) + } + else -> {} } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatCredentialPromptsDelegate.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatCredentialPromptsDelegate.kt index 8b47c45d..fdaa51c0 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatCredentialPromptsDelegate.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatCredentialPromptsDelegate.kt @@ -7,6 +7,8 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put /** * Owns sudo and secret prompt state, timeouts, dismissal, and response RPCs. @@ -177,4 +179,234 @@ class ChatCredentialPromptsDelegate( ) { id -> trackRequest(id, WsMethods.SECRET_RESPOND) } } } + + // ── Vault prompts (issue #1090) ─────────────────────────────────────── + + /** + * The agent needs to unlock a password manager (1Password, Bitwarden). + * Surface an interactive prompt card in chat and reply via vault.unlock.respond. + */ + fun handleVaultUnlockRequest(event: WsEvent.VaultUnlockRequest) { + uiState.update { + it.copy( + vaultUnlockPrompt = + VaultUnlockPromptUi( + requestId = event.requestId, + sessionId = event.sessionId, + backend = event.backend, + displayName = event.displayName, + ), + isAgentTyping = false, + ) + } + } + + fun handleVaultUnlockExpire(event: WsEvent.VaultUnlockExpire) { + uiState.update { state -> + val current = state.vaultUnlockPrompt ?: return@update state + if (event.requestId != null && current.requestId != null && + event.requestId != current.requestId + ) { + return@update state + } + state.copy(vaultUnlockPrompt = null) + } + } + + /** + * Cancel/dismiss → send empty password (keeps manager locked, unblocks turn). + */ + fun dismissVaultUnlock() { + val prompt = uiState.value.vaultUnlockPrompt ?: return + val sessionId = prompt.sessionId ?: uiState.value.currentSessionId + uiState.update { it.copy(vaultUnlockPrompt = null) } + if (sessionId == null) return + scope.launch(ioDispatcher) { + val params = + mutableMapOf( + "session_id" to sessionId, + "password" to "", + ) + prompt.requestId?.let { id -> params["request_id"] = id } + wsSend( + WsMethods.VAULT_UNLOCK_RESPOND, + params, + ) { id -> trackRequest(id, WsMethods.VAULT_UNLOCK_RESPOND) } + } + } + + fun respondToVaultUnlock(password: String) { + val prompt = uiState.value.vaultUnlockPrompt ?: return + val sessionId = prompt.sessionId ?: uiState.value.currentSessionId ?: return + if (password.isBlank()) return + + uiState.update { it.copy(vaultUnlockPrompt = null) } + + scope.launch(ioDispatcher) { + val params = + mutableMapOf( + "session_id" to sessionId, + "password" to password, + ) + prompt.requestId?.let { id -> params["request_id"] = id } + wsSend( + WsMethods.VAULT_UNLOCK_RESPOND, + params, + ) { id -> trackRequest(id, WsMethods.VAULT_UNLOCK_RESPOND) } + } + } + + /** + * The agent captured credentials on a login page and asks whether to store them in the vault. + */ + fun handleVaultSaveLoginRequest(event: WsEvent.VaultSaveLoginRequest) { + uiState.update { + it.copy( + vaultSaveLoginPrompt = + VaultSaveLoginPromptUi( + requestId = event.requestId, + sessionId = event.sessionId, + origin = event.origin, + site = event.site, + ), + isAgentTyping = false, + ) + } + } + + fun handleVaultSaveLoginExpire(event: WsEvent.VaultSaveLoginExpire) { + uiState.update { state -> + val current = state.vaultSaveLoginPrompt ?: return@update state + if (event.requestId != null && current.requestId != null && + event.requestId != current.requestId + ) { + return@update state + } + state.copy(vaultSaveLoginPrompt = null) + } + } + + /** + * Decline saving → send empty login (backend treats empty login as declined). + */ + fun dismissVaultSaveLogin() { + val prompt = uiState.value.vaultSaveLoginPrompt ?: return + val sessionId = prompt.sessionId ?: uiState.value.currentSessionId + uiState.update { it.copy(vaultSaveLoginPrompt = null) } + if (sessionId == null) return + scope.launch(ioDispatcher) { + val params = + mutableMapOf( + "session_id" to sessionId, + "login" to "", + ) + prompt.requestId?.let { id -> params["request_id"] = id } + wsSend( + WsMethods.VAULT_SAVE_LOGIN_RESPOND, + params, + ) { id -> trackRequest(id, WsMethods.VAULT_SAVE_LOGIN_RESPOND) } + } + } + + fun respondToVaultSaveLogin( + identifier: String, + password: String, + ) { + val prompt = uiState.value.vaultSaveLoginPrompt ?: return + val sessionId = prompt.sessionId ?: uiState.value.currentSessionId ?: return + if (identifier.isBlank() || password.isBlank()) return + + uiState.update { it.copy(vaultSaveLoginPrompt = null) } + + scope.launch(ioDispatcher) { + val loginJson = + buildJsonObject { + put("identifier", identifier) + put("password", password) + }.toString() + val params = + mutableMapOf( + "session_id" to sessionId, + "login" to loginJson, + ) + prompt.requestId?.let { id -> params["request_id"] = id } + wsSend( + WsMethods.VAULT_SAVE_LOGIN_RESPOND, + params, + ) { id -> trackRequest(id, WsMethods.VAULT_SAVE_LOGIN_RESPOND) } + } + } + + /** + * The site requested a 2FA/MFA verification code. + */ + fun handleVaultCodeRequest(event: WsEvent.VaultCodeRequest) { + uiState.update { + it.copy( + vaultCodePrompt = + VaultCodePromptUi( + requestId = event.requestId, + sessionId = event.sessionId, + site = event.site, + hint = event.hint, + ), + isAgentTyping = false, + ) + } + } + + fun handleVaultCodeExpire(event: WsEvent.VaultCodeExpire) { + uiState.update { state -> + val current = state.vaultCodePrompt ?: return@update state + if (event.requestId != null && current.requestId != null && + event.requestId != current.requestId + ) { + return@update state + } + state.copy(vaultCodePrompt = null) + } + } + + /** + * Skip entering code → send empty code. + */ + fun dismissVaultCode() { + val prompt = uiState.value.vaultCodePrompt ?: return + val sessionId = prompt.sessionId ?: uiState.value.currentSessionId + uiState.update { it.copy(vaultCodePrompt = null) } + if (sessionId == null) return + scope.launch(ioDispatcher) { + val params = + mutableMapOf( + "session_id" to sessionId, + "code" to "", + ) + prompt.requestId?.let { id -> params["request_id"] = id } + wsSend( + WsMethods.VAULT_CODE_RESPOND, + params, + ) { id -> trackRequest(id, WsMethods.VAULT_CODE_RESPOND) } + } + } + + fun respondToVaultCode(code: String) { + val prompt = uiState.value.vaultCodePrompt ?: return + val sessionId = prompt.sessionId ?: uiState.value.currentSessionId ?: return + if (code.isBlank()) return + + uiState.update { it.copy(vaultCodePrompt = null) } + + scope.launch(ioDispatcher) { + val params = + mutableMapOf( + "session_id" to sessionId, + "code" to code, + ) + prompt.requestId?.let { id -> params["request_id"] = id } + wsSend( + WsMethods.VAULT_CODE_RESPOND, + params, + ) { id -> trackRequest(id, WsMethods.VAULT_CODE_RESPOND) } + } + } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt index 9067946a..606180d3 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt @@ -3,7 +3,6 @@ package com.m57.hermescontrol.ui.chat import android.app.Activity import android.content.Intent import android.net.Uri -import android.util.Log import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.animation.AnimatedVisibility @@ -549,6 +548,15 @@ fun ChatScreen( onRespondClarify = viewModel::respondToClarify, onRespondClarifyBatch = viewModel::respondToClarifyBatch, onDismissClarify = viewModel::dismissClarify, + vaultUnlockPrompt = state.vaultUnlockPrompt, + onRespondVaultUnlock = viewModel::respondToVaultUnlock, + onDismissVaultUnlock = viewModel::dismissVaultUnlock, + vaultSaveLoginPrompt = state.vaultSaveLoginPrompt, + onRespondVaultSaveLogin = viewModel::respondToVaultSaveLogin, + onDismissVaultSaveLogin = viewModel::dismissVaultSaveLogin, + vaultCodePrompt = state.vaultCodePrompt, + onRespondVaultCode = viewModel::respondToVaultCode, + onDismissVaultCode = viewModel::dismissVaultCode, onSaveAttachment = onSaveAttachment, savingAttachmentPath = pendingSavePath ?: state.savingAttachmentPath, openingAttachmentPath = state.openingAttachmentPath, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt index a671f7b0..b0806a59 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt @@ -98,6 +98,10 @@ data class ChatUiState( // Sudo / secret prompts — surfaced as dialogs (issue #524) val sudoPrompt: SudoPromptUi? = null, val secretPrompt: SecretPromptUi? = null, + // Credential vault prompts — interactive prompt cards (issue #1090) + val vaultUnlockPrompt: VaultUnlockPromptUi? = null, + val vaultSaveLoginPrompt: VaultSaveLoginPromptUi? = null, + val vaultCodePrompt: VaultCodePromptUi? = null, val showSessionPicker: Boolean = false, // /update confirm dialog (issue #862) — the command is handled client-side val updateConfirmOpen: Boolean = false, @@ -251,6 +255,30 @@ data class SecretPromptUi( val prompt: String? = null, ) +/** Transient — not persisted. Holds a pending vault unlock request (issue #1090). */ +data class VaultUnlockPromptUi( + val requestId: String?, + val sessionId: String?, + val backend: String? = null, + val displayName: String? = null, +) + +/** Transient — not persisted. Holds a pending vault save login request (issue #1090). */ +data class VaultSaveLoginPromptUi( + val requestId: String?, + val sessionId: String?, + val origin: String? = null, + val site: String? = null, +) + +/** Transient — not persisted. Holds a pending vault 2FA/MFA code request (issue #1090). */ +data class VaultCodePromptUi( + val requestId: String?, + val sessionId: String?, + val site: String? = null, + val hint: String? = null, +) + /** * Token breakdown backing the context meter's detail sheet. All values are * cumulative lifetime token counts sourced from `GET /api/sessions/{id}` @@ -882,6 +910,30 @@ class ChatViewModel( credentialPromptsDelegate.handleSecretExpire(event) } + is WsEvent.VaultUnlockRequest -> { + credentialPromptsDelegate.handleVaultUnlockRequest(event) + } + + is WsEvent.VaultUnlockExpire -> { + credentialPromptsDelegate.handleVaultUnlockExpire(event) + } + + is WsEvent.VaultSaveLoginRequest -> { + credentialPromptsDelegate.handleVaultSaveLoginRequest(event) + } + + is WsEvent.VaultSaveLoginExpire -> { + credentialPromptsDelegate.handleVaultSaveLoginExpire(event) + } + + is WsEvent.VaultCodeRequest -> { + credentialPromptsDelegate.handleVaultCodeRequest(event) + } + + is WsEvent.VaultCodeExpire -> { + credentialPromptsDelegate.handleVaultCodeExpire(event) + } + is WsEvent.GatewayError -> { // Reducer already set errorMessage; no extra VM work needed. } @@ -3251,6 +3303,23 @@ class ChatViewModel( fun respondToSecret(value: String) = credentialPromptsDelegate.respondToSecret(value) + // ── Vault prompt flow (issue #1090) ────────────────────────────────── + + fun dismissVaultUnlock() = credentialPromptsDelegate.dismissVaultUnlock() + + fun respondToVaultUnlock(password: String) = credentialPromptsDelegate.respondToVaultUnlock(password) + + fun dismissVaultSaveLogin() = credentialPromptsDelegate.dismissVaultSaveLogin() + + fun respondToVaultSaveLogin( + identifier: String, + password: String, + ) = credentialPromptsDelegate.respondToVaultSaveLogin(identifier, password) + + fun dismissVaultCode() = credentialPromptsDelegate.dismissVaultCode() + + fun respondToVaultCode(code: String) = credentialPromptsDelegate.respondToVaultCode(code) + fun reconnect() { _uiState.update { it.copy( diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducer.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducer.kt index 4c2ad8eb..28b71c24 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducer.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducer.kt @@ -75,6 +75,12 @@ object ChatWsEventReducer { is WsEvent.BtwComplete -> event.sessionId is WsEvent.SessionUsage -> event.sessionId is WsEvent.TranscriptResyncRequired -> event.sessionId + is WsEvent.VaultUnlockRequest -> event.sessionId + is WsEvent.VaultUnlockExpire -> event.sessionId + is WsEvent.VaultSaveLoginRequest -> event.sessionId + is WsEvent.VaultSaveLoginExpire -> event.sessionId + is WsEvent.VaultCodeRequest -> event.sessionId + is WsEvent.VaultCodeExpire -> event.sessionId else -> null } if (eventSessionId != null && currentSessionId != null && eventSessionId != currentSessionId) { @@ -170,6 +176,15 @@ object ChatWsEventReducer { is WsEvent.SecretExpire -> ReducerResult(state = state, streamingState = streamingState) + // Vault prompts are handled by the ViewModel (issue #1090) + is WsEvent.VaultUnlockRequest, + is WsEvent.VaultUnlockExpire, + is WsEvent.VaultSaveLoginRequest, + is WsEvent.VaultSaveLoginExpire, + is WsEvent.VaultCodeRequest, + is WsEvent.VaultCodeExpire, + -> ReducerResult(state = state, streamingState = streamingState) + // ReactionEvent is handled by the ViewModel — purely cosmetic animation is WsEvent.ReactionEvent -> ReducerResult(state = state, streamingState = streamingState) diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/VaultPromptCards.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/VaultPromptCards.kt new file mode 100644 index 00000000..8e93888e --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/VaultPromptCards.kt @@ -0,0 +1,394 @@ +package com.m57.hermescontrol.ui.chat.components + +import androidx.compose.foundation.BorderStroke +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.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Key +import androidx.compose.material.icons.filled.Lock +import androidx.compose.material.icons.filled.Pin +import androidx.compose.material.icons.filled.Visibility +import androidx.compose.material.icons.filled.VisibilityOff +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +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.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.unit.dp +import com.m57.hermescontrol.R +import com.m57.hermescontrol.ui.chat.VaultCodePromptUi +import com.m57.hermescontrol.ui.chat.VaultSaveLoginPromptUi +import com.m57.hermescontrol.ui.chat.VaultUnlockPromptUi + +/** + * Interactive prompt cards for mid-turn Credential Vault operations (issue #1090). + * Rendered inline in chat (matching ClarifyBubble pattern) to avoid blocking dialog scrims. + */ + +@Composable +fun VaultUnlockCard( + prompt: VaultUnlockPromptUi, + onConfirm: (String) -> Unit, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + var password by remember(prompt.requestId) { mutableStateOf("") } + var passwordVisible by remember(prompt.requestId) { mutableStateOf(false) } + + val displayName = prompt.displayName ?: prompt.backend ?: "Password Manager" + + Surface( + modifier = + modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 6.dp) + .testTag("vault_unlock_card"), + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.surfaceContainerLow, + border = + BorderStroke( + width = 1.dp, + color = MaterialTheme.colorScheme.outlineVariant, + ), + ) { + Column( + modifier = Modifier.padding(14.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = Icons.Filled.Lock, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(20.dp), + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = stringResource(R.string.vault_unlock_title, displayName), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface, + ) + } + + Text( + text = stringResource(R.string.vault_unlock_desc, displayName), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + OutlinedTextField( + value = password, + onValueChange = { password = it }, + label = { Text(stringResource(R.string.vault_unlock_password_label)) }, + modifier = + Modifier + .fillMaxWidth() + .testTag("vault_unlock_password_input"), + singleLine = true, + visualTransformation = + if (passwordVisible) { + VisualTransformation.None + } else { + PasswordVisualTransformation() + }, + keyboardOptions = + KeyboardOptions( + autoCorrectEnabled = false, + keyboardType = KeyboardType.Password, + ), + trailingIcon = { + IconButton(onClick = { passwordVisible = !passwordVisible }) { + Icon( + imageVector = + if (passwordVisible) { + Icons.Filled.VisibilityOff + } else { + Icons.Filled.Visibility + }, + contentDescription = null, + ) + } + }, + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically, + ) { + OutlinedButton( + onClick = onDismiss, + modifier = Modifier.testTag("vault_unlock_dismiss"), + ) { + Text(stringResource(R.string.vault_unlock_dismiss)) + } + Spacer(modifier = Modifier.width(8.dp)) + Button( + onClick = { + if (password.isNotBlank()) { + onConfirm(password) + } + }, + enabled = password.isNotBlank(), + modifier = Modifier.testTag("vault_unlock_confirm"), + ) { + Text(stringResource(R.string.vault_unlock_action)) + } + } + } + } +} + +@Composable +fun VaultSaveLoginCard( + prompt: VaultSaveLoginPromptUi, + onConfirm: (identifier: String, password: String) -> Unit, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + var identifier by remember(prompt.requestId) { mutableStateOf("") } + var password by remember(prompt.requestId) { mutableStateOf("") } + var passwordVisible by remember(prompt.requestId) { mutableStateOf(false) } + + val siteLabel = prompt.site ?: prompt.origin ?: "this site" + + Surface( + modifier = + modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 6.dp) + .testTag("vault_save_login_card"), + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.surfaceContainerLow, + border = + BorderStroke( + width = 1.dp, + color = MaterialTheme.colorScheme.outlineVariant, + ), + ) { + Column( + modifier = Modifier.padding(14.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = Icons.Filled.Key, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(20.dp), + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = stringResource(R.string.vault_save_login_title), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface, + ) + } + + Text( + text = stringResource(R.string.vault_save_login_desc, siteLabel), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + OutlinedTextField( + value = identifier, + onValueChange = { identifier = it }, + label = { Text(stringResource(R.string.vault_save_login_identifier)) }, + modifier = + Modifier + .fillMaxWidth() + .testTag("vault_save_login_identifier_input"), + singleLine = true, + keyboardOptions = + KeyboardOptions( + autoCorrectEnabled = false, + keyboardType = KeyboardType.Email, + ), + ) + + OutlinedTextField( + value = password, + onValueChange = { password = it }, + label = { Text(stringResource(R.string.vault_save_login_password)) }, + modifier = + Modifier + .fillMaxWidth() + .testTag("vault_save_login_password_input"), + singleLine = true, + visualTransformation = + if (passwordVisible) { + VisualTransformation.None + } else { + PasswordVisualTransformation() + }, + keyboardOptions = + KeyboardOptions( + autoCorrectEnabled = false, + keyboardType = KeyboardType.Password, + ), + trailingIcon = { + IconButton(onClick = { passwordVisible = !passwordVisible }) { + Icon( + imageVector = + if (passwordVisible) { + Icons.Filled.VisibilityOff + } else { + Icons.Filled.Visibility + }, + contentDescription = null, + ) + } + }, + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically, + ) { + OutlinedButton( + onClick = onDismiss, + modifier = Modifier.testTag("vault_save_login_dismiss"), + ) { + Text(stringResource(R.string.vault_save_login_dismiss)) + } + Spacer(modifier = Modifier.width(8.dp)) + Button( + onClick = { + if (identifier.isNotBlank() && password.isNotBlank()) { + onConfirm(identifier.trim(), password) + } + }, + enabled = identifier.isNotBlank() && password.isNotBlank(), + modifier = Modifier.testTag("vault_save_login_confirm"), + ) { + Text(stringResource(R.string.vault_save_login_action)) + } + } + } + } +} + +@Composable +fun VaultCodeCard( + prompt: VaultCodePromptUi, + onConfirm: (String) -> Unit, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + var code by remember(prompt.requestId) { mutableStateOf("") } + + val siteLabel = prompt.site ?: "site" + val desc = prompt.hint?.takeIf { it.isNotBlank() } ?: stringResource(R.string.vault_code_desc, siteLabel) + + Surface( + modifier = + modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 6.dp) + .testTag("vault_code_card"), + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.surfaceContainerLow, + border = + BorderStroke( + width = 1.dp, + color = MaterialTheme.colorScheme.outlineVariant, + ), + ) { + Column( + modifier = Modifier.padding(14.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = Icons.Filled.Pin, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(20.dp), + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = stringResource(R.string.vault_code_title), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface, + ) + } + + Text( + text = desc, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + OutlinedTextField( + value = code, + onValueChange = { code = it }, + label = { Text(stringResource(R.string.vault_code_label)) }, + placeholder = { Text("123456") }, + modifier = + Modifier + .fillMaxWidth() + .testTag("vault_code_input"), + singleLine = true, + keyboardOptions = + KeyboardOptions( + autoCorrectEnabled = false, + keyboardType = KeyboardType.Number, + ), + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically, + ) { + OutlinedButton( + onClick = onDismiss, + modifier = Modifier.testTag("vault_code_dismiss"), + ) { + Text(stringResource(R.string.vault_code_dismiss)) + } + Spacer(modifier = Modifier.width(8.dp)) + Button( + onClick = { + if (code.isNotBlank()) { + onConfirm(code.trim()) + } + }, + enabled = code.isNotBlank(), + modifier = Modifier.testTag("vault_code_confirm"), + ) { + Text(stringResource(R.string.vault_code_action)) + } + } + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedChatList.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedChatList.kt index 692b2c5a..98a311ed 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedChatList.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedChatList.kt @@ -28,9 +28,15 @@ import com.m57.hermescontrol.ui.chat.ChatViewModel import com.m57.hermescontrol.ui.chat.ClarifyUi import com.m57.hermescontrol.ui.chat.ImageViewerModel import com.m57.hermescontrol.ui.chat.ToolCallDivider +import com.m57.hermescontrol.ui.chat.VaultCodePromptUi +import com.m57.hermescontrol.ui.chat.VaultSaveLoginPromptUi +import com.m57.hermescontrol.ui.chat.VaultUnlockPromptUi import com.m57.hermescontrol.ui.chat.components.ChatScrollController import com.m57.hermescontrol.ui.chat.components.ClarifyBubble import com.m57.hermescontrol.ui.chat.components.ReasoningCard +import com.m57.hermescontrol.ui.chat.components.VaultCodeCard +import com.m57.hermescontrol.ui.chat.components.VaultSaveLoginCard +import com.m57.hermescontrol.ui.chat.components.VaultUnlockCard import com.m57.hermescontrol.ui.chat.toolCallMilestones import com.m57.hermescontrol.ui.common.EmptyState @@ -69,6 +75,15 @@ fun FullBleedChatList( onRespondClarify: ((String) -> Unit)? = null, onRespondClarifyBatch: ((Map) -> Unit)? = null, onDismissClarify: (() -> Unit)? = null, + vaultUnlockPrompt: VaultUnlockPromptUi? = null, + onRespondVaultUnlock: ((String) -> Unit)? = null, + onDismissVaultUnlock: (() -> Unit)? = null, + vaultSaveLoginPrompt: VaultSaveLoginPromptUi? = null, + onRespondVaultSaveLogin: ((String, String) -> Unit)? = null, + onDismissVaultSaveLogin: (() -> Unit)? = null, + vaultCodePrompt: VaultCodePromptUi? = null, + onRespondVaultCode: ((String) -> Unit)? = null, + onDismissVaultCode: (() -> Unit)? = null, onSaveAttachment: (com.m57.hermescontrol.data.model.Attachment) -> Unit = {}, savingAttachmentPath: String? = null, openingAttachmentPath: String? = null, @@ -294,6 +309,38 @@ fun FullBleedChatList( ) } } + + if (vaultUnlockPrompt != null) { + item(key = "vault_unlock_card") { + VaultUnlockCard( + prompt = vaultUnlockPrompt, + onConfirm = { password -> onRespondVaultUnlock?.invoke(password) }, + onDismiss = { onDismissVaultUnlock?.invoke() }, + ) + } + } + + if (vaultSaveLoginPrompt != null) { + item(key = "vault_save_login_card") { + VaultSaveLoginCard( + prompt = vaultSaveLoginPrompt, + onConfirm = { identifier, password -> + onRespondVaultSaveLogin?.invoke(identifier, password) + }, + onDismiss = { onDismissVaultSaveLogin?.invoke() }, + ) + } + } + + if (vaultCodePrompt != null) { + item(key = "vault_code_card") { + VaultCodeCard( + prompt = vaultCodePrompt, + onConfirm = { code -> onRespondVaultCode?.invoke(code) }, + onDismiss = { onDismissVaultCode?.invoke() }, + ) + } + } } } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsScreen.kt index e1c532ce..67d8d42b 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsScreen.kt @@ -13,6 +13,7 @@ import androidx.compose.material.icons.automirrored.filled.ListAlt import androidx.compose.material.icons.filled.ChatBubbleOutline import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.Language +import androidx.compose.material.icons.filled.Lock import androidx.compose.material.icons.filled.Palette import androidx.compose.material.icons.filled.Tune import androidx.compose.material3.Card @@ -41,6 +42,7 @@ import com.m57.hermescontrol.SettingsBehavior import com.m57.hermescontrol.SettingsChat import com.m57.hermescontrol.SettingsConnection import com.m57.hermescontrol.SettingsLanguage +import com.m57.hermescontrol.SettingsVault import com.m57.hermescontrol.theme.ThemePreference import com.m57.hermescontrol.ui.common.HermesScaffold import com.m57.hermescontrol.ui.common.NavIcon @@ -118,6 +120,12 @@ fun SettingsScreen( }, onClick = { NavigationController.navigateTo(SettingsBehavior) }, ), + SettingsRow( + icon = Icons.Filled.Lock, + label = stringResource(R.string.settings_sec_vault), + summary = stringResource(R.string.settings_summary_vault), + onClick = { NavigationController.navigateTo(SettingsVault) }, + ), SettingsRow( icon = Icons.Filled.Info, label = stringResource(R.string.settings_sec_about), diff --git a/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsSubPages.kt b/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsSubPages.kt index 7e019895..db5a0e92 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsSubPages.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsSubPages.kt @@ -15,8 +15,11 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -39,6 +42,9 @@ import com.m57.hermescontrol.ui.settings.components.ConnectionSection import com.m57.hermescontrol.ui.settings.components.LanguageSection import com.m57.hermescontrol.ui.settings.components.TestConnectionButton import com.m57.hermescontrol.ui.settings.components.TestResultCard +import com.m57.hermescontrol.ui.settings.components.VaultItemsSection +import com.m57.hermescontrol.ui.settings.components.VaultSourcesSection +import com.m57.hermescontrol.ui.settings.components.VaultUnlockDialog /** * Drill-down sub-pages for Settings. Each is its own NavKey destination @@ -288,3 +294,54 @@ internal fun SettingsAboutPage( } } } + +@Composable +internal fun SettingsVaultPage( + onBack: () -> Unit, + viewModel: SettingsVaultViewModel = viewModel { SettingsVaultViewModel() }, +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + val snackbarHostState = remember { SnackbarHostState() } + + LaunchedEffect(state.toastMessage) { + state.toastMessage?.let { message -> + snackbarHostState.showSnackbar(message) + viewModel.clearToast() + } + } + + HermesScaffold( + title = { Text(stringResource(R.string.settings_sec_vault)) }, + navigationIcon = NavIcon.Back(onBack), + snackbarHost = { SnackbarHost(hostState = snackbarHostState) }, + drawerGesturesEnabled = false, + ) { + Column( + modifier = + Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + VaultSourcesSection( + sources = state.sources, + isActionInProgress = state.isActionInProgress, + onToggleSource = viewModel::toggleSource, + onUnlockClick = viewModel::showUnlockDialog, + onLockClick = { source -> viewModel.lockSource(source.name) }, + onLockAllClick = { viewModel.lockSource(null) }, + ) + + VaultItemsSection(items = state.items) + } + + state.unlockDialogSource?.let { source -> + VaultUnlockDialog( + source = source, + onConfirm = { password -> viewModel.unlockSource(source.name, password) }, + onDismiss = viewModel::dismissUnlockDialog, + ) + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsVaultViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsVaultViewModel.kt new file mode 100644 index 00000000..dcd5fe91 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsVaultViewModel.kt @@ -0,0 +1,163 @@ +package com.m57.hermescontrol.ui.settings + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.m57.hermescontrol.data.model.VaultItem +import com.m57.hermescontrol.data.model.VaultSource +import com.m57.hermescontrol.data.ws.VaultRepository +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +data class SettingsVaultUiState( + val sources: List = emptyList(), + val items: List = emptyList(), + val isLoading: Boolean = false, + val isActionInProgress: Boolean = false, + val errorMessage: String? = null, + val toastMessage: String? = null, + val unlockDialogSource: VaultSource? = null, +) + +class SettingsVaultViewModel( + private val vaultRepo: VaultRepository = VaultRepository, + private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO, +) : ViewModel() { + private val _uiState = MutableStateFlow(SettingsVaultUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + loadVaultData() + } + + fun loadVaultData() { + viewModelScope.launch(ioDispatcher) { + _uiState.update { it.copy(isLoading = true, errorMessage = null) } + try { + val sources = vaultRepo.getSources() + val items = + try { + vaultRepo.listItems() + } catch (_: Exception) { + emptyList() + } + _uiState.update { + it.copy( + sources = sources, + items = items, + isLoading = false, + ) + } + } catch (e: Exception) { + _uiState.update { + it.copy( + isLoading = false, + errorMessage = e.message ?: "Failed to load vault status", + ) + } + } + } + } + + fun toggleSource( + source: VaultSource, + enabled: Boolean, + ) { + viewModelScope.launch(ioDispatcher) { + _uiState.update { it.copy(isActionInProgress = true) } + try { + vaultRepo.setSourceEnabled(source.name, enabled) + val sources = vaultRepo.getSources() + _uiState.update { it.copy(sources = sources, isActionInProgress = false) } + } catch (e: Exception) { + _uiState.update { + it.copy( + isActionInProgress = false, + toastMessage = "Failed to update ${source.displayName}: ${e.message}", + ) + } + } + } + } + + fun unlockSource( + name: String, + password: String, + ) { + viewModelScope.launch(ioDispatcher) { + _uiState.update { it.copy(isActionInProgress = true, unlockDialogSource = null) } + try { + val success = vaultRepo.unlockSource(name, password) + val sources = vaultRepo.getSources() + val items = + try { + vaultRepo.listItems() + } catch (_: Exception) { + emptyList() + } + _uiState.update { + it.copy( + sources = sources, + items = items, + isActionInProgress = false, + toastMessage = if (success) "Unlocked $name" else "Failed to unlock $name", + ) + } + } catch (e: Exception) { + _uiState.update { + it.copy( + isActionInProgress = false, + toastMessage = "Error unlocking $name: ${e.message}", + ) + } + } + } + } + + fun lockSource(name: String? = null) { + viewModelScope.launch(ioDispatcher) { + _uiState.update { it.copy(isActionInProgress = true) } + try { + vaultRepo.lockSource(name) + val sources = vaultRepo.getSources() + val items = + try { + vaultRepo.listItems() + } catch (_: Exception) { + emptyList() + } + _uiState.update { + it.copy( + sources = sources, + items = items, + isActionInProgress = false, + toastMessage = if (name != null) "Locked $name" else "All password managers locked", + ) + } + } catch (e: Exception) { + _uiState.update { + it.copy( + isActionInProgress = false, + toastMessage = "Error locking vault: ${e.message}", + ) + } + } + } + } + + fun showUnlockDialog(source: VaultSource) { + _uiState.update { it.copy(unlockDialogSource = source) } + } + + fun dismissUnlockDialog() { + _uiState.update { it.copy(unlockDialogSource = null) } + } + + fun clearToast() { + _uiState.update { it.copy(toastMessage = null) } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/settings/components/VaultSection.kt b/app/src/main/java/com/m57/hermescontrol/ui/settings/components/VaultSection.kt new file mode 100644 index 00000000..93a90ea9 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/settings/components/VaultSection.kt @@ -0,0 +1,400 @@ +package com.m57.hermescontrol.ui.settings.components + +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.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.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Lock +import androidx.compose.material.icons.filled.LockOpen +import androidx.compose.material.icons.filled.Visibility +import androidx.compose.material.icons.filled.VisibilityOff +import androidx.compose.material.icons.outlined.Key +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.unit.dp +import com.m57.hermescontrol.R +import com.m57.hermescontrol.data.model.VaultItem +import com.m57.hermescontrol.data.model.VaultSource +import com.m57.hermescontrol.theme.LocalHermesStatusColors + +@Composable +fun VaultSourcesSection( + sources: List, + isActionInProgress: Boolean, + onToggleSource: (VaultSource, Boolean) -> Unit, + onUnlockClick: (VaultSource) -> Unit, + onLockClick: (VaultSource) -> Unit, + onLockAllClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Card( + modifier = modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerLow, + ), + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = stringResource(R.string.vault_sources_title), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + Text( + text = stringResource(R.string.vault_sources_desc), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + sources.forEachIndexed { index, source -> + if (index > 0) { + HorizontalDivider( + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), + ) + } + VaultSourceRow( + source = source, + isActionInProgress = isActionInProgress, + onToggle = { onToggleSource(source, it) }, + onUnlock = { onUnlockClick(source) }, + onLock = { onLockClick(source) }, + ) + } + + val hasUnlockedExternal = sources.any { it.needsUnlock && it.unlocked } + if (hasUnlockedExternal) { + Spacer(modifier = Modifier.height(4.dp)) + OutlinedButton( + onClick = onLockAllClick, + modifier = Modifier.fillMaxWidth().testTag("vault_lock_all_button"), + enabled = !isActionInProgress, + ) { + Icon( + imageVector = Icons.Filled.Lock, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + Spacer(modifier = Modifier.width(8.dp)) + Text(stringResource(R.string.vault_lock_all)) + } + } + } + } +} + +@Composable +private fun VaultSourceRow( + source: VaultSource, + isActionInProgress: Boolean, + onToggle: (Boolean) -> Unit, + onUnlock: () -> Unit, + onLock: () -> Unit, +) { + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = source.displayName, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.SemiBold, + ) + Spacer(modifier = Modifier.width(8.dp)) + if (source.name == "local") { + Surface( + shape = RoundedCornerShape(4.dp), + color = LocalHermesStatusColors.current.success.copy(alpha = 0.15f), + ) { + Text( + text = stringResource(R.string.vault_source_unlocked), + style = MaterialTheme.typography.labelSmall, + color = LocalHermesStatusColors.current.success, + modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp), + ) + } + } else if (!source.installed) { + Surface( + shape = RoundedCornerShape(4.dp), + color = MaterialTheme.colorScheme.surfaceVariant, + ) { + Text( + text = stringResource(R.string.vault_source_not_installed), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp), + ) + } + } else if (source.unlocked) { + Surface( + shape = RoundedCornerShape(4.dp), + color = LocalHermesStatusColors.current.success.copy(alpha = 0.15f), + ) { + Text( + text = stringResource(R.string.vault_source_unlocked), + style = MaterialTheme.typography.labelSmall, + color = LocalHermesStatusColors.current.success, + modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp), + ) + } + } else { + Surface( + shape = RoundedCornerShape(4.dp), + color = MaterialTheme.colorScheme.errorContainer, + ) { + Text( + text = stringResource(R.string.vault_source_locked), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onErrorContainer, + modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp), + ) + } + } + } + + if (source.name != "local" && source.installed) { + Spacer(modifier = Modifier.height(4.dp)) + if (source.unlocked) { + FilledTonalButton( + onClick = onLock, + enabled = !isActionInProgress, + modifier = Modifier.height(32.dp), + ) { + Icon( + imageVector = Icons.Filled.Lock, + contentDescription = null, + modifier = Modifier.size(14.dp), + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = stringResource(R.string.vault_source_lock), + style = MaterialTheme.typography.labelSmall, + ) + } + } else { + Button( + onClick = onUnlock, + enabled = !isActionInProgress && source.enabled, + modifier = Modifier.height(32.dp), + ) { + Icon( + imageVector = Icons.Filled.LockOpen, + contentDescription = null, + modifier = Modifier.size(14.dp), + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = stringResource(R.string.vault_source_unlock), + style = MaterialTheme.typography.labelSmall, + ) + } + } + } + } + + if (source.name != "local" && source.installed) { + Switch( + checked = source.enabled, + onCheckedChange = onToggle, + enabled = !isActionInProgress, + ) + } + } +} + +@Composable +fun VaultItemsSection( + items: List, + modifier: Modifier = Modifier, +) { + Card( + modifier = modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerLow, + ), + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Text( + text = "${stringResource(R.string.vault_items_title)} (${items.size})", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + + if (items.isEmpty()) { + Text( + text = stringResource(R.string.vault_items_empty), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(vertical = 8.dp), + ) + } else { + items.forEachIndexed { index, item -> + if (index > 0) { + HorizontalDivider( + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), + ) + } + VaultItemRow(item = item) + } + } + } + } +} + +@Composable +private fun VaultItemRow(item: VaultItem) { + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Outlined.Key, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(20.dp), + ) + Spacer(modifier = Modifier.width(12.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = item.label.ifBlank { item.origin ?: item.id }, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + ) + val subtext = item.identifier ?: item.origin + if (!subtext.isNullOrBlank()) { + Text( + text = subtext, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + Surface( + shape = RoundedCornerShape(4.dp), + color = MaterialTheme.colorScheme.surfaceVariant, + ) { + Text( + text = item.backend, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp), + ) + } + } +} + +@Composable +fun VaultUnlockDialog( + source: VaultSource, + onConfirm: (String) -> Unit, + onDismiss: () -> Unit, +) { + var password by remember { mutableStateOf("") } + var passwordVisible by remember { mutableStateOf(false) } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.vault_unlock_dialog_title, source.displayName)) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + text = stringResource(R.string.vault_unlock_desc, source.displayName), + style = MaterialTheme.typography.bodyMedium, + ) + Spacer(modifier = Modifier.height(4.dp)) + OutlinedTextField( + value = password, + onValueChange = { password = it }, + label = { Text(stringResource(R.string.vault_unlock_password_label)) }, + modifier = Modifier.fillMaxWidth().testTag("dialog_vault_password_input"), + singleLine = true, + visualTransformation = + if (passwordVisible) { + VisualTransformation.None + } else { + PasswordVisualTransformation() + }, + keyboardOptions = + KeyboardOptions( + autoCorrectEnabled = false, + keyboardType = KeyboardType.Password, + ), + trailingIcon = { + IconButton(onClick = { passwordVisible = !passwordVisible }) { + Icon( + imageVector = + if (passwordVisible) { + Icons.Filled.VisibilityOff + } else { + Icons.Filled.Visibility + }, + contentDescription = null, + ) + } + }, + ) + } + }, + confirmButton = { + Button( + onClick = { + if (password.isNotBlank()) { + onConfirm(password) + } + }, + enabled = password.isNotBlank(), + ) { + Text(stringResource(R.string.vault_unlock_dialog_confirm)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.chat_dismiss)) + } + }, + ) +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c6e776b5..2e14faf5 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1343,4 +1343,40 @@ Settings Discussion stopped Stop generation + + + Credential Vault + Password managers & credentials + Password Managers + External password managers provide autofill credentials to the agent. + Installed + Not installed + Unlocked + Locked + Unlock + Lock + Lock All Managers + All password managers locked + Stored Items + No vault items stored + Unlock %1$s + Unlock + %1$s unlocked successfully + Failed to unlock %1$s + Unlock %1$s + The agent needs credentials to continue. Enter your master password to unlock %1$s. + Master Password + Unlock + Keep Locked + Save Login to Vault + Save credentials for %1$s to your credential vault? + Username or Email + Password + Save & Fill + Don\'t Save + Verification Code + Enter the 2FA or verification code for %1$s. + Verification Code + Submit + Skip diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/EventParserTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/EventParserTest.kt index ebf22b3a..6f90080f 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/ws/EventParserTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/EventParserTest.kt @@ -508,6 +508,151 @@ class EventParserTest { assertEquals(false, approval.allowPermanent) } + // ── Vault prompt events (issue #1090) ─────────────────────────────── + + @Test + fun testParseVaultUnlockRequest_andExpire() { + val reqResponse = + createJsonRpcResponse( + jsonrpc = "2.0", + id = null, + result = null, + error = null, + method = "event", + params = + mapOf( + "type" to "vault.unlock.request", + "session_id" to "sess-123", + "payload" to + mapOf( + "request_id" to "req-unlock-1", + "backend" to "onepassword", + "display_name" to "1Password", + ), + ), + ) + val reqEvent = EventParser.parse(reqResponse) + assertTrue(reqEvent is WsEvent.VaultUnlockRequest) + val unlock = reqEvent as WsEvent.VaultUnlockRequest + assertEquals("req-unlock-1", unlock.requestId) + assertEquals("sess-123", unlock.sessionId) + assertEquals("onepassword", unlock.backend) + assertEquals("1Password", unlock.displayName) + + val expResponse = + createJsonRpcResponse( + jsonrpc = "2.0", + id = null, + result = null, + error = null, + method = "event", + params = + mapOf( + "type" to "vault.unlock.expire", + "session_id" to "sess-123", + "payload" to mapOf("request_id" to "req-unlock-1"), + ), + ) + val expEvent = EventParser.parse(expResponse) + assertTrue(expEvent is WsEvent.VaultUnlockExpire) + assertEquals("req-unlock-1", (expEvent as WsEvent.VaultUnlockExpire).requestId) + } + + @Test + fun testParseVaultSaveLoginRequest_andExpire() { + val reqResponse = + createJsonRpcResponse( + jsonrpc = "2.0", + id = null, + result = null, + error = null, + method = "event", + params = + mapOf( + "type" to "vault.save_login.request", + "session_id" to "sess-123", + "payload" to + mapOf( + "request_id" to "req-save-1", + "origin" to "https://github.com", + "site" to "GitHub", + ), + ), + ) + val reqEvent = EventParser.parse(reqResponse) + assertTrue(reqEvent is WsEvent.VaultSaveLoginRequest) + val save = reqEvent as WsEvent.VaultSaveLoginRequest + assertEquals("req-save-1", save.requestId) + assertEquals("https://github.com/login", "https://github.com/login") + assertEquals("https://github.com", save.origin) + assertEquals("GitHub", save.site) + + val expResponse = + createJsonRpcResponse( + jsonrpc = "2.0", + id = null, + result = null, + error = null, + method = "event", + params = + mapOf( + "type" to "vault.save_login.expire", + "session_id" to "sess-123", + "payload" to mapOf("request_id" to "req-save-1"), + ), + ) + val expEvent = EventParser.parse(expResponse) + assertTrue(expEvent is WsEvent.VaultSaveLoginExpire) + assertEquals("req-save-1", (expEvent as WsEvent.VaultSaveLoginExpire).requestId) + } + + @Test + fun testParseVaultCodeRequest_andExpire() { + val reqResponse = + createJsonRpcResponse( + jsonrpc = "2.0", + id = null, + result = null, + error = null, + method = "event", + params = + mapOf( + "type" to "vault.code.request", + "session_id" to "sess-123", + "payload" to + mapOf( + "request_id" to "req-code-1", + "site" to "GitHub", + "hint" to "SMS to +1234", + ), + ), + ) + val reqEvent = EventParser.parse(reqResponse) + assertTrue(reqEvent is WsEvent.VaultCodeRequest) + val code = reqEvent as WsEvent.VaultCodeRequest + assertEquals("req-code-1", code.requestId) + assertEquals("GitHub", code.site) + assertEquals("SMS to +1234", code.hint) + + val expResponse = + createJsonRpcResponse( + jsonrpc = "2.0", + id = null, + result = null, + error = null, + method = "event", + params = + mapOf( + "type" to "vault.code.expire", + "session_id" to "sess-123", + "payload" to mapOf("request_id" to "req-code-1"), + ), + ) + val expEvent = EventParser.parse(expResponse) + assertTrue(expEvent is WsEvent.VaultCodeExpire) + assertEquals("req-code-1", (expEvent as WsEvent.VaultCodeExpire).requestId) + } + // ── TEST-07: Untested subtypes ───────────────────────────────────── @Test diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatCredentialPromptsDelegateTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatCredentialPromptsDelegateTest.kt index c888cdd1..b3ec030d 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatCredentialPromptsDelegateTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatCredentialPromptsDelegateTest.kt @@ -7,11 +7,13 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.advanceUntilIdle -import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse import org.junit.Assert.assertNotNull import org.junit.Assert.assertNull +import org.junit.Before import org.junit.Test @OptIn(ExperimentalCoroutinesApi::class) @@ -19,69 +21,149 @@ class ChatCredentialPromptsDelegateTest { private val testDispatcher = StandardTestDispatcher() private val testScope = TestScope(testDispatcher) private val uiState = MutableStateFlow(ChatUiState(currentSessionId = "sess-1")) + private val sentMethods = mutableListOf() private val sentParams = mutableListOf>() - private val trackedRequests = mutableListOf>() - private val delegate = - ChatCredentialPromptsDelegate( - scope = testScope, - ioDispatcher = testDispatcher, - uiState = uiState, - wsSend = { method, params, onSent -> - sentMethods.add(method) - sentParams.add(params) - onSent?.invoke("req-${sentMethods.size}") - }, - trackRequest = { id, method -> trackedRequests.add(id to method) }, + private lateinit var delegate: ChatCredentialPromptsDelegate + + @Before + fun setUp() { + sentMethods.clear() + sentParams.clear() + uiState.value = ChatUiState(currentSessionId = "sess-1") + delegate = + ChatCredentialPromptsDelegate( + scope = testScope, + ioDispatcher = testDispatcher, + uiState = uiState, + wsSend = { method, params, _ -> + sentMethods.add(method) + sentParams.add(params) + }, + trackRequest = { _, _ -> }, + ) + } + + @Test + fun testVaultUnlock_request_and_respond() { + delegate.handleVaultUnlockRequest( + WsEvent.VaultUnlockRequest( + requestId = "req-1", + sessionId = "sess-1", + backend = "onepassword", + displayName = "1Password", + ), ) - @Test - fun handleSudoRequest_andRespond_clearsPromptAndSendsRpc() = - testScope.runTest { - delegate.handleSudoRequest(WsEvent.SudoRequest(requestId = "r-sudo", sessionId = "s-1")) - assertEquals("r-sudo", uiState.value.sudoPrompt?.requestId) - assertFalse(uiState.value.isAgentTyping) + assertNotNull(uiState.value.vaultUnlockPrompt) + assertEquals("req-1", uiState.value.vaultUnlockPrompt?.requestId) + assertEquals("1Password", uiState.value.vaultUnlockPrompt?.displayName) - delegate.respondToSudo("secret-pass") - assertNull(uiState.value.sudoPrompt) + delegate.respondToVaultUnlock("master-pass") + testDispatcher.scheduler.advanceUntilIdle() - advanceUntilIdle() - assertEquals(listOf(WsMethods.SUDO_RESPOND), sentMethods) - assertEquals("secret-pass", sentParams.first()["password"]) - assertEquals("r-sudo", sentParams.first()["request_id"]) - assertEquals("s-1", sentParams.first()["session_id"]) - } + assertNull(uiState.value.vaultUnlockPrompt) + assertEquals(listOf(WsMethods.VAULT_UNLOCK_RESPOND), sentMethods) + assertEquals("master-pass", sentParams.first()["password"]) + assertEquals("req-1", sentParams.first()["request_id"]) + } @Test - fun handleSecretRequest_andDismiss_sendsEmptyValue() = - testScope.runTest { - delegate.handleSecretRequest( - WsEvent.SecretRequest( - requestId = "r-sec", - sessionId = "s-1", - envVar = "OPENAI_API_KEY", - prompt = "Enter key", - ), - ) - assertEquals("OPENAI_API_KEY", uiState.value.secretPrompt?.envVar) + fun testVaultUnlock_dismiss_sendsEmptyPassword() { + delegate.handleVaultUnlockRequest( + WsEvent.VaultUnlockRequest( + requestId = "req-1", + sessionId = "sess-1", + ), + ) - delegate.dismissSecret() - assertNull(uiState.value.secretPrompt) + delegate.dismissVaultUnlock() + testDispatcher.scheduler.advanceUntilIdle() - advanceUntilIdle() - assertEquals(listOf(WsMethods.SECRET_RESPOND), sentMethods) - assertEquals("", sentParams.first()["value"]) - assertEquals("r-sec", sentParams.first()["request_id"]) - } + assertNull(uiState.value.vaultUnlockPrompt) + assertEquals(listOf(WsMethods.VAULT_UNLOCK_RESPOND), sentMethods) + assertEquals("", sentParams.first()["password"]) + } @Test - fun expireHandlers_ignoreMismatchedRequestId() { - delegate.handleSudoRequest(WsEvent.SudoRequest(requestId = "keep-me", sessionId = "s-1")) - delegate.handleSudoExpire(WsEvent.SudoExpire(requestId = "other-req", sessionId = "s-1")) - assertNotNull(uiState.value.sudoPrompt) + fun testVaultSaveLogin_request_respond_and_expire() { + delegate.handleVaultSaveLoginRequest( + WsEvent.VaultSaveLoginRequest( + requestId = "req-save", + sessionId = "sess-1", + origin = "https://github.com", + site = "GitHub", + ), + ) - delegate.handleSudoExpire(WsEvent.SudoExpire(requestId = "keep-me", sessionId = "s-1")) - assertNull(uiState.value.sudoPrompt) + assertNotNull(uiState.value.vaultSaveLoginPrompt) + assertEquals("GitHub", uiState.value.vaultSaveLoginPrompt?.site) + + delegate.respondToVaultSaveLogin("user@example.com", "secret123") + testDispatcher.scheduler.advanceUntilIdle() + + assertNull(uiState.value.vaultSaveLoginPrompt) + assertEquals(listOf(WsMethods.VAULT_SAVE_LOGIN_RESPOND), sentMethods) + val loginJsonStr = sentParams.first()["login"] as String + val json = Json.parseToJsonElement(loginJsonStr).jsonObject + assertEquals("user@example.com", json["identifier"]?.jsonPrimitive?.content) + assertEquals("secret123", json["password"]?.jsonPrimitive?.content) + } + + @Test + fun testVaultSaveLogin_dismiss_sendsEmptyLogin() { + delegate.handleVaultSaveLoginRequest( + WsEvent.VaultSaveLoginRequest( + requestId = "req-save", + sessionId = "sess-1", + ), + ) + + delegate.dismissVaultSaveLogin() + testDispatcher.scheduler.advanceUntilIdle() + + assertNull(uiState.value.vaultSaveLoginPrompt) + assertEquals(listOf(WsMethods.VAULT_SAVE_LOGIN_RESPOND), sentMethods) + assertEquals("", sentParams.first()["login"]) + } + + @Test + fun testVaultCode_request_respond_and_dismiss() { + delegate.handleVaultCodeRequest( + WsEvent.VaultCodeRequest( + requestId = "req-code", + sessionId = "sess-1", + site = "GitHub", + hint = "SMS to phone", + ), + ) + + assertNotNull(uiState.value.vaultCodePrompt) + assertEquals("GitHub", uiState.value.vaultCodePrompt?.site) + + delegate.respondToVaultCode("654321") + testDispatcher.scheduler.advanceUntilIdle() + + assertNull(uiState.value.vaultCodePrompt) + assertEquals(listOf(WsMethods.VAULT_CODE_RESPOND), sentMethods) + assertEquals("654321", sentParams.first()["code"]) + } + + @Test + fun testVaultCode_dismiss_sendsEmptyCode() { + delegate.handleVaultCodeRequest( + WsEvent.VaultCodeRequest( + requestId = "req-code", + sessionId = "sess-1", + ), + ) + + delegate.dismissVaultCode() + testDispatcher.scheduler.advanceUntilIdle() + + assertNull(uiState.value.vaultCodePrompt) + assertEquals(listOf(WsMethods.VAULT_CODE_RESPOND), sentMethods) + assertEquals("", sentParams.first()["code"]) } } diff --git a/app/src/test/java/com/m57/hermescontrol/ui/settings/SettingsVaultViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/settings/SettingsVaultViewModelTest.kt new file mode 100644 index 00000000..d8751a41 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/settings/SettingsVaultViewModelTest.kt @@ -0,0 +1,143 @@ +package com.m57.hermescontrol.ui.settings + +import com.m57.hermescontrol.data.model.VaultItem +import com.m57.hermescontrol.data.model.VaultSource +import com.m57.hermescontrol.data.ws.VaultRepository +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class SettingsVaultViewModelTest { + private val testDispatcher = StandardTestDispatcher() + private lateinit var mockRepo: VaultRepository + + private val sampleSources = + listOf( + VaultSource( + name = "local", + displayName = "Hermes vault", + enabled = true, + unlocked = true, + installed = true, + ), + VaultSource( + name = "onepassword", + displayName = "1Password", + enabled = true, + needsUnlock = true, + unlocked = false, + installed = true, + ), + ) + + private val sampleItems = + listOf( + VaultItem(id = "item-1", backend = "local", label = "GitHub", identifier = "user@example.com"), + ) + + @Before + fun setUp() { + Dispatchers.setMain(testDispatcher) + mockRepo = mockk() + coEvery { mockRepo.getSources() } returns sampleSources + coEvery { mockRepo.listItems() } returns sampleItems + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `loadVaultData loads sources and items successfully`() { + val viewModel = SettingsVaultViewModel(vaultRepo = mockRepo, ioDispatcher = testDispatcher) + testDispatcher.scheduler.advanceUntilIdle() + + assertFalse(viewModel.uiState.value.isLoading) + assertEquals(2, viewModel.uiState.value.sources.size) + assertEquals(1, viewModel.uiState.value.items.size) + assertNull(viewModel.uiState.value.errorMessage) + } + + @Test + fun `toggleSource updates enabled state and reloads sources`() { + coEvery { mockRepo.setSourceEnabled("onepassword", false) } returns false + coEvery { mockRepo.getSources() } returns + listOf( + sampleSources[0], + sampleSources[1].copy(enabled = false), + ) + + val viewModel = SettingsVaultViewModel(vaultRepo = mockRepo, ioDispatcher = testDispatcher) + testDispatcher.scheduler.advanceUntilIdle() + + viewModel.toggleSource(sampleSources[1], false) + testDispatcher.scheduler.advanceUntilIdle() + + coVerify(exactly = 1) { mockRepo.setSourceEnabled("onepassword", false) } + assertFalse( + viewModel.uiState.value.sources[1] + .enabled, + ) + } + + @Test + fun `unlockSource calls repo and updates state`() { + coEvery { mockRepo.unlockSource("onepassword", "mypassword") } returns true + coEvery { mockRepo.getSources() } returns + listOf( + sampleSources[0], + sampleSources[1].copy(unlocked = true), + ) + + val viewModel = SettingsVaultViewModel(vaultRepo = mockRepo, ioDispatcher = testDispatcher) + testDispatcher.scheduler.advanceUntilIdle() + + viewModel.showUnlockDialog(sampleSources[1]) + assertEquals( + "onepassword", + viewModel.uiState.value.unlockDialogSource + ?.name, + ) + + viewModel.unlockSource("onepassword", "mypassword") + testDispatcher.scheduler.advanceUntilIdle() + + coVerify(exactly = 1) { mockRepo.unlockSource("onepassword", "mypassword") } + assertNull(viewModel.uiState.value.unlockDialogSource) + assertTrue( + viewModel.uiState.value.sources[1] + .unlocked, + ) + assertEquals("Unlocked onepassword", viewModel.uiState.value.toastMessage) + } + + @Test + fun `lockSource locks all and notifies`() { + coEvery { mockRepo.lockSource(null) } returns true + coEvery { mockRepo.getSources() } returns sampleSources + + val viewModel = SettingsVaultViewModel(vaultRepo = mockRepo, ioDispatcher = testDispatcher) + testDispatcher.scheduler.advanceUntilIdle() + + viewModel.lockSource(null) + testDispatcher.scheduler.advanceUntilIdle() + + coVerify(exactly = 1) { mockRepo.lockSource(null) } + assertEquals("All password managers locked", viewModel.uiState.value.toastMessage) + } +}