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:
shuaiplus
2026-03-23 01:10:48 +08:00
parent b50673f7d9
commit f7b5534cd0
28 changed files with 1179 additions and 106 deletions
@@ -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')}
+14 -2
View File
@@ -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');