mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 13:00:39 +00:00
fix(webapp): add CSV export and stabilize dialog dismissal
fix(webapp): 添加 CSV 导出并稳定弹窗关闭行为
This commit is contained in:
@@ -83,6 +83,7 @@ export default function ConfirmDialog(props: ConfirmDialogProps) {
|
|||||||
const [present, setPresent] = useState(props.open);
|
const [present, setPresent] = useState(props.open);
|
||||||
const [closing, setClosing] = useState(false);
|
const [closing, setClosing] = useState(false);
|
||||||
const cardRef = useRef<HTMLFormElement | null>(null);
|
const cardRef = useRef<HTMLFormElement | null>(null);
|
||||||
|
const maskPointerStartedRef = useRef(false);
|
||||||
const restoreFocusRef = useRef<HTMLElement | null>(null);
|
const restoreFocusRef = useRef<HTMLElement | null>(null);
|
||||||
const dialogId = useMemo(() => `confirm-dialog-${++dialogIdCounter}`, []);
|
const dialogId = useMemo(() => `confirm-dialog-${++dialogIdCounter}`, []);
|
||||||
const titleId = `${dialogId}-title`;
|
const titleId = `${dialogId}-title`;
|
||||||
@@ -176,8 +177,11 @@ export default function ConfirmDialog(props: ConfirmDialogProps) {
|
|||||||
return createPortal((
|
return createPortal((
|
||||||
<div
|
<div
|
||||||
className={`dialog-mask ${props.variant === 'warning' ? 'warning' : ''} ${props.open && !closing ? 'open' : ''} ${closing ? 'closing' : ''}`}
|
className={`dialog-mask ${props.variant === 'warning' ? 'warning' : ''} ${props.open && !closing ? 'open' : ''} ${closing ? 'closing' : ''}`}
|
||||||
|
onPointerDown={(event) => {
|
||||||
|
maskPointerStartedRef.current = event.target === event.currentTarget;
|
||||||
|
}}
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
if (event.target !== event.currentTarget || !canDismiss) return;
|
if (event.target !== event.currentTarget || !maskPointerStartedRef.current || !canDismiss) return;
|
||||||
props.onCancel();
|
props.onCancel();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type { ExportRequest, ZipAttachmentEntry } from '@/lib/export-formats';
|
|||||||
import {
|
import {
|
||||||
attachNodeWardenEncryptedAttachmentPayload,
|
attachNodeWardenEncryptedAttachmentPayload,
|
||||||
buildAccountEncryptedBitwardenJsonString,
|
buildAccountEncryptedBitwardenJsonString,
|
||||||
|
buildBitwardenCsvString,
|
||||||
buildBitwardenZipBytes,
|
buildBitwardenZipBytes,
|
||||||
buildExportFileName,
|
buildExportFileName,
|
||||||
buildNodeWardenAttachmentRecords,
|
buildNodeWardenAttachmentRecords,
|
||||||
@@ -1190,6 +1191,12 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
|||||||
mimeType: 'application/json',
|
mimeType: 'application/json',
|
||||||
bytes: new TextEncoder().encode(await getPlainJson()),
|
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') {
|
} else if (format === 'bitwarden_encrypted_json') {
|
||||||
if (request.encryptedJsonMode === 'password') {
|
if (request.encryptedJsonMode === 'password') {
|
||||||
const plainJson = await getPlainJson();
|
const plainJson = await getPlainJson();
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ configureZipJs({ useWebWorkers: false });
|
|||||||
|
|
||||||
export const EXPORT_FORMATS = [
|
export const EXPORT_FORMATS = [
|
||||||
{ id: 'bitwarden_json', label: 'Bitwarden (vault as json)' },
|
{ 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_encrypted_json', label: 'Bitwarden (encrypted vault as json)' },
|
||||||
{ id: 'bitwarden_json_zip', label: 'Bitwarden (vault + attachments as zip)' },
|
{ id: 'bitwarden_json_zip', label: 'Bitwarden (vault + attachments as zip)' },
|
||||||
{ id: 'bitwarden_encrypted_json_zip', label: 'Bitwarden (encrypted 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<string, unknown> {
|
|||||||
return !!value && typeof value === 'object';
|
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 {
|
function isCipherString(value: string): boolean {
|
||||||
return /^\d+\.[A-Za-z0-9+/=]+\|[A-Za-z0-9+/=]+(?:\|[A-Za-z0-9+/=]+)?$/.test(String(value || '').trim());
|
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);
|
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<string, unknown>, 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<string, string> {
|
||||||
|
const out = new Map<string, string>();
|
||||||
|
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<string, unknown> | 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, unknown>): 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<string> {
|
export async function buildAccountEncryptedBitwardenJsonString(args: BuildEncryptedJsonArgs): Promise<string> {
|
||||||
const userEnc = base64ToBytes(args.userEncB64);
|
const userEnc = base64ToBytes(args.userEncB64);
|
||||||
const userMac = base64ToBytes(args.userMacB64);
|
const userMac = base64ToBytes(args.userMacB64);
|
||||||
@@ -566,11 +689,13 @@ function nowStamp(now = new Date()): string {
|
|||||||
export function buildExportFileName(format: ExportFormatId, zipEncrypted = false): string {
|
export function buildExportFileName(format: ExportFormatId, zipEncrypted = false): string {
|
||||||
const stamp = nowStamp();
|
const stamp = nowStamp();
|
||||||
if (
|
if (
|
||||||
|
format === 'bitwarden_csv' ||
|
||||||
format === 'bitwarden_json' ||
|
format === 'bitwarden_json' ||
|
||||||
format === 'bitwarden_encrypted_json' ||
|
format === 'bitwarden_encrypted_json' ||
|
||||||
format === 'nodewarden_json' ||
|
format === 'nodewarden_json' ||
|
||||||
format === 'nodewarden_encrypted_json'
|
format === 'nodewarden_encrypted_json'
|
||||||
) {
|
) {
|
||||||
|
if (format === 'bitwarden_csv') return `bitwarden_export_${stamp}.csv`;
|
||||||
if (format.startsWith('nodewarden_')) return `nodewarden_export_${stamp}.json`;
|
if (format.startsWith('nodewarden_')) return `nodewarden_export_${stamp}.json`;
|
||||||
return `bitwarden_export_${stamp}.json`;
|
return `bitwarden_export_${stamp}.json`;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user