This commit is contained in:
23 files changed
+2216
-45
No files matched your search
+120
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -62,6 +62,7 @@ import com.m57.hermescontrol.ui.settings.SettingsBehaviorPage
|
|||||||
import com.m57.hermescontrol.ui.settings.SettingsChatPage
|
import com.m57.hermescontrol.ui.settings.SettingsChatPage
|
||||||
import com.m57.hermescontrol.ui.settings.SettingsConnectionPage
|
import com.m57.hermescontrol.ui.settings.SettingsConnectionPage
|
||||||
import com.m57.hermescontrol.ui.settings.SettingsLanguagePage
|
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.settings.SettingsViewModel
|
||||||
import com.m57.hermescontrol.ui.toolsets.ToolsetDetailScreen
|
import com.m57.hermescontrol.ui.toolsets.ToolsetDetailScreen
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -137,6 +138,11 @@ private fun appEntryProvider(
|
|||||||
viewModel = viewModel { SettingsViewModel() },
|
viewModel = viewModel { SettingsViewModel() },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
entry<SettingsVault> {
|
||||||
|
SettingsVaultPage(
|
||||||
|
onBack = { NavigationController.goBack() },
|
||||||
|
)
|
||||||
|
}
|
||||||
entry<SettingsAbout> {
|
entry<SettingsAbout> {
|
||||||
SettingsAboutPage(
|
SettingsAboutPage(
|
||||||
onBack = { NavigationController.goBack() },
|
onBack = { NavigationController.goBack() },
|
||||||
|
|||||||
@@ -87,4 +87,6 @@ import kotlinx.serialization.Serializable
|
|||||||
|
|
||||||
@Serializable data object SettingsBehavior : NavKey
|
@Serializable data object SettingsBehavior : NavKey
|
||||||
|
|
||||||
|
@Serializable data object SettingsVault : NavKey
|
||||||
|
|
||||||
@Serializable data object SettingsAbout : NavKey
|
@Serializable data object SettingsAbout : NavKey
|
||||||
@@ -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<VaultSource> = 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<VaultItem> = 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,
|
||||||
|
)
|
||||||
@@ -341,6 +341,43 @@ object EventParser {
|
|||||||
WsEvent.SecretExpire(requestId, sessionId)
|
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 -> {
|
else -> {
|
||||||
Log.w(TAG, "Unknown event type: $eventType")
|
Log.w(TAG, "Unknown event type: $eventType")
|
||||||
WsEvent.Unknown(rawJson)
|
WsEvent.Unknown(rawJson)
|
||||||
|
|||||||
@@ -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 <reified T> 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<T>(), element)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
private fun anyToJsonElement(value: Any?): JsonElement =
|
||||||
|
when (value) {
|
||||||
|
null -> JsonNull
|
||||||
|
is JsonElement -> value
|
||||||
|
is Map<*, *> -> JsonObject((value as Map<String, Any?>).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<VaultSource> {
|
||||||
|
val result = HermesWsClient.request(WsMethods.VAULT_SOURCES).await()
|
||||||
|
return decode<VaultSourcesResponse>(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<VaultSourceSetResponse>(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<VaultUnlockResponse>(result)?.unlocked == true
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun lockSource(name: String? = null): Boolean {
|
||||||
|
val params = if (name != null) mapOf("name" to name) else emptyMap<String, Any>()
|
||||||
|
val result = HermesWsClient.request(WsMethods.VAULT_LOCK, params).await()
|
||||||
|
return decode<VaultLockResponse>(result)?.locked == true
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun listItems(): List<VaultItem> {
|
||||||
|
val result = HermesWsClient.request(WsMethods.VAULT_LIST).await()
|
||||||
|
return decode<VaultListResponse>(result)?.items ?: emptyList()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -303,6 +303,44 @@ sealed class WsEvent {
|
|||||||
val sessionId: String?,
|
val sessionId: String?,
|
||||||
) : WsEvent()
|
) : 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 ───────────────────────────────────────────
|
// ── Gateway-level errors ───────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -43,6 +43,16 @@ object WsMethods {
|
|||||||
const val SUDO_RESPOND = "sudo.respond"
|
const val SUDO_RESPOND = "sudo.respond"
|
||||||
const val SECRET_RESPOND = "secret.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) ──────────────────────────────────
|
// ── Heartbeat & Latency (issue #1017) ──────────────────────────────────
|
||||||
|
|
||||||
/** Ultra-lightweight liveness check and round-trip latency measurement. */
|
/** Ultra-lightweight liveness check and round-trip latency measurement. */
|
||||||
|
|||||||
@@ -140,6 +140,13 @@ class ChatNotificationService : Service() {
|
|||||||
showReplyNotification(preview.take(100), null)
|
showReplyNotification(preview.take(100), null)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
is WsEvent.VaultUnlockRequest,
|
||||||
|
is WsEvent.VaultSaveLoginRequest,
|
||||||
|
is WsEvent.VaultCodeRequest,
|
||||||
|
-> {
|
||||||
|
showReplyNotification(getString(R.string.notif_input_needed), null)
|
||||||
|
}
|
||||||
|
|
||||||
else -> {}
|
else -> {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import kotlinx.coroutines.CoroutineScope
|
|||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
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.
|
* Owns sudo and secret prompt state, timeouts, dismissal, and response RPCs.
|
||||||
@@ -177,4 +179,234 @@ class ChatCredentialPromptsDelegate(
|
|||||||
) { id -> trackRequest(id, WsMethods.SECRET_RESPOND) }
|
) { 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<String, Any>(
|
||||||
|
"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<String, Any>(
|
||||||
|
"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<String, Any>(
|
||||||
|
"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<String, Any>(
|
||||||
|
"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<String, Any>(
|
||||||
|
"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<String, Any>(
|
||||||
|
"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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -3,7 +3,6 @@ package com.m57.hermescontrol.ui.chat
|
|||||||
import android.app.Activity
|
import android.app.Activity
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.util.Log
|
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
@@ -549,6 +548,15 @@ fun ChatScreen(
|
|||||||
onRespondClarify = viewModel::respondToClarify,
|
onRespondClarify = viewModel::respondToClarify,
|
||||||
onRespondClarifyBatch = viewModel::respondToClarifyBatch,
|
onRespondClarifyBatch = viewModel::respondToClarifyBatch,
|
||||||
onDismissClarify = viewModel::dismissClarify,
|
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,
|
onSaveAttachment = onSaveAttachment,
|
||||||
savingAttachmentPath = pendingSavePath ?: state.savingAttachmentPath,
|
savingAttachmentPath = pendingSavePath ?: state.savingAttachmentPath,
|
||||||
openingAttachmentPath = state.openingAttachmentPath,
|
openingAttachmentPath = state.openingAttachmentPath,
|
||||||
|
|||||||
@@ -98,6 +98,10 @@ data class ChatUiState(
|
|||||||
// Sudo / secret prompts — surfaced as dialogs (issue #524)
|
// Sudo / secret prompts — surfaced as dialogs (issue #524)
|
||||||
val sudoPrompt: SudoPromptUi? = null,
|
val sudoPrompt: SudoPromptUi? = null,
|
||||||
val secretPrompt: SecretPromptUi? = 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,
|
val showSessionPicker: Boolean = false,
|
||||||
// /update confirm dialog (issue #862) — the command is handled client-side
|
// /update confirm dialog (issue #862) — the command is handled client-side
|
||||||
val updateConfirmOpen: Boolean = false,
|
val updateConfirmOpen: Boolean = false,
|
||||||
@@ -251,6 +255,30 @@ data class SecretPromptUi(
|
|||||||
val prompt: String? = null,
|
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
|
* Token breakdown backing the context meter's detail sheet. All values are
|
||||||
* cumulative lifetime token counts sourced from `GET /api/sessions/{id}`
|
* cumulative lifetime token counts sourced from `GET /api/sessions/{id}`
|
||||||
@@ -882,6 +910,30 @@ class ChatViewModel(
|
|||||||
credentialPromptsDelegate.handleSecretExpire(event)
|
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 -> {
|
is WsEvent.GatewayError -> {
|
||||||
// Reducer already set errorMessage; no extra VM work needed.
|
// Reducer already set errorMessage; no extra VM work needed.
|
||||||
}
|
}
|
||||||
@@ -3251,6 +3303,23 @@ class ChatViewModel(
|
|||||||
|
|
||||||
fun respondToSecret(value: String) = credentialPromptsDelegate.respondToSecret(value)
|
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() {
|
fun reconnect() {
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
|
|||||||
@@ -75,6 +75,12 @@ object ChatWsEventReducer {
|
|||||||
is WsEvent.BtwComplete -> event.sessionId
|
is WsEvent.BtwComplete -> event.sessionId
|
||||||
is WsEvent.SessionUsage -> event.sessionId
|
is WsEvent.SessionUsage -> event.sessionId
|
||||||
is WsEvent.TranscriptResyncRequired -> 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
|
else -> null
|
||||||
}
|
}
|
||||||
if (eventSessionId != null && currentSessionId != null && eventSessionId != currentSessionId) {
|
if (eventSessionId != null && currentSessionId != null && eventSessionId != currentSessionId) {
|
||||||
@@ -170,6 +176,15 @@ object ChatWsEventReducer {
|
|||||||
|
|
||||||
is WsEvent.SecretExpire -> ReducerResult(state = state, streamingState = streamingState)
|
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
|
// ReactionEvent is handled by the ViewModel — purely cosmetic animation
|
||||||
is WsEvent.ReactionEvent -> ReducerResult(state = state, streamingState = streamingState)
|
is WsEvent.ReactionEvent -> ReducerResult(state = state, streamingState = streamingState)
|
||||||
|
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,9 +28,15 @@ import com.m57.hermescontrol.ui.chat.ChatViewModel
|
|||||||
import com.m57.hermescontrol.ui.chat.ClarifyUi
|
import com.m57.hermescontrol.ui.chat.ClarifyUi
|
||||||
import com.m57.hermescontrol.ui.chat.ImageViewerModel
|
import com.m57.hermescontrol.ui.chat.ImageViewerModel
|
||||||
import com.m57.hermescontrol.ui.chat.ToolCallDivider
|
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.ChatScrollController
|
||||||
import com.m57.hermescontrol.ui.chat.components.ClarifyBubble
|
import com.m57.hermescontrol.ui.chat.components.ClarifyBubble
|
||||||
import com.m57.hermescontrol.ui.chat.components.ReasoningCard
|
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.chat.toolCallMilestones
|
||||||
import com.m57.hermescontrol.ui.common.EmptyState
|
import com.m57.hermescontrol.ui.common.EmptyState
|
||||||
|
|
||||||
@@ -69,6 +75,15 @@ fun FullBleedChatList(
|
|||||||
onRespondClarify: ((String) -> Unit)? = null,
|
onRespondClarify: ((String) -> Unit)? = null,
|
||||||
onRespondClarifyBatch: ((Map<String, String>) -> Unit)? = null,
|
onRespondClarifyBatch: ((Map<String, String>) -> Unit)? = null,
|
||||||
onDismissClarify: (() -> 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 = {},
|
onSaveAttachment: (com.m57.hermescontrol.data.model.Attachment) -> Unit = {},
|
||||||
savingAttachmentPath: String? = null,
|
savingAttachmentPath: String? = null,
|
||||||
openingAttachmentPath: 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() },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.ChatBubbleOutline
|
||||||
import androidx.compose.material.icons.filled.Info
|
import androidx.compose.material.icons.filled.Info
|
||||||
import androidx.compose.material.icons.filled.Language
|
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.Palette
|
||||||
import androidx.compose.material.icons.filled.Tune
|
import androidx.compose.material.icons.filled.Tune
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
@@ -41,6 +42,7 @@ import com.m57.hermescontrol.SettingsBehavior
|
|||||||
import com.m57.hermescontrol.SettingsChat
|
import com.m57.hermescontrol.SettingsChat
|
||||||
import com.m57.hermescontrol.SettingsConnection
|
import com.m57.hermescontrol.SettingsConnection
|
||||||
import com.m57.hermescontrol.SettingsLanguage
|
import com.m57.hermescontrol.SettingsLanguage
|
||||||
|
import com.m57.hermescontrol.SettingsVault
|
||||||
import com.m57.hermescontrol.theme.ThemePreference
|
import com.m57.hermescontrol.theme.ThemePreference
|
||||||
import com.m57.hermescontrol.ui.common.HermesScaffold
|
import com.m57.hermescontrol.ui.common.HermesScaffold
|
||||||
import com.m57.hermescontrol.ui.common.NavIcon
|
import com.m57.hermescontrol.ui.common.NavIcon
|
||||||
@@ -118,6 +120,12 @@ fun SettingsScreen(
|
|||||||
},
|
},
|
||||||
onClick = { NavigationController.navigateTo(SettingsBehavior) },
|
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(
|
SettingsRow(
|
||||||
icon = Icons.Filled.Info,
|
icon = Icons.Filled.Info,
|
||||||
label = stringResource(R.string.settings_sec_about),
|
label = stringResource(R.string.settings_sec_about),
|
||||||
|
|||||||
@@ -15,8 +15,11 @@ import androidx.compose.foundation.verticalScroll
|
|||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.material3.ButtonDefaults
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.SnackbarHost
|
||||||
|
import androidx.compose.material3.SnackbarHostState
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
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.LanguageSection
|
||||||
import com.m57.hermescontrol.ui.settings.components.TestConnectionButton
|
import com.m57.hermescontrol.ui.settings.components.TestConnectionButton
|
||||||
import com.m57.hermescontrol.ui.settings.components.TestResultCard
|
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
|
* 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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<VaultSource> = emptyList(),
|
||||||
|
val items: List<VaultItem> = 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<SettingsVaultUiState> = _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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<VaultSource>,
|
||||||
|
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<VaultItem>,
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1343,4 +1343,40 @@
|
|||||||
<string name="group_chat_action_settings">Settings</string>
|
<string name="group_chat_action_settings">Settings</string>
|
||||||
<string name="group_chat_stopped_by_user">Discussion stopped</string>
|
<string name="group_chat_stopped_by_user">Discussion stopped</string>
|
||||||
<string name="group_chat_action_stop">Stop generation</string>
|
<string name="group_chat_action_stop">Stop generation</string>
|
||||||
|
|
||||||
|
<!-- Credential Vault (issue #1090) -->
|
||||||
|
<string name="settings_sec_vault">Credential Vault</string>
|
||||||
|
<string name="settings_summary_vault">Password managers & credentials</string>
|
||||||
|
<string name="vault_sources_title">Password Managers</string>
|
||||||
|
<string name="vault_sources_desc">External password managers provide autofill credentials to the agent.</string>
|
||||||
|
<string name="vault_source_installed">Installed</string>
|
||||||
|
<string name="vault_source_not_installed">Not installed</string>
|
||||||
|
<string name="vault_source_unlocked">Unlocked</string>
|
||||||
|
<string name="vault_source_locked">Locked</string>
|
||||||
|
<string name="vault_source_unlock">Unlock</string>
|
||||||
|
<string name="vault_source_lock">Lock</string>
|
||||||
|
<string name="vault_lock_all">Lock All Managers</string>
|
||||||
|
<string name="vault_lock_all_success">All password managers locked</string>
|
||||||
|
<string name="vault_items_title">Stored Items</string>
|
||||||
|
<string name="vault_items_empty">No vault items stored</string>
|
||||||
|
<string name="vault_unlock_dialog_title">Unlock %1$s</string>
|
||||||
|
<string name="vault_unlock_dialog_confirm">Unlock</string>
|
||||||
|
<string name="vault_unlock_success">%1$s unlocked successfully</string>
|
||||||
|
<string name="vault_unlock_failed">Failed to unlock %1$s</string>
|
||||||
|
<string name="vault_unlock_title">Unlock %1$s</string>
|
||||||
|
<string name="vault_unlock_desc">The agent needs credentials to continue. Enter your master password to unlock %1$s.</string>
|
||||||
|
<string name="vault_unlock_password_label">Master Password</string>
|
||||||
|
<string name="vault_unlock_action">Unlock</string>
|
||||||
|
<string name="vault_unlock_dismiss">Keep Locked</string>
|
||||||
|
<string name="vault_save_login_title">Save Login to Vault</string>
|
||||||
|
<string name="vault_save_login_desc">Save credentials for %1$s to your credential vault?</string>
|
||||||
|
<string name="vault_save_login_identifier">Username or Email</string>
|
||||||
|
<string name="vault_save_login_password">Password</string>
|
||||||
|
<string name="vault_save_login_action">Save & Fill</string>
|
||||||
|
<string name="vault_save_login_dismiss">Don\'t Save</string>
|
||||||
|
<string name="vault_code_title">Verification Code</string>
|
||||||
|
<string name="vault_code_desc">Enter the 2FA or verification code for %1$s.</string>
|
||||||
|
<string name="vault_code_label">Verification Code</string>
|
||||||
|
<string name="vault_code_action">Submit</string>
|
||||||
|
<string name="vault_code_dismiss">Skip</string>
|
||||||
</resources>
|
</resources>
|
||||||
@@ -508,6 +508,151 @@ class EventParserTest {
|
|||||||
assertEquals(false, approval.allowPermanent)
|
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-07: Untested subtypes ─────────────────────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
+126
-44
@@ -7,11 +7,13 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
|||||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||||
import kotlinx.coroutines.test.TestScope
|
import kotlinx.coroutines.test.TestScope
|
||||||
import kotlinx.coroutines.test.advanceUntilIdle
|
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.assertEquals
|
||||||
import org.junit.Assert.assertFalse
|
|
||||||
import org.junit.Assert.assertNotNull
|
import org.junit.Assert.assertNotNull
|
||||||
import org.junit.Assert.assertNull
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Before
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
@@ -19,69 +21,149 @@ class ChatCredentialPromptsDelegateTest {
|
|||||||
private val testDispatcher = StandardTestDispatcher()
|
private val testDispatcher = StandardTestDispatcher()
|
||||||
private val testScope = TestScope(testDispatcher)
|
private val testScope = TestScope(testDispatcher)
|
||||||
private val uiState = MutableStateFlow(ChatUiState(currentSessionId = "sess-1"))
|
private val uiState = MutableStateFlow(ChatUiState(currentSessionId = "sess-1"))
|
||||||
|
|
||||||
private val sentMethods = mutableListOf<String>()
|
private val sentMethods = mutableListOf<String>()
|
||||||
private val sentParams = mutableListOf<Map<String, Any>>()
|
private val sentParams = mutableListOf<Map<String, Any>>()
|
||||||
private val trackedRequests = mutableListOf<Pair<String, String>>()
|
|
||||||
|
|
||||||
private val delegate =
|
private lateinit var delegate: ChatCredentialPromptsDelegate
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setUp() {
|
||||||
|
sentMethods.clear()
|
||||||
|
sentParams.clear()
|
||||||
|
uiState.value = ChatUiState(currentSessionId = "sess-1")
|
||||||
|
delegate =
|
||||||
ChatCredentialPromptsDelegate(
|
ChatCredentialPromptsDelegate(
|
||||||
scope = testScope,
|
scope = testScope,
|
||||||
ioDispatcher = testDispatcher,
|
ioDispatcher = testDispatcher,
|
||||||
uiState = uiState,
|
uiState = uiState,
|
||||||
wsSend = { method, params, onSent ->
|
wsSend = { method, params, _ ->
|
||||||
sentMethods.add(method)
|
sentMethods.add(method)
|
||||||
sentParams.add(params)
|
sentParams.add(params)
|
||||||
onSent?.invoke("req-${sentMethods.size}")
|
|
||||||
},
|
},
|
||||||
trackRequest = { id, method -> trackedRequests.add(id to method) },
|
trackRequest = { _, _ -> },
|
||||||
)
|
)
|
||||||
|
|
||||||
@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)
|
|
||||||
|
|
||||||
delegate.respondToSudo("secret-pass")
|
|
||||||
assertNull(uiState.value.sudoPrompt)
|
|
||||||
|
|
||||||
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"])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun handleSecretRequest_andDismiss_sendsEmptyValue() =
|
fun testVaultUnlock_request_and_respond() {
|
||||||
testScope.runTest {
|
delegate.handleVaultUnlockRequest(
|
||||||
delegate.handleSecretRequest(
|
WsEvent.VaultUnlockRequest(
|
||||||
WsEvent.SecretRequest(
|
requestId = "req-1",
|
||||||
requestId = "r-sec",
|
sessionId = "sess-1",
|
||||||
sessionId = "s-1",
|
backend = "onepassword",
|
||||||
envVar = "OPENAI_API_KEY",
|
displayName = "1Password",
|
||||||
prompt = "Enter key",
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
assertEquals("OPENAI_API_KEY", uiState.value.secretPrompt?.envVar)
|
|
||||||
|
|
||||||
delegate.dismissSecret()
|
assertNotNull(uiState.value.vaultUnlockPrompt)
|
||||||
assertNull(uiState.value.secretPrompt)
|
assertEquals("req-1", uiState.value.vaultUnlockPrompt?.requestId)
|
||||||
|
assertEquals("1Password", uiState.value.vaultUnlockPrompt?.displayName)
|
||||||
|
|
||||||
advanceUntilIdle()
|
delegate.respondToVaultUnlock("master-pass")
|
||||||
assertEquals(listOf(WsMethods.SECRET_RESPOND), sentMethods)
|
testDispatcher.scheduler.advanceUntilIdle()
|
||||||
assertEquals("", sentParams.first()["value"])
|
|
||||||
assertEquals("r-sec", sentParams.first()["request_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
|
@Test
|
||||||
fun expireHandlers_ignoreMismatchedRequestId() {
|
fun testVaultUnlock_dismiss_sendsEmptyPassword() {
|
||||||
delegate.handleSudoRequest(WsEvent.SudoRequest(requestId = "keep-me", sessionId = "s-1"))
|
delegate.handleVaultUnlockRequest(
|
||||||
delegate.handleSudoExpire(WsEvent.SudoExpire(requestId = "other-req", sessionId = "s-1"))
|
WsEvent.VaultUnlockRequest(
|
||||||
assertNotNull(uiState.value.sudoPrompt)
|
requestId = "req-1",
|
||||||
|
sessionId = "sess-1",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
delegate.handleSudoExpire(WsEvent.SudoExpire(requestId = "keep-me", sessionId = "s-1"))
|
delegate.dismissVaultUnlock()
|
||||||
assertNull(uiState.value.sudoPrompt)
|
testDispatcher.scheduler.advanceUntilIdle()
|
||||||
|
|
||||||
|
assertNull(uiState.value.vaultUnlockPrompt)
|
||||||
|
assertEquals(listOf(WsMethods.VAULT_UNLOCK_RESPOND), sentMethods)
|
||||||
|
assertEquals("", sentParams.first()["password"])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testVaultSaveLogin_request_respond_and_expire() {
|
||||||
|
delegate.handleVaultSaveLoginRequest(
|
||||||
|
WsEvent.VaultSaveLoginRequest(
|
||||||
|
requestId = "req-save",
|
||||||
|
sessionId = "sess-1",
|
||||||
|
origin = "https://github.com",
|
||||||
|
site = "GitHub",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
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"])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user