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:
Generated
+12
@@ -11,6 +11,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@noble/hashes": "^2.0.1",
|
"@noble/hashes": "^2.0.1",
|
||||||
"@tanstack/react-query": "^5.90.21",
|
"@tanstack/react-query": "^5.90.21",
|
||||||
|
"@zip.js/zip.js": "^2.8.22",
|
||||||
"fflate": "^0.8.2",
|
"fflate": "^0.8.2",
|
||||||
"lucide-preact": "^0.575.0",
|
"lucide-preact": "^0.575.0",
|
||||||
"preact": "^10.28.4",
|
"preact": "^10.28.4",
|
||||||
@@ -2089,6 +2090,17 @@
|
|||||||
"undici-types": "~7.16.0"
|
"undici-types": "~7.16.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@zip.js/zip.js": {
|
||||||
|
"version": "2.8.22",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@zip.js/zip.js/-/zip.js-2.8.22.tgz",
|
||||||
|
"integrity": "sha512-0KlzbVR6r8irIX2o3zvUlosBDef62VDl47oUfa1U/qgEs67h4/eGBrX/6HWa1RQbt+J6sAeVmtyFKbTHNdF8qQ==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"engines": {
|
||||||
|
"bun": ">=0.7.0",
|
||||||
|
"deno": ">=1.0.0",
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/babel-plugin-transform-hook-names": {
|
"node_modules/babel-plugin-transform-hook-names": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmmirror.com/babel-plugin-transform-hook-names/-/babel-plugin-transform-hook-names-1.0.2.tgz",
|
"resolved": "https://registry.npmmirror.com/babel-plugin-transform-hook-names/-/babel-plugin-transform-hook-names-1.0.2.tgz",
|
||||||
|
|||||||
@@ -47,6 +47,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@noble/hashes": "^2.0.1",
|
"@noble/hashes": "^2.0.1",
|
||||||
"@tanstack/react-query": "^5.90.21",
|
"@tanstack/react-query": "^5.90.21",
|
||||||
|
"@zip.js/zip.js": "^2.8.22",
|
||||||
"fflate": "^0.8.2",
|
"fflate": "^0.8.2",
|
||||||
"lucide-preact": "^0.575.0",
|
"lucide-preact": "^0.575.0",
|
||||||
"preact": "^10.28.4",
|
"preact": "^10.28.4",
|
||||||
|
|||||||
+15
-1
@@ -1,6 +1,6 @@
|
|||||||
import { Env, Cipher, Folder, CipherType } from '../types';
|
import { Env, Cipher, Folder, CipherType } from '../types';
|
||||||
import { StorageService } from '../services/storage';
|
import { StorageService } from '../services/storage';
|
||||||
import { errorResponse } from '../utils/response';
|
import { errorResponse, jsonResponse } from '../utils/response';
|
||||||
import { generateUUID } from '../utils/uuid';
|
import { generateUUID } from '../utils/uuid';
|
||||||
import { LIMITS } from '../config/limits';
|
import { LIMITS } from '../config/limits';
|
||||||
import { normalizeCipherLoginForCompatibility } from './ciphers';
|
import { normalizeCipherLoginForCompatibility } from './ciphers';
|
||||||
@@ -8,6 +8,7 @@ import { normalizeCipherLoginForCompatibility } from './ciphers';
|
|||||||
// Bitwarden client import request format
|
// Bitwarden client import request format
|
||||||
interface CiphersImportRequest {
|
interface CiphersImportRequest {
|
||||||
ciphers: Array<{
|
ciphers: Array<{
|
||||||
|
id?: string | null;
|
||||||
type: number;
|
type: number;
|
||||||
name?: string | null;
|
name?: string | null;
|
||||||
notes?: string | null;
|
notes?: string | null;
|
||||||
@@ -90,6 +91,8 @@ async function runBatchInChunks(db: D1Database, statements: D1PreparedStatement[
|
|||||||
// POST /api/ciphers/import - Bitwarden client import endpoint
|
// POST /api/ciphers/import - Bitwarden client import endpoint
|
||||||
export async function handleCiphersImport(request: Request, env: Env, userId: string): Promise<Response> {
|
export async function handleCiphersImport(request: Request, env: Env, userId: string): Promise<Response> {
|
||||||
const storage = new StorageService(env.DB);
|
const storage = new StorageService(env.DB);
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const returnCipherMap = url.searchParams.get('returnCipherMap') === '1';
|
||||||
|
|
||||||
let importData: CiphersImportRequest;
|
let importData: CiphersImportRequest;
|
||||||
try {
|
try {
|
||||||
@@ -151,9 +154,12 @@ export async function handleCiphersImport(request: Request, env: Env, userId: st
|
|||||||
|
|
||||||
// Create ciphers
|
// Create ciphers
|
||||||
const cipherRows: Cipher[] = [];
|
const cipherRows: Cipher[] = [];
|
||||||
|
const cipherMapRows: Array<{ index: number; sourceId: string | null; id: string }> = [];
|
||||||
for (let i = 0; i < ciphers.length; i++) {
|
for (let i = 0; i < ciphers.length; i++) {
|
||||||
const c = ciphers[i];
|
const c = ciphers[i];
|
||||||
const folderId = cipherFolderMap.get(i) || null;
|
const folderId = cipherFolderMap.get(i) || null;
|
||||||
|
const sourceIdRaw = String(c?.id ?? '').trim();
|
||||||
|
const sourceId = sourceIdRaw || null;
|
||||||
|
|
||||||
const cipher: Cipher = {
|
const cipher: Cipher = {
|
||||||
...c,
|
...c,
|
||||||
@@ -229,6 +235,7 @@ export async function handleCiphersImport(request: Request, env: Env, userId: st
|
|||||||
cipher.login = normalizeCipherLoginForCompatibility(cipher.login);
|
cipher.login = normalizeCipherLoginForCompatibility(cipher.login);
|
||||||
|
|
||||||
cipherRows.push(cipher);
|
cipherRows.push(cipher);
|
||||||
|
cipherMapRows.push({ index: i, sourceId, id: cipher.id });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (cipherRows.length > 0) {
|
if (cipherRows.length > 0) {
|
||||||
@@ -263,5 +270,12 @@ export async function handleCiphersImport(request: Request, env: Env, userId: st
|
|||||||
// Update revision date
|
// Update revision date
|
||||||
await storage.updateRevisionDate(userId);
|
await storage.updateRevisionDate(userId);
|
||||||
|
|
||||||
|
if (returnCipherMap) {
|
||||||
|
return jsonResponse({
|
||||||
|
object: 'import-result',
|
||||||
|
cipherMap: cipherMapRows,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return new Response(null, { status: 200 });
|
return new Response(null, { status: 200 });
|
||||||
}
|
}
|
||||||
|
|||||||
+429
-10
@@ -15,14 +15,17 @@ import SecurityDevicesPage from '@/components/SecurityDevicesPage';
|
|||||||
import AdminPage from '@/components/AdminPage';
|
import AdminPage from '@/components/AdminPage';
|
||||||
import HelpPage from '@/components/HelpPage';
|
import HelpPage from '@/components/HelpPage';
|
||||||
import ImportPage from '@/components/ImportPage';
|
import ImportPage from '@/components/ImportPage';
|
||||||
|
import type { ImportAttachmentFile, ImportResultSummary } from '@/components/ImportPage';
|
||||||
import {
|
import {
|
||||||
changeMasterPassword,
|
changeMasterPassword,
|
||||||
createFolder,
|
createFolder,
|
||||||
updateFolder,
|
updateFolder,
|
||||||
|
deleteCipherAttachment,
|
||||||
deleteFolder,
|
deleteFolder,
|
||||||
createCipher,
|
createCipher,
|
||||||
createAuthedFetch,
|
createAuthedFetch,
|
||||||
createInvite,
|
createInvite,
|
||||||
|
downloadCipherAttachmentDecrypted,
|
||||||
importCiphers,
|
importCiphers,
|
||||||
createSend,
|
createSend,
|
||||||
deleteAllInvites,
|
deleteAllInvites,
|
||||||
@@ -30,9 +33,11 @@ import {
|
|||||||
deleteSend,
|
deleteSend,
|
||||||
deleteUser,
|
deleteUser,
|
||||||
deriveLoginHash,
|
deriveLoginHash,
|
||||||
|
getAttachmentDownloadInfo,
|
||||||
bulkMoveCiphers,
|
bulkMoveCiphers,
|
||||||
getCiphers,
|
getCiphers,
|
||||||
getFolders,
|
getFolders,
|
||||||
|
getPreloginKdfConfig,
|
||||||
getProfile,
|
getProfile,
|
||||||
getAuthorizedDevices,
|
getAuthorizedDevices,
|
||||||
getSetupStatus,
|
getSetupStatus,
|
||||||
@@ -53,13 +58,28 @@ import {
|
|||||||
setTotp,
|
setTotp,
|
||||||
setUserStatus,
|
setUserStatus,
|
||||||
deleteAuthorizedDevice,
|
deleteAuthorizedDevice,
|
||||||
|
uploadCipherAttachment,
|
||||||
updateCipher,
|
updateCipher,
|
||||||
updateSend,
|
updateSend,
|
||||||
buildSendShareKey,
|
buildSendShareKey,
|
||||||
unlockVaultKey,
|
unlockVaultKey,
|
||||||
verifyMasterPassword,
|
verifyMasterPassword,
|
||||||
|
type ImportedCipherMapEntry,
|
||||||
} from '@/lib/api';
|
} from '@/lib/api';
|
||||||
import { base64ToBytes, decryptBw, decryptStr, hkdf } from '@/lib/crypto';
|
import { base64ToBytes, decryptBw, decryptBwFileData, decryptStr, hkdf } from '@/lib/crypto';
|
||||||
|
import {
|
||||||
|
attachNodeWardenEncryptedAttachmentPayload,
|
||||||
|
buildAccountEncryptedBitwardenJsonString,
|
||||||
|
buildBitwardenZipBytes,
|
||||||
|
buildExportFileName,
|
||||||
|
buildNodeWardenAttachmentRecords,
|
||||||
|
buildNodeWardenPlainJsonDocument,
|
||||||
|
buildPasswordProtectedBitwardenJsonString,
|
||||||
|
buildPlainBitwardenJsonString,
|
||||||
|
encryptZipBytesWithPassword,
|
||||||
|
type ExportRequest,
|
||||||
|
type ZipAttachmentEntry,
|
||||||
|
} from '@/lib/export-formats';
|
||||||
import { t } from '@/lib/i18n';
|
import { t } from '@/lib/i18n';
|
||||||
import type { CiphersImportPayload } from '@/lib/api';
|
import type { CiphersImportPayload } from '@/lib/api';
|
||||||
import type { AppPhase, AuthorizedDevice, Cipher, Folder, Profile, Send, SendDraft, SessionState, ToastMessage, VaultDraft } from '@/lib/types';
|
import type { AppPhase, AuthorizedDevice, Cipher, Folder, Profile, Send, SendDraft, SessionState, ToastMessage, VaultDraft } from '@/lib/types';
|
||||||
@@ -86,6 +106,35 @@ function asText(value: unknown): string {
|
|||||||
return String(value);
|
return String(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function summarizeImportResult(
|
||||||
|
ciphers: Array<Record<string, unknown>>,
|
||||||
|
folderCount: number
|
||||||
|
): ImportResultSummary {
|
||||||
|
const counter = new Map<string, number>();
|
||||||
|
const typeLabel = (type: number): string => {
|
||||||
|
if (type === 1) return '登录';
|
||||||
|
if (type === 2) return '安全备注';
|
||||||
|
if (type === 3) return '卡片';
|
||||||
|
if (type === 4) return '身份';
|
||||||
|
if (type === 5) return 'SSH 密钥';
|
||||||
|
return '其他';
|
||||||
|
};
|
||||||
|
for (const raw of ciphers) {
|
||||||
|
const t = Number(raw?.type || 1) || 1;
|
||||||
|
const label = typeLabel(t);
|
||||||
|
counter.set(label, (counter.get(label) || 0) + 1);
|
||||||
|
}
|
||||||
|
const order = ['登录', '安全备注', '卡片', '身份', 'SSH 密钥', '其他'];
|
||||||
|
const typeCounts = order
|
||||||
|
.filter((label) => (counter.get(label) || 0) > 0)
|
||||||
|
.map((label) => ({ label, count: counter.get(label) || 0 }));
|
||||||
|
return {
|
||||||
|
totalItems: ciphers.length,
|
||||||
|
folderCount: Math.max(0, folderCount),
|
||||||
|
typeCounts,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function buildEmptyImportDraft(type: number): VaultDraft {
|
function buildEmptyImportDraft(type: number): VaultDraft {
|
||||||
return {
|
return {
|
||||||
type,
|
type,
|
||||||
@@ -670,6 +719,14 @@ export default function App() {
|
|||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (Array.isArray(cipher.attachments)) {
|
||||||
|
nextCipher.attachments = await Promise.all(
|
||||||
|
cipher.attachments.map(async (attachment) => ({
|
||||||
|
...attachment,
|
||||||
|
decFileName: await decryptField(attachment.fileName || '', itemEnc, itemMac),
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
return nextCipher;
|
return nextCipher;
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -836,10 +893,13 @@ export default function App() {
|
|||||||
pushToast('success', t('txt_device_removed'));
|
pushToast('success', t('txt_device_removed'));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createVaultItem(draft: VaultDraft) {
|
async function createVaultItem(draft: VaultDraft, attachments: File[] = []) {
|
||||||
if (!session) return;
|
if (!session) return;
|
||||||
try {
|
try {
|
||||||
await createCipher(authedFetch, session, draft);
|
const created = await createCipher(authedFetch, session, draft);
|
||||||
|
for (const file of attachments) {
|
||||||
|
await uploadCipherAttachment(authedFetch, session, created.id, file);
|
||||||
|
}
|
||||||
await Promise.all([ciphersQuery.refetch(), foldersQuery.refetch()]);
|
await Promise.all([ciphersQuery.refetch(), foldersQuery.refetch()]);
|
||||||
pushToast('success', t('txt_item_created'));
|
pushToast('success', t('txt_item_created'));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -848,10 +908,24 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function updateVaultItem(cipher: Cipher, draft: VaultDraft) {
|
async function updateVaultItem(
|
||||||
|
cipher: Cipher,
|
||||||
|
draft: VaultDraft,
|
||||||
|
options?: { addFiles?: File[]; removeAttachmentIds?: string[] }
|
||||||
|
) {
|
||||||
if (!session) return;
|
if (!session) return;
|
||||||
|
const addFiles = Array.isArray(options?.addFiles) ? options.addFiles : [];
|
||||||
|
const removeAttachmentIds = Array.isArray(options?.removeAttachmentIds) ? options.removeAttachmentIds : [];
|
||||||
try {
|
try {
|
||||||
await updateCipher(authedFetch, session, cipher, draft);
|
await updateCipher(authedFetch, session, cipher, draft);
|
||||||
|
for (const attachmentId of removeAttachmentIds) {
|
||||||
|
const id = String(attachmentId || '').trim();
|
||||||
|
if (!id) continue;
|
||||||
|
await deleteCipherAttachment(authedFetch, cipher.id, id);
|
||||||
|
}
|
||||||
|
for (const file of addFiles) {
|
||||||
|
await uploadCipherAttachment(authedFetch, session, cipher.id, file, cipher);
|
||||||
|
}
|
||||||
await Promise.all([ciphersQuery.refetch(), foldersQuery.refetch()]);
|
await Promise.all([ciphersQuery.refetch(), foldersQuery.refetch()]);
|
||||||
pushToast('success', t('txt_item_updated'));
|
pushToast('success', t('txt_item_updated'));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -860,6 +934,29 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function downloadVaultAttachment(cipher: Cipher, attachmentId: string) {
|
||||||
|
if (!session) return;
|
||||||
|
try {
|
||||||
|
const file = await downloadCipherAttachmentDecrypted(authedFetch, session, cipher, attachmentId);
|
||||||
|
const fileName = String(file.fileName || '').trim() || 'attachment.bin';
|
||||||
|
const payload = new ArrayBuffer(file.bytes.byteLength);
|
||||||
|
new Uint8Array(payload).set(file.bytes);
|
||||||
|
const blob = new Blob([payload], { type: 'application/octet-stream' });
|
||||||
|
const href = URL.createObjectURL(blob);
|
||||||
|
const anchor = document.createElement('a');
|
||||||
|
anchor.href = href;
|
||||||
|
anchor.download = fileName;
|
||||||
|
anchor.rel = 'noopener';
|
||||||
|
document.body.appendChild(anchor);
|
||||||
|
anchor.click();
|
||||||
|
anchor.remove();
|
||||||
|
URL.revokeObjectURL(href);
|
||||||
|
} catch (error) {
|
||||||
|
pushToast('error', error instanceof Error ? error.message : t('txt_download_failed'));
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function deleteVaultItem(cipher: Cipher) {
|
async function deleteVaultItem(cipher: Cipher) {
|
||||||
try {
|
try {
|
||||||
await deleteCipher(authedFetch, cipher.id);
|
await deleteCipher(authedFetch, cipher.id);
|
||||||
@@ -1001,15 +1098,83 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildImportedCipherMaps(
|
||||||
|
payloadCiphers: Array<Record<string, unknown>>,
|
||||||
|
createdCipherIdsByIndex: Map<number, string>
|
||||||
|
): { byIndex: Map<number, string>; bySourceId: Map<string, string> } {
|
||||||
|
const byIndex = new Map<number, string>(createdCipherIdsByIndex);
|
||||||
|
const bySourceId = new Map<string, string>();
|
||||||
|
for (const [index, id] of createdCipherIdsByIndex.entries()) {
|
||||||
|
const raw = (payloadCiphers[index] || {}) as Record<string, unknown>;
|
||||||
|
const sourceId = String(raw.id || '').trim();
|
||||||
|
if (sourceId) bySourceId.set(sourceId, id);
|
||||||
|
}
|
||||||
|
return { byIndex, bySourceId };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadImportedAttachments(
|
||||||
|
attachments: ImportAttachmentFile[],
|
||||||
|
idMaps: { byIndex: Map<number, string>; bySourceId: Map<string, string> }
|
||||||
|
): Promise<void> {
|
||||||
|
if (!attachments.length) return;
|
||||||
|
if (!session?.symEncKey || !session?.symMacKey) throw new Error('Vault key unavailable');
|
||||||
|
|
||||||
|
const initialCiphers = (await ciphersQuery.refetch()).data || [];
|
||||||
|
const cipherById = new Map(initialCiphers.map((cipher) => [String(cipher.id || ''), cipher]));
|
||||||
|
const unresolved: ImportAttachmentFile[] = [];
|
||||||
|
|
||||||
|
for (const attachment of attachments) {
|
||||||
|
const sourceId = String(attachment.sourceCipherId || '').trim();
|
||||||
|
const sourceIndex = Number(attachment.sourceCipherIndex);
|
||||||
|
const byId = sourceId ? idMaps.bySourceId.get(sourceId) : null;
|
||||||
|
const byIndex = Number.isFinite(sourceIndex) ? idMaps.byIndex.get(sourceIndex) : null;
|
||||||
|
const targetCipherId = byId || byIndex || null;
|
||||||
|
if (!targetCipherId) {
|
||||||
|
unresolved.push(attachment);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = String(attachment.fileName || '').trim() || 'attachment.bin';
|
||||||
|
const fileBytes = Uint8Array.from(attachment.bytes);
|
||||||
|
const file = new File([fileBytes], name, { type: 'application/octet-stream' });
|
||||||
|
const cipher = cipherById.get(targetCipherId) || null;
|
||||||
|
await uploadCipherAttachment(authedFetch, session, targetCipherId, file, cipher);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (unresolved.length) {
|
||||||
|
throw new Error(`Failed to map ${unresolved.length} attachment(s) to imported items.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await ciphersQuery.refetch();
|
||||||
|
}
|
||||||
|
|
||||||
|
function toImportedCipherMapsFromResponse(
|
||||||
|
cipherMap: ImportedCipherMapEntry[] | null
|
||||||
|
): { byIndex: Map<number, string>; bySourceId: Map<string, string> } {
|
||||||
|
const byIndex = new Map<number, string>();
|
||||||
|
const bySourceId = new Map<string, string>();
|
||||||
|
for (const row of cipherMap || []) {
|
||||||
|
const idx = Number(row?.index);
|
||||||
|
const id = String(row?.id || '').trim();
|
||||||
|
if (!Number.isFinite(idx) || !id) continue;
|
||||||
|
byIndex.set(idx, id);
|
||||||
|
const sourceId = String(row?.sourceId || '').trim();
|
||||||
|
if (sourceId) bySourceId.set(sourceId, id);
|
||||||
|
}
|
||||||
|
return { byIndex, bySourceId };
|
||||||
|
}
|
||||||
|
|
||||||
async function handleImportAction(
|
async function handleImportAction(
|
||||||
payload: CiphersImportPayload,
|
payload: CiphersImportPayload,
|
||||||
options: { folderMode: 'original' | 'none' | 'target'; targetFolderId: string | null }
|
options: { folderMode: 'original' | 'none' | 'target'; targetFolderId: string | null },
|
||||||
) {
|
attachments: ImportAttachmentFile[] = []
|
||||||
|
): Promise<ImportResultSummary> {
|
||||||
if (!session?.symEncKey || !session?.symMacKey) throw new Error('Vault key unavailable');
|
if (!session?.symEncKey || !session?.symMacKey) throw new Error('Vault key unavailable');
|
||||||
|
|
||||||
const mode = options.folderMode || 'original';
|
const mode = options.folderMode || 'original';
|
||||||
const targetFolderId = (options.targetFolderId || '').trim() || null;
|
const targetFolderId = (options.targetFolderId || '').trim() || null;
|
||||||
const folderIdByCipherIndex = new Map<number, string>();
|
const folderIdByCipherIndex = new Map<number, string>();
|
||||||
|
let createdFolderCount = 0;
|
||||||
if (mode === 'original') {
|
if (mode === 'original') {
|
||||||
const folderIdByImportIndex = new Map<number, string>();
|
const folderIdByImportIndex = new Map<number, string>();
|
||||||
const folderIdByLegacyId = new Map<string, string>();
|
const folderIdByLegacyId = new Map<string, string>();
|
||||||
@@ -1024,6 +1189,7 @@ export default function App() {
|
|||||||
const created = await createFolder(authedFetch, session, name);
|
const created = await createFolder(authedFetch, session, name);
|
||||||
folderId = created.id;
|
folderId = created.id;
|
||||||
createdFolderIdByName.set(name, folderId);
|
createdFolderIdByName.set(name, folderId);
|
||||||
|
createdFolderCount += 1;
|
||||||
}
|
}
|
||||||
folderIdByImportIndex.set(i, folderId);
|
folderIdByImportIndex.set(i, folderId);
|
||||||
folderIdByName.set(name, folderId);
|
folderIdByName.set(name, folderId);
|
||||||
@@ -1076,13 +1242,20 @@ export default function App() {
|
|||||||
await bulkMoveCiphers(authedFetch, ids, folderId);
|
await bulkMoveCiphers(authedFetch, ids, folderId);
|
||||||
}
|
}
|
||||||
|
|
||||||
await Promise.all([ciphersQuery.refetch(), foldersQuery.refetch()]);
|
const idMaps = buildImportedCipherMaps(payload.ciphers, createdCipherIdsByIndex);
|
||||||
|
await foldersQuery.refetch();
|
||||||
|
await ciphersQuery.refetch();
|
||||||
|
if (attachments.length) {
|
||||||
|
await uploadImportedAttachments(attachments, idMaps);
|
||||||
|
}
|
||||||
|
return summarizeImportResult(payload.ciphers, mode === 'original' ? createdFolderCount : 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleImportEncryptedRawAction(
|
async function handleImportEncryptedRawAction(
|
||||||
payload: CiphersImportPayload,
|
payload: CiphersImportPayload,
|
||||||
options: { folderMode: 'original' | 'none' | 'target'; targetFolderId: string | null }
|
options: { folderMode: 'original' | 'none' | 'target'; targetFolderId: string | null },
|
||||||
) {
|
attachments: ImportAttachmentFile[] = []
|
||||||
|
): Promise<ImportResultSummary> {
|
||||||
const mode = options.folderMode || 'original';
|
const mode = options.folderMode || 'original';
|
||||||
const targetFolderId = (options.targetFolderId || '').trim() || null;
|
const targetFolderId = (options.targetFolderId || '').trim() || null;
|
||||||
const nextPayload: CiphersImportPayload = {
|
const nextPayload: CiphersImportPayload = {
|
||||||
@@ -1096,8 +1269,247 @@ export default function App() {
|
|||||||
for (const raw of nextPayload.ciphers) (raw as Record<string, unknown>).folderId = targetFolderId;
|
for (const raw of nextPayload.ciphers) (raw as Record<string, unknown>).folderId = targetFolderId;
|
||||||
}
|
}
|
||||||
|
|
||||||
await importCiphers(authedFetch, nextPayload);
|
const importedCipherMap = await importCiphers(authedFetch, nextPayload, {
|
||||||
|
returnCipherMap: attachments.length > 0,
|
||||||
|
});
|
||||||
await Promise.all([ciphersQuery.refetch(), foldersQuery.refetch()]);
|
await Promise.all([ciphersQuery.refetch(), foldersQuery.refetch()]);
|
||||||
|
if (attachments.length) {
|
||||||
|
const idMaps = toImportedCipherMapsFromResponse(importedCipherMap);
|
||||||
|
await uploadImportedAttachments(attachments, idMaps);
|
||||||
|
}
|
||||||
|
return summarizeImportResult(nextPayload.ciphers, mode === 'original' ? nextPayload.folders.length : 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleExportAction(request: ExportRequest) {
|
||||||
|
if (!session?.symEncKey || !session?.symMacKey) throw new Error('Vault key unavailable');
|
||||||
|
const masterPassword = String(request.masterPassword || '').trim();
|
||||||
|
if (!masterPassword) throw new Error(t('txt_master_password_is_required'));
|
||||||
|
const email = String(profile?.email || session.email || '').trim().toLowerCase();
|
||||||
|
if (!email) throw new Error(t('txt_profile_unavailable'));
|
||||||
|
const verifyDerived = await deriveLoginHash(email, masterPassword, defaultKdfIterations);
|
||||||
|
await verifyMasterPassword(authedFetch, verifyDerived.hash);
|
||||||
|
|
||||||
|
const rawFolders = foldersQuery.data || [];
|
||||||
|
const rawCiphers = ciphersQuery.data || [];
|
||||||
|
if (!rawFolders || !rawCiphers) throw new Error('Vault is not ready yet');
|
||||||
|
|
||||||
|
let plainJsonCache: string | null = null;
|
||||||
|
let plainJsonDocCache: Record<string, unknown> | null = null;
|
||||||
|
let encryptedJsonCache: string | null = null;
|
||||||
|
let nodeWardenAttachmentsCache: ReturnType<typeof buildNodeWardenAttachmentRecords> | null = null;
|
||||||
|
const getPlainJson = async () => {
|
||||||
|
if (!plainJsonCache) {
|
||||||
|
plainJsonCache = await buildPlainBitwardenJsonString({
|
||||||
|
folders: rawFolders,
|
||||||
|
ciphers: rawCiphers,
|
||||||
|
userEncB64: session.symEncKey!,
|
||||||
|
userMacB64: session.symMacKey!,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return plainJsonCache;
|
||||||
|
};
|
||||||
|
const getPlainJsonDoc = async () => {
|
||||||
|
if (!plainJsonDocCache) {
|
||||||
|
plainJsonDocCache = JSON.parse(await getPlainJson()) as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
return plainJsonDocCache;
|
||||||
|
};
|
||||||
|
const getEncryptedJson = async () => {
|
||||||
|
if (!encryptedJsonCache) {
|
||||||
|
encryptedJsonCache = await buildAccountEncryptedBitwardenJsonString({
|
||||||
|
folders: rawFolders,
|
||||||
|
ciphers: rawCiphers,
|
||||||
|
userEncB64: session.symEncKey!,
|
||||||
|
userMacB64: session.symMacKey!,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return encryptedJsonCache;
|
||||||
|
};
|
||||||
|
|
||||||
|
const zipAttachments = async (): Promise<ZipAttachmentEntry[]> => {
|
||||||
|
const userEnc = base64ToBytes(session.symEncKey!);
|
||||||
|
const userMac = base64ToBytes(session.symMacKey!);
|
||||||
|
const out: ZipAttachmentEntry[] = [];
|
||||||
|
const activeCiphers = rawCiphers.filter((cipher) => !cipher.deletedDate && !(cipher as { organizationId?: unknown }).organizationId);
|
||||||
|
|
||||||
|
for (const cipher of activeCiphers) {
|
||||||
|
const cipherId = String(cipher.id || '').trim();
|
||||||
|
if (!cipherId) continue;
|
||||||
|
const attachments = Array.isArray(cipher.attachments) ? cipher.attachments : [];
|
||||||
|
if (!attachments.length) continue;
|
||||||
|
|
||||||
|
let itemEnc = userEnc;
|
||||||
|
let itemMac = userMac;
|
||||||
|
const itemKey = String(cipher.key || '').trim();
|
||||||
|
if (itemKey && looksLikeCipherString(itemKey)) {
|
||||||
|
try {
|
||||||
|
const rawItemKey = await decryptBw(itemKey, userEnc, userMac);
|
||||||
|
if (rawItemKey.length >= 64) {
|
||||||
|
itemEnc = rawItemKey.slice(0, 32);
|
||||||
|
itemMac = rawItemKey.slice(32, 64);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// fallback to user key
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const attachment of attachments) {
|
||||||
|
const attachmentId = String(attachment?.id || '').trim();
|
||||||
|
if (!attachmentId) continue;
|
||||||
|
const info = await getAttachmentDownloadInfo(authedFetch, cipherId, attachmentId);
|
||||||
|
const fileResp = await fetch(info.url, { cache: 'no-store' });
|
||||||
|
if (!fileResp.ok) throw new Error(`Failed to download attachment ${attachmentId}`);
|
||||||
|
const encryptedBytes = new Uint8Array(await fileResp.arrayBuffer());
|
||||||
|
|
||||||
|
let fileEnc = itemEnc;
|
||||||
|
let fileMac = itemMac;
|
||||||
|
const attachmentKeyCipher = String(info.key || attachment?.key || '').trim();
|
||||||
|
if (attachmentKeyCipher && looksLikeCipherString(attachmentKeyCipher)) {
|
||||||
|
try {
|
||||||
|
const rawAttachmentKey = await decryptBw(attachmentKeyCipher, itemEnc, itemMac);
|
||||||
|
if (rawAttachmentKey.length >= 64) {
|
||||||
|
fileEnc = rawAttachmentKey.slice(0, 32);
|
||||||
|
fileMac = rawAttachmentKey.slice(32, 64);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// fallback to item key
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const plainBytes = await decryptBwFileData(encryptedBytes, fileEnc, fileMac);
|
||||||
|
|
||||||
|
const fileNameRaw = String(info.fileName || attachment?.fileName || '').trim();
|
||||||
|
let fileName = fileNameRaw || `attachment-${attachmentId}`;
|
||||||
|
if (fileNameRaw && looksLikeCipherString(fileNameRaw)) {
|
||||||
|
try {
|
||||||
|
fileName = (await decryptStr(fileNameRaw, itemEnc, itemMac)) || fileName;
|
||||||
|
} catch {
|
||||||
|
// fallback to raw encrypted name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
out.push({
|
||||||
|
cipherId,
|
||||||
|
fileName,
|
||||||
|
bytes: plainBytes,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getNodeWardenAttachmentRecords = async () => {
|
||||||
|
if (nodeWardenAttachmentsCache) return nodeWardenAttachmentsCache;
|
||||||
|
const [doc, attachments] = await Promise.all([getPlainJsonDoc(), zipAttachments()]);
|
||||||
|
const cipherIndexById = new Map<string, number>();
|
||||||
|
const items = Array.isArray(doc.items) ? (doc.items as Array<Record<string, unknown>>) : [];
|
||||||
|
for (let i = 0; i < items.length; i++) {
|
||||||
|
const id = String(items[i]?.id || '').trim();
|
||||||
|
if (id) cipherIndexById.set(id, i);
|
||||||
|
}
|
||||||
|
nodeWardenAttachmentsCache = buildNodeWardenAttachmentRecords(attachments, cipherIndexById);
|
||||||
|
return nodeWardenAttachmentsCache;
|
||||||
|
};
|
||||||
|
|
||||||
|
const format = request.format;
|
||||||
|
if (format === 'bitwarden_json') {
|
||||||
|
const bytes = new TextEncoder().encode(await getPlainJson());
|
||||||
|
return {
|
||||||
|
fileName: buildExportFileName(format),
|
||||||
|
mimeType: 'application/json',
|
||||||
|
bytes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (format === 'bitwarden_encrypted_json') {
|
||||||
|
if (request.encryptedJsonMode === 'password') {
|
||||||
|
const plainJson = await getPlainJson();
|
||||||
|
const kdf = await getPreloginKdfConfig(profile?.email || session.email, defaultKdfIterations);
|
||||||
|
const encrypted = await buildPasswordProtectedBitwardenJsonString({
|
||||||
|
plaintextJson: plainJson,
|
||||||
|
password: String(request.filePassword || ''),
|
||||||
|
kdf,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
fileName: buildExportFileName(format),
|
||||||
|
mimeType: 'application/json',
|
||||||
|
bytes: new TextEncoder().encode(encrypted),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const bytes = new TextEncoder().encode(await getEncryptedJson());
|
||||||
|
return {
|
||||||
|
fileName: buildExportFileName(format),
|
||||||
|
mimeType: 'application/json',
|
||||||
|
bytes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (format === 'nodewarden_json') {
|
||||||
|
const [plainDoc, attachments] = await Promise.all([getPlainJsonDoc(), getNodeWardenAttachmentRecords()]);
|
||||||
|
const nodeWardenDoc = buildNodeWardenPlainJsonDocument(plainDoc, attachments);
|
||||||
|
return {
|
||||||
|
fileName: buildExportFileName(format),
|
||||||
|
mimeType: 'application/json',
|
||||||
|
bytes: new TextEncoder().encode(JSON.stringify(nodeWardenDoc, null, 2)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (format === 'nodewarden_encrypted_json') {
|
||||||
|
if (request.encryptedJsonMode === 'password') {
|
||||||
|
const [plainDoc, attachments] = await Promise.all([getPlainJsonDoc(), getNodeWardenAttachmentRecords()]);
|
||||||
|
const nodeWardenDoc = buildNodeWardenPlainJsonDocument(plainDoc, attachments);
|
||||||
|
const kdf = await getPreloginKdfConfig(profile?.email || session.email, defaultKdfIterations);
|
||||||
|
const encrypted = await buildPasswordProtectedBitwardenJsonString({
|
||||||
|
plaintextJson: JSON.stringify(nodeWardenDoc, null, 2),
|
||||||
|
password: String(request.filePassword || ''),
|
||||||
|
kdf,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
fileName: buildExportFileName(format),
|
||||||
|
mimeType: 'application/json',
|
||||||
|
bytes: new TextEncoder().encode(encrypted),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const [encryptedJson, attachments] = await Promise.all([getEncryptedJson(), getNodeWardenAttachmentRecords()]);
|
||||||
|
const withAttachments = await attachNodeWardenEncryptedAttachmentPayload(
|
||||||
|
encryptedJson,
|
||||||
|
attachments,
|
||||||
|
session.symEncKey!,
|
||||||
|
session.symMacKey!
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
fileName: buildExportFileName(format),
|
||||||
|
mimeType: 'application/json',
|
||||||
|
bytes: new TextEncoder().encode(withAttachments),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (format === 'bitwarden_json_zip' || format === 'bitwarden_encrypted_json_zip') {
|
||||||
|
let dataJson = await getPlainJson();
|
||||||
|
if (format === 'bitwarden_encrypted_json_zip') {
|
||||||
|
if (request.encryptedJsonMode === 'password') {
|
||||||
|
const kdf = await getPreloginKdfConfig(profile?.email || session.email, defaultKdfIterations);
|
||||||
|
dataJson = await buildPasswordProtectedBitwardenJsonString({
|
||||||
|
plaintextJson: await getPlainJson(),
|
||||||
|
password: String(request.filePassword || ''),
|
||||||
|
kdf,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
dataJson = await getEncryptedJson();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const attachments = await zipAttachments();
|
||||||
|
const zipBytes = buildBitwardenZipBytes(dataJson, attachments);
|
||||||
|
const encryptedZip = await encryptZipBytesWithPassword(zipBytes, String(request.zipPassword || ''));
|
||||||
|
return {
|
||||||
|
fileName: buildExportFileName(format, encryptedZip.encrypted),
|
||||||
|
mimeType: 'application/zip',
|
||||||
|
bytes: encryptedZip.bytes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('Unsupported export format');
|
||||||
}
|
}
|
||||||
|
|
||||||
const hashPathRaw = typeof window !== 'undefined' ? window.location.hash || '' : '';
|
const hashPathRaw = typeof window !== 'undefined' ? window.location.hash || '' : '';
|
||||||
@@ -1311,6 +1723,7 @@ export default function App() {
|
|||||||
onNotify={pushToast}
|
onNotify={pushToast}
|
||||||
onCreateFolder={createFolderAction}
|
onCreateFolder={createFolderAction}
|
||||||
onDeleteFolder={deleteFolderAction}
|
onDeleteFolder={deleteFolderAction}
|
||||||
|
onDownloadAttachment={downloadVaultAttachment}
|
||||||
/>
|
/>
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="/settings">
|
<Route path="/settings">
|
||||||
@@ -1432,6 +1845,7 @@ export default function App() {
|
|||||||
accountKeys={session?.symEncKey && session?.symMacKey ? { encB64: session.symEncKey, macB64: session.symMacKey } : null}
|
accountKeys={session?.symEncKey && session?.symMacKey ? { encB64: session.symEncKey, macB64: session.symMacKey } : null}
|
||||||
onNotify={pushToast}
|
onNotify={pushToast}
|
||||||
folders={decryptedFolders}
|
folders={decryptedFolders}
|
||||||
|
onExport={handleExportAction}
|
||||||
/>
|
/>
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="/tools/import">
|
<Route path="/tools/import">
|
||||||
@@ -1441,6 +1855,7 @@ export default function App() {
|
|||||||
accountKeys={session?.symEncKey && session?.symMacKey ? { encB64: session.symEncKey, macB64: session.symMacKey } : null}
|
accountKeys={session?.symEncKey && session?.symMacKey ? { encB64: session.symEncKey, macB64: session.symMacKey } : null}
|
||||||
onNotify={pushToast}
|
onNotify={pushToast}
|
||||||
folders={decryptedFolders}
|
folders={decryptedFolders}
|
||||||
|
onExport={handleExportAction}
|
||||||
/>
|
/>
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="/tools/import-export">
|
<Route path="/tools/import-export">
|
||||||
@@ -1450,6 +1865,7 @@ export default function App() {
|
|||||||
accountKeys={session?.symEncKey && session?.symMacKey ? { encB64: session.symEncKey, macB64: session.symMacKey } : null}
|
accountKeys={session?.symEncKey && session?.symMacKey ? { encB64: session.symEncKey, macB64: session.symMacKey } : null}
|
||||||
onNotify={pushToast}
|
onNotify={pushToast}
|
||||||
folders={decryptedFolders}
|
folders={decryptedFolders}
|
||||||
|
onExport={handleExportAction}
|
||||||
/>
|
/>
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="/tools/import-data">
|
<Route path="/tools/import-data">
|
||||||
@@ -1459,6 +1875,7 @@ export default function App() {
|
|||||||
accountKeys={session?.symEncKey && session?.symMacKey ? { encB64: session.symEncKey, macB64: session.symMacKey } : null}
|
accountKeys={session?.symEncKey && session?.symMacKey ? { encB64: session.symEncKey, macB64: session.symMacKey } : null}
|
||||||
onNotify={pushToast}
|
onNotify={pushToast}
|
||||||
folders={decryptedFolders}
|
folders={decryptedFolders}
|
||||||
|
onExport={handleExportAction}
|
||||||
/>
|
/>
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="/import">
|
<Route path="/import">
|
||||||
@@ -1468,6 +1885,7 @@ export default function App() {
|
|||||||
accountKeys={session?.symEncKey && session?.symMacKey ? { encB64: session.symEncKey, macB64: session.symMacKey } : null}
|
accountKeys={session?.symEncKey && session?.symMacKey ? { encB64: session.symEncKey, macB64: session.symMacKey } : null}
|
||||||
onNotify={pushToast}
|
onNotify={pushToast}
|
||||||
folders={decryptedFolders}
|
folders={decryptedFolders}
|
||||||
|
onExport={handleExportAction}
|
||||||
/>
|
/>
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="/import-export">
|
<Route path="/import-export">
|
||||||
@@ -1477,6 +1895,7 @@ export default function App() {
|
|||||||
accountKeys={session?.symEncKey && session?.symMacKey ? { encB64: session.symEncKey, macB64: session.symMacKey } : null}
|
accountKeys={session?.symEncKey && session?.symMacKey ? { encB64: session.symEncKey, macB64: session.symMacKey } : null}
|
||||||
onNotify={pushToast}
|
onNotify={pushToast}
|
||||||
folders={decryptedFolders}
|
folders={decryptedFolders}
|
||||||
|
onExport={handleExportAction}
|
||||||
/>
|
/>
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="/help">
|
<Route path="/help">
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { ComponentChildren } from 'preact';
|
import type { ComponentChildren } from 'preact';
|
||||||
|
import { Check, X } from 'lucide-preact';
|
||||||
import { t } from '@/lib/i18n';
|
import { t } from '@/lib/i18n';
|
||||||
|
|
||||||
interface ConfirmDialogProps {
|
interface ConfirmDialogProps {
|
||||||
@@ -28,9 +29,11 @@ export default function ConfirmDialog(props: ConfirmDialogProps) {
|
|||||||
className={`btn ${props.danger ? 'btn-danger' : 'btn-primary'} dialog-btn`}
|
className={`btn ${props.danger ? 'btn-danger' : 'btn-primary'} dialog-btn`}
|
||||||
onClick={props.onConfirm}
|
onClick={props.onConfirm}
|
||||||
>
|
>
|
||||||
|
<Check size={14} className="btn-icon" />
|
||||||
{props.confirmText || t('txt_yes')}
|
{props.confirmText || t('txt_yes')}
|
||||||
</button>
|
</button>
|
||||||
<button type="button" className="btn btn-secondary dialog-btn" onClick={props.onCancel}>
|
<button type="button" className="btn btn-secondary dialog-btn" onClick={props.onCancel}>
|
||||||
|
<X size={14} className="btn-icon" />
|
||||||
{props.cancelText || t('txt_no')}
|
{props.cancelText || t('txt_no')}
|
||||||
</button>
|
</button>
|
||||||
{props.afterActions}
|
{props.afterActions}
|
||||||
|
|||||||
@@ -1,9 +1,17 @@
|
|||||||
import { useState } from 'preact/hooks';
|
import { useState } from 'preact/hooks';
|
||||||
import { argon2idAsync } from '@noble/hashes/argon2.js';
|
import { argon2idAsync } from '@noble/hashes/argon2.js';
|
||||||
import { strFromU8, unzipSync } from 'fflate';
|
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 ConfirmDialog from '@/components/ConfirmDialog';
|
||||||
import type { CiphersImportPayload } from '@/lib/api';
|
import type { CiphersImportPayload } from '@/lib/api';
|
||||||
|
import {
|
||||||
|
type EncryptedJsonMode,
|
||||||
|
EXPORT_FORMATS,
|
||||||
|
type ExportDownloadPayload,
|
||||||
|
type ExportFormatId,
|
||||||
|
type ExportRequest,
|
||||||
|
} from '@/lib/export-formats';
|
||||||
import {
|
import {
|
||||||
getFileAcceptBySource,
|
getFileAcceptBySource,
|
||||||
IMPORT_SOURCES,
|
IMPORT_SOURCES,
|
||||||
@@ -17,18 +25,36 @@ import { base64ToBytes, decryptStr, hkdfExpand, pbkdf2 } from '@/lib/crypto';
|
|||||||
import { t } from '@/lib/i18n';
|
import { t } from '@/lib/i18n';
|
||||||
import type { Folder } from '@/lib/types';
|
import type { Folder } from '@/lib/types';
|
||||||
|
|
||||||
|
configureZipJs({ useWebWorkers: false });
|
||||||
|
|
||||||
|
export interface ImportAttachmentFile {
|
||||||
|
sourceCipherId: string | null;
|
||||||
|
sourceCipherIndex: number | null;
|
||||||
|
fileName: string;
|
||||||
|
bytes: Uint8Array;
|
||||||
|
}
|
||||||
|
|
||||||
interface ImportPageProps {
|
interface ImportPageProps {
|
||||||
onImport: (
|
onImport: (
|
||||||
payload: CiphersImportPayload,
|
payload: CiphersImportPayload,
|
||||||
options: { folderMode: 'original' | 'none' | 'target'; targetFolderId: string | null }
|
options: { folderMode: 'original' | 'none' | 'target'; targetFolderId: string | null },
|
||||||
) => Promise<void>;
|
attachments?: ImportAttachmentFile[]
|
||||||
|
) => Promise<ImportResultSummary>;
|
||||||
onImportEncryptedRaw: (
|
onImportEncryptedRaw: (
|
||||||
payload: CiphersImportPayload,
|
payload: CiphersImportPayload,
|
||||||
options: { folderMode: 'original' | 'none' | 'target'; targetFolderId: string | null }
|
options: { folderMode: 'original' | 'none' | 'target'; targetFolderId: string | null },
|
||||||
) => Promise<void>;
|
attachments?: ImportAttachmentFile[]
|
||||||
|
) => Promise<ImportResultSummary>;
|
||||||
accountKeys?: { encB64: string; macB64: string } | null;
|
accountKeys?: { encB64: string; macB64: string } | null;
|
||||||
onNotify: (type: 'success' | 'error', text: string) => void;
|
onNotify: (type: 'success' | 'error', text: string) => void;
|
||||||
folders: Folder[];
|
folders: Folder[];
|
||||||
|
onExport: (request: ExportRequest) => Promise<ExportDownloadPayload>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImportResultSummary {
|
||||||
|
totalItems: number;
|
||||||
|
folderCount: number;
|
||||||
|
typeCounts: Array<{ label: string; count: number }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface BitwardenPasswordProtectedInput extends BitwardenJsonInput {
|
interface BitwardenPasswordProtectedInput extends BitwardenJsonInput {
|
||||||
@@ -45,6 +71,8 @@ interface BitwardenPasswordProtectedInput extends BitwardenJsonInput {
|
|||||||
const COMMON_IMPORT_SOURCE_IDS: ImportSourceId[] = [
|
const COMMON_IMPORT_SOURCE_IDS: ImportSourceId[] = [
|
||||||
'bitwarden_json',
|
'bitwarden_json',
|
||||||
'bitwarden_csv',
|
'bitwarden_csv',
|
||||||
|
'bitwarden_zip',
|
||||||
|
'nodewarden_json',
|
||||||
'onepassword_1pux',
|
'onepassword_1pux',
|
||||||
'onepassword_1pif',
|
'onepassword_1pif',
|
||||||
'onepassword_mac_csv',
|
'onepassword_mac_csv',
|
||||||
@@ -80,7 +108,7 @@ async function derivePasswordProtectedFileKey(
|
|||||||
const iterations = Number(parsed.kdfIterations || 0);
|
const iterations = Number(parsed.kdfIterations || 0);
|
||||||
const kdfType = Number(parsed.kdfType);
|
const kdfType = Number(parsed.kdfType);
|
||||||
if (!salt || !Number.isFinite(iterations) || iterations <= 0) {
|
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;
|
let keyMaterial: Uint8Array;
|
||||||
@@ -113,11 +141,11 @@ async function derivePasswordProtectedFileKey(
|
|||||||
|
|
||||||
async function decryptPasswordProtectedExport(parsed: BitwardenPasswordProtectedInput, password: string): Promise<unknown> {
|
async function decryptPasswordProtectedExport(parsed: BitwardenPasswordProtectedInput, password: string): Promise<unknown> {
|
||||||
if (!parsed.encKeyValidation_DO_NOT_EDIT || !parsed.data) {
|
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();
|
const pass = String(password || '').trim();
|
||||||
if (!pass) {
|
if (!pass) {
|
||||||
throw new Error('Please enter file password.');
|
throw new Error(t('txt_import_file_password_required'));
|
||||||
}
|
}
|
||||||
|
|
||||||
const key = await derivePasswordProtectedFileKey(parsed, pass);
|
const key = await derivePasswordProtectedFileKey(parsed, pass);
|
||||||
@@ -131,7 +159,7 @@ async function decryptPasswordProtectedExport(parsed: BitwardenPasswordProtected
|
|||||||
try {
|
try {
|
||||||
return JSON.parse(plainJson);
|
return JSON.parse(plainJson);
|
||||||
} catch {
|
} 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 {
|
function readZipText(bytes: Uint8Array, source: ImportSourceId): string {
|
||||||
const unzipped = unzipSync(bytes);
|
const unzipped = unzipSync(bytes);
|
||||||
const fileNames = Object.keys(unzipped);
|
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'];
|
const preferred = source === 'onepassword_1pux' ? ['export.data', 'export.json'] : ['protonpass.json', 'export.json'];
|
||||||
for (const p of preferred) {
|
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'));
|
const firstJson = fileNames.find((n) => n.toLowerCase().endsWith('.json') || n.toLowerCase().endsWith('.data'));
|
||||||
if (firstJson) return strFromU8(unzipped[firstJson]);
|
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> {
|
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);
|
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 [source, setSource] = useState<ImportSourceId>('bitwarden_json');
|
||||||
const [file, setFile] = useState<File | null>(null);
|
const [file, setFile] = useState<File | null>(null);
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const [isPasswordSubmitting, setIsPasswordSubmitting] = useState(false);
|
const [isPasswordSubmitting, setIsPasswordSubmitting] = useState(false);
|
||||||
const [passwordDialogOpen, setPasswordDialogOpen] = useState(false);
|
const [passwordDialogOpen, setPasswordDialogOpen] = useState(false);
|
||||||
const [importPassword, setImportPassword] = useState('');
|
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 [folderMode, setFolderMode] = useState<'original' | 'none' | 'target'>('original');
|
||||||
const [targetFolderId, setTargetFolderId] = useState('');
|
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 commonSourceSet = new Set<ImportSourceId>(COMMON_IMPORT_SOURCE_IDS);
|
||||||
const commonSources = IMPORT_SOURCES.filter((item) => commonSourceSet.has(item.id as ImportSourceId));
|
const commonSources = IMPORT_SOURCES.filter((item) => commonSourceSet.has(item.id as ImportSourceId));
|
||||||
const otherSources = 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) {
|
if (isRecord(parsed) && parsed.encrypted === true) {
|
||||||
const accountEncrypted = parsed as BitwardenJsonInput;
|
const accountEncrypted = parsed as BitwardenJsonInput;
|
||||||
if (!accountKeys?.encB64 || !accountKeys?.macB64) {
|
if (!accountKeys?.encB64 || !accountKeys?.macB64) {
|
||||||
@@ -193,16 +328,53 @@ export default function ImportPage({ onImport, onImportEncryptedRaw, accountKeys
|
|||||||
} catch {
|
} catch {
|
||||||
throw new Error('This encrypted export belongs to another account.');
|
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,
|
folderMode,
|
||||||
targetFolderId: folderMode === 'target' ? targetFolderId || null : null,
|
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), {
|
const accountEnc = base64ToBytes(accountKeys.encB64);
|
||||||
folderMode,
|
const accountMac = base64ToBytes(accountKeys.macB64);
|
||||||
targetFolderId: folderMode === 'target' ? targetFolderId || null : null,
|
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() {
|
async function handleSubmit() {
|
||||||
@@ -213,31 +385,77 @@ export default function ImportPage({ onImport, onImportEncryptedRaw, accountKeys
|
|||||||
|
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
try {
|
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);
|
const text = await readImportText(file, source);
|
||||||
if (source === 'bitwarden_json') {
|
if (source === 'bitwarden_json' || source === 'nodewarden_json') {
|
||||||
let parsed: unknown;
|
let parsed: unknown;
|
||||||
try {
|
try {
|
||||||
parsed = JSON.parse(text);
|
parsed = JSON.parse(text);
|
||||||
} catch {
|
} catch {
|
||||||
throw new Error('Invalid JSON file');
|
throw new Error(t('txt_import_invalid_json_file'));
|
||||||
}
|
}
|
||||||
if (isPasswordProtectedExport(parsed)) {
|
if (isPasswordProtectedExport(parsed)) {
|
||||||
setPendingPasswordImport(parsed);
|
setPendingPasswordImport({
|
||||||
|
parsed,
|
||||||
|
source,
|
||||||
|
attachments: [],
|
||||||
|
});
|
||||||
setImportPassword('');
|
setImportPassword('');
|
||||||
setPasswordDialogOpen(true);
|
setPasswordDialogOpen(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await runBitwardenJsonImport(parsed);
|
const summary =
|
||||||
|
source === 'nodewarden_json'
|
||||||
|
? await runNodeWardenJsonImport(parsed)
|
||||||
|
: await runBitwardenJsonImport(parsed);
|
||||||
|
setImportSummary(summary);
|
||||||
} else {
|
} else {
|
||||||
await onImport(parseImportPayloadBySource(source, text), {
|
const summary = await onImport(
|
||||||
folderMode,
|
parseImportPayloadBySource(source, text),
|
||||||
targetFolderId: folderMode === 'target' ? targetFolderId || null : null,
|
{
|
||||||
});
|
folderMode,
|
||||||
|
targetFolderId: folderMode === 'target' ? targetFolderId || null : null,
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
setImportSummary(summary);
|
||||||
}
|
}
|
||||||
setFile(null);
|
setFile(null);
|
||||||
onNotify('success', 'Import completed');
|
|
||||||
} catch (error) {
|
} 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);
|
onNotify('error', message);
|
||||||
} finally {
|
} finally {
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
@@ -248,31 +466,130 @@ export default function ImportPage({ onImport, onImportEncryptedRaw, accountKeys
|
|||||||
if (!pendingPasswordImport) return;
|
if (!pendingPasswordImport) return;
|
||||||
setIsPasswordSubmitting(true);
|
setIsPasswordSubmitting(true);
|
||||||
try {
|
try {
|
||||||
const parsed = await decryptPasswordProtectedExport(pendingPasswordImport, importPassword);
|
const summary = await processPasswordProtectedImport(pendingPasswordImport);
|
||||||
await runBitwardenJsonImport(parsed);
|
setImportSummary(summary);
|
||||||
setFile(null);
|
setFile(null);
|
||||||
setImportPassword('');
|
setImportPassword('');
|
||||||
setPendingPasswordImport(null);
|
setPendingPasswordImport(null);
|
||||||
setPasswordDialogOpen(false);
|
setPasswordDialogOpen(false);
|
||||||
onNotify('success', 'Import completed');
|
|
||||||
} catch (error) {
|
} 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);
|
onNotify('error', message);
|
||||||
} finally {
|
} finally {
|
||||||
setIsPasswordSubmitting(false);
|
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 (
|
return (
|
||||||
<div className="stack">
|
<div className="stack">
|
||||||
<section className="card">
|
<section className="card">
|
||||||
<h3>Import</h3>
|
<h3>{t('txt_import')}</h3>
|
||||||
<p className="muted" style={{ textAlign: 'left', marginBottom: 12 }}>
|
<p className="muted" style={{ textAlign: 'left', marginBottom: 12 }}>
|
||||||
Import vault data into your current account.
|
{t('txt_import_vault_data_hint')}
|
||||||
</p>
|
</p>
|
||||||
<div className="field-grid">
|
<div className="field-grid">
|
||||||
<label className="field field-span-2">
|
<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)}>
|
<select className="input" value={source} onChange={(e) => setSource((e.currentTarget as HTMLSelectElement).value as ImportSourceId)}>
|
||||||
{commonSources.map((item) => (
|
{commonSources.map((item) => (
|
||||||
<option key={item.id} value={item.id}>
|
<option key={item.id} value={item.id}>
|
||||||
@@ -293,7 +610,7 @@ export default function ImportPage({ onImport, onImportEncryptedRaw, accountKeys
|
|||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label className="field field-span-2">
|
<label className="field field-span-2">
|
||||||
<span>Source file</span>
|
<span>{t('txt_source_file')}</span>
|
||||||
<input
|
<input
|
||||||
className="input"
|
className="input"
|
||||||
type="file"
|
type="file"
|
||||||
@@ -306,23 +623,23 @@ export default function ImportPage({ onImport, onImportEncryptedRaw, accountKeys
|
|||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label className="field field-span-2">
|
<label className="field field-span-2">
|
||||||
<span>Folder handling</span>
|
<span>{t('txt_folder_handling')}</span>
|
||||||
<select
|
<select
|
||||||
className="input"
|
className="input"
|
||||||
value={folderMode}
|
value={folderMode}
|
||||||
onChange={(e) => setFolderMode((e.currentTarget as HTMLSelectElement).value as 'original' | 'none' | 'target')}
|
onChange={(e) => setFolderMode((e.currentTarget as HTMLSelectElement).value as 'original' | 'none' | 'target')}
|
||||||
>
|
>
|
||||||
<option value="original">Original path from import file</option>
|
<option value="original">{t('txt_import_folder_mode_original')}</option>
|
||||||
<option value="none">No folder</option>
|
<option value="none">{t('txt_import_folder_mode_none')}</option>
|
||||||
<option value="target">One selected folder</option>
|
<option value="target">{t('txt_import_folder_mode_target')}</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
{folderMode === 'target' && (
|
{folderMode === 'target' && (
|
||||||
<label className="field field-span-2">
|
<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)}>
|
<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
|
{folders
|
||||||
.slice()
|
.slice()
|
||||||
.sort((a, b) => String(a.decName || a.name || '').localeCompare(String(b.decName || b.name || '')))
|
.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)}
|
disabled={isSubmitting || (folderMode === 'target' && !targetFolderId)}
|
||||||
onClick={() => void handleSubmit()}
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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
|
<ConfirmDialog
|
||||||
open={passwordDialogOpen}
|
open={passwordDialogOpen}
|
||||||
title="Import encrypted file"
|
title={t('txt_import_encrypted_file_title')}
|
||||||
message="This Bitwarden export is password-protected. Enter the export file password to continue."
|
message={t('txt_import_encrypted_file_message')}
|
||||||
confirmText={isPasswordSubmitting ? t('txt_loading') : 'Import'}
|
confirmText={isPasswordSubmitting ? t('txt_loading') : t('txt_import')}
|
||||||
cancelText={t('txt_cancel')}
|
cancelText={t('txt_cancel')}
|
||||||
showIcon={false}
|
showIcon={false}
|
||||||
onConfirm={() => void handlePasswordImportConfirm()}
|
onConfirm={() => void handlePasswordImportConfirm()}
|
||||||
@@ -364,7 +777,7 @@ export default function ImportPage({ onImport, onImportEncryptedRaw, accountKeys
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>File password</span>
|
<span>{t('txt_file_password')}</span>
|
||||||
<input
|
<input
|
||||||
className="input"
|
className="input"
|
||||||
type="password"
|
type="password"
|
||||||
@@ -373,6 +786,74 @@ export default function ImportPage({ onImport, onImportEncryptedRaw, accountKeys
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
</ConfirmDialog>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useMemo, useState } from 'preact/hooks';
|
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 type { Send, SendDraft } from '@/lib/types';
|
||||||
import { t } from '@/lib/i18n';
|
import { t } from '@/lib/i18n';
|
||||||
|
|
||||||
@@ -224,10 +224,12 @@ export default function SendsPage(props: SendsPageProps) {
|
|||||||
setSelectedMap(map);
|
setSelectedMap(map);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
<CheckCheck size={14} className="btn-icon" />
|
||||||
{t('txt_select_all')}
|
{t('txt_select_all')}
|
||||||
</button>
|
</button>
|
||||||
{!!selectedCount && (
|
{!!selectedCount && (
|
||||||
<button type="button" className="btn btn-secondary small" onClick={() => setSelectedMap({})}>
|
<button type="button" className="btn btn-secondary small" onClick={() => setSelectedMap({})}>
|
||||||
|
<X size={14} className="btn-icon" />
|
||||||
{t('txt_cancel')}
|
{t('txt_cancel')}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
@@ -364,8 +366,12 @@ export default function SendsPage(props: SendsPageProps) {
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div className="detail-actions">
|
<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-primary small" disabled={busy} onClick={() => void saveDraft()}>
|
||||||
<button type="button" className="btn btn-secondary small" disabled={busy} onClick={() => { setIsEditing(false); setIsCreating(false); setDraft(null); setShowPassword(false); }}>{t('txt_cancel')}</button>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -182,6 +182,7 @@ export default function SettingsPage(props: SettingsPageProps) {
|
|||||||
props.onNotify?.('success', t('txt_recovery_code_copied'));
|
props.onNotify?.('success', t('txt_recovery_code_copied'));
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
<Clipboard size={14} className="btn-icon" />
|
||||||
{t('txt_copy_code')}
|
{t('txt_copy_code')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
CheckCheck,
|
CheckCheck,
|
||||||
Clipboard,
|
Clipboard,
|
||||||
CreditCard,
|
CreditCard,
|
||||||
|
Download,
|
||||||
Eye,
|
Eye,
|
||||||
EyeOff,
|
EyeOff,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
@@ -17,6 +18,7 @@ import {
|
|||||||
Globe,
|
Globe,
|
||||||
KeyRound,
|
KeyRound,
|
||||||
LayoutGrid,
|
LayoutGrid,
|
||||||
|
Paperclip,
|
||||||
Pencil,
|
Pencil,
|
||||||
Plus,
|
Plus,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
@@ -25,9 +27,10 @@ import {
|
|||||||
StarOff,
|
StarOff,
|
||||||
StickyNote,
|
StickyNote,
|
||||||
Trash2,
|
Trash2,
|
||||||
|
Upload,
|
||||||
X,
|
X,
|
||||||
} from 'lucide-preact';
|
} 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';
|
import { t } from '@/lib/i18n';
|
||||||
|
|
||||||
interface VaultPageProps {
|
interface VaultPageProps {
|
||||||
@@ -36,8 +39,8 @@ interface VaultPageProps {
|
|||||||
loading: boolean;
|
loading: boolean;
|
||||||
emailForReprompt: string;
|
emailForReprompt: string;
|
||||||
onRefresh: () => Promise<void>;
|
onRefresh: () => Promise<void>;
|
||||||
onCreate: (draft: VaultDraft) => Promise<void>;
|
onCreate: (draft: VaultDraft, attachments?: File[]) => Promise<void>;
|
||||||
onUpdate: (cipher: Cipher, draft: VaultDraft) => Promise<void>;
|
onUpdate: (cipher: Cipher, draft: VaultDraft, options?: { addFiles?: File[]; removeAttachmentIds?: string[] }) => Promise<void>;
|
||||||
onDelete: (cipher: Cipher) => Promise<void>;
|
onDelete: (cipher: Cipher) => Promise<void>;
|
||||||
onBulkDelete: (ids: string[]) => Promise<void>;
|
onBulkDelete: (ids: string[]) => Promise<void>;
|
||||||
onBulkMove: (ids: string[], folderId: string | null) => Promise<void>;
|
onBulkMove: (ids: string[], folderId: string | null) => Promise<void>;
|
||||||
@@ -45,6 +48,7 @@ interface VaultPageProps {
|
|||||||
onNotify: (type: 'success' | 'error', text: string) => void;
|
onNotify: (type: 'success' | 'error', text: string) => void;
|
||||||
onCreateFolder: (name: string) => Promise<void>;
|
onCreateFolder: (name: string) => Promise<void>;
|
||||||
onDeleteFolder: (folderId: string) => Promise<void>;
|
onDeleteFolder: (folderId: string) => Promise<void>;
|
||||||
|
onDownloadAttachment: (cipher: Cipher, attachmentId: string) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
type TypeFilter = 'login' | 'card' | 'identity' | 'note' | 'ssh';
|
type TypeFilter = 'login' | 'card' | 'identity' | 'note' | 'ssh';
|
||||||
@@ -269,6 +273,25 @@ function formatHistoryTime(value: string | null | undefined): string {
|
|||||||
return date.toLocaleString();
|
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 {
|
function firstPasskeyCreationTime(cipher: Cipher | null): string | null {
|
||||||
const credentials = cipher?.login?.fido2Credentials;
|
const credentials = cipher?.login?.fido2Credentials;
|
||||||
if (!Array.isArray(credentials) || credentials.length === 0) return null;
|
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 [pendingDeleteFolder, setPendingDeleteFolder] = useState<Folder | null>(null);
|
||||||
const [totpLive, setTotpLive] = useState<{ code: string; remain: number } | null>(null);
|
const [totpLive, setTotpLive] = useState<{ code: string; remain: number } | null>(null);
|
||||||
const [hiddenFieldVisibleMap, setHiddenFieldVisibleMap] = useState<Record<number, boolean>>({});
|
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 [busy, setBusy] = useState(false);
|
||||||
const [repromptOpen, setRepromptOpen] = useState(false);
|
const [repromptOpen, setRepromptOpen] = useState(false);
|
||||||
const [repromptPassword, setRepromptPassword] = useState('');
|
const [repromptPassword, setRepromptPassword] = useState('');
|
||||||
const [repromptApprovedCipherId, setRepromptApprovedCipherId] = useState<string | null>(null);
|
const [repromptApprovedCipherId, setRepromptApprovedCipherId] = useState<string | null>(null);
|
||||||
const createMenuRef = useRef<HTMLDivElement | null>(null);
|
const createMenuRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const attachmentInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
const sshSeedTicketRef = useRef(0);
|
const sshSeedTicketRef = useRef(0);
|
||||||
const sshFingerprintTicketRef = useRef(0);
|
const sshFingerprintTicketRef = useRef(0);
|
||||||
|
|
||||||
@@ -436,6 +462,19 @@ export default function VaultPage(props: VaultPageProps) {
|
|||||||
[props.ciphers, selectedCipherId]
|
[props.ciphers, selectedCipherId]
|
||||||
);
|
);
|
||||||
const passkeyCreatedAt = firstPasskeyCreationTime(selectedCipher);
|
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(() => {
|
useEffect(() => {
|
||||||
const raw = selectedCipher?.login?.decTotp || '';
|
const raw = selectedCipher?.login?.decTotp || '';
|
||||||
@@ -487,6 +526,8 @@ function folderName(id: string | null | undefined): string {
|
|||||||
setSelectedCipherId('');
|
setSelectedCipherId('');
|
||||||
setShowPassword(false);
|
setShowPassword(false);
|
||||||
setLocalError('');
|
setLocalError('');
|
||||||
|
setAttachmentQueue([]);
|
||||||
|
setRemovedAttachmentIds({});
|
||||||
if (type === 5) void seedSshDefaults();
|
if (type === 5) void seedSshDefaults();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -497,6 +538,8 @@ function folderName(id: string | null | undefined): string {
|
|||||||
setIsEditing(true);
|
setIsEditing(true);
|
||||||
setShowPassword(false);
|
setShowPassword(false);
|
||||||
setLocalError('');
|
setLocalError('');
|
||||||
|
setAttachmentQueue([]);
|
||||||
|
setRemovedAttachmentIds({});
|
||||||
}
|
}
|
||||||
|
|
||||||
function cancelEdit(): void {
|
function cancelEdit(): void {
|
||||||
@@ -504,6 +547,8 @@ function folderName(id: string | null | undefined): string {
|
|||||||
setIsEditing(false);
|
setIsEditing(false);
|
||||||
setIsCreating(false);
|
setIsCreating(false);
|
||||||
setLocalError('');
|
setLocalError('');
|
||||||
|
setAttachmentQueue([]);
|
||||||
|
setRemovedAttachmentIds({});
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateDraft(patch: Partial<VaultDraft>): void {
|
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> {
|
async function saveDraft(): Promise<void> {
|
||||||
if (!draft) return;
|
if (!draft) return;
|
||||||
let nextDraft = draft;
|
let nextDraft = draft;
|
||||||
@@ -589,14 +656,20 @@ function folderName(id: string | null | undefined): string {
|
|||||||
setBusy(true);
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
if (isCreating) {
|
if (isCreating) {
|
||||||
await props.onCreate(nextDraft);
|
await props.onCreate(nextDraft, attachmentQueue);
|
||||||
} else if (selectedCipher) {
|
} 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);
|
setIsCreating(false);
|
||||||
setIsEditing(false);
|
setIsEditing(false);
|
||||||
setDraft(null);
|
setDraft(null);
|
||||||
setLocalError('');
|
setLocalError('');
|
||||||
|
setAttachmentQueue([]);
|
||||||
|
setRemovedAttachmentIds({});
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
@@ -864,6 +937,9 @@ function folderName(id: string | null | undefined): string {
|
|||||||
type="button"
|
type="button"
|
||||||
className="row-main"
|
className="row-main"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
if (isEditing || isCreating) {
|
||||||
|
cancelEdit();
|
||||||
|
}
|
||||||
setSelectedCipherId(cipher.id);
|
setSelectedCipherId(cipher.id);
|
||||||
setRepromptApprovedCipherId(null);
|
setRepromptApprovedCipherId(null);
|
||||||
}}
|
}}
|
||||||
@@ -971,6 +1047,7 @@ function folderName(id: string | null | undefined): string {
|
|||||||
className="btn btn-secondary small"
|
className="btn btn-secondary small"
|
||||||
onClick={() => updateDraft({ loginUris: draft.loginUris.filter((_, i) => i !== index) })}
|
onClick={() => updateDraft({ loginUris: draft.loginUris.filter((_, i) => i !== index) })}
|
||||||
>
|
>
|
||||||
|
<X size={14} className="btn-icon" />
|
||||||
{t('txt_remove')}
|
{t('txt_remove')}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
@@ -1059,6 +1136,104 @@ function folderName(id: string | null | undefined): string {
|
|||||||
</div>
|
</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">
|
<div className="card">
|
||||||
<h4>{t('txt_additional_options')}</h4>
|
<h4>{t('txt_additional_options')}</h4>
|
||||||
<label className="field">
|
<label className="field">
|
||||||
@@ -1105,6 +1280,7 @@ function folderName(id: string | null | undefined): string {
|
|||||||
className="btn btn-secondary small"
|
className="btn btn-secondary small"
|
||||||
onClick={() => updateDraftCustomFields(draft.customFields.filter((_, i) => i !== originalIndex))}
|
onClick={() => updateDraftCustomFields(draft.customFields.filter((_, i) => i !== originalIndex))}
|
||||||
>
|
>
|
||||||
|
<X size={14} className="btn-icon" />
|
||||||
{t('txt_remove')}
|
{t('txt_remove')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -1114,14 +1290,17 @@ function folderName(id: string | null | undefined): string {
|
|||||||
<div className="detail-actions">
|
<div className="detail-actions">
|
||||||
<div className="actions">
|
<div className="actions">
|
||||||
<button type="button" className="btn btn-primary" disabled={busy} onClick={() => void saveDraft()}>
|
<button type="button" className="btn btn-primary" disabled={busy} onClick={() => void saveDraft()}>
|
||||||
|
<CheckCheck size={14} className="btn-icon" />
|
||||||
{t('txt_confirm')}
|
{t('txt_confirm')}
|
||||||
</button>
|
</button>
|
||||||
<button type="button" className="btn btn-secondary" disabled={busy} onClick={cancelEdit}>
|
<button type="button" className="btn btn-secondary" disabled={busy} onClick={cancelEdit}>
|
||||||
|
<X size={14} className="btn-icon" />
|
||||||
{t('txt_cancel')}
|
{t('txt_cancel')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{!isCreating && selectedCipher && (
|
{!isCreating && selectedCipher && (
|
||||||
<button type="button" className="btn btn-danger" disabled={busy} onClick={() => setPendingDelete(selectedCipher)}>
|
<button type="button" className="btn btn-danger" disabled={busy} onClick={() => setPendingDelete(selectedCipher)}>
|
||||||
|
<Trash2 size={14} className="btn-icon" />
|
||||||
{t('txt_delete')}
|
{t('txt_delete')}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
@@ -1351,6 +1530,39 @@ function folderName(id: string | null | undefined): string {
|
|||||||
</div>
|
</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) && (
|
{(selectedCipher.creationDate || selectedCipher.revisionDate) && (
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<h4>{t('txt_item_history')}</h4>
|
<h4>{t('txt_item_history')}</h4>
|
||||||
|
|||||||
+225
-3
@@ -80,6 +80,13 @@ export interface PreloginResult {
|
|||||||
kdfIterations: number;
|
kdfIterations: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PreloginKdfConfig {
|
||||||
|
kdfType: number;
|
||||||
|
kdfIterations: number;
|
||||||
|
kdfMemory: number | null;
|
||||||
|
kdfParallelism: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
function randomHex(length: number): string {
|
function randomHex(length: number): string {
|
||||||
const bytes = crypto.getRandomValues(new Uint8Array(Math.max(1, Math.ceil(length / 2))));
|
const bytes = crypto.getRandomValues(new Uint8Array(Math.max(1, Math.ceil(length / 2))));
|
||||||
return Array.from(bytes).map((b) => b.toString(16).padStart(2, '0')).join('').slice(0, length);
|
return Array.from(bytes).map((b) => b.toString(16).padStart(2, '0')).join('').slice(0, length);
|
||||||
@@ -130,6 +137,24 @@ export async function deriveLoginHash(email: string, password: string, fallbackI
|
|||||||
return { hash: bytesToBase64(hash), masterKey, kdfIterations: iterations };
|
return { hash: bytesToBase64(hash), masterKey, kdfIterations: iterations };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getPreloginKdfConfig(email: string, fallbackIterations: number): Promise<PreloginKdfConfig> {
|
||||||
|
const normalized = String(email || '').trim().toLowerCase();
|
||||||
|
if (!normalized) throw new Error('Email is required');
|
||||||
|
const pre = await fetch('/identity/accounts/prelogin', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ email: normalized }),
|
||||||
|
});
|
||||||
|
if (!pre.ok) throw new Error('prelogin failed');
|
||||||
|
const data = (await parseJson<{ kdf?: number; kdfIterations?: number; kdfMemory?: number | null; kdfParallelism?: number | null }>(pre)) || {};
|
||||||
|
return {
|
||||||
|
kdfType: Number(data.kdf ?? 0) || 0,
|
||||||
|
kdfIterations: Number(data.kdfIterations || fallbackIterations),
|
||||||
|
kdfMemory: data.kdfMemory == null ? null : Number(data.kdfMemory),
|
||||||
|
kdfParallelism: data.kdfParallelism == null ? null : Number(data.kdfParallelism),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export async function loginWithPassword(
|
export async function loginWithPassword(
|
||||||
email: string,
|
email: string,
|
||||||
passwordHash: string,
|
passwordHash: string,
|
||||||
@@ -369,16 +394,213 @@ export interface CiphersImportPayload {
|
|||||||
folderRelationships: Array<{ key: number; value: number }>;
|
folderRelationships: Array<{ key: number; value: number }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ImportedCipherMapEntry {
|
||||||
|
index: number;
|
||||||
|
sourceId: string | null;
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
|
|
||||||
export async function importCiphers(
|
export async function importCiphers(
|
||||||
authedFetch: (input: string, init?: RequestInit) => Promise<Response>,
|
authedFetch: (input: string, init?: RequestInit) => Promise<Response>,
|
||||||
payload: CiphersImportPayload
|
payload: CiphersImportPayload,
|
||||||
): Promise<void> {
|
options?: { returnCipherMap?: boolean }
|
||||||
const resp = await authedFetch('/api/ciphers/import', {
|
): Promise<ImportedCipherMapEntry[] | null> {
|
||||||
|
const returnCipherMap = !!options?.returnCipherMap;
|
||||||
|
const url = returnCipherMap ? '/api/ciphers/import?returnCipherMap=1' : '/api/ciphers/import';
|
||||||
|
const resp = await authedFetch(url, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
});
|
});
|
||||||
if (!resp.ok) throw new Error(await parseErrorMessage(resp, 'Import failed'));
|
if (!resp.ok) throw new Error(await parseErrorMessage(resp, 'Import failed'));
|
||||||
|
if (!returnCipherMap) return null;
|
||||||
|
const body =
|
||||||
|
(await parseJson<{
|
||||||
|
cipherMap?: Array<{ index?: number; sourceId?: string | null; id?: string }>;
|
||||||
|
}>(resp)) || {};
|
||||||
|
if (!Array.isArray(body.cipherMap)) return [];
|
||||||
|
const out: ImportedCipherMapEntry[] = [];
|
||||||
|
for (const row of body.cipherMap) {
|
||||||
|
const index = Number(row?.index);
|
||||||
|
const id = String(row?.id || '').trim();
|
||||||
|
if (!Number.isFinite(index) || !id) continue;
|
||||||
|
const sourceRaw = String(row?.sourceId || '').trim();
|
||||||
|
out.push({
|
||||||
|
index,
|
||||||
|
id,
|
||||||
|
sourceId: sourceRaw || null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AttachmentDownloadInfo {
|
||||||
|
id: string;
|
||||||
|
url: string;
|
||||||
|
fileName: string | null;
|
||||||
|
key: string | null;
|
||||||
|
size: string | null;
|
||||||
|
sizeName: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAttachmentDownloadInfo(
|
||||||
|
authedFetch: (input: string, init?: RequestInit) => Promise<Response>,
|
||||||
|
cipherId: string,
|
||||||
|
attachmentId: string
|
||||||
|
): Promise<AttachmentDownloadInfo> {
|
||||||
|
const resp = await authedFetch(`/api/ciphers/${encodeURIComponent(cipherId)}/attachment/${encodeURIComponent(attachmentId)}`);
|
||||||
|
if (!resp.ok) throw new Error(await parseErrorMessage(resp, 'Failed to load attachment'));
|
||||||
|
const body =
|
||||||
|
(await parseJson<{
|
||||||
|
id?: string;
|
||||||
|
url?: string;
|
||||||
|
fileName?: string | null;
|
||||||
|
key?: string | null;
|
||||||
|
size?: string | null;
|
||||||
|
sizeName?: string | null;
|
||||||
|
}>(resp)) || {};
|
||||||
|
const id = String(body.id || attachmentId || '').trim();
|
||||||
|
const url = String(body.url || '').trim();
|
||||||
|
if (!id || !url) throw new Error('Invalid attachment download response');
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
url,
|
||||||
|
fileName: body.fileName ?? null,
|
||||||
|
key: body.key ?? null,
|
||||||
|
size: body.size ?? null,
|
||||||
|
sizeName: body.sizeName ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function looksLikeCipherString(value: unknown): boolean {
|
||||||
|
return /^\d+\.[A-Za-z0-9+/=]+\|[A-Za-z0-9+/=]+(?:\|[A-Za-z0-9+/=]+)?$/.test(String(value || '').trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function uploadCipherAttachment(
|
||||||
|
authedFetch: (input: string, init?: RequestInit) => Promise<Response>,
|
||||||
|
session: SessionState,
|
||||||
|
cipherId: string,
|
||||||
|
file: File,
|
||||||
|
cipherForKey?: Cipher | null
|
||||||
|
): Promise<void> {
|
||||||
|
if (!session.symEncKey || !session.symMacKey) throw new Error('Vault key unavailable');
|
||||||
|
const id = String(cipherId || '').trim();
|
||||||
|
if (!id) throw new Error('Cipher id is required');
|
||||||
|
if (!file) throw new Error('File is required');
|
||||||
|
|
||||||
|
const userEnc = base64ToBytes(session.symEncKey);
|
||||||
|
const userMac = base64ToBytes(session.symMacKey);
|
||||||
|
const itemKeys = await getCipherKeys(cipherForKey || null, userEnc, userMac);
|
||||||
|
|
||||||
|
const encryptedFileName = await encryptTextValue(file.name, itemKeys.enc, itemKeys.mac);
|
||||||
|
if (!encryptedFileName) throw new Error('Invalid attachment name');
|
||||||
|
|
||||||
|
const attachmentRawKey = crypto.getRandomValues(new Uint8Array(64));
|
||||||
|
const attachmentWrappedKey = await encryptBw(attachmentRawKey, itemKeys.enc, itemKeys.mac);
|
||||||
|
const fileBytes = new Uint8Array(await file.arrayBuffer());
|
||||||
|
const encryptedBytes = await encryptBwFileData(fileBytes, attachmentRawKey.slice(0, 32), attachmentRawKey.slice(32, 64));
|
||||||
|
|
||||||
|
const metaResp = await authedFetch(`/api/ciphers/${encodeURIComponent(id)}/attachment/v2`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
fileName: encryptedFileName,
|
||||||
|
key: attachmentWrappedKey,
|
||||||
|
fileSize: encryptedBytes.byteLength,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!metaResp.ok) throw new Error(await parseErrorMessage(metaResp, 'Create attachment failed'));
|
||||||
|
|
||||||
|
const meta =
|
||||||
|
(await parseJson<{
|
||||||
|
attachmentId?: string;
|
||||||
|
url?: string;
|
||||||
|
}>(metaResp)) || {};
|
||||||
|
const attachmentId = String(meta.attachmentId || '').trim();
|
||||||
|
const uploadUrl = String(meta.url || '').trim();
|
||||||
|
if (!attachmentId || !uploadUrl) throw new Error('Create attachment failed');
|
||||||
|
|
||||||
|
const payload = new ArrayBuffer(encryptedBytes.byteLength);
|
||||||
|
new Uint8Array(payload).set(encryptedBytes);
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.set('data', new Blob([payload], { type: 'application/octet-stream' }), encryptedFileName);
|
||||||
|
|
||||||
|
const uploadResp = await authedFetch(uploadUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
if (!uploadResp.ok) {
|
||||||
|
try {
|
||||||
|
await authedFetch(`/api/ciphers/${encodeURIComponent(id)}/attachment/${encodeURIComponent(attachmentId)}`, { method: 'DELETE' });
|
||||||
|
} catch {
|
||||||
|
// ignore rollback failure
|
||||||
|
}
|
||||||
|
throw new Error(await parseErrorMessage(uploadResp, 'Upload attachment failed'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteCipherAttachment(
|
||||||
|
authedFetch: (input: string, init?: RequestInit) => Promise<Response>,
|
||||||
|
cipherId: string,
|
||||||
|
attachmentId: string
|
||||||
|
): Promise<void> {
|
||||||
|
const cid = String(cipherId || '').trim();
|
||||||
|
const aid = String(attachmentId || '').trim();
|
||||||
|
if (!cid || !aid) throw new Error('Attachment id is required');
|
||||||
|
const resp = await authedFetch(`/api/ciphers/${encodeURIComponent(cid)}/attachment/${encodeURIComponent(aid)}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
if (!resp.ok) throw new Error(await parseErrorMessage(resp, 'Delete attachment failed'));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function downloadCipherAttachmentDecrypted(
|
||||||
|
authedFetch: (input: string, init?: RequestInit) => Promise<Response>,
|
||||||
|
session: SessionState,
|
||||||
|
cipher: Cipher,
|
||||||
|
attachmentId: string
|
||||||
|
): Promise<{ fileName: string; bytes: Uint8Array }> {
|
||||||
|
if (!session.symEncKey || !session.symMacKey) throw new Error('Vault key unavailable');
|
||||||
|
const cid = String(cipher?.id || '').trim();
|
||||||
|
const aid = String(attachmentId || '').trim();
|
||||||
|
if (!cid || !aid) throw new Error('Attachment id is required');
|
||||||
|
|
||||||
|
const info = await getAttachmentDownloadInfo(authedFetch, cid, aid);
|
||||||
|
const rawResp = await fetch(info.url, { cache: 'no-store' });
|
||||||
|
if (!rawResp.ok) throw new Error('Download attachment failed');
|
||||||
|
const encryptedBytes = new Uint8Array(await rawResp.arrayBuffer());
|
||||||
|
|
||||||
|
const userEnc = base64ToBytes(session.symEncKey);
|
||||||
|
const userMac = base64ToBytes(session.symMacKey);
|
||||||
|
const itemKeys = await getCipherKeys(cipher, userEnc, userMac);
|
||||||
|
|
||||||
|
let fileEnc = itemKeys.enc;
|
||||||
|
let fileMac = itemKeys.mac;
|
||||||
|
const keyCipher = String(info.key || '').trim();
|
||||||
|
if (keyCipher && looksLikeCipherString(keyCipher)) {
|
||||||
|
try {
|
||||||
|
const fileRawKey = await decryptBw(keyCipher, itemKeys.enc, itemKeys.mac);
|
||||||
|
if (fileRawKey.length >= 64) {
|
||||||
|
fileEnc = fileRawKey.slice(0, 32);
|
||||||
|
fileMac = fileRawKey.slice(32, 64);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// fallback to item key
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const plainBytes = await decryptBwFileData(encryptedBytes, fileEnc, fileMac);
|
||||||
|
|
||||||
|
const fileNameRaw = String(info.fileName || '').trim();
|
||||||
|
let fileName = fileNameRaw || `attachment-${aid}`;
|
||||||
|
if (fileNameRaw && looksLikeCipherString(fileNameRaw)) {
|
||||||
|
try {
|
||||||
|
fileName = (await decryptStr(fileNameRaw, itemKeys.enc, itemKeys.mac)) || fileName;
|
||||||
|
} catch {
|
||||||
|
// keep fallback name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { fileName, bytes: plainBytes };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getSends(authedFetch: (input: string, init?: RequestInit) => Promise<Response>): Promise<Send[]> {
|
export async function getSends(authedFetch: (input: string, init?: RequestInit) => Promise<Response>): Promise<Send[]> {
|
||||||
|
|||||||
@@ -0,0 +1,694 @@
|
|||||||
|
import { argon2idAsync } from '@noble/hashes/argon2.js';
|
||||||
|
import { strToU8, zipSync } from 'fflate';
|
||||||
|
import { Uint8ArrayReader, Uint8ArrayWriter, ZipReader, ZipWriter, configure as configureZipJs } from '@zip.js/zip.js';
|
||||||
|
import type { PreloginKdfConfig } from './api';
|
||||||
|
import { base64ToBytes, bytesToBase64, decryptBw, decryptStr, encryptBw, hkdfExpand, pbkdf2 } from './crypto';
|
||||||
|
import type { Cipher, Folder } from './types';
|
||||||
|
|
||||||
|
configureZipJs({ useWebWorkers: false });
|
||||||
|
|
||||||
|
export const EXPORT_FORMATS = [
|
||||||
|
{ id: 'bitwarden_json', label: 'Bitwarden (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_encrypted_json_zip', label: 'Bitwarden (encrypted vault + attachments as zip)' },
|
||||||
|
{ id: 'nodewarden_json', label: 'NodeWarden (vault + attachments as json)' },
|
||||||
|
{ id: 'nodewarden_encrypted_json', label: 'NodeWarden (encrypted vault + attachments as json)' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type ExportFormatId = (typeof EXPORT_FORMATS)[number]['id'];
|
||||||
|
export type EncryptedJsonMode = 'account' | 'password';
|
||||||
|
|
||||||
|
export interface ExportRequest {
|
||||||
|
format: ExportFormatId;
|
||||||
|
encryptedJsonMode?: EncryptedJsonMode;
|
||||||
|
filePassword?: string;
|
||||||
|
zipPassword?: string;
|
||||||
|
masterPassword?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExportDownloadPayload {
|
||||||
|
fileName: string;
|
||||||
|
mimeType: string;
|
||||||
|
bytes: Uint8Array;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ZipAttachmentEntry {
|
||||||
|
cipherId: string;
|
||||||
|
fileName: string;
|
||||||
|
bytes: Uint8Array;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NodeWardenAttachmentRecord {
|
||||||
|
cipherId: string;
|
||||||
|
cipherIndex: number | null;
|
||||||
|
fileName: string;
|
||||||
|
data: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BuildPlainJsonArgs {
|
||||||
|
folders: Folder[];
|
||||||
|
ciphers: Cipher[];
|
||||||
|
userEncB64: string;
|
||||||
|
userMacB64: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BuildEncryptedJsonArgs {
|
||||||
|
folders: Folder[];
|
||||||
|
ciphers: Cipher[];
|
||||||
|
userEncB64: string;
|
||||||
|
userMacB64: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PasswordProtectedArgs {
|
||||||
|
plaintextJson: string;
|
||||||
|
password: string;
|
||||||
|
kdf: PreloginKdfConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return !!value && typeof value === 'object';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCipherString(value: string): boolean {
|
||||||
|
return /^\d+\.[A-Za-z0-9+/=]+\|[A-Za-z0-9+/=]+(?:\|[A-Za-z0-9+/=]+)?$/.test(String(value || '').trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeString(value: unknown): string | null {
|
||||||
|
if (value === null || value === undefined) return null;
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeNumber(value: unknown, fallback = 0): number {
|
||||||
|
const n = Number(value);
|
||||||
|
if (!Number.isFinite(n)) return fallback;
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cloneValue<T>(value: T): T {
|
||||||
|
if (value === null || value === undefined) return value;
|
||||||
|
if (typeof structuredClone === 'function') {
|
||||||
|
try {
|
||||||
|
return structuredClone(value);
|
||||||
|
} catch {
|
||||||
|
// ignore and fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return JSON.parse(JSON.stringify(value)) as T;
|
||||||
|
} catch {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function randomGuid(): string {
|
||||||
|
if (typeof crypto.randomUUID === 'function') return crypto.randomUUID();
|
||||||
|
const bytes = crypto.getRandomValues(new Uint8Array(16));
|
||||||
|
bytes[6] = (bytes[6] & 0x0f) | 0x40;
|
||||||
|
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
||||||
|
const hex = Array.from(bytes)
|
||||||
|
.map((b) => b.toString(16).padStart(2, '0'))
|
||||||
|
.join('');
|
||||||
|
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toAesBuffer(bytes: Uint8Array): ArrayBuffer {
|
||||||
|
return new Uint8Array(bytes).buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getCipherKeyParts(cipher: Cipher, userEnc: Uint8Array, userMac: Uint8Array): Promise<{ enc: Uint8Array; mac: Uint8Array }> {
|
||||||
|
if (cipher.key && typeof cipher.key === 'string') {
|
||||||
|
try {
|
||||||
|
const raw = await decryptBw(cipher.key, userEnc, userMac);
|
||||||
|
if (raw.length >= 64) {
|
||||||
|
return { enc: raw.slice(0, 32), mac: raw.slice(32, 64) };
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Fallback to user key.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { enc: userEnc, mac: userMac };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function decryptMaybe(value: unknown, enc: Uint8Array, mac: Uint8Array): Promise<string | null> {
|
||||||
|
if (value === null || value === undefined) return null;
|
||||||
|
if (typeof value !== 'string') return String(value);
|
||||||
|
const raw = value;
|
||||||
|
if (!raw) return '';
|
||||||
|
if (!isCipherString(raw)) return raw;
|
||||||
|
try {
|
||||||
|
return await decryptStr(raw, enc, mac);
|
||||||
|
} catch {
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deepDecryptUnknown(value: unknown, enc: Uint8Array, mac: Uint8Array): Promise<unknown> {
|
||||||
|
if (value === null || value === undefined) return value;
|
||||||
|
if (typeof value === 'string') return decryptMaybe(value, enc, mac);
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return Promise.all(value.map((item) => deepDecryptUnknown(item, enc, mac)));
|
||||||
|
}
|
||||||
|
if (isRecord(value)) {
|
||||||
|
const out: Record<string, unknown> = {};
|
||||||
|
for (const [k, v] of Object.entries(value)) {
|
||||||
|
out[k] = await deepDecryptUnknown(v, enc, mac);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapCipherCommonMetadata(cipher: Cipher): Record<string, unknown> {
|
||||||
|
const out: Record<string, unknown> = {
|
||||||
|
id: cipher.id,
|
||||||
|
type: normalizeNumber(cipher.type, 1),
|
||||||
|
reprompt: normalizeNumber(cipher.reprompt, 0),
|
||||||
|
favorite: !!cipher.favorite,
|
||||||
|
folderId: normalizeString(cipher.folderId),
|
||||||
|
creationDate: normalizeString(cipher.creationDate),
|
||||||
|
revisionDate: normalizeString(cipher.revisionDate),
|
||||||
|
collectionIds: null,
|
||||||
|
};
|
||||||
|
if ((out.creationDate as string | null) === null) delete out.creationDate;
|
||||||
|
if ((out.revisionDate as string | null) === null) delete out.revisionDate;
|
||||||
|
if ((out.folderId as string | null) === null) delete out.folderId;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapCipherEncrypted(cipher: Cipher): Record<string, unknown> {
|
||||||
|
const out = mapCipherCommonMetadata(cipher);
|
||||||
|
out.name = cipher.name ?? null;
|
||||||
|
out.notes = cipher.notes ?? null;
|
||||||
|
out.key = cipher.key ?? null;
|
||||||
|
out.fields = Array.isArray(cipher.fields)
|
||||||
|
? cipher.fields.map((field) => ({
|
||||||
|
name: field?.name ?? null,
|
||||||
|
value: field?.value ?? null,
|
||||||
|
type: normalizeNumber(field?.type, 0),
|
||||||
|
linkedId: field?.linkedId ?? null,
|
||||||
|
}))
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const login = cipher.login;
|
||||||
|
out.login = login
|
||||||
|
? {
|
||||||
|
username: login.username ?? null,
|
||||||
|
password: login.password ?? null,
|
||||||
|
totp: login.totp ?? null,
|
||||||
|
uris: Array.isArray(login.uris)
|
||||||
|
? login.uris.map((uri) => ({
|
||||||
|
uri: uri?.uri ?? null,
|
||||||
|
match: (uri as { match?: unknown })?.match ?? null,
|
||||||
|
}))
|
||||||
|
: [],
|
||||||
|
fido2Credentials: Array.isArray(login.fido2Credentials) ? cloneValue(login.fido2Credentials) : [],
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
|
||||||
|
out.card = cipher.card
|
||||||
|
? {
|
||||||
|
cardholderName: cipher.card.cardholderName ?? null,
|
||||||
|
brand: cipher.card.brand ?? null,
|
||||||
|
number: cipher.card.number ?? null,
|
||||||
|
expMonth: cipher.card.expMonth ?? null,
|
||||||
|
expYear: cipher.card.expYear ?? null,
|
||||||
|
code: cipher.card.code ?? null,
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
|
||||||
|
out.identity = cipher.identity
|
||||||
|
? {
|
||||||
|
title: cipher.identity.title ?? null,
|
||||||
|
firstName: cipher.identity.firstName ?? null,
|
||||||
|
middleName: cipher.identity.middleName ?? null,
|
||||||
|
lastName: cipher.identity.lastName ?? null,
|
||||||
|
username: cipher.identity.username ?? null,
|
||||||
|
company: cipher.identity.company ?? null,
|
||||||
|
ssn: cipher.identity.ssn ?? null,
|
||||||
|
passportNumber: cipher.identity.passportNumber ?? null,
|
||||||
|
licenseNumber: cipher.identity.licenseNumber ?? null,
|
||||||
|
email: cipher.identity.email ?? null,
|
||||||
|
phone: cipher.identity.phone ?? null,
|
||||||
|
address1: cipher.identity.address1 ?? null,
|
||||||
|
address2: cipher.identity.address2 ?? null,
|
||||||
|
address3: cipher.identity.address3 ?? null,
|
||||||
|
city: cipher.identity.city ?? null,
|
||||||
|
state: cipher.identity.state ?? null,
|
||||||
|
postalCode: cipher.identity.postalCode ?? null,
|
||||||
|
country: cipher.identity.country ?? null,
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
|
||||||
|
out.secureNote = cipher.secureNote
|
||||||
|
? {
|
||||||
|
type: normalizeNumber((cipher.secureNote as { type?: unknown }).type, 0),
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
|
||||||
|
out.passwordHistory = Array.isArray(cipher.passwordHistory)
|
||||||
|
? cipher.passwordHistory.map((entry) => ({
|
||||||
|
password: (entry as { password?: unknown }).password ?? null,
|
||||||
|
lastUsedDate: (entry as { lastUsedDate?: unknown }).lastUsedDate ?? null,
|
||||||
|
}))
|
||||||
|
: [];
|
||||||
|
|
||||||
|
out.sshKey = cipher.sshKey
|
||||||
|
? {
|
||||||
|
privateKey: cipher.sshKey.privateKey ?? null,
|
||||||
|
publicKey: cipher.sshKey.publicKey ?? null,
|
||||||
|
fingerprint: cipher.sshKey.fingerprint ?? null,
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mapCipherPlain(cipher: Cipher, userEnc: Uint8Array, userMac: Uint8Array): Promise<Record<string, unknown>> {
|
||||||
|
const keyParts = await getCipherKeyParts(cipher, userEnc, userMac);
|
||||||
|
const out = mapCipherCommonMetadata(cipher);
|
||||||
|
|
||||||
|
out.name = await decryptMaybe(cipher.name ?? null, keyParts.enc, keyParts.mac);
|
||||||
|
out.notes = await decryptMaybe(cipher.notes ?? null, keyParts.enc, keyParts.mac);
|
||||||
|
out.fields = Array.isArray(cipher.fields)
|
||||||
|
? await Promise.all(
|
||||||
|
cipher.fields.map(async (field) => ({
|
||||||
|
name: await decryptMaybe(field?.name ?? null, keyParts.enc, keyParts.mac),
|
||||||
|
value: await decryptMaybe(field?.value ?? null, keyParts.enc, keyParts.mac),
|
||||||
|
type: normalizeNumber(field?.type, 0),
|
||||||
|
linkedId: field?.linkedId ?? null,
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
if (cipher.login) {
|
||||||
|
out.login = {
|
||||||
|
username: await decryptMaybe(cipher.login.username ?? null, keyParts.enc, keyParts.mac),
|
||||||
|
password: await decryptMaybe(cipher.login.password ?? null, keyParts.enc, keyParts.mac),
|
||||||
|
totp: await decryptMaybe(cipher.login.totp ?? null, keyParts.enc, keyParts.mac),
|
||||||
|
uris: Array.isArray(cipher.login.uris)
|
||||||
|
? await Promise.all(
|
||||||
|
cipher.login.uris.map(async (uri) => ({
|
||||||
|
uri: await decryptMaybe(uri?.uri ?? null, keyParts.enc, keyParts.mac),
|
||||||
|
match: (uri as { match?: unknown })?.match ?? null,
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
: [],
|
||||||
|
fido2Credentials: Array.isArray(cipher.login.fido2Credentials)
|
||||||
|
? await Promise.all(cipher.login.fido2Credentials.map((credential) => deepDecryptUnknown(credential, keyParts.enc, keyParts.mac)))
|
||||||
|
: [],
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
out.login = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
out.card = cipher.card ? await deepDecryptUnknown(cipher.card, keyParts.enc, keyParts.mac) : null;
|
||||||
|
out.identity = cipher.identity ? await deepDecryptUnknown(cipher.identity, keyParts.enc, keyParts.mac) : null;
|
||||||
|
out.sshKey = cipher.sshKey ? await deepDecryptUnknown(cipher.sshKey, keyParts.enc, keyParts.mac) : null;
|
||||||
|
out.secureNote = cipher.secureNote
|
||||||
|
? {
|
||||||
|
type: normalizeNumber((cipher.secureNote as { type?: unknown }).type, 0),
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
|
||||||
|
out.passwordHistory = Array.isArray(cipher.passwordHistory)
|
||||||
|
? await Promise.all(
|
||||||
|
cipher.passwordHistory.map(async (entry) => ({
|
||||||
|
password: await decryptMaybe((entry as { password?: unknown }).password ?? null, keyParts.enc, keyParts.mac),
|
||||||
|
lastUsedDate: normalizeString((entry as { lastUsedDate?: unknown }).lastUsedDate),
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function decryptFolderName(folder: Folder, userEnc: Uint8Array, userMac: Uint8Array): Promise<string> {
|
||||||
|
const value = await decryptMaybe(folder.name ?? '', userEnc, userMac);
|
||||||
|
return value || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function trimNullKeys(value: Record<string, unknown>): Record<string, unknown> {
|
||||||
|
const out: Record<string, unknown> = {};
|
||||||
|
for (const [k, v] of Object.entries(value)) {
|
||||||
|
if (v !== undefined) out[k] = v;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterExportableCiphers(ciphers: Cipher[]): Cipher[] {
|
||||||
|
return ciphers.filter((cipher) => !cipher.deletedDate && !(cipher as { organizationId?: unknown }).organizationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function buildPlainBitwardenJsonDocument(args: BuildPlainJsonArgs): Promise<Record<string, unknown>> {
|
||||||
|
const userEnc = base64ToBytes(args.userEncB64);
|
||||||
|
const userMac = base64ToBytes(args.userMacB64);
|
||||||
|
|
||||||
|
const folders = await Promise.all(
|
||||||
|
args.folders.map(async (folder) => ({
|
||||||
|
id: folder.id,
|
||||||
|
name: await decryptFolderName(folder, userEnc, userMac),
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
const items = await Promise.all(filterExportableCiphers(args.ciphers).map((cipher) => mapCipherPlain(cipher, userEnc, userMac)));
|
||||||
|
|
||||||
|
return {
|
||||||
|
encrypted: false,
|
||||||
|
folders,
|
||||||
|
items: items.map((item) => trimNullKeys(item)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function buildPlainBitwardenJsonString(args: BuildPlainJsonArgs): Promise<string> {
|
||||||
|
const doc = await buildPlainBitwardenJsonDocument(args);
|
||||||
|
return JSON.stringify(doc, null, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function buildBitwardenCsvString(args: BuildPlainJsonArgs): Promise<string> {
|
||||||
|
const doc = await buildPlainBitwardenJsonDocument(args);
|
||||||
|
const folders = Array.isArray(doc.folders) ? (doc.folders as Array<Record<string, unknown>>) : [];
|
||||||
|
const items = Array.isArray(doc.items) ? (doc.items as Array<Record<string, unknown>>) : [];
|
||||||
|
|
||||||
|
const folderNameById = new Map<string, string>();
|
||||||
|
for (const folder of folders) {
|
||||||
|
const id = normalizeString(folder.id);
|
||||||
|
if (!id) continue;
|
||||||
|
folderNameById.set(id, normalizeString(folder.name) || '');
|
||||||
|
}
|
||||||
|
|
||||||
|
const header = [
|
||||||
|
'folder',
|
||||||
|
'favorite',
|
||||||
|
'type',
|
||||||
|
'name',
|
||||||
|
'notes',
|
||||||
|
'fields',
|
||||||
|
'reprompt',
|
||||||
|
'archivedDate',
|
||||||
|
'login_uri',
|
||||||
|
'login_username',
|
||||||
|
'login_password',
|
||||||
|
'login_totp',
|
||||||
|
];
|
||||||
|
|
||||||
|
const rows: string[][] = [header];
|
||||||
|
for (const item of items) {
|
||||||
|
const type = normalizeNumber(item.type, 1);
|
||||||
|
if (type !== 1 && type !== 2) continue;
|
||||||
|
const folderId = normalizeString(item.folderId);
|
||||||
|
const folderName = folderId ? folderNameById.get(folderId) || '' : '';
|
||||||
|
const fields = Array.isArray(item.fields)
|
||||||
|
? (item.fields as Array<Record<string, unknown>>)
|
||||||
|
.map((field) => {
|
||||||
|
const name = normalizeString(field.name) || '';
|
||||||
|
const value = normalizeString(field.value) || '';
|
||||||
|
if (!name && !value) return '';
|
||||||
|
return `${name}: ${value}`;
|
||||||
|
})
|
||||||
|
.filter((line) => !!line)
|
||||||
|
.join('\n')
|
||||||
|
: '';
|
||||||
|
|
||||||
|
const login = isRecord(item.login) ? (item.login as Record<string, unknown>) : null;
|
||||||
|
const loginUris = login && Array.isArray(login.uris)
|
||||||
|
? (login.uris as Array<Record<string, unknown>>)
|
||||||
|
.map((uri) => normalizeString(uri.uri) || '')
|
||||||
|
.filter((uri) => !!uri)
|
||||||
|
.join(',')
|
||||||
|
: '';
|
||||||
|
|
||||||
|
rows.push([
|
||||||
|
folderName,
|
||||||
|
item.favorite ? '1' : '',
|
||||||
|
type === 1 ? 'login' : 'note',
|
||||||
|
normalizeString(item.name) || '',
|
||||||
|
normalizeString(item.notes) || '',
|
||||||
|
fields,
|
||||||
|
String(normalizeNumber(item.reprompt, 0)),
|
||||||
|
normalizeString(item.archivedDate) || '',
|
||||||
|
loginUris,
|
||||||
|
normalizeString(login?.username) || '',
|
||||||
|
normalizeString(login?.password) || '',
|
||||||
|
normalizeString(login?.totp) || '',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const escapeCsv = (value: string): string => {
|
||||||
|
if (/[",\n\r]/.test(value)) {
|
||||||
|
return `"${value.replace(/"/g, '""')}"`;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
|
||||||
|
return rows.map((row) => row.map((cell) => escapeCsv(String(cell || ''))).join(',')).join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function buildAccountEncryptedBitwardenJsonString(args: BuildEncryptedJsonArgs): Promise<string> {
|
||||||
|
const userEnc = base64ToBytes(args.userEncB64);
|
||||||
|
const userMac = base64ToBytes(args.userMacB64);
|
||||||
|
const validation = await encryptBw(new TextEncoder().encode(randomGuid()), userEnc, userMac);
|
||||||
|
|
||||||
|
const folders = args.folders.map((folder) => ({
|
||||||
|
id: folder.id,
|
||||||
|
name: folder.name,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const items = filterExportableCiphers(args.ciphers).map((cipher) => mapCipherEncrypted(cipher));
|
||||||
|
|
||||||
|
const doc = {
|
||||||
|
encrypted: true,
|
||||||
|
encKeyValidation_DO_NOT_EDIT: validation,
|
||||||
|
folders,
|
||||||
|
items,
|
||||||
|
};
|
||||||
|
return JSON.stringify(doc, null, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function derivePasswordProtectedKey(kdf: PreloginKdfConfig, password: string, saltB64: string): Promise<{ enc: Uint8Array; mac: Uint8Array }> {
|
||||||
|
const iterations = Math.max(1, normalizeNumber(kdf.kdfIterations, 600000));
|
||||||
|
const kdfType = normalizeNumber(kdf.kdfType, 0);
|
||||||
|
const saltTextBytes = new TextEncoder().encode(saltB64);
|
||||||
|
|
||||||
|
let keyMaterial: Uint8Array;
|
||||||
|
if (kdfType === 1) {
|
||||||
|
const memoryMiB = Math.max(16, normalizeNumber(kdf.kdfMemory, 64));
|
||||||
|
const parallelism = Math.max(1, normalizeNumber(kdf.kdfParallelism, 4));
|
||||||
|
const memoryKiB = Math.floor(memoryMiB * 1024);
|
||||||
|
const maxmem = memoryKiB * 1024 + 1024 * 1024;
|
||||||
|
keyMaterial = await argon2idAsync(new TextEncoder().encode(password), saltTextBytes, {
|
||||||
|
t: Math.floor(iterations),
|
||||||
|
m: memoryKiB,
|
||||||
|
p: Math.floor(parallelism),
|
||||||
|
dkLen: 32,
|
||||||
|
maxmem,
|
||||||
|
asyncTick: 10,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
keyMaterial = await pbkdf2(password, saltTextBytes, iterations, 32);
|
||||||
|
}
|
||||||
|
|
||||||
|
const enc = await hkdfExpand(keyMaterial, 'enc', 32);
|
||||||
|
const mac = await hkdfExpand(keyMaterial, 'mac', 32);
|
||||||
|
return { enc, mac };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function buildPasswordProtectedBitwardenJsonString(args: PasswordProtectedArgs): Promise<string> {
|
||||||
|
const password = String(args.password || '').trim();
|
||||||
|
if (!password) throw new Error('File password is required');
|
||||||
|
|
||||||
|
const salt = crypto.getRandomValues(new Uint8Array(16));
|
||||||
|
const saltB64 = bytesToBase64(salt);
|
||||||
|
const key = await derivePasswordProtectedKey(args.kdf, password, saltB64);
|
||||||
|
|
||||||
|
const validation = await encryptBw(new TextEncoder().encode(randomGuid()), key.enc, key.mac);
|
||||||
|
const data = await encryptBw(new TextEncoder().encode(args.plaintextJson), key.enc, key.mac);
|
||||||
|
|
||||||
|
const kdfType = normalizeNumber(args.kdf.kdfType, 0);
|
||||||
|
const out: Record<string, unknown> = {
|
||||||
|
encrypted: true,
|
||||||
|
passwordProtected: true,
|
||||||
|
salt: saltB64,
|
||||||
|
kdfType,
|
||||||
|
kdfIterations: Math.max(1, normalizeNumber(args.kdf.kdfIterations, 600000)),
|
||||||
|
encKeyValidation_DO_NOT_EDIT: validation,
|
||||||
|
data,
|
||||||
|
};
|
||||||
|
if (kdfType === 1) {
|
||||||
|
out.kdfMemory = Math.max(16, normalizeNumber(args.kdf.kdfMemory, 64));
|
||||||
|
out.kdfParallelism = Math.max(1, normalizeNumber(args.kdf.kdfParallelism, 4));
|
||||||
|
}
|
||||||
|
|
||||||
|
return JSON.stringify(out, null, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeFileName(name: string): string {
|
||||||
|
const normalized = String(name || '').trim().replace(/[\\/]/g, '_').replace(/[\x00-\x1F\x7F]/g, '');
|
||||||
|
if (!normalized) return 'attachment.bin';
|
||||||
|
if (normalized.length > 240) {
|
||||||
|
const dot = normalized.lastIndexOf('.');
|
||||||
|
if (dot > 0 && dot > normalized.length - 16) {
|
||||||
|
const ext = normalized.slice(dot);
|
||||||
|
return `${normalized.slice(0, 240 - ext.length)}${ext}`;
|
||||||
|
}
|
||||||
|
return normalized.slice(0, 240);
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function uniqueAttachmentFileName(cipherId: string, originalName: string, used: Set<string>): string {
|
||||||
|
const safe = sanitizeFileName(originalName);
|
||||||
|
const keyBase = `${cipherId}/${safe}`;
|
||||||
|
if (!used.has(keyBase)) {
|
||||||
|
used.add(keyBase);
|
||||||
|
return safe;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dot = safe.lastIndexOf('.');
|
||||||
|
const base = dot > 0 ? safe.slice(0, dot) : safe;
|
||||||
|
const ext = dot > 0 ? safe.slice(dot) : '';
|
||||||
|
let idx = 1;
|
||||||
|
while (idx < 10000) {
|
||||||
|
const candidate = `${base} (${idx})${ext}`;
|
||||||
|
const key = `${cipherId}/${candidate}`;
|
||||||
|
if (!used.has(key)) {
|
||||||
|
used.add(key);
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
idx += 1;
|
||||||
|
}
|
||||||
|
return `${base}-${Date.now()}${ext}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildBitwardenZipBytes(dataJson: string, attachments: ZipAttachmentEntry[]): Uint8Array {
|
||||||
|
const files: Record<string, Uint8Array> = {
|
||||||
|
'data.json': strToU8(dataJson),
|
||||||
|
};
|
||||||
|
const used = new Set<string>();
|
||||||
|
for (const attachment of attachments) {
|
||||||
|
const cipherId = String(attachment.cipherId || '').trim();
|
||||||
|
if (!cipherId) continue;
|
||||||
|
const fileName = uniqueAttachmentFileName(cipherId, attachment.fileName || 'attachment.bin', used);
|
||||||
|
files[`attachments/${cipherId}/${fileName}`] = attachment.bytes;
|
||||||
|
}
|
||||||
|
return zipSync(files, { level: 6 });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function encryptZipBytesWithPassword(
|
||||||
|
zipBytes: Uint8Array,
|
||||||
|
passwordRaw: string
|
||||||
|
): Promise<{ bytes: Uint8Array; encrypted: boolean }> {
|
||||||
|
const password = String(passwordRaw || '').trim();
|
||||||
|
if (!password) return { bytes: zipBytes, encrypted: false };
|
||||||
|
const zipReader = new ZipReader(new Uint8ArrayReader(zipBytes), { useWebWorkers: false });
|
||||||
|
const zipWriter = new ZipWriter(new Uint8ArrayWriter(), { useWebWorkers: false });
|
||||||
|
try {
|
||||||
|
const entries = await zipReader.getEntries();
|
||||||
|
for (const entry of entries) {
|
||||||
|
const filename = String(entry.filename || '').trim();
|
||||||
|
if (!filename) continue;
|
||||||
|
|
||||||
|
if (entry.directory) {
|
||||||
|
await zipWriter.add(filename, undefined, {
|
||||||
|
directory: true,
|
||||||
|
password,
|
||||||
|
encryptionStrength: 3,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await entry.getData(new Uint8ArrayWriter());
|
||||||
|
await zipWriter.add(filename, new Uint8ArrayReader(data), {
|
||||||
|
password,
|
||||||
|
encryptionStrength: 3,
|
||||||
|
level: 6,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
bytes: await zipWriter.close(),
|
||||||
|
encrypted: true,
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
await zipReader.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function nowStamp(now = new Date()): string {
|
||||||
|
const y = now.getFullYear();
|
||||||
|
const m = String(now.getMonth() + 1).padStart(2, '0');
|
||||||
|
const d = String(now.getDate()).padStart(2, '0');
|
||||||
|
const hh = String(now.getHours()).padStart(2, '0');
|
||||||
|
const mm = String(now.getMinutes()).padStart(2, '0');
|
||||||
|
const ss = String(now.getSeconds()).padStart(2, '0');
|
||||||
|
return `${y}${m}${d}_${hh}${mm}${ss}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildExportFileName(format: ExportFormatId, zipEncrypted = false): string {
|
||||||
|
const stamp = nowStamp();
|
||||||
|
if (
|
||||||
|
format === 'bitwarden_json' ||
|
||||||
|
format === 'bitwarden_encrypted_json' ||
|
||||||
|
format === 'nodewarden_json' ||
|
||||||
|
format === 'nodewarden_encrypted_json'
|
||||||
|
) {
|
||||||
|
if (format.startsWith('nodewarden_')) return `nodewarden_export_${stamp}.json`;
|
||||||
|
return `bitwarden_export_${stamp}.json`;
|
||||||
|
}
|
||||||
|
if (format === 'bitwarden_json_zip' || format === 'bitwarden_encrypted_json_zip') {
|
||||||
|
if (zipEncrypted) return `bitwarden_export_${stamp}.zip`;
|
||||||
|
return `bitwarden_export_${stamp}.zip`;
|
||||||
|
}
|
||||||
|
return `bitwarden_export_${stamp}.bin`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildNodeWardenAttachmentRecords(
|
||||||
|
attachments: ZipAttachmentEntry[],
|
||||||
|
cipherIndexById?: Map<string, number>
|
||||||
|
): NodeWardenAttachmentRecord[] {
|
||||||
|
const out: NodeWardenAttachmentRecord[] = [];
|
||||||
|
for (const attachment of attachments) {
|
||||||
|
const cipherId = String(attachment.cipherId || '').trim();
|
||||||
|
if (!cipherId) continue;
|
||||||
|
const fileName = sanitizeFileName(String(attachment.fileName || '').trim() || 'attachment.bin');
|
||||||
|
out.push({
|
||||||
|
cipherId,
|
||||||
|
cipherIndex: cipherIndexById?.get(cipherId) ?? null,
|
||||||
|
fileName,
|
||||||
|
data: bytesToBase64(attachment.bytes),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildNodeWardenPlainJsonDocument(
|
||||||
|
bitwardenJsonDoc: Record<string, unknown>,
|
||||||
|
attachments: NodeWardenAttachmentRecord[]
|
||||||
|
): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
...bitwardenJsonDoc,
|
||||||
|
nodewardenFormat: 'nodewarden_json',
|
||||||
|
nodewardenVersion: 1,
|
||||||
|
nodewardenAttachments: attachments,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function attachNodeWardenEncryptedAttachmentPayload(
|
||||||
|
encryptedBitwardenJson: string,
|
||||||
|
attachments: NodeWardenAttachmentRecord[],
|
||||||
|
userEncB64: string,
|
||||||
|
userMacB64: string
|
||||||
|
): Promise<string> {
|
||||||
|
const parsed = JSON.parse(encryptedBitwardenJson) as Record<string, unknown>;
|
||||||
|
const userEnc = base64ToBytes(userEncB64);
|
||||||
|
const userMac = base64ToBytes(userMacB64);
|
||||||
|
const payload = JSON.stringify({
|
||||||
|
nodewardenFormat: 'nodewarden_json',
|
||||||
|
nodewardenVersion: 1,
|
||||||
|
nodewardenAttachments: attachments,
|
||||||
|
});
|
||||||
|
parsed.nodewardenFormat = 'nodewarden_json';
|
||||||
|
parsed.nodewardenVersion = 1;
|
||||||
|
parsed.nodewardenAttachmentsEnc = await encryptBw(new TextEncoder().encode(payload), userEnc, userMac);
|
||||||
|
return JSON.stringify(parsed, null, 2);
|
||||||
|
}
|
||||||
+87
-2
@@ -328,6 +328,10 @@ const messages: Record<Locale, Record<string, string>> = {
|
|||||||
txt_totp_verify_failed: "TOTP verify failed",
|
txt_totp_verify_failed: "TOTP verify failed",
|
||||||
txt_passkey: "Passkey",
|
txt_passkey: "Passkey",
|
||||||
txt_passkey_created_at_value: "Created at {value}",
|
txt_passkey_created_at_value: "Created at {value}",
|
||||||
|
txt_attachments: "Attachments",
|
||||||
|
txt_upload_attachments: "Upload attachments",
|
||||||
|
txt_new_attachments: "New attachments",
|
||||||
|
txt_marked_for_removal_count: "{count} attachment(s) will be removed on save",
|
||||||
txt_trash: "Trash",
|
txt_trash: "Trash",
|
||||||
txt_trust_this_device_for_30_days: "Trust this device for 30 days",
|
txt_trust_this_device_for_30_days: "Trust this device for 30 days",
|
||||||
txt_trusted_until: "Trusted Until",
|
txt_trusted_until: "Trusted Until",
|
||||||
@@ -729,9 +733,90 @@ const zhCNOverrides: Record<string, string> = {
|
|||||||
txt_copied: '已复制',
|
txt_copied: '已复制',
|
||||||
};
|
};
|
||||||
|
|
||||||
zhCNOverrides.txt_lock = '\u9501\u5b9a';
|
zhCNOverrides.txt_lock = '锁定';
|
||||||
zhCNOverrides.txt_passkey = 'Passkey';
|
zhCNOverrides.txt_passkey = 'Passkey';
|
||||||
zhCNOverrides.txt_passkey_created_at_value = '\u521b\u5efa\u4e8e {value}';
|
zhCNOverrides.txt_passkey_created_at_value = '创建于 {value}';
|
||||||
|
zhCNOverrides.txt_attachments = '附件';
|
||||||
|
zhCNOverrides.txt_upload_attachments = '上传附件';
|
||||||
|
zhCNOverrides.txt_new_attachments = '待上传附件';
|
||||||
|
zhCNOverrides.txt_marked_for_removal_count = '保存后将删除 {count} 个附件';
|
||||||
|
messages.en.txt_import = 'Import';
|
||||||
|
messages.en.txt_export = 'Export';
|
||||||
|
messages.en.txt_format = 'Format';
|
||||||
|
messages.en.txt_source_file = 'Source file';
|
||||||
|
messages.en.txt_folder_handling = 'Folder handling';
|
||||||
|
messages.en.txt_import_folder_mode_original = 'Original path from import file';
|
||||||
|
messages.en.txt_import_folder_mode_none = 'No folder';
|
||||||
|
messages.en.txt_import_folder_mode_target = 'One selected folder';
|
||||||
|
messages.en.txt_target_folder = 'Target folder';
|
||||||
|
messages.en.txt_select_folder_placeholder = '-- Select folder --';
|
||||||
|
messages.en.txt_import_vault_data_hint = 'Import vault data into your current account.';
|
||||||
|
messages.en.txt_export_vault_data_hint = 'Export vault data from your current account.';
|
||||||
|
messages.en.txt_encrypted_mode = 'Encrypted mode';
|
||||||
|
messages.en.txt_account_verification = 'Account verification';
|
||||||
|
messages.en.txt_password_verification = 'Password verification';
|
||||||
|
messages.en.txt_file_password = 'File password';
|
||||||
|
messages.en.txt_zip_password_optional = 'ZIP password (optional)';
|
||||||
|
messages.en.txt_zip_password = 'ZIP password';
|
||||||
|
messages.en.txt_close = 'Close';
|
||||||
|
messages.en.txt_total = 'Total';
|
||||||
|
messages.en.txt_import_success = 'Import successful';
|
||||||
|
messages.en.txt_import_success_number_of_items = 'Imported {count} item(s) in total.';
|
||||||
|
messages.en.txt_import_file_password_required = 'Please enter file password.';
|
||||||
|
messages.en.txt_import_invalid_zip_password = 'Invalid ZIP password.';
|
||||||
|
messages.en.txt_export_completed = 'Export completed';
|
||||||
|
messages.en.txt_export_failed = 'Export failed';
|
||||||
|
messages.en.txt_import_invalid_password_protected_file = 'Invalid password-protected export file.';
|
||||||
|
messages.en.txt_import_decrypt_failed = 'Failed to decrypt import file.';
|
||||||
|
messages.en.txt_import_empty_zip_archive = 'Empty zip archive.';
|
||||||
|
messages.en.txt_import_no_json_found_in_zip = 'No importable JSON data found in zip archive.';
|
||||||
|
messages.en.txt_import_data_json_not_found = 'data.json not found in zip archive.';
|
||||||
|
messages.en.txt_import_zip_password_required = 'ZIP password is required.';
|
||||||
|
messages.en.txt_import_invalid_json_file = 'Invalid JSON file';
|
||||||
|
messages.en.txt_import_failed = 'Import failed';
|
||||||
|
messages.en.txt_import_encrypted_file_title = 'Import encrypted file';
|
||||||
|
messages.en.txt_import_encrypted_file_message = 'This Bitwarden export is password-protected. Enter the export file password to continue.';
|
||||||
|
messages.en.txt_import_encrypted_zip_title = 'Import encrypted ZIP';
|
||||||
|
messages.en.txt_import_encrypted_zip_message = 'This ZIP archive is password-protected. Enter the ZIP password to continue.';
|
||||||
|
|
||||||
|
zhCNOverrides.txt_import = '导入';
|
||||||
|
zhCNOverrides.txt_export = '导出';
|
||||||
|
zhCNOverrides.txt_format = '格式';
|
||||||
|
zhCNOverrides.txt_source_file = '源文件';
|
||||||
|
zhCNOverrides.txt_folder_handling = '文件夹处理';
|
||||||
|
zhCNOverrides.txt_import_folder_mode_original = '保留导入文件中的原始路径';
|
||||||
|
zhCNOverrides.txt_import_folder_mode_none = '不使用文件夹';
|
||||||
|
zhCNOverrides.txt_import_folder_mode_target = '导入到指定文件夹';
|
||||||
|
zhCNOverrides.txt_target_folder = '目标文件夹';
|
||||||
|
zhCNOverrides.txt_select_folder_placeholder = '-- 选择文件夹 --';
|
||||||
|
zhCNOverrides.txt_import_vault_data_hint = '将数据导入到当前账号。';
|
||||||
|
zhCNOverrides.txt_export_vault_data_hint = '从当前账号导出数据。';
|
||||||
|
zhCNOverrides.txt_encrypted_mode = '加密方式';
|
||||||
|
zhCNOverrides.txt_account_verification = '账号验证';
|
||||||
|
zhCNOverrides.txt_password_verification = '密码验证';
|
||||||
|
zhCNOverrides.txt_file_password = '文件密码';
|
||||||
|
zhCNOverrides.txt_zip_password_optional = 'ZIP 密码(可选)';
|
||||||
|
zhCNOverrides.txt_zip_password = 'ZIP 密码';
|
||||||
|
zhCNOverrides.txt_close = '关闭';
|
||||||
|
zhCNOverrides.txt_total = '总计';
|
||||||
|
zhCNOverrides.txt_import_success = '数据导入成功';
|
||||||
|
zhCNOverrides.txt_import_success_number_of_items = '一共导入了 {count} 个项目。';
|
||||||
|
zhCNOverrides.txt_import_file_password_required = '请输入文件密码。';
|
||||||
|
zhCNOverrides.txt_import_invalid_zip_password = 'ZIP 密码错误。';
|
||||||
|
zhCNOverrides.txt_export_completed = '导出完成';
|
||||||
|
zhCNOverrides.txt_export_failed = '导出失败';
|
||||||
|
zhCNOverrides.txt_import_invalid_password_protected_file = '密码保护导出文件格式无效。';
|
||||||
|
zhCNOverrides.txt_import_decrypt_failed = '导入文件解密失败。';
|
||||||
|
zhCNOverrides.txt_import_empty_zip_archive = 'ZIP 压缩包为空。';
|
||||||
|
zhCNOverrides.txt_import_no_json_found_in_zip = 'ZIP 内未找到可导入的 JSON 数据。';
|
||||||
|
zhCNOverrides.txt_import_data_json_not_found = 'ZIP 内未找到 data.json。';
|
||||||
|
zhCNOverrides.txt_import_zip_password_required = '该 ZIP 需要密码。';
|
||||||
|
zhCNOverrides.txt_import_invalid_json_file = 'JSON 文件无效';
|
||||||
|
zhCNOverrides.txt_import_failed = '导入失败';
|
||||||
|
zhCNOverrides.txt_import_encrypted_file_title = '导入加密文件';
|
||||||
|
zhCNOverrides.txt_import_encrypted_file_message = '该 Bitwarden 导出文件已加密,请输入文件密码继续。';
|
||||||
|
zhCNOverrides.txt_import_encrypted_zip_title = '导入加密 ZIP';
|
||||||
|
zhCNOverrides.txt_import_encrypted_zip_message = '该 ZIP 压缩包已加密,请输入 ZIP 密码继续。';
|
||||||
|
|
||||||
messages['zh-CN'] = { ...messages.en, ...zhCNOverrides };
|
messages['zh-CN'] = { ...messages.en, ...zhCNOverrides };
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ type ImportSourceEntry = { id: string; label: string };
|
|||||||
export const IMPORT_SOURCES = [
|
export const IMPORT_SOURCES = [
|
||||||
{ id: 'bitwarden_json', label: 'Bitwarden (json)' },
|
{ id: 'bitwarden_json', label: 'Bitwarden (json)' },
|
||||||
{ id: 'bitwarden_csv', label: 'Bitwarden (csv)' },
|
{ id: 'bitwarden_csv', label: 'Bitwarden (csv)' },
|
||||||
|
{ id: 'bitwarden_zip', label: 'Bitwarden (zip)' },
|
||||||
|
{ id: 'nodewarden_json', label: 'NodeWarden (json)' },
|
||||||
{ id: 'onepassword_1pux', label: '1Password (1pux/json)' },
|
{ id: 'onepassword_1pux', label: '1Password (1pux/json)' },
|
||||||
{ id: 'onepassword_1pif', label: '1Password (1pif)' },
|
{ id: 'onepassword_1pif', label: '1Password (1pif)' },
|
||||||
{ id: 'onepassword_mac_csv', label: '1Password 6 and 7 Mac (csv)' },
|
{ id: 'onepassword_mac_csv', label: '1Password 6 and 7 Mac (csv)' },
|
||||||
@@ -53,8 +55,10 @@ export const IMPORT_SOURCES = [
|
|||||||
export type ImportSourceId = (typeof IMPORT_SOURCES)[number]['id'];
|
export type ImportSourceId = (typeof IMPORT_SOURCES)[number]['id'];
|
||||||
|
|
||||||
export function getFileAcceptBySource(source: ImportSourceId): string {
|
export function getFileAcceptBySource(source: ImportSourceId): string {
|
||||||
|
if (source === 'bitwarden_zip') return '.zip,application/zip,application/x-zip-compressed';
|
||||||
if (
|
if (
|
||||||
source === 'bitwarden_json' ||
|
source === 'bitwarden_json' ||
|
||||||
|
source === 'nodewarden_json' ||
|
||||||
source === 'onepassword_1pux' ||
|
source === 'onepassword_1pux' ||
|
||||||
source === 'protonpass_json' ||
|
source === 'protonpass_json' ||
|
||||||
source === 'avast_json' ||
|
source === 'avast_json' ||
|
||||||
@@ -90,6 +94,7 @@ export interface BitwardenFieldInput {
|
|||||||
linkedId?: number | null;
|
linkedId?: number | null;
|
||||||
}
|
}
|
||||||
export interface BitwardenCipherInput {
|
export interface BitwardenCipherInput {
|
||||||
|
id?: string | null;
|
||||||
type?: number | null;
|
type?: number | null;
|
||||||
name?: string | null;
|
name?: string | null;
|
||||||
notes?: string | null;
|
notes?: string | null;
|
||||||
@@ -2415,6 +2420,7 @@ export function normalizeBitwardenImport(raw: unknown): CiphersImportPayload {
|
|||||||
let hasAnyExplicitFolderLink = false;
|
let hasAnyExplicitFolderLink = false;
|
||||||
for (const item of itemsRaw) {
|
for (const item of itemsRaw) {
|
||||||
ciphers.push({
|
ciphers.push({
|
||||||
|
id: item?.id ?? null,
|
||||||
type: Number(item?.type || 1) || 1,
|
type: Number(item?.type || 1) || 1,
|
||||||
name: item?.name ?? 'Untitled',
|
name: item?.name ?? 'Untitled',
|
||||||
notes: item?.notes ?? null,
|
notes: item?.notes ?? null,
|
||||||
@@ -2498,6 +2504,12 @@ const IMPORT_SOURCE_PARSERS: Record<ImportSourceId, (textRaw: string) => Ciphers
|
|||||||
bitwarden_json: () => {
|
bitwarden_json: () => {
|
||||||
throw new Error('bitwarden_json is handled by dedicated JSON flow');
|
throw new Error('bitwarden_json is handled by dedicated JSON flow');
|
||||||
},
|
},
|
||||||
|
bitwarden_zip: () => {
|
||||||
|
throw new Error('bitwarden_zip is handled by dedicated zip flow');
|
||||||
|
},
|
||||||
|
nodewarden_json: () => {
|
||||||
|
throw new Error('nodewarden_json is handled by dedicated JSON flow');
|
||||||
|
},
|
||||||
bitwarden_csv: parseBitwardenCsv,
|
bitwarden_csv: parseBitwardenCsv,
|
||||||
onepassword_1pux: parseOnePassword1PuxJson,
|
onepassword_1pux: parseOnePassword1PuxJson,
|
||||||
onepassword_1pif: parseOnePassword1Pif,
|
onepassword_1pif: parseOnePassword1Pif,
|
||||||
|
|||||||
@@ -28,6 +28,17 @@ export interface CipherLoginUri {
|
|||||||
decUri?: string;
|
decUri?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CipherAttachment {
|
||||||
|
id?: string;
|
||||||
|
url?: string | null;
|
||||||
|
fileName?: string | null;
|
||||||
|
decFileName?: string;
|
||||||
|
key?: string | null;
|
||||||
|
size?: string | number | null;
|
||||||
|
sizeName?: string | null;
|
||||||
|
object?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface CipherLoginPasskey {
|
export interface CipherLoginPasskey {
|
||||||
creationDate?: string | null;
|
creationDate?: string | null;
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
@@ -111,6 +122,7 @@ export interface CipherField {
|
|||||||
type?: number | string | null;
|
type?: number | string | null;
|
||||||
name?: string | null;
|
name?: string | null;
|
||||||
value?: string | null;
|
value?: string | null;
|
||||||
|
linkedId?: number | null;
|
||||||
decName?: string;
|
decName?: string;
|
||||||
decValue?: string;
|
decValue?: string;
|
||||||
}
|
}
|
||||||
@@ -127,10 +139,13 @@ export interface Cipher {
|
|||||||
creationDate?: string;
|
creationDate?: string;
|
||||||
revisionDate?: string;
|
revisionDate?: string;
|
||||||
deletedDate?: string | null;
|
deletedDate?: string | null;
|
||||||
|
attachments?: CipherAttachment[] | null;
|
||||||
login?: CipherLogin | null;
|
login?: CipherLogin | null;
|
||||||
card?: CipherCard | null;
|
card?: CipherCard | null;
|
||||||
identity?: CipherIdentity | null;
|
identity?: CipherIdentity | null;
|
||||||
sshKey?: CipherSshKey | null;
|
sshKey?: CipherSshKey | null;
|
||||||
|
secureNote?: { type?: number | null } | null;
|
||||||
|
passwordHistory?: Array<{ password?: string | null; lastUsedDate?: string | null }> | null;
|
||||||
fields?: CipherField[] | null;
|
fields?: CipherField[] | null;
|
||||||
decName?: string;
|
decName?: string;
|
||||||
decNotes?: string;
|
decNotes?: string;
|
||||||
|
|||||||
@@ -321,6 +321,7 @@ input[type='file'].input::file-selector-button:hover {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
height: 50px;
|
height: 50px;
|
||||||
font-size: 22px;
|
font-size: 22px;
|
||||||
|
margin: 10px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn.small {
|
.btn.small {
|
||||||
@@ -930,6 +931,74 @@ input[type='file'].input::file-selector-button:hover {
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.attachment-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-head {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-head h4 {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-add-btn {
|
||||||
|
min-width: 32px;
|
||||||
|
padding: 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-file-input {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
border-bottom: 1px solid #ecf0f5;
|
||||||
|
padding: 10px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-row:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-main {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-text {
|
||||||
|
min-width: 0;
|
||||||
|
display: grid;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-text span {
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-row.is-removed {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-row.is-removed .attachment-text strong {
|
||||||
|
text-decoration: line-through;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-queue-title {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #64748b;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 8px 0 2px;
|
||||||
|
}
|
||||||
|
|
||||||
.custom-field-row {
|
.custom-field-row {
|
||||||
grid-template-columns: minmax(110px, 220px) minmax(0, 1fr) auto;
|
grid-template-columns: minmax(110px, 220px) minmax(0, 1fr) auto;
|
||||||
}
|
}
|
||||||
@@ -1255,6 +1324,64 @@ input[type='file'].input::file-selector-button:hover {
|
|||||||
margin: 8px 0 10px;
|
margin: 8px 0 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.import-summary-dialog {
|
||||||
|
max-width: 520px;
|
||||||
|
text-align: left;
|
||||||
|
position: relative;
|
||||||
|
padding-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-summary-close {
|
||||||
|
position: absolute;
|
||||||
|
top: 10px;
|
||||||
|
right: 10px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 24px;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-summary-close:hover {
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-summary-table-wrap {
|
||||||
|
margin-top: 8px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-summary-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-summary-table th,
|
||||||
|
.import-summary-table td {
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-summary-table th {
|
||||||
|
text-align: left;
|
||||||
|
color: #475467;
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-summary-table td:last-child,
|
||||||
|
.import-summary-table th:last-child {
|
||||||
|
text-align: right;
|
||||||
|
width: 96px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-summary-table tbody tr:last-child td {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
.settings-twofactor-grid {
|
.settings-twofactor-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
|||||||
Reference in New Issue
Block a user