From 7312086f92f3911ec5a8606c51db2075090c7469 Mon Sep 17 00:00:00 2001 From: shuaiplus <2327005759@qq.com> Date: Thu, 14 May 2026 10:40:32 +0800 Subject: [PATCH] feat: add restore functionality for deleted items with corresponding UI updates --- webapp/src/App.tsx | 5 +-- webapp/src/components/AppMainRoutes.tsx | 2 ++ webapp/src/components/VaultPage.tsx | 14 +++++++++ .../src/components/vault/VaultDetailView.tsx | 31 +++++++++++++------ webapp/src/hooks/useVaultSendActions.ts | 13 ++++++++ webapp/src/lib/api/vault.ts | 7 +++++ webapp/src/lib/demo.ts | 10 ++++++ webapp/src/lib/i18n/locales/en.ts | 2 ++ webapp/src/lib/i18n/locales/es.ts | 2 ++ webapp/src/lib/i18n/locales/ru.ts | 2 ++ webapp/src/lib/i18n/locales/zh-CN.ts | 2 ++ webapp/src/lib/i18n/locales/zh-TW.ts | 2 ++ 12 files changed, 80 insertions(+), 12 deletions(-) diff --git a/webapp/src/App.tsx b/webapp/src/App.tsx index 746bec7..0776324 100644 --- a/webapp/src/App.tsx +++ b/webapp/src/App.tsx @@ -69,7 +69,7 @@ import { createDemoMainRoutesProps, } from '@/lib/demo'; import type { AdminBackupSettings } from '@/lib/api/backup'; -import type { AdminInvite, AdminUser, AppPhase, AuthorizedDevice, Cipher, CustomEquivalentDomain, DomainRules, Folder as VaultFolder, Profile, Send, SessionState } from '@/lib/types'; +import type { AdminInvite, AdminUser, AppPhase, AuditLogSettings, AuthorizedDevice, Cipher, CustomEquivalentDomain, DomainRules, Folder as VaultFolder, Profile, Send, SessionState } from '@/lib/types'; import type { VaultCoreSnapshot } from '@/lib/vault-cache'; function isBackupProgressDetail(value: unknown): value is BackupProgressDetail { @@ -1477,6 +1477,7 @@ export default function App() { onDeleteVaultItem: vaultSendActions.deleteVaultItem, onArchiveVaultItem: vaultSendActions.archiveVaultItem, onUnarchiveVaultItem: vaultSendActions.unarchiveVaultItem, + onRestoreVaultItems: vaultSendActions.bulkRestoreVaultItems, onBulkDeleteVaultItems: vaultSendActions.bulkDeleteVaultItems, onBulkPermanentDeleteVaultItems: vaultSendActions.bulkPermanentDeleteVaultItems, onBulkRestoreVaultItems: vaultSendActions.bulkRestoreVaultItems, @@ -1531,7 +1532,7 @@ export default function App() { onRevokeInvite: adminActions.revokeInvite, onLoadAuditLogs: (filters: AuditLogFilters) => listAuditLogs(authedFetch, filters), onLoadAuditLogSettings: () => getAuditLogSettings(authedFetch), - onSaveAuditLogSettings: (settings) => saveAuditLogSettings(authedFetch, settings), + onSaveAuditLogSettings: (settings: AuditLogSettings) => saveAuditLogSettings(authedFetch, settings), onClearAuditLogs: () => clearAuditLogs(authedFetch), onExportBackup: backupActions.exportBackup, onImportBackup: backupActions.importBackup, diff --git a/webapp/src/components/AppMainRoutes.tsx b/webapp/src/components/AppMainRoutes.tsx index e951a52..8601e5c 100644 --- a/webapp/src/components/AppMainRoutes.tsx +++ b/webapp/src/components/AppMainRoutes.tsx @@ -81,6 +81,7 @@ export interface AppMainRoutesProps { onDeleteVaultItem: (cipher: Cipher) => Promise; onArchiveVaultItem: (cipher: Cipher) => Promise; onUnarchiveVaultItem: (cipher: Cipher) => Promise; + onRestoreVaultItems: (ids: string[]) => Promise; onBulkDeleteVaultItems: (ids: string[]) => Promise; onBulkPermanentDeleteVaultItems: (ids: string[]) => Promise; onBulkRestoreVaultItems: (ids: string[]) => Promise; @@ -214,6 +215,7 @@ export default function AppMainRoutes(props: AppMainRoutesProps) { onDelete={props.onDeleteVaultItem} onArchive={props.onArchiveVaultItem} onUnarchive={props.onUnarchiveVaultItem} + onRestore={props.onRestoreVaultItems} onBulkDelete={props.onBulkDeleteVaultItems} onBulkPermanentDelete={props.onBulkPermanentDeleteVaultItems} onBulkRestore={props.onBulkRestoreVaultItems} diff --git a/webapp/src/components/VaultPage.tsx b/webapp/src/components/VaultPage.tsx index eb94854..0b4ce6b 100644 --- a/webapp/src/components/VaultPage.tsx +++ b/webapp/src/components/VaultPage.tsx @@ -45,6 +45,7 @@ interface VaultPageProps { onDelete: (cipher: Cipher) => Promise; onArchive: (cipher: Cipher) => Promise; onUnarchive: (cipher: Cipher) => Promise; + onRestore: (ids: string[]) => Promise; onBulkDelete: (ids: string[]) => Promise; onBulkPermanentDelete: (ids: string[]) => Promise; onBulkRestore: (ids: string[]) => Promise; @@ -732,6 +733,18 @@ const folderName = useCallback((id: string | null | undefined): string => { } } + async function handleRestoreSelected(cipher: Cipher): Promise { + setBusy(true); + try { + await props.onRestore([cipher.id]); + if (isMobileLayout && selectedCipherId === cipher.id) { + setMobilePanel('list'); + } + } finally { + setBusy(false); + } + } + async function confirmBulkDelete(): Promise { const ids = Object.entries(selectedMap) .filter(([, selected]) => selected) @@ -1148,6 +1161,7 @@ const folderName = useCallback((id: string | null | undefined): string => { attachmentDownloadPercent={props.attachmentDownloadPercent} onStartEdit={startEdit} onDelete={setPendingDelete} + onRestore={(cipher) => void handleRestoreSelected(cipher)} onArchive={(cipher) => setPendingArchive(cipher)} onUnarchive={(cipher) => void handleUnarchiveSelected(cipher)} /> diff --git a/webapp/src/components/vault/VaultDetailView.tsx b/webapp/src/components/vault/VaultDetailView.tsx index 45d0706..2a21a3a 100644 --- a/webapp/src/components/vault/VaultDetailView.tsx +++ b/webapp/src/components/vault/VaultDetailView.tsx @@ -14,6 +14,7 @@ import { formatAttachmentSize, formatHistoryTime, formatTotp, + isCipherDeleted, maskSecret, openUri, parseFieldType, @@ -36,6 +37,7 @@ interface VaultDetailViewProps { onDownloadAttachment: (cipher: Cipher, attachmentId: string) => void; onStartEdit: () => void; onDelete: (cipher: Cipher) => void; + onRestore: (cipher: Cipher) => void | Promise; onArchive: (cipher: Cipher) => void | Promise; onUnarchive: (cipher: Cipher) => void | Promise; } @@ -84,6 +86,7 @@ export default function VaultDetailView(props: VaultDetailViewProps) { const [showSshPrivateKey, setShowSshPrivateKey] = useState(false); const [passwordHistoryOpen, setPasswordHistoryOpen] = useState(false); const isArchived = !!(props.selectedCipher.archivedDate || (props.selectedCipher as { archivedAt?: string | null }).archivedAt); + const isDeleted = isCipherDeleted(props.selectedCipher); const passwordHistoryEntries = useMemo( () => (props.selectedCipher.passwordHistory || []) @@ -446,21 +449,29 @@ export default function VaultDetailView(props: VaultDetailViewProps) {
- - {isArchived ? ( - ) : ( - + <> + + {isArchived ? ( + + ) : ( + + )} + )}
diff --git a/webapp/src/hooks/useVaultSendActions.ts b/webapp/src/hooks/useVaultSendActions.ts index 4c21e9a..1032741 100644 --- a/webapp/src/hooks/useVaultSendActions.ts +++ b/webapp/src/hooks/useVaultSendActions.ts @@ -41,6 +41,7 @@ import { encryptFolderImportName, getAttachmentDownloadInfo, importCiphers, + permanentDeleteCipher, type CiphersImportPayload, type ImportedCipherMapEntry, updateCipher, @@ -490,6 +491,18 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions) async deleteVaultItem(cipher: Cipher) { const previousCipher = { ...cipher }; + if (cipher.deletedDate || (cipher as { deletedAt?: string | null }).deletedAt) { + try { + await permanentDeleteCipher(authedFetch, cipher.id); + patchCipherBatch([cipher.id], () => null); + syncVaultCoreInBackground({ includeFolders: true }); + onNotify('success', t('txt_item_deleted_permanently')); + } catch (error) { + onNotify('error', error instanceof Error ? error.message : t('txt_permanent_delete_item_failed')); + throw error; + } + return; + } const deletedDate = new Date().toISOString(); patchCipherBatch([cipher.id], (current) => ({ ...current, deletedDate, archivedDate: null, revisionDate: deletedDate })); try { diff --git a/webapp/src/lib/api/vault.ts b/webapp/src/lib/api/vault.ts index 259b47e..27c371c 100644 --- a/webapp/src/lib/api/vault.ts +++ b/webapp/src/lib/api/vault.ts @@ -803,6 +803,13 @@ export async function deleteCipher(authedFetch: AuthedFetch, cipherId: string): return (await parseJson(resp))!; } +export async function permanentDeleteCipher(authedFetch: AuthedFetch, cipherId: string): Promise { + const id = String(cipherId || '').trim(); + if (!id) throw new Error('Cipher id is required'); + const resp = await authedFetch(`/api/ciphers/${encodeURIComponent(id)}/delete`, { method: 'DELETE' }); + if (!resp.ok) throw new Error('Permanent delete item failed'); +} + export async function archiveCipher(authedFetch: AuthedFetch, cipherId: string): Promise { const id = String(cipherId || '').trim(); if (!id) throw new Error('Cipher id is required'); diff --git a/webapp/src/lib/demo.ts b/webapp/src/lib/demo.ts index dc98329..956deab 100644 --- a/webapp/src/lib/demo.ts +++ b/webapp/src/lib/demo.ts @@ -932,6 +932,11 @@ export function createDemoMainRoutesProps(base: AppMainRoutesProps, notify: Noti notify('success', t('txt_item_updated')); }, onDeleteVaultItem: async (cipher) => { + if (cipher.deletedDate || (cipher as { deletedAt?: string | null }).deletedAt) { + state.setCiphers((prev) => prev.filter((item) => item.id !== cipher.id)); + notify('success', t('txt_item_deleted_permanently')); + return; + } const deletedDate = new Date().toISOString(); state.setCiphers((prev) => prev.map((item) => ( item.id === cipher.id ? { ...item, deletedDate, archivedDate: null, revisionDate: deletedDate } : item @@ -965,6 +970,11 @@ export function createDemoMainRoutesProps(base: AppMainRoutesProps, notify: Noti state.setCiphers((prev) => prev.filter((item) => !idSet.has(item.id))); notify('success', t('txt_deleted_selected_items_permanently')); }, + onRestoreVaultItems: async (ids) => { + const idSet = new Set(ids); + state.setCiphers((prev) => prev.map((item) => (idSet.has(item.id) ? { ...item, deletedDate: null } : item))); + notify('success', t('txt_restored_selected_items')); + }, onBulkRestoreVaultItems: async (ids) => { const idSet = new Set(ids); state.setCiphers((prev) => prev.map((item) => (idSet.has(item.id) ? { ...item, deletedDate: null } : item))); diff --git a/webapp/src/lib/i18n/locales/en.ts b/webapp/src/lib/i18n/locales/en.ts index 147b2ab..c989093 100644 --- a/webapp/src/lib/i18n/locales/en.ts +++ b/webapp/src/lib/i18n/locales/en.ts @@ -369,6 +369,7 @@ const en: Record = { "txt_delete_item": "Delete Item", "txt_delete_passkey": "Delete Passkey", "txt_delete_item_failed": "Delete item failed", + "txt_permanent_delete_item_failed": "Permanent delete item failed", "txt_delete_permanently": "Delete Permanently", "txt_archive": "Archive", "txt_archive_item": "Archive Item", @@ -501,6 +502,7 @@ const en: Record = { "txt_item": "Item", "txt_item_created": "Item created", "txt_item_deleted": "Item deleted", + "txt_item_deleted_permanently": "Item permanently deleted", "txt_item_history": "Item History", "txt_password_history": "Password History", "txt_password_updated_value": "Password updated: {value}", diff --git a/webapp/src/lib/i18n/locales/es.ts b/webapp/src/lib/i18n/locales/es.ts index 942bd6c..bf46baf 100644 --- a/webapp/src/lib/i18n/locales/es.ts +++ b/webapp/src/lib/i18n/locales/es.ts @@ -369,6 +369,7 @@ const es: Record = { "txt_delete_item": "Eliminar elemento", "txt_delete_passkey": "Eliminar clave de acceso", "txt_delete_item_failed": "Error al eliminar elemento", + "txt_permanent_delete_item_failed": "Error al eliminar elemento permanentemente", "txt_delete_permanently": "Eliminar permanentemente", "txt_archive": "Archivar", "txt_archive_item": "Archivar elemento", @@ -501,6 +502,7 @@ const es: Record = { "txt_item": "Elemento", "txt_item_created": "Elemento creado", "txt_item_deleted": "Elemento eliminado", + "txt_item_deleted_permanently": "Elemento eliminado permanentemente", "txt_item_history": "Historial del elemento", "txt_password_history": "Historial de contraseñas", "txt_password_updated_value": "Contraseña actualizada: {value}", diff --git a/webapp/src/lib/i18n/locales/ru.ts b/webapp/src/lib/i18n/locales/ru.ts index ef1fca5..3fc25bf 100644 --- a/webapp/src/lib/i18n/locales/ru.ts +++ b/webapp/src/lib/i18n/locales/ru.ts @@ -369,6 +369,7 @@ const ru: Record = { "txt_delete_item": "Удалить элемент", "txt_delete_passkey": "Удалить пароль", "txt_delete_item_failed": "Удалить элемент не удалось", + "txt_permanent_delete_item_failed": "Не удалось окончательно удалить элемент", "txt_delete_permanently": "Удалить навсегда", "txt_archive": "Архив", "txt_archive_item": "Архивный элемент", @@ -501,6 +502,7 @@ const ru: Record = { "txt_item": "Товар", "txt_item_created": "Объект создан", "txt_item_deleted": "Объект удален.", + "txt_item_deleted_permanently": "Объект окончательно удален.", "txt_item_history": "История предмета", "txt_password_history": "История паролей", "txt_password_updated_value": "Пароль обновлен: {value}", diff --git a/webapp/src/lib/i18n/locales/zh-CN.ts b/webapp/src/lib/i18n/locales/zh-CN.ts index 45fc7b1..d3485eb 100644 --- a/webapp/src/lib/i18n/locales/zh-CN.ts +++ b/webapp/src/lib/i18n/locales/zh-CN.ts @@ -369,6 +369,7 @@ const zhCN: Record = { "txt_delete_item": "删除项目", "txt_delete_passkey": "删除通行密钥", "txt_delete_item_failed": "删除项目失败", + "txt_permanent_delete_item_failed": "永久删除项目失败", "txt_delete_permanently": "永久删除", "txt_archive": "归档", "txt_archive_item": "归档项目", @@ -501,6 +502,7 @@ const zhCN: Record = { "txt_item": "项目", "txt_item_created": "项目已创建", "txt_item_deleted": "项目已删除", + "txt_item_deleted_permanently": "项目已永久删除", "txt_item_history": "项目历史", "txt_password_history": "密码历史记录", "txt_password_updated_value": "密码更新于: {value}", diff --git a/webapp/src/lib/i18n/locales/zh-TW.ts b/webapp/src/lib/i18n/locales/zh-TW.ts index 7a1f3d5..f9cc3ed 100644 --- a/webapp/src/lib/i18n/locales/zh-TW.ts +++ b/webapp/src/lib/i18n/locales/zh-TW.ts @@ -369,6 +369,7 @@ const zhTW: Record = { "txt_delete_item": "刪除項目", "txt_delete_passkey": "刪除通行密鑰", "txt_delete_item_failed": "刪除項目失敗", + "txt_permanent_delete_item_failed": "永久刪除項目失敗", "txt_delete_permanently": "永久刪除", "txt_archive": "歸檔", "txt_archive_item": "歸檔項目", @@ -501,6 +502,7 @@ const zhTW: Record = { "txt_item": "項目", "txt_item_created": "項目已創建", "txt_item_deleted": "項目已刪除", + "txt_item_deleted_permanently": "項目已永久刪除", "txt_item_history": "項目歷史", "txt_password_history": "密碼歷史記錄", "txt_password_updated_value": "密碼更新新於: {value}",