diff --git a/webapp/src/components/ConfirmDialog.tsx b/webapp/src/components/ConfirmDialog.tsx index e2ebc70..f8e1469 100644 --- a/webapp/src/components/ConfirmDialog.tsx +++ b/webapp/src/components/ConfirmDialog.tsx @@ -83,6 +83,7 @@ export default function ConfirmDialog(props: ConfirmDialogProps) { const [present, setPresent] = useState(props.open); const [closing, setClosing] = useState(false); const cardRef = useRef(null); + const maskPointerStartedRef = useRef(false); const restoreFocusRef = useRef(null); const dialogId = useMemo(() => `confirm-dialog-${++dialogIdCounter}`, []); const titleId = `${dialogId}-title`; @@ -176,8 +177,11 @@ export default function ConfirmDialog(props: ConfirmDialogProps) { return createPortal((
{ + maskPointerStartedRef.current = event.target === event.currentTarget; + }} onClick={(event) => { - if (event.target !== event.currentTarget || !canDismiss) return; + if (event.target !== event.currentTarget || !maskPointerStartedRef.current || !canDismiss) return; props.onCancel(); }} > diff --git a/webapp/src/hooks/useVaultSendActions.ts b/webapp/src/hooks/useVaultSendActions.ts index 7d64805..cbe56e3 100644 --- a/webapp/src/hooks/useVaultSendActions.ts +++ b/webapp/src/hooks/useVaultSendActions.ts @@ -4,6 +4,7 @@ import type { ExportRequest, ZipAttachmentEntry } from '@/lib/export-formats'; import { attachNodeWardenEncryptedAttachmentPayload, buildAccountEncryptedBitwardenJsonString, + buildBitwardenCsvString, buildBitwardenZipBytes, buildExportFileName, buildNodeWardenAttachmentRecords, @@ -1190,6 +1191,12 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions) mimeType: 'application/json', bytes: new TextEncoder().encode(await getPlainJson()), }; + } else if (format === 'bitwarden_csv') { + result = { + fileName: buildExportFileName(format), + mimeType: 'text/csv;charset=utf-8', + bytes: new TextEncoder().encode(buildBitwardenCsvString(await getPlainJsonDoc())), + }; } else if (format === 'bitwarden_encrypted_json') { if (request.encryptedJsonMode === 'password') { const plainJson = await getPlainJson(); diff --git a/webapp/src/lib/export-formats.ts b/webapp/src/lib/export-formats.ts index 7c6f956..b050084 100644 --- a/webapp/src/lib/export-formats.ts +++ b/webapp/src/lib/export-formats.ts @@ -9,6 +9,7 @@ configureZipJs({ useWebWorkers: false }); export const EXPORT_FORMATS = [ { id: 'bitwarden_json', label: 'Bitwarden (vault as json)' }, + { id: 'bitwarden_csv', label: 'Bitwarden (vault as csv)' }, { id: 'bitwarden_encrypted_json', label: 'Bitwarden (encrypted vault as json)' }, { id: 'bitwarden_json_zip', label: 'Bitwarden (vault + attachments as zip)' }, { id: 'bitwarden_encrypted_json_zip', label: 'Bitwarden (encrypted vault + attachments as zip)' }, @@ -70,6 +71,27 @@ function isRecord(value: unknown): value is Record { return !!value && typeof value === 'object'; } +function csvText(value: unknown): string { + if (value === null || value === undefined) return ''; + if (typeof value === 'string') return value; + if (typeof value === 'number' || typeof value === 'boolean') return String(value); + try { + return JSON.stringify(value); + } catch { + return String(value); + } +} + +function escapeCsvCell(value: unknown): string { + const text = csvText(value); + if (!/[",\r\n]/.test(text)) return text; + return `"${text.replace(/"/g, '""')}"`; +} + +function buildCsvString(rows: string[][]): string { + return `${rows.map((row) => row.map(escapeCsvCell).join(',')).join('\r\n')}\r\n`; +} + function isCipherString(value: string): boolean { return /^\d+\.[A-Za-z0-9+/=]+\|[A-Za-z0-9+/=]+(?:\|[A-Za-z0-9+/=]+)?$/.test(String(value || '').trim()); } @@ -383,6 +405,107 @@ export async function buildPlainBitwardenJsonString(args: BuildPlainJsonArgs): P return JSON.stringify(doc, null, 2); } +const BITWARDEN_CSV_HEADERS = [ + 'folder', + 'favorite', + 'type', + 'name', + 'notes', + 'fields', + 'reprompt', + 'login_uri', + 'login_username', + 'login_password', + 'login_totp', +] as const; + +function bitwardenCsvType(type: number): 'login' | 'note' { + return type === 1 ? 'login' : 'note'; +} + +function sourceTypeLabel(type: number): string { + if (type === 3) return 'card'; + if (type === 4) return 'identity'; + if (type === 5) return 'sshKey'; + if (type === 2) return 'note'; + return `type ${type}`; +} + +function appendFieldLine(lines: string[], name: unknown, value: unknown): void { + const key = csvText(name).trim(); + const text = csvText(value); + if (!key || !text) return; + lines.push(`${key}: ${text}`); +} + +function appendRecordFieldLines(lines: string[], prefix: string, value: unknown): void { + if (!isRecord(value)) return; + for (const [key, fieldValue] of Object.entries(value)) { + appendFieldLine(lines, `${prefix}.${key}`, fieldValue); + } +} + +function buildBitwardenCsvFields(item: Record, type: number): string { + const lines: string[] = []; + const fields = Array.isArray(item.fields) ? item.fields : []; + for (const field of fields) { + if (!isRecord(field)) continue; + appendFieldLine(lines, field.name, field.value); + } + if (type !== 1 && type !== 2) { + appendFieldLine(lines, 'nodewardenType', sourceTypeLabel(type)); + appendRecordFieldLines(lines, sourceTypeLabel(type), item[sourceTypeLabel(type)]); + } + return lines.join('\n'); +} + +function buildFolderNameById(foldersRaw: unknown): Map { + const out = new Map(); + const folders = Array.isArray(foldersRaw) ? foldersRaw : []; + for (const folder of folders) { + if (!isRecord(folder)) continue; + const id = csvText(folder.id).trim(); + if (!id) continue; + out.set(id, csvText(folder.name)); + } + return out; +} + +function buildBitwardenCsvLoginUri(login: Record | null): string { + const uris = Array.isArray(login?.uris) ? login.uris : []; + return uris + .map((uri) => (isRecord(uri) ? csvText(uri.uri).trim() : '')) + .filter(Boolean) + .join('\n'); +} + +export function buildBitwardenCsvString(bitwardenJsonDoc: Record): string { + const folderNameById = buildFolderNameById(bitwardenJsonDoc.folders); + const rows: string[][] = [[...BITWARDEN_CSV_HEADERS]]; + const items = Array.isArray(bitwardenJsonDoc.items) ? bitwardenJsonDoc.items : []; + for (const itemRaw of items) { + if (!isRecord(itemRaw)) continue; + const type = normalizeNumber(itemRaw.type, 1); + const isLogin = type === 1; + const login = isRecord(itemRaw.login) ? itemRaw.login : null; + const folderId = csvText(itemRaw.folderId).trim(); + rows.push([ + folderNameById.get(folderId) || '', + itemRaw.favorite ? '1' : '0', + bitwardenCsvType(type), + csvText(itemRaw.name) || '--', + csvText(itemRaw.notes), + buildBitwardenCsvFields(itemRaw, type), + String(normalizeNumber(itemRaw.reprompt, 0)), + isLogin ? buildBitwardenCsvLoginUri(login) : '', + isLogin ? csvText(login?.username) : '', + isLogin ? csvText(login?.password) : '', + isLogin ? csvText(login?.totp) : '', + ]); + } + return `\uFEFF${buildCsvString(rows)}`; +} + export async function buildAccountEncryptedBitwardenJsonString(args: BuildEncryptedJsonArgs): Promise { const userEnc = base64ToBytes(args.userEncB64); const userMac = base64ToBytes(args.userMacB64); @@ -566,11 +689,13 @@ function nowStamp(now = new Date()): string { export function buildExportFileName(format: ExportFormatId, zipEncrypted = false): string { const stamp = nowStamp(); if ( + format === 'bitwarden_csv' || format === 'bitwarden_json' || format === 'bitwarden_encrypted_json' || format === 'nodewarden_json' || format === 'nodewarden_encrypted_json' ) { + if (format === 'bitwarden_csv') return `bitwarden_export_${stamp}.csv`; if (format.startsWith('nodewarden_')) return `nodewarden_export_${stamp}.json`; return `bitwarden_export_${stamp}.json`; }