feat: add export and import functionality for Bitwarden and NodeWarden formats

- Implemented export formats for Bitwarden (JSON, encrypted JSON, ZIP) and NodeWarden (JSON).
- Added support for attachments in ciphers and introduced new types for handling attachments.
- Enhanced import formats to include Bitwarden ZIP and NodeWarden JSON.
- Updated internationalization strings for attachment-related features.
- Improved UI styles for attachment management and import summary display.
This commit is contained in:
shuaiplus
2026-03-04 01:03:49 +08:00
parent 7b4733d4c4
commit 819734ce5c
15 changed files with 2379 additions and 75 deletions
+217 -5
View File
@@ -6,6 +6,7 @@ import {
CheckCheck,
Clipboard,
CreditCard,
Download,
Eye,
EyeOff,
ExternalLink,
@@ -17,6 +18,7 @@ import {
Globe,
KeyRound,
LayoutGrid,
Paperclip,
Pencil,
Plus,
RefreshCw,
@@ -25,9 +27,10 @@ import {
StarOff,
StickyNote,
Trash2,
Upload,
X,
} from 'lucide-preact';
import type { Cipher, CustomFieldType, Folder, VaultDraft, VaultDraftField } from '@/lib/types';
import type { Cipher, CipherAttachment, CustomFieldType, Folder, VaultDraft, VaultDraftField } from '@/lib/types';
import { t } from '@/lib/i18n';
interface VaultPageProps {
@@ -36,8 +39,8 @@ interface VaultPageProps {
loading: boolean;
emailForReprompt: string;
onRefresh: () => Promise<void>;
onCreate: (draft: VaultDraft) => Promise<void>;
onUpdate: (cipher: Cipher, draft: VaultDraft) => Promise<void>;
onCreate: (draft: VaultDraft, attachments?: File[]) => Promise<void>;
onUpdate: (cipher: Cipher, draft: VaultDraft, options?: { addFiles?: File[]; removeAttachmentIds?: string[] }) => Promise<void>;
onDelete: (cipher: Cipher) => Promise<void>;
onBulkDelete: (ids: string[]) => Promise<void>;
onBulkMove: (ids: string[], folderId: string | null) => Promise<void>;
@@ -45,6 +48,7 @@ interface VaultPageProps {
onNotify: (type: 'success' | 'error', text: string) => void;
onCreateFolder: (name: string) => Promise<void>;
onDeleteFolder: (folderId: string) => Promise<void>;
onDownloadAttachment: (cipher: Cipher, attachmentId: string) => Promise<void>;
}
type TypeFilter = 'login' | 'card' | 'identity' | 'note' | 'ssh';
@@ -269,6 +273,25 @@ function formatHistoryTime(value: string | null | undefined): string {
return date.toLocaleString();
}
function parseAttachmentSizeBytes(attachment: CipherAttachment): number {
const raw = attachment?.size;
if (typeof raw === 'number' && Number.isFinite(raw) && raw >= 0) return raw;
const parsed = Number(raw);
if (Number.isFinite(parsed) && parsed >= 0) return parsed;
return 0;
}
function formatAttachmentSize(attachment: CipherAttachment): string {
const sizeName = String(attachment?.sizeName || '').trim();
if (sizeName) return sizeName;
const bytes = parseAttachmentSizeBytes(attachment);
if (bytes <= 0) return '0 B';
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(2)} KB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
}
function firstPasskeyCreationTime(cipher: Cipher | null): string | null {
const credentials = cipher?.login?.fido2Credentials;
if (!Array.isArray(credentials) || credentials.length === 0) return null;
@@ -343,11 +366,14 @@ export default function VaultPage(props: VaultPageProps) {
const [pendingDeleteFolder, setPendingDeleteFolder] = useState<Folder | null>(null);
const [totpLive, setTotpLive] = useState<{ code: string; remain: number } | null>(null);
const [hiddenFieldVisibleMap, setHiddenFieldVisibleMap] = useState<Record<number, boolean>>({});
const [attachmentQueue, setAttachmentQueue] = useState<File[]>([]);
const [removedAttachmentIds, setRemovedAttachmentIds] = useState<Record<string, boolean>>({});
const [busy, setBusy] = useState(false);
const [repromptOpen, setRepromptOpen] = useState(false);
const [repromptPassword, setRepromptPassword] = useState('');
const [repromptApprovedCipherId, setRepromptApprovedCipherId] = useState<string | null>(null);
const createMenuRef = useRef<HTMLDivElement | null>(null);
const attachmentInputRef = useRef<HTMLInputElement | null>(null);
const sshSeedTicketRef = useRef(0);
const sshFingerprintTicketRef = useRef(0);
@@ -436,6 +462,19 @@ export default function VaultPage(props: VaultPageProps) {
[props.ciphers, selectedCipherId]
);
const passkeyCreatedAt = firstPasskeyCreationTime(selectedCipher);
const selectedAttachments = useMemo(
() => (Array.isArray(selectedCipher?.attachments) ? selectedCipher.attachments : []),
[selectedCipher]
);
const editExistingAttachments = useMemo(
() =>
selectedAttachments.filter((attachment) => {
const id = String(attachment?.id || '').trim();
return !!id;
}),
[selectedAttachments]
);
const removedAttachmentCount = useMemo(() => Object.values(removedAttachmentIds).filter(Boolean).length, [removedAttachmentIds]);
useEffect(() => {
const raw = selectedCipher?.login?.decTotp || '';
@@ -487,6 +526,8 @@ function folderName(id: string | null | undefined): string {
setSelectedCipherId('');
setShowPassword(false);
setLocalError('');
setAttachmentQueue([]);
setRemovedAttachmentIds({});
if (type === 5) void seedSshDefaults();
}
@@ -497,6 +538,8 @@ function folderName(id: string | null | undefined): string {
setIsEditing(true);
setShowPassword(false);
setLocalError('');
setAttachmentQueue([]);
setRemovedAttachmentIds({});
}
function cancelEdit(): void {
@@ -504,6 +547,8 @@ function folderName(id: string | null | undefined): string {
setIsEditing(false);
setIsCreating(false);
setLocalError('');
setAttachmentQueue([]);
setRemovedAttachmentIds({});
}
function updateDraft(patch: Partial<VaultDraft>): void {
@@ -572,6 +617,28 @@ function folderName(id: string | null | undefined): string {
});
}
function queueAttachmentFiles(list: FileList | null): void {
if (!list || !list.length) return;
const next = Array.from(list).filter((file) => file && file.size >= 0);
if (!next.length) return;
setAttachmentQueue((prev) => [...prev, ...next]);
}
function removeQueuedAttachment(index: number): void {
setAttachmentQueue((prev) => prev.filter((_, i) => i !== index));
}
function toggleExistingAttachmentRemoval(attachmentId: string): void {
const id = String(attachmentId || '').trim();
if (!id) return;
setRemovedAttachmentIds((prev) => {
const next = { ...prev };
if (next[id]) delete next[id];
else next[id] = true;
return next;
});
}
async function saveDraft(): Promise<void> {
if (!draft) return;
let nextDraft = draft;
@@ -589,14 +656,20 @@ function folderName(id: string | null | undefined): string {
setBusy(true);
try {
if (isCreating) {
await props.onCreate(nextDraft);
await props.onCreate(nextDraft, attachmentQueue);
} else if (selectedCipher) {
await props.onUpdate(selectedCipher, nextDraft);
const removeAttachmentIds = Object.keys(removedAttachmentIds).filter((id) => !!removedAttachmentIds[id]);
await props.onUpdate(selectedCipher, nextDraft, {
addFiles: attachmentQueue,
removeAttachmentIds,
});
}
setIsCreating(false);
setIsEditing(false);
setDraft(null);
setLocalError('');
setAttachmentQueue([]);
setRemovedAttachmentIds({});
} finally {
setBusy(false);
}
@@ -864,6 +937,9 @@ function folderName(id: string | null | undefined): string {
type="button"
className="row-main"
onClick={() => {
if (isEditing || isCreating) {
cancelEdit();
}
setSelectedCipherId(cipher.id);
setRepromptApprovedCipherId(null);
}}
@@ -971,6 +1047,7 @@ function folderName(id: string | null | undefined): string {
className="btn btn-secondary small"
onClick={() => updateDraft({ loginUris: draft.loginUris.filter((_, i) => i !== index) })}
>
<X size={14} className="btn-icon" />
{t('txt_remove')}
</button>
)}
@@ -1059,6 +1136,104 @@ function folderName(id: string | null | undefined): string {
</div>
)}
<div className="card">
<div className="section-head attachment-head">
<h4>{t('txt_attachments')}</h4>
<button
type="button"
className="btn btn-secondary small attachment-add-btn"
disabled={busy}
onClick={() => attachmentInputRef.current?.click()}
title={t('txt_upload_attachments')}
aria-label={t('txt_upload_attachments')}
>
<Plus size={14} className="btn-icon" />
</button>
</div>
{!isCreating && selectedCipher && editExistingAttachments.length > 0 && (
<div className="attachment-list">
{editExistingAttachments.map((attachment) => {
const attachmentId = String(attachment?.id || '').trim();
if (!attachmentId) return null;
const removed = !!removedAttachmentIds[attachmentId];
const fileName = String(attachment.decFileName || attachment.fileName || attachmentId).trim() || attachmentId;
return (
<div key={`edit-attachment-${attachmentId}`} className={`attachment-row ${removed ? 'is-removed' : ''}`}>
<div className="attachment-main">
<Paperclip size={14} />
<div className="attachment-text">
<strong className="value-ellipsis" title={fileName}>{fileName}</strong>
<span>{formatAttachmentSize(attachment)}</span>
</div>
</div>
<div className="kv-actions">
<button
type="button"
className="btn btn-secondary small"
disabled={busy || removed}
onClick={() => void props.onDownloadAttachment(selectedCipher, attachmentId)}
>
<Download size={14} className="btn-icon" /> {t('txt_download')}
</button>
<button
type="button"
className="btn btn-secondary small"
disabled={busy}
onClick={() => toggleExistingAttachmentRemoval(attachmentId)}
>
<X size={14} className="btn-icon" />
{removed ? t('txt_cancel') : t('txt_remove')}
</button>
</div>
</div>
);
})}
</div>
)}
{!!removedAttachmentCount && (
<div className="detail-sub">{t('txt_marked_for_removal_count', { count: removedAttachmentCount })}</div>
)}
<input
ref={attachmentInputRef}
type="file"
className="attachment-file-input"
multiple
disabled={busy}
onChange={(e) => {
const input = e.currentTarget as HTMLInputElement;
queueAttachmentFiles(input.files);
input.value = '';
}}
/>
{!!attachmentQueue.length && (
<div className="attachment-list">
<div className="attachment-queue-title">{t('txt_new_attachments')}</div>
{attachmentQueue.map((file, index) => (
<div key={`queued-attachment-${index}-${file.name}`} className="attachment-row">
<div className="attachment-main">
<Upload size={14} />
<div className="attachment-text">
<strong className="value-ellipsis" title={file.name}>{file.name}</strong>
<span>{formatAttachmentSize({ size: file.size })}</span>
</div>
</div>
<div className="kv-actions">
<button
type="button"
className="btn btn-secondary small"
disabled={busy}
onClick={() => removeQueuedAttachment(index)}
>
<X size={14} className="btn-icon" />
{t('txt_remove')}
</button>
</div>
</div>
))}
</div>
)}
</div>
<div className="card">
<h4>{t('txt_additional_options')}</h4>
<label className="field">
@@ -1105,6 +1280,7 @@ function folderName(id: string | null | undefined): string {
className="btn btn-secondary small"
onClick={() => updateDraftCustomFields(draft.customFields.filter((_, i) => i !== originalIndex))}
>
<X size={14} className="btn-icon" />
{t('txt_remove')}
</button>
</div>
@@ -1114,14 +1290,17 @@ function folderName(id: string | null | undefined): string {
<div className="detail-actions">
<div className="actions">
<button type="button" className="btn btn-primary" disabled={busy} onClick={() => void saveDraft()}>
<CheckCheck size={14} className="btn-icon" />
{t('txt_confirm')}
</button>
<button type="button" className="btn btn-secondary" disabled={busy} onClick={cancelEdit}>
<X size={14} className="btn-icon" />
{t('txt_cancel')}
</button>
</div>
{!isCreating && selectedCipher && (
<button type="button" className="btn btn-danger" disabled={busy} onClick={() => setPendingDelete(selectedCipher)}>
<Trash2 size={14} className="btn-icon" />
{t('txt_delete')}
</button>
)}
@@ -1351,6 +1530,39 @@ function folderName(id: string | null | undefined): string {
</div>
)}
{selectedAttachments.some((attachment) => String(attachment?.id || '').trim()) && (
<div className="card">
<h4>{t('txt_attachments')}</h4>
<div className="attachment-list">
{selectedAttachments.map((attachment) => {
const attachmentId = String(attachment?.id || '').trim();
if (!attachmentId) return null;
const fileName = String(attachment.decFileName || attachment.fileName || attachmentId).trim() || attachmentId;
return (
<div key={`view-attachment-${attachmentId}`} className="attachment-row">
<div className="attachment-main">
<Paperclip size={14} />
<div className="attachment-text">
<strong className="value-ellipsis" title={fileName}>{fileName}</strong>
<span>{formatAttachmentSize(attachment)}</span>
</div>
</div>
<div className="kv-actions">
<button
type="button"
className="btn btn-secondary small"
onClick={() => void props.onDownloadAttachment(selectedCipher, attachmentId)}
>
<Download size={14} className="btn-icon" /> {t('txt_download')}
</button>
</div>
</div>
);
})}
</div>
</div>
)}
{(selectedCipher.creationDate || selectedCipher.revisionDate) && (
<div className="card">
<h4>{t('txt_item_history')}</h4>