mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 21:00:41 +00:00
feat: add restore functionality for deleted items with corresponding UI updates
This commit is contained in:
+3
-2
@@ -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,
|
||||
|
||||
@@ -81,6 +81,7 @@ export interface AppMainRoutesProps {
|
||||
onDeleteVaultItem: (cipher: Cipher) => Promise<void>;
|
||||
onArchiveVaultItem: (cipher: Cipher) => Promise<void>;
|
||||
onUnarchiveVaultItem: (cipher: Cipher) => Promise<void>;
|
||||
onRestoreVaultItems: (ids: string[]) => Promise<void>;
|
||||
onBulkDeleteVaultItems: (ids: string[]) => Promise<void>;
|
||||
onBulkPermanentDeleteVaultItems: (ids: string[]) => Promise<void>;
|
||||
onBulkRestoreVaultItems: (ids: string[]) => Promise<void>;
|
||||
@@ -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}
|
||||
|
||||
@@ -45,6 +45,7 @@ interface VaultPageProps {
|
||||
onDelete: (cipher: Cipher) => Promise<void>;
|
||||
onArchive: (cipher: Cipher) => Promise<void>;
|
||||
onUnarchive: (cipher: Cipher) => Promise<void>;
|
||||
onRestore: (ids: string[]) => Promise<void>;
|
||||
onBulkDelete: (ids: string[]) => Promise<void>;
|
||||
onBulkPermanentDelete: (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> {
|
||||
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)}
|
||||
/>
|
||||
|
||||
@@ -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<void>;
|
||||
onArchive: (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 [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) {
|
||||
|
||||
<div className="detail-actions">
|
||||
<div className="actions">
|
||||
<button type="button" className="btn btn-secondary" onClick={props.onStartEdit}>
|
||||
<Pencil size={14} className="btn-icon" /> {t('txt_edit')}
|
||||
</button>
|
||||
{isArchived ? (
|
||||
<button type="button" className="btn btn-secondary" onClick={() => void props.onUnarchive(props.selectedCipher)}>
|
||||
<RotateCcw size={14} className="btn-icon" /> {t('txt_unarchive')}
|
||||
{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={() => void props.onArchive(props.selectedCipher)}>
|
||||
<Archive size={14} className="btn-icon" /> {t('txt_archive')}
|
||||
</button>
|
||||
<>
|
||||
<button type="button" className="btn btn-secondary" onClick={props.onStartEdit}>
|
||||
<Pencil size={14} className="btn-icon" /> {t('txt_edit')}
|
||||
</button>
|
||||
{isArchived ? (
|
||||
<button type="button" className="btn btn-secondary" onClick={() => void props.onUnarchive(props.selectedCipher)}>
|
||||
<RotateCcw size={14} className="btn-icon" /> {t('txt_unarchive')}
|
||||
</button>
|
||||
) : (
|
||||
<button type="button" className="btn btn-secondary" onClick={() => void props.onArchive(props.selectedCipher)}>
|
||||
<Archive size={14} className="btn-icon" /> {t('txt_archive')}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -803,6 +803,13 @@ export async function deleteCipher(authedFetch: AuthedFetch, cipherId: string):
|
||||
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> {
|
||||
const id = String(cipherId || '').trim();
|
||||
if (!id) throw new Error('Cipher id is required');
|
||||
|
||||
@@ -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)));
|
||||
|
||||
@@ -369,6 +369,7 @@ const en: Record<string, string> = {
|
||||
"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<string, string> = {
|
||||
"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}",
|
||||
|
||||
@@ -369,6 +369,7 @@ const es: Record<string, string> = {
|
||||
"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<string, string> = {
|
||||
"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}",
|
||||
|
||||
@@ -369,6 +369,7 @@ const ru: Record<string, string> = {
|
||||
"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<string, string> = {
|
||||
"txt_item": "Товар",
|
||||
"txt_item_created": "Объект создан",
|
||||
"txt_item_deleted": "Объект удален.",
|
||||
"txt_item_deleted_permanently": "Объект окончательно удален.",
|
||||
"txt_item_history": "История предмета",
|
||||
"txt_password_history": "История паролей",
|
||||
"txt_password_updated_value": "Пароль обновлен: {value}",
|
||||
|
||||
@@ -369,6 +369,7 @@ const zhCN: Record<string, string> = {
|
||||
"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<string, string> = {
|
||||
"txt_item": "项目",
|
||||
"txt_item_created": "项目已创建",
|
||||
"txt_item_deleted": "项目已删除",
|
||||
"txt_item_deleted_permanently": "项目已永久删除",
|
||||
"txt_item_history": "项目历史",
|
||||
"txt_password_history": "密码历史记录",
|
||||
"txt_password_updated_value": "密码更新于: {value}",
|
||||
|
||||
@@ -369,6 +369,7 @@ const zhTW: Record<string, string> = {
|
||||
"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<string, string> = {
|
||||
"txt_item": "項目",
|
||||
"txt_item_created": "項目已創建",
|
||||
"txt_item_deleted": "項目已刪除",
|
||||
"txt_item_deleted_permanently": "項目已永久刪除",
|
||||
"txt_item_history": "項目歷史",
|
||||
"txt_password_history": "密碼歷史記錄",
|
||||
"txt_password_updated_value": "密碼更新新於: {value}",
|
||||
|
||||
Reference in New Issue
Block a user