diff --git a/src/components/hosts/HostsPage.tsx b/src/components/hosts/HostsPage.tsx index 712cd8009..2339a2bbd 100644 --- a/src/components/hosts/HostsPage.tsx +++ b/src/components/hosts/HostsPage.tsx @@ -61,7 +61,7 @@ import { useAllFolders } from "@/hooks/useAllFolders"; import { SnippetPickerPanel } from "./SnippetPickerPanel"; import { getHostDeleteTargetIds, shouldUseBulkHostContextMenu } from "./hostSelection"; import { buildTeamVaultTransferPlan, type TransferOperation } from "@/services/teamVaultPermissions"; -import { keepCachedOnUploadFailure } from "@/services/secretRouting"; +import { reportUploadFailure } from "@/services/secretRouting"; import { moveKeyToVault, moveIdentityToVault } from "@/services/vaultObjectSecrets"; import { saveHostFromForm, type HostFormSecrets } from "@/services/hostForm"; import { descendantFolders, foldersOutsideSubtree, itemsInFolderSubtree, newFolderData, folderItemCounter } from "@/utils/folderTree"; @@ -649,15 +649,15 @@ export default function HostsPage() { getSecret(`key:${key.id}:private`), getSecret(`key:${key.id}:public`), ]); - if (priv) await storeSecret(`key:${newKey.id}:private`, priv).catch(keepCachedOnUploadFailure("HostsPage: copy key to vault")); - if (pub) await storeSecret(`key:${newKey.id}:public`, pub).catch(keepCachedOnUploadFailure("HostsPage: copy key to vault")); + if (priv) await storeSecret(`key:${newKey.id}:private`, priv).catch(reportUploadFailure("HostsPage: copy key to vault")); + if (pub) await storeSecret(`key:${newKey.id}:public`, pub).catch(reportUploadFailure("HostsPage: copy key to vault")); newKeyId = newKey.id; } if (identityNeedsCopy) { const newIdentity = await useIdentityStore.getState().saveIdentity(copyingRulesOf({ name: identity.name, username: identity.username, key_id: newKeyId, tags: identity.tags, vault_id: vaultId }, identity.id)); const pwd = await getSecret(`identity:${identity.id}:password`); - if (pwd) await storeSecret(`identity:${newIdentity.id}:password`, pwd).catch(keepCachedOnUploadFailure("HostsPage: copy identity to vault")); + if (pwd) await storeSecret(`identity:${newIdentity.id}:password`, pwd).catch(reportUploadFailure("HostsPage: copy identity to vault")); newIdentityId = newIdentity.id; } @@ -772,10 +772,10 @@ export default function HostsPage() { getSecret(`key:${key.id}:public`), ]); if (priv) { - await storeSecret(`key:${newKey.id}:private`, priv).catch(keepCachedOnUploadFailure("HostsPage: copy folder key")); + await storeSecret(`key:${newKey.id}:private`, priv).catch(reportUploadFailure("HostsPage: copy folder key")); } if (pub) { - await storeSecret(`key:${newKey.id}:public`, pub).catch(keepCachedOnUploadFailure("HostsPage: copy folder key")); + await storeSecret(`key:${newKey.id}:public`, pub).catch(reportUploadFailure("HostsPage: copy folder key")); } keyIdMap.set(key.id, newKey.id); } @@ -787,7 +787,7 @@ export default function HostsPage() { const newIdentity = await useIdentityStore.getState().saveIdentity(copyingRulesOf({ name: identity.name, username: identity.username, key_id: newKeyId, tags: identity.tags, vault_id: vaultId }, identity.id)); const pwd = await getSecret(`identity:${identity.id}:password`); if (pwd) { - await storeSecret(`identity:${newIdentity.id}:password`, pwd).catch(keepCachedOnUploadFailure("HostsPage: copy folder identity")); + await storeSecret(`identity:${newIdentity.id}:password`, pwd).catch(reportUploadFailure("HostsPage: copy folder identity")); } identityIdMap.set(identity.id, newIdentity.id); } diff --git a/src/components/keychain/KeychainPage.tsx b/src/components/keychain/KeychainPage.tsx index 771f42168..6ab390f7b 100644 --- a/src/components/keychain/KeychainPage.tsx +++ b/src/components/keychain/KeychainPage.tsx @@ -43,7 +43,7 @@ import type { Folder, Identity, IdentityFormData, SshKey, SshKeyFormData } from import { SidePanelLayout } from "@/components/shared/SidePanelLayout"; import { useSyncedFormKey } from "@/hooks/useSyncedFormKey"; import { buildTeamVaultTransferPlan, type TransferOperation } from "@/services/teamVaultPermissions"; -import { keepCachedOnUploadFailure } from "@/services/secretRouting"; +import { reportUploadFailure } from "@/services/secretRouting"; import { moveKeyToVault, moveIdentityToVault } from "@/services/vaultObjectSecrets"; import { usePageClipboard } from "@/hooks/usePageClipboard"; import { vaultClipboardBase } from "@/utils/vaultClipboardBase"; @@ -558,9 +558,9 @@ export default function KeychainPage() { getSecret(`key:${key.id}:public`), getSecret(`key:${key.id}:passphrase`), ]); - if (priv) await storeSecret(`key:${newKey.id}:private`, priv).catch(keepCachedOnUploadFailure("KeychainPage: copy key to vault")); - if (pub) await storeSecret(`key:${newKey.id}:public`, pub).catch(keepCachedOnUploadFailure("KeychainPage: copy key to vault")); - if (pass) await storeSecret(`key:${newKey.id}:passphrase`, pass).catch(keepCachedOnUploadFailure("KeychainPage: copy key to vault")); + if (priv) await storeSecret(`key:${newKey.id}:private`, priv).catch(reportUploadFailure("KeychainPage: copy key to vault")); + if (pub) await storeSecret(`key:${newKey.id}:public`, pub).catch(reportUploadFailure("KeychainPage: copy key to vault")); + if (pass) await storeSecret(`key:${newKey.id}:passphrase`, pass).catch(reportUploadFailure("KeychainPage: copy key to vault")); } catch (err) { setError(describeError(err, t)); } }; @@ -608,14 +608,14 @@ export default function KeychainPage() { getSecret(`key:${key.id}:private`), getSecret(`key:${key.id}:public`), ]); - if (priv) await storeSecret(`key:${newKey.id}:private`, priv).catch(keepCachedOnUploadFailure("KeychainPage: copy identity's key to vault")); - if (pub) await storeSecret(`key:${newKey.id}:public`, pub).catch(keepCachedOnUploadFailure("KeychainPage: copy identity's key to vault")); + if (priv) await storeSecret(`key:${newKey.id}:private`, priv).catch(reportUploadFailure("KeychainPage: copy identity's key to vault")); + if (pub) await storeSecret(`key:${newKey.id}:public`, pub).catch(reportUploadFailure("KeychainPage: copy identity's key to vault")); newKeyId = newKey.id; } const newIdentity = await saveIdentity(copyingRulesOf({ name: identity.name, username: identity.username, key_id: newKeyId, tags: identity.tags, vault_id: vaultId }, identity.id)); const pwd = await getSecret(`identity:${identity.id}:password`); - if (pwd) await storeSecret(`identity:${newIdentity.id}:password`, pwd).catch(keepCachedOnUploadFailure("KeychainPage: copy identity to vault")); + if (pwd) await storeSecret(`identity:${newIdentity.id}:password`, pwd).catch(reportUploadFailure("KeychainPage: copy identity to vault")); } catch (err) { setError(describeError(err, t)); } }, }); @@ -700,15 +700,15 @@ export default function KeychainPage() { getSecret(`key:${key.id}:private`), getSecret(`key:${key.id}:public`), ]); - if (priv) await storeSecret(`key:${newKey.id}:private`, priv).catch(keepCachedOnUploadFailure("KeychainPage: copy folder key")); - if (pub) await storeSecret(`key:${newKey.id}:public`, pub).catch(keepCachedOnUploadFailure("KeychainPage: copy folder key")); + if (priv) await storeSecret(`key:${newKey.id}:private`, priv).catch(reportUploadFailure("KeychainPage: copy folder key")); + if (pub) await storeSecret(`key:${newKey.id}:public`, pub).catch(reportUploadFailure("KeychainPage: copy folder key")); keyIdMap.set(key.id, newKey.id); } for (const identity of treeIdentities) { const newKeyId = identity.key_id ? (keyIdMap.get(identity.key_id) ?? identity.key_id) : undefined; const newIdentity = await useIdentityStore.getState().saveIdentity(copyingRulesOf({ name: identity.name, username: identity.username, key_id: newKeyId, tags: identity.tags, vault_id: vaultId }, identity.id)); const pwd = await getSecret(`identity:${identity.id}:password`); - if (pwd) await storeSecret(`identity:${newIdentity.id}:password`, pwd).catch(keepCachedOnUploadFailure("KeychainPage: copy folder identity")); + if (pwd) await storeSecret(`identity:${newIdentity.id}:password`, pwd).catch(reportUploadFailure("KeychainPage: copy folder identity")); } } catch (err) { setError(describeError(err, t)); } }, @@ -741,7 +741,7 @@ export default function KeychainPage() { const value = await getSecret(`key:${key.id}:${part}`); if (!value) continue; const localKey = `key:${newKey.id}:${part}`; - await storeSecret(localKey, value).catch(keepCachedOnUploadFailure("duplicateKeyInto")); + await storeSecret(localKey, value).catch(reportUploadFailure("duplicateKeyInto")); } return newKey; } @@ -765,7 +765,7 @@ export default function KeychainPage() { const pwd = await getSecret(`identity:${identity.id}:password`); if (pwd) { const localKey = `identity:${newIdentity.id}:password`; - await storeSecret(localKey, pwd).catch(keepCachedOnUploadFailure("duplicateIdentityInto")); + await storeSecret(localKey, pwd).catch(reportUploadFailure("duplicateIdentityInto")); } return newIdentity; } diff --git a/src/i18n/locales/cs/common.json b/src/i18n/locales/cs/common.json index 11830784b..608da36c4 100644 --- a/src/i18n/locales/cs/common.json +++ b/src/i18n/locales/cs/common.json @@ -170,6 +170,8 @@ "secretsUploadPending": "Přesunuto, ale přihlašovací údaje se zatím nepodařilo nahrát. Zůstávají na tomto zařízení a pokus o nahrání se zopakuje při příštím načtení trezoru: {{error}}", "failedToSaveTeamSecret": "Týmové tajemství se nepodařilo uložit: {{status}}", "serverLacksTeamSecretType": "Tento server Voltius je příliš starý na to, aby toto uložil do týmového trezoru. Požádejte jeho správce o aktualizaci serveru a pak uložte znovu.", + "teamSecretRefused": "Týmový trezor tuto hodnotu nepřijal, takže nebyla uložena: {{error}}", + "teamSecretUploadPending": "Zatím nenahráno do týmového trezoru. Hodnota zůstane uložena do zavření aplikace Voltius a pokus o nahrání se zopakuje při příštím načtení trezoru: {{error}}", "failedToLoadServerCapabilities": "Nepodařilo se ověřit, co server podporuje ({{status}})", "keyFetchFailed": "Načtení klíče se nezdařilo: {{error}}", "failedToUploadVaultKeys": "Klíče trezoru se nepodařilo nahrát: {{status}}", diff --git a/src/i18n/locales/en/common.json b/src/i18n/locales/en/common.json index 4d4eada7d..5050114fd 100644 --- a/src/i18n/locales/en/common.json +++ b/src/i18n/locales/en/common.json @@ -170,6 +170,8 @@ "secretsUploadPending": "Moved, but its credentials could not be uploaded yet. They stay on this device and are retried the next time the vault loads: {{error}}", "failedToSaveTeamSecret": "Failed to save team secret: {{status}}", "serverLacksTeamSecretType": "This Voltius server is too old to store this in a team vault. Ask its administrator to update the server, then save again.", + "teamSecretRefused": "The team vault did not accept this, so it was not saved: {{error}}", + "teamSecretUploadPending": "Not uploaded to the team vault yet. It is kept until Voltius closes and retried the next time the vault loads: {{error}}", "failedToLoadServerCapabilities": "Couldn't check what the server supports ({{status}})", "keyFetchFailed": "Key fetch failed: {{error}}", "failedToUploadVaultKeys": "Failed to upload vault keys: {{status}}", diff --git a/src/i18n/locales/fr/common.json b/src/i18n/locales/fr/common.json index ef82d2b84..77c0e32e8 100644 --- a/src/i18n/locales/fr/common.json +++ b/src/i18n/locales/fr/common.json @@ -170,6 +170,8 @@ "secretsUploadPending": "Déplacé, mais ses identifiants n'ont pas encore pu être envoyés. Ils restent sur cet appareil et seront renvoyés au prochain chargement du coffre : {{error}}", "failedToSaveTeamSecret": "Échec de l'enregistrement du secret d'équipe : {{status}}", "serverLacksTeamSecretType": "Ce serveur Voltius est trop ancien pour stocker cet élément dans un coffre d'équipe. Demandez à son administrateur de mettre le serveur à jour, puis enregistrez à nouveau.", + "teamSecretRefused": "Le coffre d'équipe n'a pas accepté cet élément, il n'a donc pas été enregistré : {{error}}", + "teamSecretUploadPending": "Pas encore envoyé au coffre d'équipe. L'élément est conservé jusqu'à la fermeture de Voltius et renvoyé au prochain chargement du coffre : {{error}}", "failedToLoadServerCapabilities": "Impossible de vérifier ce que le serveur prend en charge ({{status}})", "keyFetchFailed": "Échec de la récupération de la clé : {{error}}", "failedToUploadVaultKeys": "Échec de l'envoi des clés de coffre : {{status}}", diff --git a/src/i18n/locales/ru/common.json b/src/i18n/locales/ru/common.json index c8b6fd19f..b5cba31eb 100644 --- a/src/i18n/locales/ru/common.json +++ b/src/i18n/locales/ru/common.json @@ -170,6 +170,8 @@ "secretsUploadPending": "Перемещено, но учётные данные пока не удалось отправить. Они остаются на этом устройстве и будут отправлены повторно при следующей загрузке хранилища: {{error}}", "failedToSaveTeamSecret": "Не удалось сохранить секрет команды: {{status}}", "serverLacksTeamSecretType": "Этот сервер Voltius слишком старый, чтобы хранить это в командном хранилище. Попросите администратора обновить сервер, затем сохраните снова.", + "teamSecretRefused": "Командное хранилище не приняло это значение, поэтому оно не сохранено: {{error}}", + "teamSecretUploadPending": "Пока не отправлено в командное хранилище. Значение хранится до закрытия Voltius и будет отправлено повторно при следующей загрузке хранилища: {{error}}", "failedToLoadServerCapabilities": "Не удалось проверить, что поддерживает сервер ({{status}})", "keyFetchFailed": "Не удалось получить ключ: {{error}}", "failedToUploadVaultKeys": "Не удалось загрузить ключи хранилища: {{status}}", diff --git a/src/i18n/locales/tr/common.json b/src/i18n/locales/tr/common.json index 6c48f6be4..fc855d3c9 100644 --- a/src/i18n/locales/tr/common.json +++ b/src/i18n/locales/tr/common.json @@ -170,6 +170,8 @@ "secretsUploadPending": "Taşındı, ancak kimlik bilgileri henüz yüklenemedi. Bu cihazda kalıyor ve kasa bir sonraki yüklendiğinde yeniden denenecek: {{error}}", "failedToSaveTeamSecret": "Ekibin gizli bilgisi kaydedilemedi: {{status}}", "serverLacksTeamSecretType": "Bu Voltius sunucusu, bunu bir ekip kasasında saklamak için çok eski. Yöneticisinden sunucuyu güncellemesini isteyin, ardından yeniden kaydedin.", + "teamSecretRefused": "Ekip kasası bunu kabul etmedi, bu yüzden kaydedilmedi: {{error}}", + "teamSecretUploadPending": "Henüz ekip kasasına yüklenmedi. Voltius kapanana kadar saklanıyor ve kasa bir sonraki yüklendiğinde yeniden denenecek: {{error}}", "failedToLoadServerCapabilities": "Sunucunun neleri desteklediği denetlenemedi ({{status}})", "keyFetchFailed": "Anahtar alınamadı: {{error}}", "failedToUploadVaultKeys": "Kasa anahtarları yüklenemedi: {{status}}", diff --git a/src/i18n/locales/zh/common.json b/src/i18n/locales/zh/common.json index c4b4831cb..e29131e66 100644 --- a/src/i18n/locales/zh/common.json +++ b/src/i18n/locales/zh/common.json @@ -170,6 +170,8 @@ "secretsUploadPending": "已移动,但其凭据暂时无法上传。它们会保留在此设备上,并在下次加载保险库时重试:{{error}}", "failedToSaveTeamSecret": "保存团队密钥失败:{{status}}", "serverLacksTeamSecretType": "此 Voltius 服务器版本过旧,无法在团队保险库中存储此内容。请让管理员更新服务器,然后重新保存。", + "teamSecretRefused": "团队保险库未接受此内容,因此未保存:{{error}}", + "teamSecretUploadPending": "尚未上传到团队保险库。它会保留到 Voltius 关闭为止,并在下次加载保险库时重试:{{error}}", "failedToLoadServerCapabilities": "无法检查服务器支持的功能({{status}})", "keyFetchFailed": "获取密钥失败:{{error}}", "failedToUploadVaultKeys": "上传保险库密钥失败:{{status}}", diff --git a/src/plugins/domains/objects.ts b/src/plugins/domains/objects.ts index ef57b6157..1155cab22 100644 --- a/src/plugins/domains/objects.ts +++ b/src/plugins/domains/objects.ts @@ -33,7 +33,7 @@ import { moveFolderTreeToVault } from "@/utils/folderMove"; import { snippetToForm } from "@/utils/snippetForm"; import { ruleToForm } from "@/utils/portForwardingForm"; import { getSecret, storeSecret } from "@/services/vault"; -import { keepCachedOnUploadFailure } from "@/services/secretRouting"; +import { reportUploadFailure } from "@/services/secretRouting"; import { moveKeyToVault, moveIdentityToVault } from "@/services/vaultObjectSecrets"; import { duplicateConnection, moveConnectionToVault } from "@/services/connectionDuplicate"; import { copyingRulesOf } from "@/services/ruleSetIntent"; @@ -203,7 +203,7 @@ function resolveTab( const copySecret = async (from: string, to: string): Promise => { const value = await getSecret(from).catch(() => null); - if (value) await storeSecret(to, value).catch(keepCachedOnUploadFailure("copySecret")); + if (value) await storeSecret(to, value).catch(reportUploadFailure("copySecret")); }; interface DuplicateOpts { diff --git a/src/plugins/runtime.ts b/src/plugins/runtime.ts index ad90ee5b7..00416ed0b 100644 --- a/src/plugins/runtime.ts +++ b/src/plugins/runtime.ts @@ -51,7 +51,7 @@ import { addKeyToHost } from "@/services/keyExport"; import { isValidSshPublicKey } from "@/services/sshPublicKey"; import type { Connection, TerminalSession } from "@/types"; import { storePluginSecret, getPluginSecret, deletePluginSecret, storeSecret, deleteLocalSecret } from "@/services/vault"; -import { keepCachedOnUploadFailure } from "@/services/secretRouting"; +import { reportUploadFailure } from "@/services/secretRouting"; import { appFetch } from "@/services/http"; import { sseFetch } from "@/services/sseFetch"; import { registerLxcExecSession } from "@/services/proxmox"; @@ -1019,8 +1019,8 @@ function createPluginAPI(manifest: PluginManifest): PluginAPI { throw new Error("publicKey is not a valid SSH public key"); } const key = await keyService.saveKey({ name: data.name, key_type: data.key_type, tags: data.tags ?? [] }); - await storeSecret(`key:${key.id}:private`, privateKey).catch(keepCachedOnUploadFailure("plugin: keys.create")); - if (publicKey) await storeSecret(`key:${key.id}:public`, publicKey).catch(keepCachedOnUploadFailure("plugin: keys.create")); + await storeSecret(`key:${key.id}:private`, privateKey).catch(reportUploadFailure("plugin: keys.create")); + if (publicKey) await storeSecret(`key:${key.id}:public`, publicKey).catch(reportUploadFailure("plugin: keys.create")); return key as PluginKey; }, async delete(keyId) { diff --git a/src/services/clipboard/connections.ts b/src/services/clipboard/connections.ts index 64bc6685f..9575a2205 100644 --- a/src/services/clipboard/connections.ts +++ b/src/services/clipboard/connections.ts @@ -2,7 +2,7 @@ import type { Connection, ConnectionFormData, Identity, IdentityFormData, SshKey import type { VaultClipboardKind } from "@/stores/vaultClipboardStore"; import type { CascadeEntry } from "@/services/vaultClipboard"; import { getSecret, storeSecret } from "@/services/vault"; -import { keepCachedOnUploadFailure } from "@/services/secretRouting"; +import { reportUploadFailure } from "@/services/secretRouting"; import { moveWithSecrets, moveKeyToVault, moveIdentityToVault } from "@/services/vaultObjectSecrets"; import { connectionToFormData } from "@/stores/connectionStore"; import { nameIsFree } from "@/utils/cloneName"; @@ -171,8 +171,8 @@ export function connectionsClipboardHalf( getSecret(`key:${key.id}:private`).catch(() => null), getSecret(`key:${key.id}:public`).catch(() => null), ]); - if (priv) await storeSecret(`key:${created.id}:private`, priv).catch(keepCachedOnUploadFailure("clipboard: paste key")); - if (pub) await storeSecret(`key:${created.id}:public`, pub).catch(keepCachedOnUploadFailure("clipboard: paste key")); + if (priv) await storeSecret(`key:${created.id}:private`, priv).catch(reportUploadFailure("clipboard: paste key")); + if (pub) await storeSecret(`key:${created.id}:public`, pub).catch(reportUploadFailure("clipboard: paste key")); cascadeRemap.keys.set(key.id, created.id); } @@ -193,7 +193,7 @@ export function connectionsClipboardHalf( tags: identity.tags, vault_id: destination, }, identity.id)); const pwd = await getSecret(`identity:${identity.id}:password`).catch(() => null); - if (pwd) await storeSecret(`identity:${created.id}:password`, pwd).catch(keepCachedOnUploadFailure("clipboard: paste identity")); + if (pwd) await storeSecret(`identity:${created.id}:password`, pwd).catch(reportUploadFailure("clipboard: paste identity")); cascadeRemap.identities.set(identity.id, created.id); } }, diff --git a/src/services/connectionDuplicate.ts b/src/services/connectionDuplicate.ts index 9879a5959..35833e89c 100644 --- a/src/services/connectionDuplicate.ts +++ b/src/services/connectionDuplicate.ts @@ -1,7 +1,7 @@ import type { Connection, ConnectionFormData } from "@/types"; import { connectionToFormData } from "@/stores/connectionStore"; import { getSecret, storeSecret } from "@/services/vault"; -import { keepCachedOnUploadFailure } from "@/services/secretRouting"; +import { reportUploadFailure } from "@/services/secretRouting"; import { moveWithSecrets } from "@/services/vaultObjectSecrets"; import { secretKeysFor } from "@/services/teamVaultSecretKeys"; import { copyingRulesOf } from "@/services/ruleSetIntent"; @@ -31,7 +31,7 @@ export async function copyConnectionSecrets( const pending = getSecret(fromKey); const value = opts.swallowFetchErrors ? await pending.catch(() => null) : await pending; if (!value) continue; - await storeSecret(targets[i], value).catch(keepCachedOnUploadFailure("copyConnectionSecrets")); + await storeSecret(targets[i], value).catch(reportUploadFailure("copyConnectionSecrets")); } } diff --git a/src/services/editorMoveSecrets.test.ts b/src/services/editorMoveSecrets.test.ts index 3f43304d0..a31e7d085 100644 --- a/src/services/editorMoveSecrets.test.ts +++ b/src/services/editorMoveSecrets.test.ts @@ -56,7 +56,7 @@ vi.mock("@/stores/pendingTeamSecretUploadStore", () => ({ vi.mock("@/stores/teamVaultStateStore", () => ({ useTeamVaultStateStore: { getState: () => ({ credentialsUnavailableByTeamId: h.unavailable }) }, })); -vi.mock("@/stores/notificationStore", () => ({ useNotificationStore: { getState: () => ({ addToast: h.addToast }) } })); +vi.mock("@/stores/notificationStore", () => ({ useNotificationStore: { getState: () => ({ addToast: h.addToast, toasts: [] }) } })); vi.mock("@/i18n", () => ({ default: { t: (k: string) => k } })); vi.mock("@/lib/logger", () => ({ logFailure: () => () => {} })); diff --git a/src/services/import-export/handlers/connections.ts b/src/services/import-export/handlers/connections.ts index 36b487f1a..7257607e5 100644 --- a/src/services/import-export/handlers/connections.ts +++ b/src/services/import-export/handlers/connections.ts @@ -4,7 +4,7 @@ import type { DataTypeHandler } from "../handler"; import type { ConnectionExport, JumpHostExport, ExportBundle } from "../formats"; import type { ExportCtx, ImportCtx, ReloadFns } from "../context"; import { dupesOf, selectionMethods, skipItem } from "../context"; -import { keepCachedOnUploadFailure } from "@/services/secretRouting"; +import { reportUploadFailure } from "@/services/secretRouting"; import { omitSecrets, fetchConnectionSecrets, storeConnectionSecrets, resolveConnectionKeyEid, resolveConnectionKeyId } from "../secretsLogic"; export const connectionsHandler: DataTypeHandler = { @@ -104,7 +104,7 @@ export const connectionsHandler: DataTypeHandler = { }); if (conn._eid) ctx.connectionEidMap.set(conn._eid, saved.id); await storeConnectionSecrets(conn, saved.id, (key, value) => - storeSecret(key, value).catch(keepCachedOnUploadFailure("import")), + storeSecret(key, value).catch(reportUploadFailure("import")), ); imported++; } catch { errors++; } diff --git a/src/services/import-export/handlers/identities.ts b/src/services/import-export/handlers/identities.ts index 79b09cb3d..3475c4ff3 100644 --- a/src/services/import-export/handlers/identities.ts +++ b/src/services/import-export/handlers/identities.ts @@ -1,6 +1,6 @@ import type { Identity } from "@/types"; import { storeSecret } from "@/services/vault"; -import { keepCachedOnUploadFailure } from "@/services/secretRouting"; +import { reportUploadFailure } from "@/services/secretRouting"; import type { DataTypeHandler } from "../handler"; import type { ExportBundle, IdentityExport } from "../formats"; import type { ExportCtx, ImportCtx, ReloadFns } from "../context"; @@ -57,7 +57,7 @@ export const identitiesHandler: DataTypeHandler = { }); if (identity._eid) ctx.identityEidMap.set(identity._eid, saved.id); await storeIdentitySecrets(identity, saved.id, (key, value) => - storeSecret(key, value).catch(keepCachedOnUploadFailure("import")), + storeSecret(key, value).catch(reportUploadFailure("import")), ); imported++; } catch { errors++; } diff --git a/src/services/import-export/handlers/keys.ts b/src/services/import-export/handlers/keys.ts index d75c4d3e1..34f4f3d32 100644 --- a/src/services/import-export/handlers/keys.ts +++ b/src/services/import-export/handlers/keys.ts @@ -4,7 +4,7 @@ import type { DataTypeHandler } from "../handler"; import type { ExportBundle, KeyExport, KeyRefExport } from "../formats"; import type { ExportCtx, ImportCtx, ReloadFns } from "../context"; import { dupesOf, resolveRefs, selectionMethods, skipItem } from "../context"; -import { keepCachedOnUploadFailure } from "@/services/secretRouting"; +import { reportUploadFailure } from "@/services/secretRouting"; import { fetchKeySecrets, storeKeySecrets } from "../secretsLogic"; export const keysHandler: DataTypeHandler = { @@ -47,7 +47,7 @@ export const keysHandler: DataTypeHandler = { vault_id: ctx.vault_id, }); await storeKeySecrets(key, saved.id, (k, value) => - storeSecret(k, value).catch(keepCachedOnUploadFailure("import")), + storeSecret(k, value).catch(reportUploadFailure("import")), ); if (key._eid) ctx.keyEidMap.set(key._eid, saved.id); imported++; diff --git a/src/services/keychainForm.ts b/src/services/keychainForm.ts index 303e3aa7d..d80b02826 100644 --- a/src/services/keychainForm.ts +++ b/src/services/keychainForm.ts @@ -2,7 +2,7 @@ import { useKeyStore } from "@/stores/keyStore"; import { useIdentityStore } from "@/stores/identityStore"; import { connectionToFormData } from "@/stores/connectionStore"; import { storeSecret, getSecret } from "@/services/vault"; -import { keepCachedOnUploadFailure } from "@/services/secretRouting"; +import { reportUploadFailure } from "@/services/secretRouting"; import { moveWithSecrets, storeNewSecrets, type SecretEdit } from "@/services/vaultObjectSecrets"; import type { AuthType, Connection, ConnectionFormData, Identity, IdentityFormData, SshKey, SshKeyFormData } from "@/types"; @@ -80,7 +80,7 @@ export async function unlinkIdentityFromHost( identity_id: undefined, key_id: undefined, }); - const keepCached = keepCachedOnUploadFailure("IdentityForm unlink"); + const keepCached = reportUploadFailure("IdentityForm unlink"); if (password) await storeSecret(`password:${conn.id}`, password).catch(keepCached); if (privateKey) await storeSecret(`key:${conn.id}`, privateKey).catch(keepCached); } diff --git a/src/services/publicKeyStore.ts b/src/services/publicKeyStore.ts index b3bd458ed..2d00764bb 100644 --- a/src/services/publicKeyStore.ts +++ b/src/services/publicKeyStore.ts @@ -1,6 +1,6 @@ import { invoke } from "@/lib/invoke"; import { getSecret, storeSecret } from "@/services/vault"; -import { keepCachedOnUploadFailure } from "@/services/secretRouting"; +import { reportUploadFailure } from "@/services/secretRouting"; import { isValidSshPublicKey } from "@/services/sshPublicKey"; import type { SshKey } from "@/types"; @@ -50,7 +50,7 @@ export async function ensurePublicKey(sshKey: SshKey): Promise { if ("error" in result) return null; if (!isValidSshPublicKey(result.publicKey)) return null; - await storeSecret(localKey, result.publicKey).catch(keepCachedOnUploadFailure("ensurePublicKey")); + await storeSecret(localKey, result.publicKey).catch(reportUploadFailure("ensurePublicKey", { toast: false })); return result.publicKey; } diff --git a/src/services/secretFailureToast.ts b/src/services/secretFailureToast.ts new file mode 100644 index 000000000..740f08baa --- /dev/null +++ b/src/services/secretFailureToast.ts @@ -0,0 +1,5 @@ +import { useNotificationStore } from "@/stores/notificationStore"; + +export function toastSecretFailure(message: string, severity: "warning" | "error" = "error"): void { + useNotificationStore.getState().addToast({ source: { kind: "plugin", id: "system", name: "Voltius" }, type: "toast", message, severity, duration: 10000 }); +} diff --git a/src/services/secretRouting.test.ts b/src/services/secretRouting.test.ts index 14bba46ce..65cd475b9 100644 --- a/src/services/secretRouting.test.ts +++ b/src/services/secretRouting.test.ts @@ -26,8 +26,10 @@ vi.mock("@/services/clientHeaders", () => ({ clientVersion: async () => "9.9.9" vi.mock("@/i18n", () => ({ default: { t: (k: string) => k } })); import { - readSecretAt, writeSecretAt, removeSecretAt, keepCachedOnUploadFailure, TeamSecretUploadError, TeamSecretUnsupportedError, + readSecretAt, writeSecretAt, removeSecretAt, retryUnsentTeamSecrets, reportUploadFailure, + TeamSecretUploadError, TeamSecretUnsupportedError, } from "./secretRouting"; +import { useNotificationStore } from "@/stores/notificationStore"; import { teamSecretCache } from "./teamSecretCache"; import { resetServerCapabilities, serverLacksSecretType } from "./serverCapabilities"; import { usePendingTeamSecretUploadStore } from "@/stores/pendingTeamSecretUploadStore"; @@ -37,6 +39,7 @@ beforeEach(() => { teamSecretCache.clearAll(); usePendingTeamSecretUploadStore.getState().clearAll(); resetServerCapabilities(); + useNotificationStore.setState({ toasts: [] }); h.fetchServerMeta.mockResolvedValue({}); }); @@ -61,12 +64,91 @@ test("a team write caches, uploads, and never touches the local store", async () expect(h.storeLocalSecret).not.toHaveBeenCalled(); }); -test("a failed upload throws TeamSecretUploadError and keeps the value usable this session", async () => { - h.saveTeamVaultSecret.mockRejectedValue(Object.assign(new Error("403"), { status: 403 })); +const failing = (status?: number) => Object.assign(new Error(`upload failed ${status ?? "offline"}`), status ? { status } : {}); + +test.each([undefined, 500, 503, 401, 408, 429])("an upload the server never answered (%s) stays usable and unsent", async (status) => { + teamSecretCache.set("t1", "password:c1", "served"); + h.saveTeamVaultSecret.mockRejectedValue(failing(status)); + const err = await writeSecretAt("t1", "password:c1", "pw").catch((e) => e); + expect(err).toBeInstanceOf(TeamSecretUploadError); expect(err.localKey).toBe("password:c1"); + expect(err.pending).toBe(true); + expect(err.message).toBe("common.error.teamSecretUploadPending"); + expect(teamSecretCache.get("t1", "password:c1")).toBe("pw"); + expect([...teamSecretCache.unsent("t1")]).toEqual([["password:c1", "pw"]]); + expect(h.storeLocalSecret).not.toHaveBeenCalled(); + expect(h.warned.mock.calls[0].join(" ")).toContain("password:c1"); +}); + +test.each([400, 402, 403, 404, 413, 426])("an upload the server refused (%s) shows what the server holds again", async (status) => { + teamSecretCache.set("t1", "password:c1", "served"); + h.saveTeamVaultSecret.mockRejectedValue(failing(status)); + + const err = await writeSecretAt("t1", "password:c1", "pw").catch((e) => e); + + expect(err).toBeInstanceOf(TeamSecretUploadError); + expect(err.pending).toBe(false); + expect(err.message).toBe("common.error.teamSecretRefused"); + expect(teamSecretCache.get("t1", "password:c1")).toBe("served"); + expect(teamSecretCache.unsent("t1").size).toBe(0); +}); + +test("a refused value the server never held is gone", async () => { + h.saveTeamVaultSecret.mockRejectedValue(failing(403)); + await expect(writeSecretAt("t1", "password:c1", "pw")).rejects.toBeInstanceOf(TeamSecretUploadError); + expect(teamSecretCache.get("t1", "password:c1")).toBeUndefined(); +}); + +test("a reload of the team keeps an unsent value, and the retry uploads it", async () => { + h.saveTeamVaultSecret.mockRejectedValueOnce(failing()); + await writeSecretAt("t1", "password:c1", "pw").catch(() => {}); + teamSecretCache.replaceTeam("t1", new Map([["password:c1", "served"]])); expect(teamSecretCache.get("t1", "password:c1")).toBe("pw"); + + h.saveTeamVaultSecret.mockResolvedValue(undefined); + await retryUnsentTeamSecrets("t1"); + + expect(h.saveTeamVaultSecret).toHaveBeenLastCalledWith("t1", "password:c1", "pw"); + expect(teamSecretCache.unsent("t1").size).toBe(0); + expect(teamSecretCache.entries("t1").get("password:c1")).toBe("pw"); +}); + +test("the retry drops a value the server now refuses and stops once the server is out of reach", async () => { + h.saveTeamVaultSecret.mockRejectedValue(failing()); + for (const key of ["password:c1", "password:c2", "password:c3"]) await writeSecretAt("t1", key, "pw").catch(() => {}); + h.saveTeamVaultSecret.mockReset(); + h.saveTeamVaultSecret.mockRejectedValueOnce(failing(404)).mockRejectedValueOnce(failing(503)); + + await retryUnsentTeamSecrets("t1"); + + expect(h.saveTeamVaultSecret).toHaveBeenCalledTimes(2); + expect([...teamSecretCache.unsent("t1").keys()]).toEqual(["password:c2", "password:c3"]); +}); + +test("the retry leaves a key queued by a move to the sweep that owns its local copy", async () => { + usePendingTeamSecretUploadStore.getState().enqueue("t1", ["password:c1"]); + h.saveTeamVaultSecret.mockRejectedValue(failing()); + h.storeLocalSecret.mockResolvedValue(undefined); + await writeSecretAt("t1", "password:c1", "pw").catch(() => {}); + h.saveTeamVaultSecret.mockClear(); + + await retryUnsentTeamSecrets("t1"); + + expect(h.saveTeamVaultSecret).not.toHaveBeenCalled(); +}); + +test("a value cleared while unsent is never uploaded later, even when the delete fails", async () => { + teamSecretCache.set("t1", "password:c1", "served"); + h.saveTeamVaultSecret.mockRejectedValue(failing()); + await writeSecretAt("t1", "password:c1", "pw").catch(() => {}); + h.deleteTeamSecret.mockRejectedValue(new Error("offline")); + + await expect(removeSecretAt("t1", "password:c1")).rejects.toThrow("offline"); + + expect(teamSecretCache.unsent("t1").size).toBe(0); + expect(teamSecretCache.get("t1", "password:c1")).toBe("served"); }); test("a personal write goes to the local store only", async () => { @@ -96,13 +178,24 @@ test("a personal delete goes to the local store", async () => { expect(h.deleteLocalSecret).toHaveBeenCalledWith("password:c1"); }); -test("keepCachedOnUploadFailure swallows only upload failures", () => { - const handler = keepCachedOnUploadFailure("ctx"); - expect(() => handler(new TeamSecretUploadError("password:c1", new Error("x")))).not.toThrow(); - expect(h.logged).toHaveBeenCalled(); +test("reportUploadFailure swallows only upload failures, and says so once", () => { + const handler = reportUploadFailure("ctx"); + const failure = new TeamSecretUploadError("password:c1", new Error("x"), "not uploaded"); + expect(() => handler(failure)).not.toThrow(); + handler(new TeamSecretUploadError("password:c2", new Error("x"), "not uploaded")); + expect(h.logged).toHaveBeenCalledTimes(2); + expect(useNotificationStore.getState().toasts.map((toast) => [toast.message, toast.severity])).toEqual([["not uploaded", "error"]]); expect(() => handler(new Error("vault locked"))).toThrow("vault locked"); }); +test("reportUploadFailure warns about a pending upload and can stay quiet", () => { + const pending = Object.assign(new TeamSecretUploadError("password:c1", new Error("x"), "later"), { pending: true }); + reportUploadFailure("ctx", { toast: false })(pending); + expect(useNotificationStore.getState().toasts).toEqual([]); + reportUploadFailure("ctx")(pending); + expect(useNotificationStore.getState().toasts[0].severity).toBe("warning"); +}); + const pendingIn = (teamId: string) => usePendingTeamSecretUploadStore.getState().keysByTeamId[teamId] ?? []; test("a successful upload of a key queued for retry settles it, so the retry never re-uploads an older copy", async () => { @@ -128,15 +221,6 @@ test("a failed upload of a key queued for retry refreshes the local copy the ret expect(pendingIn("t1")).toEqual(["password:c1"]); }); -test("a failed upload of a key not queued for retry leaves the local store alone", async () => { - usePendingTeamSecretUploadStore.getState().enqueue("t2", ["password:c1"]); - h.saveTeamVaultSecret.mockRejectedValue(new Error("503")); - - await expect(writeSecretAt("t1", "password:c1", "newest")).rejects.toBeInstanceOf(TeamSecretUploadError); - - expect(h.storeLocalSecret).not.toHaveBeenCalled(); -}); - const refused400 = () => Object.assign(new Error("Failed to save team secret: 400"), { status: 400 }); test("a type the server does not advertise is never uploaded, and the cache keeps what the server holds", async () => { @@ -163,14 +247,15 @@ test("a 400 from a server too old to advertise its types drops the unsaved value expect(serverLacksSecretType("connection_knock_sequence")).toBe(true); }); -test("a 400 for a type every server stores stays an ordinary upload failure", async () => { +test("a 400 for a type every server stores is an ordinary refusal", async () => { h.saveTeamVaultSecret.mockRejectedValue(refused400()); const err = await writeSecretAt("t1", "password:c1", "pw").catch((e) => e); expect(err).toBeInstanceOf(TeamSecretUploadError); expect(err).not.toBeInstanceOf(TeamSecretUnsupportedError); - expect(teamSecretCache.get("t1", "password:c1")).toBe("pw"); + expect(serverLacksSecretType("connection_password")).toBe(false); + expect(teamSecretCache.get("t1", "password:c1")).toBeUndefined(); }); test("an unsupported type queued for retry stays readable here and keeps its local copy current", async () => { diff --git a/src/services/secretRouting.ts b/src/services/secretRouting.ts index 99a8c44cb..99f82a177 100644 --- a/src/services/secretRouting.ts +++ b/src/services/secretRouting.ts @@ -8,12 +8,19 @@ import { refreshServerCapabilities, serverLacksSecretType, noteRefusedSecretType import { clientVersion } from "@/services/clientHeaders"; import { log, logFailure } from "@/lib/logger"; import { usePendingTeamSecretUploadStore } from "@/stores/pendingTeamSecretUploadStore"; +import { toastSecretFailure } from "@/services/secretFailureToast"; +import { useNotificationStore } from "@/stores/notificationStore"; + +const reasonOf = (reason: unknown): string => (reason instanceof Error ? reason.message : String(reason)); export class TeamSecretUploadError extends Error { + /** Kept in memory and uploaded again at the vault's next load, until the app closes. */ + pending = false; + constructor( readonly localKey: string, readonly reason: unknown, - message = `team secret upload failed for ${localKey}: ${reason instanceof Error ? reason.message : String(reason)}`, + message = `team secret upload failed for ${localKey}: ${reasonOf(reason)}`, ) { super(message); this.name = "TeamSecretUploadError"; @@ -50,36 +57,72 @@ async function uploadSupported(teamId: string, localKey: string, value: string): throw new TeamSecretUnsupportedError(localKey, secretType, refusal); } +const uploads = () => usePendingTeamSecretUploadStore.getState(); +const isQueued = (teamId: string, localKey: string): boolean => uploads().keysByTeamId[teamId]?.includes(localKey) ?? false; + +// These pass with time; any other 4xx is the server's final answer for the value. +const RETRYABLE_STATUSES = new Set([401, 408, 429]); + +function isRefusal(e: unknown): boolean { + if (e instanceof TeamSecretUnsupportedError) return true; + const status = (e as { status?: number } | null)?.status; + return status !== undefined && status >= 400 && status < 500 && !RETRYABLE_STATUSES.has(status); +} + +async function failedUpload(teamId: string, localKey: string, value: string, e: unknown): Promise { + const queued = isQueued(teamId, localKey); + // A queued retry uploads the local copy, so it must hold the newest value. + if (queued) await storeLocalSecret(localKey, value).catch(logFailure(`refresh queued team secret ${localKey}`)); + // A queued key came from a move that withdrew its source, so here is the only place left to read it. + const refused = isRefusal(e) && !queued; + if (refused) teamSecretCache.release(teamId, localKey, value); + if (e instanceof TeamSecretUploadError) return e; + log.warn(`team secret upload ${refused ? "refused" : "pending"} key=${localKey}: ${reasonOf(e)}`); + const error = new TeamSecretUploadError( + localKey, + e, + i18n.t(refused ? "common.error.teamSecretRefused" : "common.error.teamSecretUploadPending", { error: reasonOf(e) }), + ); + error.pending = !refused; + return error; +} + export async function writeSecretAt(teamId: string | null, localKey: string, value: string): Promise { if (!teamId) return storeLocalSecret(localKey, value); - const held = teamSecretCache.get(teamId, localKey); - teamSecretCache.set(teamId, localKey, value); - const uploads = () => usePendingTeamSecretUploadStore.getState(); + teamSecretCache.hold(teamId, localKey, value); try { await uploadSupported(teamId, localKey, value); } catch (e) { - // A queued retry uploads the local copy, so it must hold the newest value. - if (uploads().keysByTeamId[teamId]?.includes(localKey)) { - await storeLocalSecret(localKey, value).catch(logFailure(`refresh queued team secret ${localKey}`)); - } else if (e instanceof TeamSecretUnsupportedError) { - if (held === undefined) teamSecretCache.delete(teamId, localKey); - else teamSecretCache.set(teamId, localKey, held); - } - throw e instanceof TeamSecretUploadError ? e : new TeamSecretUploadError(localKey, e); + throw await failedUpload(teamId, localKey, value, e); } + teamSecretCache.set(teamId, localKey, value); uploads().resolve(teamId, [localKey]); } +/** Uploads what earlier writes could not, stopping at the first value the server still cannot take. */ +export async function retryUnsentTeamSecrets(teamId: string): Promise { + for (const [localKey, value] of teamSecretCache.unsent(teamId)) { + if (isQueued(teamId, localKey)) continue; + const failure = await writeSecretAt(teamId, localKey, value).then(() => null, (e: unknown) => e); + if (failure instanceof TeamSecretUploadError && failure.pending) return; + } +} + export async function removeSecretAt(teamId: string | null, localKey: string): Promise { if (!teamId) return deleteLocalSecret(localKey); + const unsent = teamSecretCache.unsent(teamId).get(localKey); + if (unsent !== undefined) teamSecretCache.release(teamId, localKey, unsent); const parts = teamSecretFromLocalKey(localKey); if (parts) await deleteTeamSecret(teamId, parts.secretId); teamSecretCache.delete(teamId, localKey); } -export function keepCachedOnUploadFailure(context: string): (e: unknown) => void { +export function reportUploadFailure(context: string, { toast = true } = {}): (e: unknown) => void { return (e) => { if (!(e instanceof TeamSecretUploadError)) throw e; logFailure(context)(e); + // A bulk import or paste fails the same way for every secret it carries. + if (!toast || useNotificationStore.getState().toasts.some((shown) => shown.message === e.message)) return; + toastSecretFailure(e.message, e.pending ? "warning" : "error"); }; } diff --git a/src/services/teamSecretCache.test.ts b/src/services/teamSecretCache.test.ts index 8adba3496..d5d52fdd0 100644 --- a/src/services/teamSecretCache.test.ts +++ b/src/services/teamSecretCache.test.ts @@ -58,3 +58,46 @@ test("a team counts as hydrated from replaceTeam until it is cleared", () => { teamSecretCache.clearAll(); expect(teamSecretCache.isHydrated("t2")).toBe(false); }); + +test("an unsent value shows over the server's and survives a reload of the team", () => { + teamSecretCache.set("t1", "password:c1", "served"); + teamSecretCache.hold("t1", "password:c1", "typed"); + expect(teamSecretCache.get("t1", "password:c1")).toBe("typed"); + expect(teamSecretCache.entries("t1").get("password:c1")).toBe("served"); + + teamSecretCache.replaceTeam("t1", new Map([["password:c1", "served again"]])); + + expect(teamSecretCache.get("t1", "password:c1")).toBe("typed"); + expect([...teamSecretCache.unsent("t1")]).toEqual([["password:c1", "typed"]]); +}); + +test("releasing an unsent value shows what the server holds again", () => { + teamSecretCache.set("t1", "password:c1", "served"); + teamSecretCache.hold("t1", "password:c1", "typed"); + teamSecretCache.release("t1", "password:c1", "typed"); + expect(teamSecretCache.get("t1", "password:c1")).toBe("served"); + expect(teamSecretCache.unsent("t1").size).toBe(0); +}); + +test("settling an older write leaves a newer unsent value showing", () => { + teamSecretCache.hold("t1", "password:c1", "newer"); + teamSecretCache.release("t1", "password:c1", "older"); + teamSecretCache.set("t1", "password:c1", "older"); + expect(teamSecretCache.get("t1", "password:c1")).toBe("newer"); + + teamSecretCache.set("t1", "password:c1", "newer"); + expect(teamSecretCache.unsent("t1").size).toBe(0); + expect(teamSecretCache.get("t1", "password:c1")).toBe("newer"); +}); + +test("delete, clearTeam and clearAll drop unsent values too", () => { + teamSecretCache.hold("t1", "a", "1"); + teamSecretCache.delete("t1", "a"); + expect(teamSecretCache.get("t1", "a")).toBeUndefined(); + teamSecretCache.hold("t1", "b", "2"); + teamSecretCache.clearTeam("t1"); + expect(teamSecretCache.get("t1", "b")).toBeUndefined(); + teamSecretCache.hold("t2", "c", "3"); + teamSecretCache.clearAll(); + expect(teamSecretCache.get("t2", "c")).toBeUndefined(); +}); diff --git a/src/services/teamSecretCache.ts b/src/services/teamSecretCache.ts index 70de4b337..0390b3b2e 100644 --- a/src/services/teamSecretCache.ts +++ b/src/services/teamSecretCache.ts @@ -1,6 +1,8 @@ import { create } from "zustand"; +// What the server holds, and over it the values whose upload has not landed yet. const byTeam = new Map>(); +const unsent = new Map>(); export const useTeamSecretsHydrated = create<{ byTeam: Record }>(() => ({ byTeam: {} })); @@ -14,29 +16,49 @@ function markTeam(teamId: string, change: "hydrated" | "changed" | "cleared"): v }); } +const layerOf = (layers: Map>, teamId: string): Map => { + const layer = layers.get(teamId) ?? new Map(); + layers.set(teamId, layer); + return layer; +}; + export const teamSecretCache = { - get: (teamId: string, key: string): string | undefined => byTeam.get(teamId)?.get(key), + get: (teamId: string, key: string): string | undefined => unsent.get(teamId)?.get(key) ?? byTeam.get(teamId)?.get(key), + /** Records a value the server holds; an unsent value for the key survives unless it is this one. */ set(teamId: string, key: string, value: string): void { - const entries = byTeam.get(teamId) ?? new Map(); - entries.set(key, value); - byTeam.set(teamId, entries); + layerOf(byTeam, teamId).set(key, value); + if (unsent.get(teamId)?.get(key) === value) unsent.get(teamId)?.delete(key); + markTeam(teamId, "changed"); + }, + hold(teamId: string, key: string, value: string): void { + layerOf(unsent, teamId).set(key, value); + markTeam(teamId, "changed"); + }, + /** Stops showing `value` as unsent, unless a newer write replaced it meanwhile. */ + release(teamId: string, key: string, value: string): void { + if (unsent.get(teamId)?.get(key) !== value) return; + unsent.get(teamId)?.delete(key); markTeam(teamId, "changed"); }, delete: (teamId: string, key: string): void => { byTeam.get(teamId)?.delete(key); + unsent.get(teamId)?.delete(key); markTeam(teamId, "changed"); }, entries: (teamId: string): Map => new Map(byTeam.get(teamId) ?? []), + unsent: (teamId: string): Map => new Map(unsent.get(teamId) ?? []), replaceTeam: (teamId: string, entries: Map): void => { byTeam.set(teamId, new Map(entries)); markTeam(teamId, "hydrated"); }, clearTeam: (teamId: string): void => { byTeam.delete(teamId); + unsent.delete(teamId); markTeam(teamId, "cleared"); }, clearAll: (): void => { byTeam.clear(); + unsent.clear(); useTeamSecretsHydrated.setState({ byTeam: {} }); }, isHydrated: (teamId: string): boolean => teamId in useTeamSecretsHydrated.getState().byTeam, diff --git a/src/services/teamVaultSync.credentials.test.ts b/src/services/teamVaultSync.credentials.test.ts index f33edbf06..fdbcb8fb0 100644 --- a/src/services/teamVaultSync.credentials.test.ts +++ b/src/services/teamVaultSync.credentials.test.ts @@ -8,7 +8,9 @@ const h = vi.hoisted(() => ({ hydrateTeamVaultSecrets: vi.fn(), checkAndRotateTeamKey: vi.fn(), purge: vi.fn(), + retryUnsent: vi.fn(), })); +vi.mock("@/services/secretRouting", () => ({ retryUnsentTeamSecrets: h.retryUnsent })); vi.mock("@tauri-apps/api/core", () => ({ invoke: h.invoke })); vi.mock("@/services/http", () => ({ appFetch: h.appFetch })); vi.mock("@/services/teamObjects", () => ({ listTeamObjects: h.listTeamObjects })); @@ -51,6 +53,7 @@ beforeEach(() => { h.hydrateTeamVaultSecrets.mockReset().mockResolvedValue(undefined); h.checkAndRotateTeamKey.mockReset().mockResolvedValue(undefined); h.purge.mockReset().mockImplementation(async (keys: string[]) => keys); + h.retryUnsent.mockReset().mockResolvedValue(undefined); useTeamVaultStateStore.getState().clearAll(); teamSecretCache.clearAll(); usePendingSecretWipeStore.getState().clearAll(); @@ -133,6 +136,11 @@ test("a background refresh never sweeps", async () => { expect(h.purge).not.toHaveBeenCalled(); }); +test.each([{ background: false }, { background: true }])("a load ($background) retries the secrets earlier saves could not upload", async (options) => { + await fetchTeamData("t1", options); + expect(h.retryUnsent).toHaveBeenCalledWith("t1"); +}); + test("a failed sweep is queued for the next login", async () => { h.purge.mockRejectedValue(new Error("locked")); await fetchTeamData("t1"); diff --git a/src/services/teamVaultSync.ts b/src/services/teamVaultSync.ts index 1dc755e93..2486e43a9 100644 --- a/src/services/teamVaultSync.ts +++ b/src/services/teamVaultSync.ts @@ -486,7 +486,7 @@ async function _fetchTeamData(teamId: string, options: TeamVaultRefreshOptions): : false; } stateStore.setCredentialsUnavailable(teamId, !credentialsOk); - if (!options.background) await sweepLocalTeamSecrets(teamId); + await settleLoadedTeamSecrets(teamId, options); stateStore.setStatus(teamId, "loaded"); return; } @@ -582,7 +582,7 @@ async function _fetchTeamData(teamId: string, options: TeamVaultRefreshOptions): usePortForwardingStore.getState().setTeamRules(teamId, slices.portForwardingRules as PortForwardingRule[]); teamSecretCache.replaceTeam(teamId, new Map(Object.entries(blobPayload.secrets ?? {}))); - if (!options.background) await sweepLocalTeamSecrets(teamId); + await settleLoadedTeamSecrets(teamId, options); stateStore.setStatus(teamId, "loaded"); } @@ -766,6 +766,12 @@ export async function sweepLocalTeamSecrets(teamId: string): Promise { await purgeTeamObjectSecrets(teamId, stillPending, ownedKeys); } +async function settleLoadedTeamSecrets(teamId: string, options: TeamVaultRefreshOptions): Promise { + if (!options.background) await sweepLocalTeamSecrets(teamId); + const { retryUnsentTeamSecrets } = await import("@/services/secretRouting"); + await retryUnsentTeamSecrets(teamId); +} + /** Retries failed local-store purges, keeping queued any key now owned locally or still waiting to upload. */ export async function drainPendingSecretWipes(): Promise { const { usePendingSecretWipeStore } = await import("@/stores/pendingSecretWipeStore"); diff --git a/src/services/vaultObjectSecrets.ts b/src/services/vaultObjectSecrets.ts index 4d6699127..5c6f10781 100644 --- a/src/services/vaultObjectSecrets.ts +++ b/src/services/vaultObjectSecrets.ts @@ -1,22 +1,15 @@ import i18n from "@/i18n"; import type { SshKey, SshKeyFormData, Identity, IdentityFormData } from "@/types"; -import { useNotificationStore } from "@/stores/notificationStore"; import { useTeamVaultStateStore } from "@/stores/teamVaultStateStore"; import { usePendingTeamSecretUploadStore } from "@/stores/pendingTeamSecretUploadStore"; import { storeSecret, deleteSecret } from "@/services/vault"; -import { readSecretAt, writeSecretAt, removeSecretAt, teamIdOfVault, keepCachedOnUploadFailure } from "@/services/secretRouting"; +import { readSecretAt, writeSecretAt, removeSecretAt, teamIdOfVault, reportUploadFailure } from "@/services/secretRouting"; +import { toastSecretFailure } from "@/services/secretFailureToast"; import { secretKeysFor, type SecretObjectKind } from "@/services/teamVaultSecretKeys"; import { logFailure } from "@/lib/logger"; -function toastSecretError(messageKey: string, error: unknown): void { - useNotificationStore.getState().addToast({ - source: { kind: "plugin", id: "system", name: "Voltius" }, - type: "toast", - message: i18n.t(messageKey, { error: error instanceof Error ? error.message : String(error) }), - severity: "error", - duration: 10000, - }); -} +const toastSecretError = (messageKey: string, error: unknown): void => + toastSecretFailure(i18n.t(messageKey, { error: error instanceof Error ? error.message : String(error) })); /** Reports a failed withdrawal instead of throwing: the object has already moved, but the material is still readable where it was. */ async function withdrawOrWarn(withdrawal: Promise): Promise { @@ -121,7 +114,7 @@ export async function moveWithSecrets( if (editsAtSource) { await applyEdits( edits, - (k, v) => writeSecretAt(from, k, v).catch(keepCachedOnUploadFailure(`secret edit before move ${k}`)), + (k, v) => writeSecretAt(from, k, v).catch(reportUploadFailure(`secret edit before move ${k}`)), (k) => removeSecretAt(from, k), ); } diff --git a/src/stores/recreateHistory.ts b/src/stores/recreateHistory.ts index dd18d44ad..991fd6325 100644 --- a/src/stores/recreateHistory.ts +++ b/src/stores/recreateHistory.ts @@ -70,9 +70,9 @@ export function pushTeamDeleteHistory(opts: Tea await saveTeamVaultObject(teamId, type, item); opts.putBack(item); if (secrets.length === 0) return; - const { writeSecretAt, keepCachedOnUploadFailure } = await import("@/services/secretRouting"); + const { writeSecretAt, reportUploadFailure } = await import("@/services/secretRouting"); for (const [key, value] of secrets) { - await writeSecretAt(teamId, key, value).catch(keepCachedOnUploadFailure(`restore team secret ${key}`)); + await writeSecretAt(teamId, key, value).catch(reportUploadFailure(`restore team secret ${key}`)); } }, redo: () => opts.remove(item.id), diff --git a/src/stores/sessionStore.ts b/src/stores/sessionStore.ts index 8d59b8824..35aae5aae 100644 --- a/src/stores/sessionStore.ts +++ b/src/stores/sessionStore.ts @@ -15,7 +15,7 @@ import { setEphemeralCredentials, clearEphemeralCredentials } from "@/services/e import { storeSecret, getSecret } from "@/services/vault"; import { identityPickIssueOf, type IdentityPickIssue } from "@/services/credentialPlan"; import { backendErrorCode, describeError, type BackendErrorCode } from "@/services/backendErrors"; -import { keepCachedOnUploadFailure } from "@/services/secretRouting"; +import { reportUploadFailure } from "@/services/secretRouting"; import { useIdentityPickStore } from "@/stores/identityPickStore"; import { findLoadedIdentity, isOwnLoadedIdentity } from "@/services/loadedIdentities"; import { resolveTeamIdForVaultId } from "@/services/teamVaultSecrets"; @@ -492,15 +492,15 @@ async function persistConnectAuth(connection: Connection, override: ConnectRetry data.identity_id = undefined; data.key_id = undefined; data.auth_type = "key"; - await storeSecret(`key:${connection.id}`, override.privateKey.trim()).catch(keepCachedOnUploadFailure("persistConnectAuth")); + await storeSecret(`key:${connection.id}`, override.privateKey.trim()).catch(reportUploadFailure("persistConnectAuth")); if (override.passphrase) { - await storeSecret(`passphrase:${connection.id}`, override.passphrase).catch(keepCachedOnUploadFailure("persistConnectAuth")); + await storeSecret(`passphrase:${connection.id}`, override.passphrase).catch(reportUploadFailure("persistConnectAuth")); } } else if (override.password) { data.identity_id = undefined; data.key_id = undefined; data.auth_type = "password"; - await storeSecret(`password:${connection.id}`, override.password).catch(keepCachedOnUploadFailure("persistConnectAuth")); + await storeSecret(`password:${connection.id}`, override.password).catch(reportUploadFailure("persistConnectAuth")); } await useConnectionStore.getState().updateConnection(connection.id, data); @@ -1049,7 +1049,7 @@ export const useSessionStore = create((set, get) => ({ credentials = await resolveSessionCredentials(connection, sessionId); const target = save ? passphraseSecretKey(connection, credentials) : null; - if (target) await storeSecret(target, passphrase).catch(keepCachedOnUploadFailure("reconnectWithPassphrase")); + if (target) await storeSecret(target, passphrase).catch(reportUploadFailure("reconnectWithPassphrase")); const opts = await buildSshConnectOptions(connection, sessionId); await sshConnect({ diff --git a/src/stores/vaultObjectStores.characterisation.test.ts b/src/stores/vaultObjectStores.characterisation.test.ts index 154554e90..c3d5093ec 100644 --- a/src/stores/vaultObjectStores.characterisation.test.ts +++ b/src/stores/vaultObjectStores.characterisation.test.ts @@ -33,7 +33,7 @@ vi.mock("@/services/teamObjectPersistence", () => ({ })); vi.mock("@/services/secretRouting", () => ({ writeSecretAt: h.writeSecretAt, - keepCachedOnUploadFailure: () => () => {}, + reportUploadFailure: () => () => {}, })); vi.mock("@/services/auditMutations", () => ({ reportAuditMutation: h.reportAuditMutation })); vi.mock("@/services/sync", () => ({ scheduleSync: h.scheduleSync }));