mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 21:00:41 +00:00
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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user