mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 21:00:41 +00:00
feat: add archiving functionality for ciphers
- Introduced `archive` and `unarchive` endpoints in the API for ciphers. - Implemented bulk archiving and unarchiving of ciphers in the vault. - Updated the storage schema to include `archived_at` timestamps for ciphers. - Enhanced user interface to support archiving actions in the vault. - Added necessary translations for archive-related actions. - Updated user and device models to accommodate new fields related to archiving.
This commit is contained in:
@@ -64,9 +64,13 @@ export interface AppMainRoutesProps {
|
||||
onCreateVaultItem: (draft: VaultDraft, attachments?: File[]) => Promise<void>;
|
||||
onUpdateVaultItem: (cipher: Cipher, draft: VaultDraft, options?: { addFiles?: File[]; removeAttachmentIds?: string[] }) => Promise<void>;
|
||||
onDeleteVaultItem: (cipher: Cipher) => Promise<void>;
|
||||
onArchiveVaultItem: (cipher: Cipher) => Promise<void>;
|
||||
onUnarchiveVaultItem: (cipher: Cipher) => Promise<void>;
|
||||
onBulkDeleteVaultItems: (ids: string[]) => Promise<void>;
|
||||
onBulkPermanentDeleteVaultItems: (ids: string[]) => Promise<void>;
|
||||
onBulkRestoreVaultItems: (ids: string[]) => Promise<void>;
|
||||
onBulkArchiveVaultItems: (ids: string[]) => Promise<void>;
|
||||
onBulkUnarchiveVaultItems: (ids: string[]) => Promise<void>;
|
||||
onBulkMoveVaultItems: (ids: string[], folderId: string | null) => Promise<void>;
|
||||
onVerifyMasterPassword: (email: string, password: string) => Promise<void>;
|
||||
onCreateFolder: (name: string) => Promise<void>;
|
||||
@@ -174,9 +178,13 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
|
||||
onCreate={props.onCreateVaultItem}
|
||||
onUpdate={props.onUpdateVaultItem}
|
||||
onDelete={props.onDeleteVaultItem}
|
||||
onArchive={props.onArchiveVaultItem}
|
||||
onUnarchive={props.onUnarchiveVaultItem}
|
||||
onBulkDelete={props.onBulkDeleteVaultItems}
|
||||
onBulkPermanentDelete={props.onBulkPermanentDeleteVaultItems}
|
||||
onBulkRestore={props.onBulkRestoreVaultItems}
|
||||
onBulkArchive={props.onBulkArchiveVaultItems}
|
||||
onBulkUnarchive={props.onBulkUnarchiveVaultItems}
|
||||
onBulkMove={props.onBulkMoveVaultItems}
|
||||
onVerifyMasterPassword={props.onVerifyMasterPassword}
|
||||
onNotify={props.onNotify}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { copyTextToClipboard as copyTextWithFeedback } from '@/lib/clipboard';
|
||||
import { calcTotpNow } from '@/lib/crypto';
|
||||
import { t } from '@/lib/i18n';
|
||||
import type { Cipher } from '@/lib/types';
|
||||
import { websiteIconUrl } from '@/components/vault/vault-page-helpers';
|
||||
import { isCipherVisibleInNormalVault, websiteIconUrl } from '@/components/vault/vault-page-helpers';
|
||||
|
||||
interface TotpCodesPageProps {
|
||||
ciphers: Cipher[];
|
||||
@@ -82,8 +82,7 @@ export default function TotpCodesPage(props: TotpCodesPageProps) {
|
||||
() =>
|
||||
props.ciphers
|
||||
.filter((cipher) => {
|
||||
const isDeleted = !!(cipher.deletedDate || (cipher as { deletedAt?: string | null }).deletedAt);
|
||||
return !isDeleted && !!cipher.login?.decTotp;
|
||||
return isCipherVisibleInNormalVault(cipher) && !!cipher.login?.decTotp;
|
||||
})
|
||||
.sort((a, b) => {
|
||||
const nameA = (a.decName || a.name || '').trim().toLowerCase();
|
||||
|
||||
@@ -17,6 +17,9 @@ import {
|
||||
buildCipherDuplicateSignature,
|
||||
firstCipherUri,
|
||||
firstPasskeyCreationTime,
|
||||
isCipherVisibleInArchive,
|
||||
isCipherVisibleInNormalVault,
|
||||
isCipherVisibleInTrash,
|
||||
sortTimeValue,
|
||||
type SidebarFilter,
|
||||
type VaultSortMode,
|
||||
@@ -36,9 +39,13 @@ interface VaultPageProps {
|
||||
onCreate: (draft: VaultDraft, attachments?: File[]) => Promise<void>;
|
||||
onUpdate: (cipher: Cipher, draft: VaultDraft, options?: { addFiles?: File[]; removeAttachmentIds?: string[] }) => Promise<void>;
|
||||
onDelete: (cipher: Cipher) => Promise<void>;
|
||||
onArchive: (cipher: Cipher) => Promise<void>;
|
||||
onUnarchive: (cipher: Cipher) => Promise<void>;
|
||||
onBulkDelete: (ids: string[]) => Promise<void>;
|
||||
onBulkPermanentDelete: (ids: string[]) => Promise<void>;
|
||||
onBulkRestore: (ids: string[]) => Promise<void>;
|
||||
onBulkArchive: (ids: string[]) => Promise<void>;
|
||||
onBulkUnarchive: (ids: string[]) => Promise<void>;
|
||||
onBulkMove: (ids: string[], folderId: string | null) => Promise<void>;
|
||||
onVerifyMasterPassword: (email: string, password: string) => Promise<void>;
|
||||
onNotify: (type: 'success' | 'error' | 'warning', text: string) => void;
|
||||
@@ -229,8 +236,7 @@ export default function VaultPage(props: VaultPageProps) {
|
||||
const duplicateSignatureCounts = useMemo(() => {
|
||||
const counts = new Map<string, number>();
|
||||
for (const cipher of props.ciphers) {
|
||||
const isDeleted = !!(cipher.deletedDate || (cipher as { deletedAt?: string | null }).deletedAt);
|
||||
if (isDeleted) continue;
|
||||
if (!isCipherVisibleInNormalVault(cipher)) continue;
|
||||
const signature = buildCipherDuplicateSignature(cipher);
|
||||
counts.set(signature, (counts.get(signature) || 0) + 1);
|
||||
}
|
||||
@@ -239,11 +245,12 @@ export default function VaultPage(props: VaultPageProps) {
|
||||
|
||||
const filteredCiphers = useMemo(() => {
|
||||
const next = props.ciphers.filter((cipher) => {
|
||||
const isDeleted = !!(cipher.deletedDate || (cipher as any).deletedAt);
|
||||
if (sidebarFilter.kind === 'trash') {
|
||||
if (!isDeleted) return false;
|
||||
if (!isCipherVisibleInTrash(cipher)) return false;
|
||||
} else if (sidebarFilter.kind === 'archive') {
|
||||
if (!isCipherVisibleInArchive(cipher)) return false;
|
||||
} else {
|
||||
if (isDeleted) return false;
|
||||
if (!isCipherVisibleInNormalVault(cipher)) return false;
|
||||
if (sidebarFilter.kind === 'duplicates' && (duplicateSignatureCounts.get(buildCipherDuplicateSignature(cipher)) || 0) < 2) {
|
||||
return false;
|
||||
}
|
||||
@@ -677,6 +684,34 @@ function folderName(id: string | null | undefined): string {
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmBulkArchive(): Promise<void> {
|
||||
const ids = Object.entries(selectedMap)
|
||||
.filter(([, selected]) => selected)
|
||||
.map(([id]) => id);
|
||||
if (!ids.length) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await props.onBulkArchive(ids);
|
||||
setSelectedMap({});
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmBulkUnarchive(): Promise<void> {
|
||||
const ids = Object.entries(selectedMap)
|
||||
.filter(([, selected]) => selected)
|
||||
.map(([id]) => id);
|
||||
if (!ids.length) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await props.onBulkUnarchive(ids);
|
||||
setSelectedMap({});
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmDeleteAllFolders(): Promise<void> {
|
||||
if (!props.folders.length) return;
|
||||
setBusy(true);
|
||||
@@ -760,6 +795,8 @@ function folderName(id: string | null | undefined): string {
|
||||
onToggleCreateMenu={() => setCreateMenuOpen((open) => !open)}
|
||||
onStartCreate={startCreate}
|
||||
onBulkRestore={() => void confirmBulkRestore()}
|
||||
onBulkArchive={() => void confirmBulkArchive()}
|
||||
onBulkUnarchive={() => void confirmBulkUnarchive()}
|
||||
onOpenMove={() => {
|
||||
setMoveFolderId('__none__');
|
||||
setMoveOpen(true);
|
||||
@@ -851,6 +888,8 @@ function folderName(id: string | null | undefined): string {
|
||||
attachmentDownloadPercent={props.attachmentDownloadPercent}
|
||||
onStartEdit={startEdit}
|
||||
onDelete={setPendingDelete}
|
||||
onArchive={props.onArchive}
|
||||
onUnarchive={props.onUnarchive}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from 'preact/hooks';
|
||||
import { Clipboard, Download, Eye, EyeOff, ExternalLink, Paperclip, Pencil, Trash2 } from 'lucide-preact';
|
||||
import { Archive, Clipboard, Download, Eye, EyeOff, ExternalLink, Paperclip, Pencil, RotateCcw, Trash2 } from 'lucide-preact';
|
||||
import type { Cipher } from '@/lib/types';
|
||||
import { t } from '@/lib/i18n';
|
||||
import {
|
||||
@@ -31,11 +31,14 @@ interface VaultDetailViewProps {
|
||||
onDownloadAttachment: (cipher: Cipher, attachmentId: string) => void;
|
||||
onStartEdit: () => void;
|
||||
onDelete: (cipher: Cipher) => void;
|
||||
onArchive: (cipher: Cipher) => Promise<void>;
|
||||
onUnarchive: (cipher: Cipher) => Promise<void>;
|
||||
}
|
||||
|
||||
export default function VaultDetailView(props: VaultDetailViewProps) {
|
||||
const selectedAttachments = Array.isArray(props.selectedCipher.attachments) ? props.selectedCipher.attachments : [];
|
||||
const [showSshPrivateKey, setShowSshPrivateKey] = useState(false);
|
||||
const isArchived = !!(props.selectedCipher.archivedDate || (props.selectedCipher as { archivedAt?: string | null }).archivedAt);
|
||||
const formatDownloadLabel = (attachmentId: string) => {
|
||||
const downloadKey = `${props.selectedCipher.id}:${attachmentId}`;
|
||||
if (props.downloadingAttachmentKey !== downloadKey) return t('txt_download');
|
||||
@@ -62,6 +65,7 @@ export default function VaultDetailView(props: VaultDetailViewProps) {
|
||||
<div className="card">
|
||||
<h3 className="detail-title">{props.selectedCipher.decName || t('txt_no_name')}</h3>
|
||||
<div className="detail-sub">{props.folderName(props.selectedCipher.folderId)}</div>
|
||||
{isArchived && <div className="list-badge" style={{ marginTop: '8px', width: 'fit-content' }}>{t('txt_archived')}</div>}
|
||||
</div>
|
||||
|
||||
{props.selectedCipher.login && (
|
||||
@@ -351,6 +355,15 @@ export default function VaultDetailView(props: VaultDetailViewProps) {
|
||||
<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')}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { RefObject } from 'preact';
|
||||
import { ArrowUpDown, Check, CheckCheck, FolderInput, Plus, RefreshCw, Trash2, X } from 'lucide-preact';
|
||||
import { Archive, ArrowUpDown, Check, CheckCheck, FolderInput, Plus, RefreshCw, RotateCcw, Trash2, X } from 'lucide-preact';
|
||||
import type { Cipher } from '@/lib/types';
|
||||
import { t } from '@/lib/i18n';
|
||||
import {
|
||||
@@ -48,6 +48,8 @@ interface VaultListPanelProps {
|
||||
onToggleCreateMenu: () => void;
|
||||
onStartCreate: (type: number) => void;
|
||||
onBulkRestore: () => void;
|
||||
onBulkArchive: () => void;
|
||||
onBulkUnarchive: () => void;
|
||||
onOpenMove: () => void;
|
||||
onClearSelection: () => void;
|
||||
onScroll: (top: number) => void;
|
||||
@@ -139,7 +141,17 @@ export default function VaultListPanel(props: VaultListPanelProps) {
|
||||
<RefreshCw size={14} className="btn-icon" /> {t('txt_restore')}
|
||||
</button>
|
||||
)}
|
||||
{props.selectedCount > 0 && props.sidebarFilter.kind !== 'trash' && props.sidebarFilter.kind !== 'duplicates' && (
|
||||
{props.selectedCount > 0 && props.sidebarFilter.kind === 'archive' && (
|
||||
<button type="button" className="btn btn-secondary small" disabled={props.busy} onClick={props.onBulkUnarchive}>
|
||||
<RotateCcw size={14} className="btn-icon" /> {t('txt_unarchive')}
|
||||
</button>
|
||||
)}
|
||||
{props.selectedCount > 0 && props.sidebarFilter.kind !== 'trash' && props.sidebarFilter.kind !== 'archive' && props.sidebarFilter.kind !== 'duplicates' && (
|
||||
<button type="button" className="btn btn-secondary small" disabled={props.busy} onClick={props.onBulkArchive}>
|
||||
<Archive size={14} className="btn-icon" /> {t('txt_archive_selected')}
|
||||
</button>
|
||||
)}
|
||||
{props.selectedCount > 0 && props.sidebarFilter.kind !== 'trash' && props.sidebarFilter.kind !== 'archive' && props.sidebarFilter.kind !== 'duplicates' && (
|
||||
<button type="button" className="btn btn-secondary small" disabled={props.busy} onClick={props.onOpenMove}>
|
||||
<FolderInput size={14} className="btn-icon" /> {t('txt_move')}
|
||||
</button>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
Archive,
|
||||
Copy,
|
||||
CreditCard,
|
||||
Folder as FolderIcon,
|
||||
@@ -48,6 +49,9 @@ export default function VaultSidebar(props: VaultSidebarProps) {
|
||||
<button type="button" className={`tree-btn ${props.sidebarFilter.kind === 'favorite' ? 'active' : ''}`} onClick={() => props.onChangeFilter({ kind: 'favorite' })}>
|
||||
<Star size={14} className="tree-icon" /> <span className="tree-label">{t('txt_favorites')}</span>
|
||||
</button>
|
||||
<button type="button" className={`tree-btn ${props.sidebarFilter.kind === 'archive' ? 'active' : ''}`} onClick={() => props.onChangeFilter({ kind: 'archive' })}>
|
||||
<Archive size={14} className="tree-icon" /> <span className="tree-label">{t('txt_archive')}</span>
|
||||
</button>
|
||||
<button type="button" className={`tree-btn ${props.sidebarFilter.kind === 'trash' ? 'active' : ''}`} onClick={() => props.onChangeFilter({ kind: 'trash' })}>
|
||||
<Trash2 size={14} className="tree-icon" /> <span className="tree-label">{t('txt_trash')}</span>
|
||||
</button>
|
||||
|
||||
@@ -16,6 +16,7 @@ export type VaultSortMode = 'edited' | 'created' | 'name';
|
||||
export type SidebarFilter =
|
||||
| { kind: 'all' }
|
||||
| { kind: 'favorite' }
|
||||
| { kind: 'archive' }
|
||||
| { kind: 'trash' }
|
||||
| { kind: 'duplicates' }
|
||||
| { kind: 'type'; value: TypeFilter }
|
||||
@@ -71,6 +72,34 @@ export function cipherTypeKey(type: number): TypeFilter {
|
||||
return 'ssh';
|
||||
}
|
||||
|
||||
function cipherDeletedValue(cipher: Cipher): boolean {
|
||||
return !!(cipher.deletedDate || (cipher as { deletedAt?: string | null }).deletedAt);
|
||||
}
|
||||
|
||||
function cipherArchivedValue(cipher: Cipher): boolean {
|
||||
return !!(cipher.archivedDate || (cipher as { archivedAt?: string | null }).archivedAt);
|
||||
}
|
||||
|
||||
export function isCipherDeleted(cipher: Cipher): boolean {
|
||||
return cipherDeletedValue(cipher);
|
||||
}
|
||||
|
||||
export function isCipherArchived(cipher: Cipher): boolean {
|
||||
return cipherArchivedValue(cipher) && !cipherDeletedValue(cipher);
|
||||
}
|
||||
|
||||
export function isCipherVisibleInNormalVault(cipher: Cipher): boolean {
|
||||
return !cipherDeletedValue(cipher) && !cipherArchivedValue(cipher);
|
||||
}
|
||||
|
||||
export function isCipherVisibleInArchive(cipher: Cipher): boolean {
|
||||
return !cipherDeletedValue(cipher) && cipherArchivedValue(cipher);
|
||||
}
|
||||
|
||||
export function isCipherVisibleInTrash(cipher: Cipher): boolean {
|
||||
return cipherDeletedValue(cipher);
|
||||
}
|
||||
|
||||
export function cipherTypeLabel(type: number): string {
|
||||
if (type === 1) return t('txt_login');
|
||||
if (type === 3) return t('txt_card');
|
||||
|
||||
Reference in New Issue
Block a user