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:
@@ -1,4 +1,5 @@
|
||||
import type { ComponentChildren } from 'preact';
|
||||
import { Check, X } from 'lucide-preact';
|
||||
import { t } from '@/lib/i18n';
|
||||
|
||||
interface ConfirmDialogProps {
|
||||
@@ -28,9 +29,11 @@ export default function ConfirmDialog(props: ConfirmDialogProps) {
|
||||
className={`btn ${props.danger ? 'btn-danger' : 'btn-primary'} dialog-btn`}
|
||||
onClick={props.onConfirm}
|
||||
>
|
||||
<Check size={14} className="btn-icon" />
|
||||
{props.confirmText || t('txt_yes')}
|
||||
</button>
|
||||
<button type="button" className="btn btn-secondary dialog-btn" onClick={props.onCancel}>
|
||||
<X size={14} className="btn-icon" />
|
||||
{props.cancelText || t('txt_no')}
|
||||
</button>
|
||||
{props.afterActions}
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
import { useState } from 'preact/hooks';
|
||||
import { useState } from 'preact/hooks';
|
||||
import { argon2idAsync } from '@noble/hashes/argon2.js';
|
||||
import { strFromU8, unzipSync } from 'fflate';
|
||||
import { FileUp } from 'lucide-preact';
|
||||
import { BlobReader, Uint8ArrayWriter, ZipReader, configure as configureZipJs } from '@zip.js/zip.js';
|
||||
import { Download, FileUp } from 'lucide-preact';
|
||||
import ConfirmDialog from '@/components/ConfirmDialog';
|
||||
import type { CiphersImportPayload } from '@/lib/api';
|
||||
import {
|
||||
type EncryptedJsonMode,
|
||||
EXPORT_FORMATS,
|
||||
type ExportDownloadPayload,
|
||||
type ExportFormatId,
|
||||
type ExportRequest,
|
||||
} from '@/lib/export-formats';
|
||||
import {
|
||||
getFileAcceptBySource,
|
||||
IMPORT_SOURCES,
|
||||
@@ -17,18 +25,36 @@ import { base64ToBytes, decryptStr, hkdfExpand, pbkdf2 } from '@/lib/crypto';
|
||||
import { t } from '@/lib/i18n';
|
||||
import type { Folder } from '@/lib/types';
|
||||
|
||||
configureZipJs({ useWebWorkers: false });
|
||||
|
||||
export interface ImportAttachmentFile {
|
||||
sourceCipherId: string | null;
|
||||
sourceCipherIndex: number | null;
|
||||
fileName: string;
|
||||
bytes: Uint8Array;
|
||||
}
|
||||
|
||||
interface ImportPageProps {
|
||||
onImport: (
|
||||
payload: CiphersImportPayload,
|
||||
options: { folderMode: 'original' | 'none' | 'target'; targetFolderId: string | null }
|
||||
) => Promise<void>;
|
||||
options: { folderMode: 'original' | 'none' | 'target'; targetFolderId: string | null },
|
||||
attachments?: ImportAttachmentFile[]
|
||||
) => Promise<ImportResultSummary>;
|
||||
onImportEncryptedRaw: (
|
||||
payload: CiphersImportPayload,
|
||||
options: { folderMode: 'original' | 'none' | 'target'; targetFolderId: string | null }
|
||||
) => Promise<void>;
|
||||
options: { folderMode: 'original' | 'none' | 'target'; targetFolderId: string | null },
|
||||
attachments?: ImportAttachmentFile[]
|
||||
) => Promise<ImportResultSummary>;
|
||||
accountKeys?: { encB64: string; macB64: string } | null;
|
||||
onNotify: (type: 'success' | 'error', text: string) => void;
|
||||
folders: Folder[];
|
||||
onExport: (request: ExportRequest) => Promise<ExportDownloadPayload>;
|
||||
}
|
||||
|
||||
export interface ImportResultSummary {
|
||||
totalItems: number;
|
||||
folderCount: number;
|
||||
typeCounts: Array<{ label: string; count: number }>;
|
||||
}
|
||||
|
||||
interface BitwardenPasswordProtectedInput extends BitwardenJsonInput {
|
||||
@@ -45,6 +71,8 @@ interface BitwardenPasswordProtectedInput extends BitwardenJsonInput {
|
||||
const COMMON_IMPORT_SOURCE_IDS: ImportSourceId[] = [
|
||||
'bitwarden_json',
|
||||
'bitwarden_csv',
|
||||
'bitwarden_zip',
|
||||
'nodewarden_json',
|
||||
'onepassword_1pux',
|
||||
'onepassword_1pif',
|
||||
'onepassword_mac_csv',
|
||||
@@ -80,7 +108,7 @@ async function derivePasswordProtectedFileKey(
|
||||
const iterations = Number(parsed.kdfIterations || 0);
|
||||
const kdfType = Number(parsed.kdfType);
|
||||
if (!salt || !Number.isFinite(iterations) || iterations <= 0) {
|
||||
throw new Error('Invalid password-protected export file.');
|
||||
throw new Error(t('txt_import_invalid_password_protected_file'));
|
||||
}
|
||||
|
||||
let keyMaterial: Uint8Array;
|
||||
@@ -113,11 +141,11 @@ async function derivePasswordProtectedFileKey(
|
||||
|
||||
async function decryptPasswordProtectedExport(parsed: BitwardenPasswordProtectedInput, password: string): Promise<unknown> {
|
||||
if (!parsed.encKeyValidation_DO_NOT_EDIT || !parsed.data) {
|
||||
throw new Error('Invalid password-protected export file.');
|
||||
throw new Error(t('txt_import_invalid_password_protected_file'));
|
||||
}
|
||||
const pass = String(password || '').trim();
|
||||
if (!pass) {
|
||||
throw new Error('Please enter file password.');
|
||||
throw new Error(t('txt_import_file_password_required'));
|
||||
}
|
||||
|
||||
const key = await derivePasswordProtectedFileKey(parsed, pass);
|
||||
@@ -131,7 +159,7 @@ async function decryptPasswordProtectedExport(parsed: BitwardenPasswordProtected
|
||||
try {
|
||||
return JSON.parse(plainJson);
|
||||
} catch {
|
||||
throw new Error('Failed to decrypt import file.');
|
||||
throw new Error(t('txt_import_decrypt_failed'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,7 +170,7 @@ function isZipPayload(bytes: Uint8Array): boolean {
|
||||
function readZipText(bytes: Uint8Array, source: ImportSourceId): string {
|
||||
const unzipped = unzipSync(bytes);
|
||||
const fileNames = Object.keys(unzipped);
|
||||
if (!fileNames.length) throw new Error('Empty zip archive.');
|
||||
if (!fileNames.length) throw new Error(t('txt_import_empty_zip_archive'));
|
||||
|
||||
const preferred = source === 'onepassword_1pux' ? ['export.data', 'export.json'] : ['protonpass.json', 'export.json'];
|
||||
for (const p of preferred) {
|
||||
@@ -152,7 +180,7 @@ function readZipText(bytes: Uint8Array, source: ImportSourceId): string {
|
||||
|
||||
const firstJson = fileNames.find((n) => n.toLowerCase().endsWith('.json') || n.toLowerCase().endsWith('.data'));
|
||||
if (firstJson) return strFromU8(unzipped[firstJson]);
|
||||
throw new Error('No importable JSON data found in zip archive.');
|
||||
throw new Error(t('txt_import_no_json_found_in_zip'));
|
||||
}
|
||||
|
||||
async function readImportText(file: File, source: ImportSourceId): Promise<string> {
|
||||
@@ -164,21 +192,128 @@ async function readImportText(file: File, source: ImportSourceId): Promise<strin
|
||||
return new TextDecoder().decode(bytes);
|
||||
}
|
||||
|
||||
export default function ImportPage({ onImport, onImportEncryptedRaw, accountKeys, onNotify, folders }: ImportPageProps) {
|
||||
interface PendingPasswordImportContext {
|
||||
parsed: BitwardenPasswordProtectedInput;
|
||||
source: 'bitwarden_json' | 'nodewarden_json' | 'bitwarden_zip';
|
||||
attachments: ImportAttachmentFile[];
|
||||
}
|
||||
|
||||
class ZipNeedsPasswordError extends Error {}
|
||||
class ZipInvalidPasswordError extends Error {}
|
||||
|
||||
function looksLikeZipPasswordError(error: unknown): boolean {
|
||||
const message = error instanceof Error ? String(error.message || '').toLowerCase() : '';
|
||||
if (!message) return false;
|
||||
return message.includes('password') || message.includes('encrypted');
|
||||
}
|
||||
|
||||
async function readBitwardenZipPayload(
|
||||
file: File,
|
||||
passwordRaw: string
|
||||
): Promise<{ jsonText: string; attachments: ImportAttachmentFile[] }> {
|
||||
const password = String(passwordRaw || '').trim();
|
||||
const reader = new ZipReader(new BlobReader(file), { useWebWorkers: false });
|
||||
try {
|
||||
const entries = await reader.getEntries();
|
||||
if (!entries.length) throw new Error(t('txt_import_empty_zip_archive'));
|
||||
|
||||
let jsonText = '';
|
||||
const attachments: ImportAttachmentFile[] = [];
|
||||
const options = password ? { password } : undefined;
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.directory) continue;
|
||||
const name = String(entry.filename || '').trim().replace(/\\/g, '/');
|
||||
if (!name) continue;
|
||||
|
||||
const bytes = await entry.getData(new Uint8ArrayWriter(), options);
|
||||
const lower = name.toLowerCase();
|
||||
if (lower === 'data.json') {
|
||||
jsonText = new TextDecoder().decode(bytes);
|
||||
continue;
|
||||
}
|
||||
|
||||
const attachmentMatch = name.match(/^attachments\/([^/]+)\/(.+)$/i);
|
||||
if (!attachmentMatch) continue;
|
||||
const sourceCipherId = String(attachmentMatch[1] || '').trim() || null;
|
||||
const fileName = String(attachmentMatch[2] || '').trim() || 'attachment.bin';
|
||||
attachments.push({
|
||||
sourceCipherId,
|
||||
sourceCipherIndex: null,
|
||||
fileName,
|
||||
bytes,
|
||||
});
|
||||
}
|
||||
|
||||
if (!jsonText) throw new Error(t('txt_import_data_json_not_found'));
|
||||
return { jsonText, attachments };
|
||||
} catch (error) {
|
||||
if (looksLikeZipPasswordError(error)) {
|
||||
if (!password) throw new ZipNeedsPasswordError(t('txt_import_zip_password_required'));
|
||||
throw new ZipInvalidPasswordError(t('txt_import_invalid_zip_password'));
|
||||
}
|
||||
if (!password && error instanceof Error && /invalid|corrupt|unsupported/.test(error.message.toLowerCase())) {
|
||||
throw error;
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
await reader.close();
|
||||
}
|
||||
}
|
||||
|
||||
function parseNodeWardenAttachmentArray(raw: unknown): ImportAttachmentFile[] {
|
||||
if (!Array.isArray(raw)) return [];
|
||||
const out: ImportAttachmentFile[] = [];
|
||||
for (const entry of raw) {
|
||||
if (!entry || typeof entry !== 'object') continue;
|
||||
const row = entry as Record<string, unknown>;
|
||||
const fileName = String(row.fileName || '').trim() || 'attachment.bin';
|
||||
const base64 = String(row.data || '').trim();
|
||||
if (!base64) continue;
|
||||
try {
|
||||
const bytes = base64ToBytes(base64);
|
||||
const sourceCipherId = String(row.cipherId || '').trim() || null;
|
||||
const indexRaw = Number(row.cipherIndex);
|
||||
out.push({
|
||||
sourceCipherId,
|
||||
sourceCipherIndex: Number.isFinite(indexRaw) ? indexRaw : null,
|
||||
fileName,
|
||||
bytes,
|
||||
});
|
||||
} catch {
|
||||
// skip malformed attachment row
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export default function ImportPage({ onImport, onImportEncryptedRaw, accountKeys, onNotify, folders, onExport }: ImportPageProps) {
|
||||
const [source, setSource] = useState<ImportSourceId>('bitwarden_json');
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [isPasswordSubmitting, setIsPasswordSubmitting] = useState(false);
|
||||
const [passwordDialogOpen, setPasswordDialogOpen] = useState(false);
|
||||
const [importPassword, setImportPassword] = useState('');
|
||||
const [pendingPasswordImport, setPendingPasswordImport] = useState<BitwardenPasswordProtectedInput | null>(null);
|
||||
const [pendingPasswordImport, setPendingPasswordImport] = useState<PendingPasswordImportContext | null>(null);
|
||||
const [zipPasswordDialogOpen, setZipPasswordDialogOpen] = useState(false);
|
||||
const [zipImportPassword, setZipImportPassword] = useState('');
|
||||
const [pendingZipFile, setPendingZipFile] = useState<File | null>(null);
|
||||
const [isZipPasswordSubmitting, setIsZipPasswordSubmitting] = useState(false);
|
||||
const [folderMode, setFolderMode] = useState<'original' | 'none' | 'target'>('original');
|
||||
const [targetFolderId, setTargetFolderId] = useState('');
|
||||
const [exportFormat, setExportFormat] = useState<ExportFormatId>('bitwarden_json');
|
||||
const [encryptedJsonMode, setEncryptedJsonMode] = useState<EncryptedJsonMode>('account');
|
||||
const [exportPassword, setExportPassword] = useState('');
|
||||
const [zipPassword, setZipPassword] = useState('');
|
||||
const [isExporting, setIsExporting] = useState(false);
|
||||
const [exportAuthDialogOpen, setExportAuthDialogOpen] = useState(false);
|
||||
const [exportAuthPassword, setExportAuthPassword] = useState('');
|
||||
const [importSummary, setImportSummary] = useState<ImportResultSummary | null>(null);
|
||||
const commonSourceSet = new Set<ImportSourceId>(COMMON_IMPORT_SOURCE_IDS);
|
||||
const commonSources = IMPORT_SOURCES.filter((item) => commonSourceSet.has(item.id as ImportSourceId));
|
||||
const otherSources = IMPORT_SOURCES.filter((item) => !commonSourceSet.has(item.id as ImportSourceId));
|
||||
|
||||
async function runBitwardenJsonImport(parsed: unknown): Promise<void> {
|
||||
async function runBitwardenJsonImport(parsed: unknown, attachments: ImportAttachmentFile[] = []): Promise<ImportResultSummary> {
|
||||
if (isRecord(parsed) && parsed.encrypted === true) {
|
||||
const accountEncrypted = parsed as BitwardenJsonInput;
|
||||
if (!accountKeys?.encB64 || !accountKeys?.macB64) {
|
||||
@@ -193,16 +328,53 @@ export default function ImportPage({ onImport, onImportEncryptedRaw, accountKeys
|
||||
} catch {
|
||||
throw new Error('This encrypted export belongs to another account.');
|
||||
}
|
||||
await onImportEncryptedRaw(normalizeBitwardenEncryptedAccountImport(accountEncrypted), {
|
||||
return onImportEncryptedRaw(
|
||||
normalizeBitwardenEncryptedAccountImport(accountEncrypted),
|
||||
{
|
||||
folderMode,
|
||||
targetFolderId: folderMode === 'target' ? targetFolderId || null : null,
|
||||
},
|
||||
attachments
|
||||
);
|
||||
}
|
||||
return onImport(
|
||||
normalizeBitwardenImport(parsed),
|
||||
{
|
||||
folderMode,
|
||||
targetFolderId: folderMode === 'target' ? targetFolderId || null : null,
|
||||
});
|
||||
return;
|
||||
},
|
||||
attachments
|
||||
);
|
||||
}
|
||||
|
||||
async function extractNodeWardenAttachments(parsed: unknown): Promise<ImportAttachmentFile[]> {
|
||||
if (!isRecord(parsed)) return [];
|
||||
const direct = parseNodeWardenAttachmentArray(parsed.nodewardenAttachments);
|
||||
if (direct.length) return direct;
|
||||
|
||||
const encryptedPayload = String(parsed.nodewardenAttachmentsEnc || '').trim();
|
||||
if (!encryptedPayload) return [];
|
||||
if (!accountKeys?.encB64 || !accountKeys?.macB64) {
|
||||
throw new Error('Vault key unavailable. Please unlock vault and try again.');
|
||||
}
|
||||
await onImport(normalizeBitwardenImport(parsed), {
|
||||
folderMode,
|
||||
targetFolderId: folderMode === 'target' ? targetFolderId || null : null,
|
||||
});
|
||||
const accountEnc = base64ToBytes(accountKeys.encB64);
|
||||
const accountMac = base64ToBytes(accountKeys.macB64);
|
||||
const plain = await decryptStr(encryptedPayload, accountEnc, accountMac);
|
||||
const unpacked = JSON.parse(plain) as Record<string, unknown>;
|
||||
return parseNodeWardenAttachmentArray(unpacked.nodewardenAttachments);
|
||||
}
|
||||
|
||||
async function runNodeWardenJsonImport(parsed: unknown, extraAttachments: ImportAttachmentFile[] = []): Promise<ImportResultSummary> {
|
||||
const bundled = await extractNodeWardenAttachments(parsed);
|
||||
return runBitwardenJsonImport(parsed, [...bundled, ...extraAttachments]);
|
||||
}
|
||||
|
||||
async function processPasswordProtectedImport(ctx: PendingPasswordImportContext): Promise<ImportResultSummary> {
|
||||
const parsed = await decryptPasswordProtectedExport(ctx.parsed, importPassword);
|
||||
if (ctx.source === 'nodewarden_json') {
|
||||
return runNodeWardenJsonImport(parsed, ctx.attachments);
|
||||
}
|
||||
return runBitwardenJsonImport(parsed, ctx.attachments);
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
@@ -213,31 +385,77 @@ export default function ImportPage({ onImport, onImportEncryptedRaw, accountKeys
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
if (source === 'bitwarden_zip') {
|
||||
try {
|
||||
const bundle = await readBitwardenZipPayload(file, '');
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(bundle.jsonText);
|
||||
} catch {
|
||||
throw new Error(t('txt_import_invalid_json_file'));
|
||||
}
|
||||
if (isPasswordProtectedExport(parsed)) {
|
||||
setPendingPasswordImport({
|
||||
parsed,
|
||||
source: 'bitwarden_zip',
|
||||
attachments: bundle.attachments,
|
||||
});
|
||||
setImportPassword('');
|
||||
setPasswordDialogOpen(true);
|
||||
return;
|
||||
}
|
||||
const summary = await runBitwardenJsonImport(parsed, bundle.attachments);
|
||||
setImportSummary(summary);
|
||||
setFile(null);
|
||||
return;
|
||||
} catch (error) {
|
||||
if (error instanceof ZipNeedsPasswordError) {
|
||||
setPendingZipFile(file);
|
||||
setZipImportPassword('');
|
||||
setZipPasswordDialogOpen(true);
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const text = await readImportText(file, source);
|
||||
if (source === 'bitwarden_json') {
|
||||
if (source === 'bitwarden_json' || source === 'nodewarden_json') {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(text);
|
||||
} catch {
|
||||
throw new Error('Invalid JSON file');
|
||||
throw new Error(t('txt_import_invalid_json_file'));
|
||||
}
|
||||
if (isPasswordProtectedExport(parsed)) {
|
||||
setPendingPasswordImport(parsed);
|
||||
setPendingPasswordImport({
|
||||
parsed,
|
||||
source,
|
||||
attachments: [],
|
||||
});
|
||||
setImportPassword('');
|
||||
setPasswordDialogOpen(true);
|
||||
return;
|
||||
}
|
||||
await runBitwardenJsonImport(parsed);
|
||||
const summary =
|
||||
source === 'nodewarden_json'
|
||||
? await runNodeWardenJsonImport(parsed)
|
||||
: await runBitwardenJsonImport(parsed);
|
||||
setImportSummary(summary);
|
||||
} else {
|
||||
await onImport(parseImportPayloadBySource(source, text), {
|
||||
folderMode,
|
||||
targetFolderId: folderMode === 'target' ? targetFolderId || null : null,
|
||||
});
|
||||
const summary = await onImport(
|
||||
parseImportPayloadBySource(source, text),
|
||||
{
|
||||
folderMode,
|
||||
targetFolderId: folderMode === 'target' ? targetFolderId || null : null,
|
||||
},
|
||||
[]
|
||||
);
|
||||
setImportSummary(summary);
|
||||
}
|
||||
setFile(null);
|
||||
onNotify('success', 'Import completed');
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Import failed';
|
||||
const message = error instanceof Error ? error.message : t('txt_import_failed');
|
||||
onNotify('error', message);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
@@ -248,31 +466,130 @@ export default function ImportPage({ onImport, onImportEncryptedRaw, accountKeys
|
||||
if (!pendingPasswordImport) return;
|
||||
setIsPasswordSubmitting(true);
|
||||
try {
|
||||
const parsed = await decryptPasswordProtectedExport(pendingPasswordImport, importPassword);
|
||||
await runBitwardenJsonImport(parsed);
|
||||
const summary = await processPasswordProtectedImport(pendingPasswordImport);
|
||||
setImportSummary(summary);
|
||||
setFile(null);
|
||||
setImportPassword('');
|
||||
setPendingPasswordImport(null);
|
||||
setPasswordDialogOpen(false);
|
||||
onNotify('success', 'Import completed');
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Import failed';
|
||||
const message = error instanceof Error ? error.message : t('txt_import_failed');
|
||||
onNotify('error', message);
|
||||
} finally {
|
||||
setIsPasswordSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleZipPasswordImportConfirm() {
|
||||
if (!pendingZipFile) return;
|
||||
setIsZipPasswordSubmitting(true);
|
||||
try {
|
||||
const bundle = await readBitwardenZipPayload(pendingZipFile, zipImportPassword);
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(bundle.jsonText);
|
||||
} catch {
|
||||
throw new Error(t('txt_import_invalid_json_file'));
|
||||
}
|
||||
if (isPasswordProtectedExport(parsed)) {
|
||||
setPendingPasswordImport({
|
||||
parsed,
|
||||
source: 'bitwarden_zip',
|
||||
attachments: bundle.attachments,
|
||||
});
|
||||
setImportPassword('');
|
||||
setPasswordDialogOpen(true);
|
||||
} else {
|
||||
const summary = await runBitwardenJsonImport(parsed, bundle.attachments);
|
||||
setImportSummary(summary);
|
||||
setFile(null);
|
||||
}
|
||||
setZipPasswordDialogOpen(false);
|
||||
setPendingZipFile(null);
|
||||
setZipImportPassword('');
|
||||
} catch (error) {
|
||||
if (error instanceof ZipInvalidPasswordError) {
|
||||
onNotify('error', t('txt_import_invalid_zip_password'));
|
||||
return;
|
||||
}
|
||||
const message = error instanceof Error ? error.message : t('txt_import_failed');
|
||||
onNotify('error', message);
|
||||
} finally {
|
||||
setIsZipPasswordSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
const exportNeedsMode =
|
||||
exportFormat === 'bitwarden_encrypted_json' ||
|
||||
exportFormat === 'bitwarden_encrypted_json_zip' ||
|
||||
exportFormat === 'nodewarden_encrypted_json';
|
||||
const exportNeedsFilePassword = exportNeedsMode && encryptedJsonMode === 'password';
|
||||
const exportIsZip = exportFormat === 'bitwarden_json_zip' || exportFormat === 'bitwarden_encrypted_json_zip';
|
||||
|
||||
async function runExportWithMasterPassword(masterPassword: string) {
|
||||
const filePassword = exportPassword.trim();
|
||||
const zipPass = zipPassword.trim();
|
||||
if (exportNeedsFilePassword && !filePassword) {
|
||||
onNotify('error', t('txt_import_file_password_required'));
|
||||
return;
|
||||
}
|
||||
|
||||
setIsExporting(true);
|
||||
try {
|
||||
const payload = await onExport({
|
||||
format: exportFormat,
|
||||
encryptedJsonMode: exportNeedsMode ? encryptedJsonMode : undefined,
|
||||
filePassword,
|
||||
zipPassword: exportIsZip ? zipPass : '',
|
||||
masterPassword,
|
||||
});
|
||||
const blobBytes = Uint8Array.from(payload.bytes);
|
||||
const blob = new Blob([blobBytes], { type: payload.mimeType });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = payload.fileName;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
onNotify('success', t('txt_export_completed'));
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : t('txt_export_failed');
|
||||
onNotify('error', message);
|
||||
} finally {
|
||||
setIsExporting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleExportConfirmPassword() {
|
||||
const masterPassword = String(exportAuthPassword || '').trim();
|
||||
if (!masterPassword) {
|
||||
onNotify('error', t('txt_master_password_is_required'));
|
||||
return;
|
||||
}
|
||||
await runExportWithMasterPassword(masterPassword);
|
||||
if (!isExporting) {
|
||||
setExportAuthPassword('');
|
||||
setExportAuthDialogOpen(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleExport() {
|
||||
setExportAuthPassword('');
|
||||
setExportAuthDialogOpen(true);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="stack">
|
||||
<section className="card">
|
||||
<h3>Import</h3>
|
||||
<h3>{t('txt_import')}</h3>
|
||||
<p className="muted" style={{ textAlign: 'left', marginBottom: 12 }}>
|
||||
Import vault data into your current account.
|
||||
{t('txt_import_vault_data_hint')}
|
||||
</p>
|
||||
<div className="field-grid">
|
||||
<label className="field field-span-2">
|
||||
<span>Format</span>
|
||||
<span>{t('txt_format')}</span>
|
||||
<select className="input" value={source} onChange={(e) => setSource((e.currentTarget as HTMLSelectElement).value as ImportSourceId)}>
|
||||
{commonSources.map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
@@ -293,7 +610,7 @@ export default function ImportPage({ onImport, onImportEncryptedRaw, accountKeys
|
||||
</label>
|
||||
|
||||
<label className="field field-span-2">
|
||||
<span>Source file</span>
|
||||
<span>{t('txt_source_file')}</span>
|
||||
<input
|
||||
className="input"
|
||||
type="file"
|
||||
@@ -306,23 +623,23 @@ export default function ImportPage({ onImport, onImportEncryptedRaw, accountKeys
|
||||
</label>
|
||||
|
||||
<label className="field field-span-2">
|
||||
<span>Folder handling</span>
|
||||
<span>{t('txt_folder_handling')}</span>
|
||||
<select
|
||||
className="input"
|
||||
value={folderMode}
|
||||
onChange={(e) => setFolderMode((e.currentTarget as HTMLSelectElement).value as 'original' | 'none' | 'target')}
|
||||
>
|
||||
<option value="original">Original path from import file</option>
|
||||
<option value="none">No folder</option>
|
||||
<option value="target">One selected folder</option>
|
||||
<option value="original">{t('txt_import_folder_mode_original')}</option>
|
||||
<option value="none">{t('txt_import_folder_mode_none')}</option>
|
||||
<option value="target">{t('txt_import_folder_mode_target')}</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
{folderMode === 'target' && (
|
||||
<label className="field field-span-2">
|
||||
<span>Target folder</span>
|
||||
<span>{t('txt_target_folder')}</span>
|
||||
<select className="input" value={targetFolderId} onChange={(e) => setTargetFolderId((e.currentTarget as HTMLSelectElement).value)}>
|
||||
<option value="">-- Select folder --</option>
|
||||
<option value="">{t('txt_select_folder_placeholder')}</option>
|
||||
{folders
|
||||
.slice()
|
||||
.sort((a, b) => String(a.decName || a.name || '').localeCompare(String(b.decName || b.name || '')))
|
||||
@@ -343,16 +660,112 @@ export default function ImportPage({ onImport, onImportEncryptedRaw, accountKeys
|
||||
disabled={isSubmitting || (folderMode === 'target' && !targetFolderId)}
|
||||
onClick={() => void handleSubmit()}
|
||||
>
|
||||
<FileUp size={15} /> {isSubmitting ? t('txt_loading') : 'Import'}
|
||||
<FileUp size={15} /> {isSubmitting ? t('txt_loading') : t('txt_import')}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="card">
|
||||
<h3>{t('txt_export')}</h3>
|
||||
<p className="muted" style={{ textAlign: 'left', marginBottom: 12 }}>
|
||||
{t('txt_export_vault_data_hint')}
|
||||
</p>
|
||||
<div className="field-grid">
|
||||
<label className="field field-span-2">
|
||||
<span>{t('txt_format')}</span>
|
||||
<select
|
||||
className="input"
|
||||
value={exportFormat}
|
||||
onChange={(e) => {
|
||||
const next = (e.currentTarget as HTMLSelectElement).value as ExportFormatId;
|
||||
setExportFormat(next);
|
||||
}}
|
||||
>
|
||||
{EXPORT_FORMATS.map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
{exportNeedsMode && (
|
||||
<label className="field field-span-2">
|
||||
<span>{t('txt_encrypted_mode')}</span>
|
||||
<select
|
||||
className="input"
|
||||
value={encryptedJsonMode}
|
||||
onChange={(e) => setEncryptedJsonMode((e.currentTarget as HTMLSelectElement).value as EncryptedJsonMode)}
|
||||
>
|
||||
<option value="account">{t('txt_account_verification')}</option>
|
||||
<option value="password">{t('txt_password_verification')}</option>
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
|
||||
{exportNeedsFilePassword && (
|
||||
<label className="field field-span-2">
|
||||
<span>{t('txt_file_password')}</span>
|
||||
<input
|
||||
className="input"
|
||||
type="password"
|
||||
value={exportPassword}
|
||||
onInput={(e) => setExportPassword((e.currentTarget as HTMLInputElement).value)}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
|
||||
{exportIsZip && (
|
||||
<label className="field field-span-2">
|
||||
<span>{t('txt_zip_password_optional')}</span>
|
||||
<input
|
||||
className="input"
|
||||
type="password"
|
||||
value={zipPassword}
|
||||
onInput={(e) => setZipPassword((e.currentTarget as HTMLInputElement).value)}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="actions">
|
||||
<button type="button" className="btn btn-primary" disabled={isExporting} onClick={() => void handleExport()}>
|
||||
<Download size={15} className="btn-icon" />
|
||||
{isExporting ? t('txt_loading') : t('txt_export')}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<ConfirmDialog
|
||||
open={exportAuthDialogOpen}
|
||||
title={t('txt_export')}
|
||||
message={t('txt_enter_master_password_to_view_this_item')}
|
||||
confirmText={isExporting ? t('txt_loading') : t('txt_verify')}
|
||||
cancelText={t('txt_cancel')}
|
||||
showIcon={false}
|
||||
onConfirm={() => void handleExportConfirmPassword()}
|
||||
onCancel={() => {
|
||||
if (isExporting) return;
|
||||
setExportAuthDialogOpen(false);
|
||||
setExportAuthPassword('');
|
||||
}}
|
||||
>
|
||||
<label className="field">
|
||||
<span>{t('txt_master_password')}</span>
|
||||
<input
|
||||
className="input"
|
||||
type="password"
|
||||
value={exportAuthPassword}
|
||||
onInput={(e) => setExportAuthPassword((e.currentTarget as HTMLInputElement).value)}
|
||||
/>
|
||||
</label>
|
||||
</ConfirmDialog>
|
||||
|
||||
<ConfirmDialog
|
||||
open={passwordDialogOpen}
|
||||
title="Import encrypted file"
|
||||
message="This Bitwarden export is password-protected. Enter the export file password to continue."
|
||||
confirmText={isPasswordSubmitting ? t('txt_loading') : 'Import'}
|
||||
title={t('txt_import_encrypted_file_title')}
|
||||
message={t('txt_import_encrypted_file_message')}
|
||||
confirmText={isPasswordSubmitting ? t('txt_loading') : t('txt_import')}
|
||||
cancelText={t('txt_cancel')}
|
||||
showIcon={false}
|
||||
onConfirm={() => void handlePasswordImportConfirm()}
|
||||
@@ -364,7 +777,7 @@ export default function ImportPage({ onImport, onImportEncryptedRaw, accountKeys
|
||||
}}
|
||||
>
|
||||
<label className="field">
|
||||
<span>File password</span>
|
||||
<span>{t('txt_file_password')}</span>
|
||||
<input
|
||||
className="input"
|
||||
type="password"
|
||||
@@ -373,6 +786,74 @@ export default function ImportPage({ onImport, onImportEncryptedRaw, accountKeys
|
||||
/>
|
||||
</label>
|
||||
</ConfirmDialog>
|
||||
|
||||
<ConfirmDialog
|
||||
open={zipPasswordDialogOpen}
|
||||
title={t('txt_import_encrypted_zip_title')}
|
||||
message={t('txt_import_encrypted_zip_message')}
|
||||
confirmText={isZipPasswordSubmitting ? t('txt_loading') : t('txt_import')}
|
||||
cancelText={t('txt_cancel')}
|
||||
showIcon={false}
|
||||
onConfirm={() => void handleZipPasswordImportConfirm()}
|
||||
onCancel={() => {
|
||||
if (isZipPasswordSubmitting) return;
|
||||
setZipPasswordDialogOpen(false);
|
||||
setZipImportPassword('');
|
||||
setPendingZipFile(null);
|
||||
}}
|
||||
>
|
||||
<label className="field">
|
||||
<span>{t('txt_zip_password')}</span>
|
||||
<input
|
||||
className="input"
|
||||
type="password"
|
||||
value={zipImportPassword}
|
||||
onInput={(e) => setZipImportPassword((e.currentTarget as HTMLInputElement).value)}
|
||||
/>
|
||||
</label>
|
||||
</ConfirmDialog>
|
||||
|
||||
{importSummary && (
|
||||
<div className="dialog-mask">
|
||||
<section className="dialog-card import-summary-dialog">
|
||||
<button
|
||||
type="button"
|
||||
className="import-summary-close"
|
||||
onClick={() => setImportSummary(null)}
|
||||
aria-label={t('txt_close')}
|
||||
>
|
||||
X
|
||||
</button>
|
||||
<h3 className="dialog-title">{t('txt_import_success')}</h3>
|
||||
<div className="dialog-message">{t('txt_import_success_number_of_items', { count: importSummary.totalItems })}</div>
|
||||
<div className="import-summary-table-wrap">
|
||||
<table className="import-summary-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('txt_type')}</th>
|
||||
<th>{t('txt_total')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{importSummary.typeCounts.map((row) => (
|
||||
<tr key={row.label}>
|
||||
<td>{row.label}</td>
|
||||
<td>{row.count}</td>
|
||||
</tr>
|
||||
))}
|
||||
<tr>
|
||||
<td>{t('txt_folder')}</td>
|
||||
<td>{importSummary.folderCount}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<button type="button" className="btn btn-primary dialog-btn" onClick={() => setImportSummary(null)}>
|
||||
{t('txt_confirm')}
|
||||
</button>
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useMemo, useState } from 'preact/hooks';
|
||||
import { Copy, Eye, EyeOff, File, FileText, LayoutGrid, Pencil, Plus, RefreshCw, Send as SendIcon, Trash2 } from 'lucide-preact';
|
||||
import { CheckCheck, Copy, Eye, EyeOff, File, FileText, LayoutGrid, Pencil, Plus, RefreshCw, Save, Send as SendIcon, Trash2, X } from 'lucide-preact';
|
||||
import type { Send, SendDraft } from '@/lib/types';
|
||||
import { t } from '@/lib/i18n';
|
||||
|
||||
@@ -224,10 +224,12 @@ export default function SendsPage(props: SendsPageProps) {
|
||||
setSelectedMap(map);
|
||||
}}
|
||||
>
|
||||
<CheckCheck size={14} className="btn-icon" />
|
||||
{t('txt_select_all')}
|
||||
</button>
|
||||
{!!selectedCount && (
|
||||
<button type="button" className="btn btn-secondary small" onClick={() => setSelectedMap({})}>
|
||||
<X size={14} className="btn-icon" />
|
||||
{t('txt_cancel')}
|
||||
</button>
|
||||
)}
|
||||
@@ -364,8 +366,12 @@ export default function SendsPage(props: SendsPageProps) {
|
||||
</label>
|
||||
</div>
|
||||
<div className="detail-actions">
|
||||
<button type="button" className="btn btn-primary small" disabled={busy} onClick={() => void saveDraft()}>{t('txt_save')}</button>
|
||||
<button type="button" className="btn btn-secondary small" disabled={busy} onClick={() => { setIsEditing(false); setIsCreating(false); setDraft(null); setShowPassword(false); }}>{t('txt_cancel')}</button>
|
||||
<button type="button" className="btn btn-primary small" disabled={busy} onClick={() => void saveDraft()}>
|
||||
<Save size={14} className="btn-icon" /> {t('txt_save')}
|
||||
</button>
|
||||
<button type="button" className="btn btn-secondary small" disabled={busy} onClick={() => { setIsEditing(false); setIsCreating(false); setDraft(null); setShowPassword(false); }}>
|
||||
<X size={14} className="btn-icon" /> {t('txt_cancel')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -182,6 +182,7 @@ export default function SettingsPage(props: SettingsPageProps) {
|
||||
props.onNotify?.('success', t('txt_recovery_code_copied'));
|
||||
}}
|
||||
>
|
||||
<Clipboard size={14} className="btn-icon" />
|
||||
{t('txt_copy_code')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -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