feat: add restore functionality for deleted items with corresponding UI updates

This commit is contained in:
shuaiplus
2026-05-14 10:40:32 +08:00
parent 3e4c104e1d
commit 7312086f92
12 changed files with 80 additions and 12 deletions
+3 -2
View File
@@ -69,7 +69,7 @@ import {
createDemoMainRoutesProps, createDemoMainRoutesProps,
} from '@/lib/demo'; } from '@/lib/demo';
import type { AdminBackupSettings } from '@/lib/api/backup'; 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'; import type { VaultCoreSnapshot } from '@/lib/vault-cache';
function isBackupProgressDetail(value: unknown): value is BackupProgressDetail { function isBackupProgressDetail(value: unknown): value is BackupProgressDetail {
@@ -1477,6 +1477,7 @@ export default function App() {
onDeleteVaultItem: vaultSendActions.deleteVaultItem, onDeleteVaultItem: vaultSendActions.deleteVaultItem,
onArchiveVaultItem: vaultSendActions.archiveVaultItem, onArchiveVaultItem: vaultSendActions.archiveVaultItem,
onUnarchiveVaultItem: vaultSendActions.unarchiveVaultItem, onUnarchiveVaultItem: vaultSendActions.unarchiveVaultItem,
onRestoreVaultItems: vaultSendActions.bulkRestoreVaultItems,
onBulkDeleteVaultItems: vaultSendActions.bulkDeleteVaultItems, onBulkDeleteVaultItems: vaultSendActions.bulkDeleteVaultItems,
onBulkPermanentDeleteVaultItems: vaultSendActions.bulkPermanentDeleteVaultItems, onBulkPermanentDeleteVaultItems: vaultSendActions.bulkPermanentDeleteVaultItems,
onBulkRestoreVaultItems: vaultSendActions.bulkRestoreVaultItems, onBulkRestoreVaultItems: vaultSendActions.bulkRestoreVaultItems,
@@ -1531,7 +1532,7 @@ export default function App() {
onRevokeInvite: adminActions.revokeInvite, onRevokeInvite: adminActions.revokeInvite,
onLoadAuditLogs: (filters: AuditLogFilters) => listAuditLogs(authedFetch, filters), onLoadAuditLogs: (filters: AuditLogFilters) => listAuditLogs(authedFetch, filters),
onLoadAuditLogSettings: () => getAuditLogSettings(authedFetch), onLoadAuditLogSettings: () => getAuditLogSettings(authedFetch),
onSaveAuditLogSettings: (settings) => saveAuditLogSettings(authedFetch, settings), onSaveAuditLogSettings: (settings: AuditLogSettings) => saveAuditLogSettings(authedFetch, settings),
onClearAuditLogs: () => clearAuditLogs(authedFetch), onClearAuditLogs: () => clearAuditLogs(authedFetch),
onExportBackup: backupActions.exportBackup, onExportBackup: backupActions.exportBackup,
onImportBackup: backupActions.importBackup, onImportBackup: backupActions.importBackup,
+2
View File
@@ -81,6 +81,7 @@ export interface AppMainRoutesProps {
onDeleteVaultItem: (cipher: Cipher) => Promise<void>; onDeleteVaultItem: (cipher: Cipher) => Promise<void>;
onArchiveVaultItem: (cipher: Cipher) => Promise<void>; onArchiveVaultItem: (cipher: Cipher) => Promise<void>;
onUnarchiveVaultItem: (cipher: Cipher) => Promise<void>; onUnarchiveVaultItem: (cipher: Cipher) => Promise<void>;
onRestoreVaultItems: (ids: string[]) => Promise<void>;
onBulkDeleteVaultItems: (ids: string[]) => Promise<void>; onBulkDeleteVaultItems: (ids: string[]) => Promise<void>;
onBulkPermanentDeleteVaultItems: (ids: string[]) => Promise<void>; onBulkPermanentDeleteVaultItems: (ids: string[]) => Promise<void>;
onBulkRestoreVaultItems: (ids: string[]) => Promise<void>; onBulkRestoreVaultItems: (ids: string[]) => Promise<void>;
@@ -214,6 +215,7 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
onDelete={props.onDeleteVaultItem} onDelete={props.onDeleteVaultItem}
onArchive={props.onArchiveVaultItem} onArchive={props.onArchiveVaultItem}
onUnarchive={props.onUnarchiveVaultItem} onUnarchive={props.onUnarchiveVaultItem}
onRestore={props.onRestoreVaultItems}
onBulkDelete={props.onBulkDeleteVaultItems} onBulkDelete={props.onBulkDeleteVaultItems}
onBulkPermanentDelete={props.onBulkPermanentDeleteVaultItems} onBulkPermanentDelete={props.onBulkPermanentDeleteVaultItems}
onBulkRestore={props.onBulkRestoreVaultItems} onBulkRestore={props.onBulkRestoreVaultItems}
+14
View File
@@ -45,6 +45,7 @@ interface VaultPageProps {
onDelete: (cipher: Cipher) => Promise<void>; onDelete: (cipher: Cipher) => Promise<void>;
onArchive: (cipher: Cipher) => Promise<void>; onArchive: (cipher: Cipher) => Promise<void>;
onUnarchive: (cipher: Cipher) => Promise<void>; onUnarchive: (cipher: Cipher) => Promise<void>;
onRestore: (ids: string[]) => Promise<void>;
onBulkDelete: (ids: string[]) => Promise<void>; onBulkDelete: (ids: string[]) => Promise<void>;
onBulkPermanentDelete: (ids: string[]) => Promise<void>; onBulkPermanentDelete: (ids: string[]) => Promise<void>;
onBulkRestore: (ids: string[]) => Promise<void>; onBulkRestore: (ids: string[]) => Promise<void>;
@@ -732,6 +733,18 @@ const folderName = useCallback((id: string | null | undefined): string => {
} }
} }
async function handleRestoreSelected(cipher: Cipher): Promise<void> {
setBusy(true);
try {
await props.onRestore([cipher.id]);
if (isMobileLayout && selectedCipherId === cipher.id) {
setMobilePanel('list');
}
} finally {
setBusy(false);
}
}
async function confirmBulkDelete(): Promise<void> { async function confirmBulkDelete(): Promise<void> {
const ids = Object.entries(selectedMap) const ids = Object.entries(selectedMap)
.filter(([, selected]) => selected) .filter(([, selected]) => selected)
@@ -1148,6 +1161,7 @@ const folderName = useCallback((id: string | null | undefined): string => {
attachmentDownloadPercent={props.attachmentDownloadPercent} attachmentDownloadPercent={props.attachmentDownloadPercent}
onStartEdit={startEdit} onStartEdit={startEdit}
onDelete={setPendingDelete} onDelete={setPendingDelete}
onRestore={(cipher) => void handleRestoreSelected(cipher)}
onArchive={(cipher) => setPendingArchive(cipher)} onArchive={(cipher) => setPendingArchive(cipher)}
onUnarchive={(cipher) => void handleUnarchiveSelected(cipher)} onUnarchive={(cipher) => void handleUnarchiveSelected(cipher)}
/> />
@@ -14,6 +14,7 @@ import {
formatAttachmentSize, formatAttachmentSize,
formatHistoryTime, formatHistoryTime,
formatTotp, formatTotp,
isCipherDeleted,
maskSecret, maskSecret,
openUri, openUri,
parseFieldType, parseFieldType,
@@ -36,6 +37,7 @@ interface VaultDetailViewProps {
onDownloadAttachment: (cipher: Cipher, attachmentId: string) => void; onDownloadAttachment: (cipher: Cipher, attachmentId: string) => void;
onStartEdit: () => void; onStartEdit: () => void;
onDelete: (cipher: Cipher) => void; onDelete: (cipher: Cipher) => void;
onRestore: (cipher: Cipher) => void | Promise<void>;
onArchive: (cipher: Cipher) => void | Promise<void>; onArchive: (cipher: Cipher) => void | Promise<void>;
onUnarchive: (cipher: Cipher) => void | Promise<void>; onUnarchive: (cipher: Cipher) => void | Promise<void>;
} }
@@ -84,6 +86,7 @@ export default function VaultDetailView(props: VaultDetailViewProps) {
const [showSshPrivateKey, setShowSshPrivateKey] = useState(false); const [showSshPrivateKey, setShowSshPrivateKey] = useState(false);
const [passwordHistoryOpen, setPasswordHistoryOpen] = useState(false); const [passwordHistoryOpen, setPasswordHistoryOpen] = useState(false);
const isArchived = !!(props.selectedCipher.archivedDate || (props.selectedCipher as { archivedAt?: string | null }).archivedAt); const isArchived = !!(props.selectedCipher.archivedDate || (props.selectedCipher as { archivedAt?: string | null }).archivedAt);
const isDeleted = isCipherDeleted(props.selectedCipher);
const passwordHistoryEntries = useMemo( const passwordHistoryEntries = useMemo(
() => () =>
(props.selectedCipher.passwordHistory || []) (props.selectedCipher.passwordHistory || [])
@@ -446,6 +449,12 @@ export default function VaultDetailView(props: VaultDetailViewProps) {
<div className="detail-actions"> <div className="detail-actions">
<div className="actions"> <div className="actions">
{isDeleted ? (
<button type="button" className="btn btn-secondary" onClick={() => void props.onRestore(props.selectedCipher)}>
<RotateCcw size={14} className="btn-icon" /> {t('txt_restore')}
</button>
) : (
<>
<button type="button" className="btn btn-secondary" onClick={props.onStartEdit}> <button type="button" className="btn btn-secondary" onClick={props.onStartEdit}>
<Pencil size={14} className="btn-icon" /> {t('txt_edit')} <Pencil size={14} className="btn-icon" /> {t('txt_edit')}
</button> </button>
@@ -458,9 +467,11 @@ export default function VaultDetailView(props: VaultDetailViewProps) {
<Archive size={14} className="btn-icon" /> {t('txt_archive')} <Archive size={14} className="btn-icon" /> {t('txt_archive')}
</button> </button>
)} )}
</>
)}
</div> </div>
<button type="button" className="btn btn-danger" onClick={() => props.onDelete(props.selectedCipher)}> <button type="button" className="btn btn-danger" onClick={() => props.onDelete(props.selectedCipher)}>
<Trash2 size={14} className="btn-icon" /> {t('txt_delete')} <Trash2 size={14} className="btn-icon" /> {isDeleted ? t('txt_delete_permanently') : t('txt_delete')}
</button> </button>
</div> </div>
</> </>
+13
View File
@@ -41,6 +41,7 @@ import {
encryptFolderImportName, encryptFolderImportName,
getAttachmentDownloadInfo, getAttachmentDownloadInfo,
importCiphers, importCiphers,
permanentDeleteCipher,
type CiphersImportPayload, type CiphersImportPayload,
type ImportedCipherMapEntry, type ImportedCipherMapEntry,
updateCipher, updateCipher,
@@ -490,6 +491,18 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
async deleteVaultItem(cipher: Cipher) { async deleteVaultItem(cipher: Cipher) {
const previousCipher = { ...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(); const deletedDate = new Date().toISOString();
patchCipherBatch([cipher.id], (current) => ({ ...current, deletedDate, archivedDate: null, revisionDate: deletedDate })); patchCipherBatch([cipher.id], (current) => ({ ...current, deletedDate, archivedDate: null, revisionDate: deletedDate }));
try { try {
+7
View File
@@ -803,6 +803,13 @@ export async function deleteCipher(authedFetch: AuthedFetch, cipherId: string):
return (await parseJson<Cipher>(resp))!; return (await parseJson<Cipher>(resp))!;
} }
export async function permanentDeleteCipher(authedFetch: AuthedFetch, cipherId: string): Promise<void> {
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<Cipher> { export async function archiveCipher(authedFetch: AuthedFetch, cipherId: string): Promise<Cipher> {
const id = String(cipherId || '').trim(); const id = String(cipherId || '').trim();
if (!id) throw new Error('Cipher id is required'); if (!id) throw new Error('Cipher id is required');
+10
View File
@@ -932,6 +932,11 @@ export function createDemoMainRoutesProps(base: AppMainRoutesProps, notify: Noti
notify('success', t('txt_item_updated')); notify('success', t('txt_item_updated'));
}, },
onDeleteVaultItem: async (cipher) => { 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(); const deletedDate = new Date().toISOString();
state.setCiphers((prev) => prev.map((item) => ( state.setCiphers((prev) => prev.map((item) => (
item.id === cipher.id ? { ...item, deletedDate, archivedDate: null, revisionDate: deletedDate } : 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))); state.setCiphers((prev) => prev.filter((item) => !idSet.has(item.id)));
notify('success', t('txt_deleted_selected_items_permanently')); 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) => { onBulkRestoreVaultItems: async (ids) => {
const idSet = new Set(ids); const idSet = new Set(ids);
state.setCiphers((prev) => prev.map((item) => (idSet.has(item.id) ? { ...item, deletedDate: null } : item))); state.setCiphers((prev) => prev.map((item) => (idSet.has(item.id) ? { ...item, deletedDate: null } : item)));
+2
View File
@@ -369,6 +369,7 @@ const en: Record<string, string> = {
"txt_delete_item": "Delete Item", "txt_delete_item": "Delete Item",
"txt_delete_passkey": "Delete Passkey", "txt_delete_passkey": "Delete Passkey",
"txt_delete_item_failed": "Delete item failed", "txt_delete_item_failed": "Delete item failed",
"txt_permanent_delete_item_failed": "Permanent delete item failed",
"txt_delete_permanently": "Delete Permanently", "txt_delete_permanently": "Delete Permanently",
"txt_archive": "Archive", "txt_archive": "Archive",
"txt_archive_item": "Archive Item", "txt_archive_item": "Archive Item",
@@ -501,6 +502,7 @@ const en: Record<string, string> = {
"txt_item": "Item", "txt_item": "Item",
"txt_item_created": "Item created", "txt_item_created": "Item created",
"txt_item_deleted": "Item deleted", "txt_item_deleted": "Item deleted",
"txt_item_deleted_permanently": "Item permanently deleted",
"txt_item_history": "Item History", "txt_item_history": "Item History",
"txt_password_history": "Password History", "txt_password_history": "Password History",
"txt_password_updated_value": "Password updated: {value}", "txt_password_updated_value": "Password updated: {value}",
+2
View File
@@ -369,6 +369,7 @@ const es: Record<string, string> = {
"txt_delete_item": "Eliminar elemento", "txt_delete_item": "Eliminar elemento",
"txt_delete_passkey": "Eliminar clave de acceso", "txt_delete_passkey": "Eliminar clave de acceso",
"txt_delete_item_failed": "Error al eliminar elemento", "txt_delete_item_failed": "Error al eliminar elemento",
"txt_permanent_delete_item_failed": "Error al eliminar elemento permanentemente",
"txt_delete_permanently": "Eliminar permanentemente", "txt_delete_permanently": "Eliminar permanentemente",
"txt_archive": "Archivar", "txt_archive": "Archivar",
"txt_archive_item": "Archivar elemento", "txt_archive_item": "Archivar elemento",
@@ -501,6 +502,7 @@ const es: Record<string, string> = {
"txt_item": "Elemento", "txt_item": "Elemento",
"txt_item_created": "Elemento creado", "txt_item_created": "Elemento creado",
"txt_item_deleted": "Elemento eliminado", "txt_item_deleted": "Elemento eliminado",
"txt_item_deleted_permanently": "Elemento eliminado permanentemente",
"txt_item_history": "Historial del elemento", "txt_item_history": "Historial del elemento",
"txt_password_history": "Historial de contraseñas", "txt_password_history": "Historial de contraseñas",
"txt_password_updated_value": "Contraseña actualizada: {value}", "txt_password_updated_value": "Contraseña actualizada: {value}",
+2
View File
@@ -369,6 +369,7 @@ const ru: Record<string, string> = {
"txt_delete_item": "Удалить элемент", "txt_delete_item": "Удалить элемент",
"txt_delete_passkey": "Удалить пароль", "txt_delete_passkey": "Удалить пароль",
"txt_delete_item_failed": "Удалить элемент не удалось", "txt_delete_item_failed": "Удалить элемент не удалось",
"txt_permanent_delete_item_failed": "Не удалось окончательно удалить элемент",
"txt_delete_permanently": "Удалить навсегда", "txt_delete_permanently": "Удалить навсегда",
"txt_archive": "Архив", "txt_archive": "Архив",
"txt_archive_item": "Архивный элемент", "txt_archive_item": "Архивный элемент",
@@ -501,6 +502,7 @@ const ru: Record<string, string> = {
"txt_item": "Товар", "txt_item": "Товар",
"txt_item_created": "Объект создан", "txt_item_created": "Объект создан",
"txt_item_deleted": "Объект удален.", "txt_item_deleted": "Объект удален.",
"txt_item_deleted_permanently": "Объект окончательно удален.",
"txt_item_history": "История предмета", "txt_item_history": "История предмета",
"txt_password_history": "История паролей", "txt_password_history": "История паролей",
"txt_password_updated_value": "Пароль обновлен: {value}", "txt_password_updated_value": "Пароль обновлен: {value}",
+2
View File
@@ -369,6 +369,7 @@ const zhCN: Record<string, string> = {
"txt_delete_item": "删除项目", "txt_delete_item": "删除项目",
"txt_delete_passkey": "删除通行密钥", "txt_delete_passkey": "删除通行密钥",
"txt_delete_item_failed": "删除项目失败", "txt_delete_item_failed": "删除项目失败",
"txt_permanent_delete_item_failed": "永久删除项目失败",
"txt_delete_permanently": "永久删除", "txt_delete_permanently": "永久删除",
"txt_archive": "归档", "txt_archive": "归档",
"txt_archive_item": "归档项目", "txt_archive_item": "归档项目",
@@ -501,6 +502,7 @@ const zhCN: Record<string, string> = {
"txt_item": "项目", "txt_item": "项目",
"txt_item_created": "项目已创建", "txt_item_created": "项目已创建",
"txt_item_deleted": "项目已删除", "txt_item_deleted": "项目已删除",
"txt_item_deleted_permanently": "项目已永久删除",
"txt_item_history": "项目历史", "txt_item_history": "项目历史",
"txt_password_history": "密码历史记录", "txt_password_history": "密码历史记录",
"txt_password_updated_value": "密码更新于: {value}", "txt_password_updated_value": "密码更新于: {value}",
+2
View File
@@ -369,6 +369,7 @@ const zhTW: Record<string, string> = {
"txt_delete_item": "刪除項目", "txt_delete_item": "刪除項目",
"txt_delete_passkey": "刪除通行密鑰", "txt_delete_passkey": "刪除通行密鑰",
"txt_delete_item_failed": "刪除項目失敗", "txt_delete_item_failed": "刪除項目失敗",
"txt_permanent_delete_item_failed": "永久刪除項目失敗",
"txt_delete_permanently": "永久刪除", "txt_delete_permanently": "永久刪除",
"txt_archive": "歸檔", "txt_archive": "歸檔",
"txt_archive_item": "歸檔項目", "txt_archive_item": "歸檔項目",
@@ -501,6 +502,7 @@ const zhTW: Record<string, string> = {
"txt_item": "項目", "txt_item": "項目",
"txt_item_created": "項目已創建", "txt_item_created": "項目已創建",
"txt_item_deleted": "項目已刪除", "txt_item_deleted": "項目已刪除",
"txt_item_deleted_permanently": "項目已永久刪除",
"txt_item_history": "項目歷史", "txt_item_history": "項目歷史",
"txt_password_history": "密碼歷史記錄", "txt_password_history": "密碼歷史記錄",
"txt_password_updated_value": "密碼更新新於: {value}", "txt_password_updated_value": "密碼更新新於: {value}",