diff --git a/AGENTS.md b/AGENTS.md index 5e2d45f..8109478 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -83,11 +83,12 @@ Password verification uses a test payload (random string encrypted at vault crea ## Export / Import -- `exportSelected(groupIds, vaultKey, exportPassword)` — group IDs to export; `null`/`[]` = full export (include `''` for ungrouped). Vault meta (salt, test payload) is always included for plain import decryption. - - **Plain export** (no export password): unchanged — entries keep their source-vault-encrypted passwords; import needs the source vault's master password. - - **Password-protected export**: pass a `vaultKey` (in-memory) plus an independent `exportPassword`. Re-keys every exported entry's password to the export-derived key and AES-256-GCM-seals the entire payload (titles/usernames/notes included). Returns `{ format: 'encrypted-export', salt, data }`. -- `importAll(data, mode, sourcePassword, targetKey)` detects a sealed export (`data.format === 'encrypted-export'`): `sourcePassword` is then the EXPORT password and may differ from any vault's master password. A wrong export password rejects the import (it never silently skips entries). The entry loop decrypts with the source key and re-encrypts under `targetKey`. -- `ImportExport.svelte` fetches groups/entries on modal open and shows a checkbox list for group selection with live entry count. The export dialog has an optional "separate password" field; the import dialog's field is a generic file password (export password for sealed files, source vault master for plain files). +- `exportSelected(groupIds, options)` — group IDs to export; `null`/`[]` = full export (include `''` for ungrouped). `options = { vaultKey, password = '', useExistingPassword = false }`. An explicit protection choice is required; every protected export AES-256-GCM-seals the whole payload (titles/usernames/notes included) into `{ format: 'encrypted-export', salt, data }`. + - **New password** (`options.password`): re-keys every exported entry's password to a key derived from that password + a fresh random salt (embedded in the envelope), so import needs only this password — never the vault's. + - **Reuse existing password** (`options.useExistingPassword`): seals with the vault's own key and keeps the vault's salt embedded, so import derives the key from the vault master password. No second password to create/remember. + - **No option set**: falls back to a plaintext JSON export (entries keep source-vault-encrypted passwords) for backward compatibility; the UI never offers this. +- `importAll(data, mode, password, targetKey)` detects a sealed export (`data.format === 'encrypted-export'`): `password` is whichever password opens the envelope — a separate export password OR the source vault's master password (for reuse-existing). A wrong password rejects the import (never silently skips). The entry loop decrypts with the reproduced source key and re-encrypts under `targetKey`. +- `ImportExport.svelte` fetches groups/entries on modal open and shows a checkbox list for group selection with live entry count. The export dialog forces an explicit choice via two radio options: "Use a new password" (shows a short password field, validated non-empty) or "Reuse my vault password". The import dialog's single password field is generic (export password for sealed files, source vault master for plain/old files). ## Known Bug Fixes diff --git a/dist/index.html b/dist/index.html index ca2254e..2daa46a 100644 --- a/dist/index.html +++ b/dist/index.html @@ -5587,23 +5587,47 @@ async function moveEntryToGroup(entryId, groupId) { await db.put("entries", entry); } /** -* Export data (entries + groups + meta) as a JSON object. -* Entries remain encrypted with the source vault's key. The import function -* requires the source vault's master password to decrypt and re-encrypt -* entries under the target vault's key. +* Export data (entries + groups + meta). * -* @param {string[]} [groupIds] - Array of group IDs to export. If null/empty, exports everything. -* Include '' to export ungrouped entries. +* The caller must choose how the file is protected - an explicit choice, never +* an ambiguous "optional" field. Wraps the whole payload (entries + groups + +* meta, incl. titles/usernames/notes) in an AES-256-GCM envelope so the file +* is unreadable without the unlock key: +* +* - `password`: a NEW, separate password. The envelope is sealed with a key +* derived from this password + a fresh random salt (embedded in the +* envelope), so importing needs ONLY this password - never the vault's. +* Each entry's password is re-encrypted from the vault key to the export key. +* +* - `useExistingPassword`: reuse the SAME password that unlocks this vault. +* The envelope is sealed with the vault's own key and the vault's salt +* stays embedded, so import derives that key from the master password. No +* new password needs creating or remembering. +* +* Supplying neither password nor useExistingPassword falls back to a plaintext +* JSON export (entries keep their source-vault-encrypted passwords) - kept for +* programmatic/backward compatibility, though the UI always chooses one of the +* two protected modes. +* +* @param {string[]} [groupIds] - Group IDs to export. null/[] = full (include '' for ungrouped). +* @param {Object} [options] +* @param {CryptoKey|null} [options.vaultKey] - Current vault's in-memory encryption key (required to seal). +* @param {string} [options.password] - Optional NEW separate password to seal with. +* @param {boolean} [options.useExistingPassword] - Seal with the vault's own key instead of a new password. * @returns {Promise} */ -async function exportSelected(groupIds) { +async function exportSelected(groupIds = null, options = {}) { + const { vaultKey = null, password = "", useExistingPassword = false } = options; const db = await getDb(); const allEntries = await db.getAll("entries"); const allGroups = await db.getAll("groups"); const saltRow = await db.get("meta", "salt"); const testEncryptedRow = await db.get("meta", "testEncrypted"); const testPlaintextRow = await db.get("meta", "testPlaintext"); - if (!groupIds || groupIds.length === 0) return { + const full = !groupIds || groupIds.length === 0; + const pickEntry = full ? () => true : (e) => groupIds.includes(e.groupId); + const pickGroup = full ? () => true : (g) => groupIds.includes(g.id); + const payload = { version: DB_VERSION, exportedAt: (/* @__PURE__ */ new Date()).toISOString(), meta: { @@ -5611,21 +5635,35 @@ async function exportSelected(groupIds) { testEncrypted: testEncryptedRow?.value || null, testPlaintext: testPlaintextRow?.value || null }, - groups: allGroups, - entries: allEntries + groups: allGroups.filter(pickGroup), + entries: allEntries.filter(pickEntry) }; - const entries = allEntries.filter((e) => groupIds.includes(e.groupId)); - const groups = allGroups.filter((g) => groupIds.includes(g.id)); + if (!!!(password || useExistingPassword)) return payload; + if (!vaultKey) throw new Error("The vault key is required to create a protected export"); + let sealKey; + let envelopeSalt; + if (useExistingPassword) { + sealKey = vaultKey; + envelopeSalt = payload.meta.salt; + } else { + if (!password) throw new Error("Enter a new password to encrypt this export, or choose to reuse the vault password"); + const exportSalt = generateSalt(); + sealKey = await deriveKey(password, exportSalt); + envelopeSalt = uint8ArrayToBase64(exportSalt); + for (const entry of payload.entries) { + if (!entry.encryptedPassword) continue; + entry.encryptedPassword = await encrypt(await decrypt(entry.encryptedPassword, vaultKey), sealKey); + } + payload.meta.salt = envelopeSalt; + } + const sealed = await encrypt(JSON.stringify(payload), sealKey); return { - version: DB_VERSION, + version: 1, exportedAt: (/* @__PURE__ */ new Date()).toISOString(), - meta: { - salt: saltRow?.value || null, - testEncrypted: testEncryptedRow?.value || null, - testPlaintext: testPlaintextRow?.value || null - }, - groups, - entries + format: "encrypted-export", + kdfIterations: 6e5, + salt: envelopeSalt, + data: sealed }; } /** @@ -5642,6 +5680,17 @@ async function exportSelected(groupIds) { * @returns {Promise<{ imported: { entries: number, groups: number }, skipped: number }>} */ async function importAll(data, mode = "merge", sourcePassword = "", targetKey = null) { + if (data && data.format === "encrypted-export") { + if (!sourcePassword) throw new Error("The export password is required to import this encrypted file"); + const exportKey = await deriveKey(sourcePassword, base64ToUint8Array(data.salt)); + let innerJson; + try { + innerJson = await decrypt(data.data, exportKey); + } catch { + throw new Error("Incorrect export password - could not decrypt this file"); + } + data = JSON.parse(innerJson); + } if (!data || !Array.isArray(data.entries) || !Array.isArray(data.groups)) throw new Error("Invalid import data format"); let sourceKey = null; if (data.meta?.salt && sourcePassword) sourceKey = await deriveKey(sourcePassword, base64ToUint8Array(data.meta.salt)); @@ -5815,7 +5864,7 @@ function autofocus(node, condition = true) { } //#endregion //#region src/components/LockScreen.svelte -var root_1$7 = /* @__PURE__ */ from_html(``); +var root_1$7 = /* @__PURE__ */ from_html(``); var root_2$6 = /* @__PURE__ */ from_html(``); var root_3$6 = /* @__PURE__ */ from_html(`
`); var root$7 = /* @__PURE__ */ from_html(`
🔐

Password Vault

`); @@ -6008,7 +6057,7 @@ var root_2$5 = /* @__PURE__ */ from_html(`
var root_4$5 = /* @__PURE__ */ from_html(`
`); var root_5$5 = /* @__PURE__ */ from_html(``); var root_3$5 = /* @__PURE__ */ from_html(``); -var root_6$3 = /* @__PURE__ */ from_html(``); +var root_6$4 = /* @__PURE__ */ from_html(``); var root$6 = /* @__PURE__ */ from_html(``); function Sidebar($$anchor, $$props) { push($$props, true); @@ -6218,7 +6267,7 @@ function Sidebar($$anchor, $$props) { }); var node_4 = sibling(node_2, 2); var consequent_3 = ($$anchor) => { - var div_13 = root_6$3(); + var div_13 = root_6$4(); var div_14 = child(div_13); var p = sibling(child(div_14), 2); var strong = sibling(child(p)); @@ -6264,9 +6313,9 @@ var root_1$6 = /* @__PURE__ */ from_html(`
Lo var root_2$4 = /* @__PURE__ */ from_html(`
`); var root_4$4 = /* @__PURE__ */ from_html(``); var root_3$4 = /* @__PURE__ */ from_html(`

`); -var root_6$2 = /* @__PURE__ */ from_html(`matching " "`, 1); -var root_7$4 = /* @__PURE__ */ from_html(``); -var root_9$1 = /* @__PURE__ */ from_html(``); +var root_6$3 = /* @__PURE__ */ from_html(`matching " "`, 1); +var root_7$3 = /* @__PURE__ */ from_html(``); +var root_9$2 = /* @__PURE__ */ from_html(``); var root_10$1 = /* @__PURE__ */ from_html(`
🔍
`); var root_11$1 = /* @__PURE__ */ from_html(``); var root_12$1 = /* @__PURE__ */ from_html(``); @@ -6360,7 +6409,7 @@ function EntryList($$anchor, $$props) { var text_4 = child(span); var node_2 = sibling(text_4); var consequent_4 = ($$anchor) => { - var fragment_1 = root_6$2(); + var fragment_1 = root_6$3(); var strong = sibling(first_child(fragment_1)); var text_5 = child(strong, true); reset(strong); @@ -6378,7 +6427,7 @@ function EntryList($$anchor, $$props) { var tr = child(thead); var node_3 = sibling(child(tr), 4); var consequent_5 = ($$anchor) => { - append($$anchor, root_7$4()); + append($$anchor, root_7$3()); }; if_block(node_3, ($$render) => { if (get(isTrashView)) $$render(consequent_5); @@ -6391,7 +6440,7 @@ function EntryList($$anchor, $$props) { var td = child(tr_1); var node_4 = child(td); var consequent_6 = ($$anchor) => { - append($$anchor, root_9$1()); + append($$anchor, root_9$2()); }; if_block(node_4, ($$render) => { if (!get(isTrashView)) $$render(consequent_6); @@ -6490,10 +6539,10 @@ var root_1$5 = /* @__PURE__ */ from_html(`
Loading...
`); var root_3$3 = /* @__PURE__ */ from_html(`
`); var root_4$3 = /* @__PURE__ */ from_html(`
Entry not found
`); -var root_6$1 = /* @__PURE__ */ from_html(` `, 1); -var root_7$3 = /* @__PURE__ */ from_html(` `, 1); +var root_6$2 = /* @__PURE__ */ from_html(` `, 1); +var root_7$2 = /* @__PURE__ */ from_html(` `, 1); var root_8$2 = /* @__PURE__ */ from_html(`
Username
`); -var root_9 = /* @__PURE__ */ from_html(`
URL
`); +var root_9$1 = /* @__PURE__ */ from_html(`
URL
`); var root_10 = /* @__PURE__ */ from_html(`
Notes
`); var root_11 = /* @__PURE__ */ from_html(``); var root_12 = /* @__PURE__ */ from_html(``); @@ -6610,7 +6659,7 @@ function EntryDetail($$anchor, $$props) { var div_7 = sibling(h2, 2); var node_2 = child(div_7); var consequent_4 = ($$anchor) => { - var fragment_1 = root_6$1(); + var fragment_1 = root_6$2(); var button = first_child(fragment_1); var button_1 = sibling(button, 2); delegated("click", button, () => $$props.onEdit(get(entry).id)); @@ -6618,7 +6667,7 @@ function EntryDetail($$anchor, $$props) { append($$anchor, fragment_1); }; var alternate = ($$anchor) => { - var fragment_2 = root_7$3(); + var fragment_2 = root_7$2(); var button_2 = first_child(fragment_2); var button_3 = sibling(button_2, 2); delegated("click", button_2, () => $$props.onEdit(get(entry).id)); @@ -6662,7 +6711,7 @@ function EntryDetail($$anchor, $$props) { reset(div_11); var node_4 = sibling(div_11, 2); var consequent_6 = ($$anchor) => { - var div_13 = root_9(); + var div_13 = root_9$1(); var div_14 = sibling(child(div_13), 2); var a = child(div_14); var text_7 = child(a, true); @@ -6795,7 +6844,7 @@ var root_1$4 = /* @__PURE__ */ from_html(`
Loa var root_3$2 = /* @__PURE__ */ from_html(`
`); var root_5$2 = /* @__PURE__ */ from_html(`
`); var root_4$2 = /* @__PURE__ */ from_html(`
`); -var root_7$2 = /* @__PURE__ */ from_html(``); +var root_7$1 = /* @__PURE__ */ from_html(``); var root_2$2 = /* @__PURE__ */ from_html(`
`, 1); var root$3 = /* @__PURE__ */ from_html(`
`); function EntryForm($$anchor, $$props) { @@ -6943,7 +6992,7 @@ function EntryForm($$anchor, $$props) { var fragment_1 = comment(); var node_4 = first_child(fragment_1); var consequent_3 = ($$anchor) => { - var option_1 = root_7$2(); + var option_1 = root_7$1(); var text_3 = child(option_1, true); reset(option_1); var option_1_value = {}; @@ -7006,12 +7055,13 @@ delegate(["click"]); //#endregion //#region src/components/ImportExport.svelte var root_2$1 = /* @__PURE__ */ from_html(``); -var root_1$3 = /* @__PURE__ */ from_html(``); -var root_4$1 = /* @__PURE__ */ from_html(`
`); -var root_5$1 = /* @__PURE__ */ from_html(`
`); -var root_7$1 = /* @__PURE__ */ from_html(`

File loaded. Enter the source vault's master password to decrypt and re-encrypt entries under your current vault.

`, 1); -var root_8$1 = /* @__PURE__ */ from_html(`

Select how to handle existing data:

`, 1); -var root_3$1 = /* @__PURE__ */ from_html(``); +var root_3$1 = /* @__PURE__ */ from_html(`
`); +var root_1$3 = /* @__PURE__ */ from_html(``); +var root_5$1 = /* @__PURE__ */ from_html(`
`); +var root_6$1 = /* @__PURE__ */ from_html(`
`); +var root_8$1 = /* @__PURE__ */ from_html(`

File loaded. Enter the password protecting this file — for an encrypted export that is the export password you chose; for a plain export it is the source vault's master password.

`, 1); +var root_9 = /* @__PURE__ */ from_html(`

Select how to handle existing data:

`, 1); +var root_4$1 = /* @__PURE__ */ from_html(``); var root$2 = /* @__PURE__ */ from_html(`
`); function ImportExport($$anchor, $$props) { push($$props, true); @@ -7036,15 +7086,27 @@ function ImportExport($$anchor, $$props) { let exporting = /* @__PURE__ */ state(false); let sourcePassword = /* @__PURE__ */ state(""); let parsedFileData = /* @__PURE__ */ state(null); + let exportPassword = /* @__PURE__ */ state(""); + let exportUseExistingPassword = /* @__PURE__ */ state(false); let allGroups = /* @__PURE__ */ state(proxy([])); let allEntries = /* @__PURE__ */ state(proxy([])); let selectedGroupIds = /* @__PURE__ */ state(proxy([])); let selectAll = /* @__PURE__ */ user_derived(() => get(allGroups).length > 0 && get(allGroups).every((g) => get(selectedGroupIds).includes(g.id))); let exportEntryCount = /* @__PURE__ */ user_derived(() => get(allEntries).filter((e) => get(selectedGroupIds).includes(e.groupId)).length); async function handleExport() { + if (!get(exportUseExistingPassword) && !get(exportPassword).trim()) { + set(importError, "Choose a new password to encrypt the export"); + return; + } set(exporting, true); try { - set(exportData, await exportSelected(get(selectedGroupIds).length === get(allGroups).length ? null : get(selectedGroupIds)), true); + set(exportData, await exportSelected(get(selectedGroupIds).length === get(allGroups).length ? null : get(selectedGroupIds), { + vaultKey: app$1.encryptionKey, + password: get(exportUseExistingPassword) ? "" : get(exportPassword).trim(), + useExistingPassword: get(exportUseExistingPassword) + }), true); + set(exportPassword, ""); + set(exportUseExistingPassword, false); const json = JSON.stringify(get(exportData), null, 2); const blob = new Blob([json], { type: "application/json" }); const url = URL.createObjectURL(blob); @@ -7109,7 +7171,7 @@ function ImportExport($$anchor, $$props) { var button = child(div); var button_1 = sibling(button, 2); var node = sibling(button_1, 2); - var consequent = ($$anchor) => { + var consequent_1 = ($$anchor) => { var div_1 = root_1$3(); var div_2 = child(div_1); var div_3 = sibling(child(div_2), 4); @@ -7142,94 +7204,122 @@ function ImportExport($$anchor, $$props) { }); reset(div_4); var div_5 = sibling(div_4, 2); - var button_2 = child(div_5); + var label_2 = child(div_5); + var input_2 = child(label_2); + remove_input_defaults(input_2); + next(2); + reset(label_2); + var node_1 = sibling(label_2, 2); + var consequent = ($$anchor) => { + var div_6 = root_3$1(); + var input_3 = child(div_6); + remove_input_defaults(input_3); + reset(div_6); + bind_value(input_3, () => get(exportPassword), ($$value) => set(exportPassword, $$value)); + append($$anchor, div_6); + }; + if_block(node_1, ($$render) => { + if (!get(exportUseExistingPassword)) $$render(consequent); + }); + var label_3 = sibling(node_1, 2); + var input_4 = child(label_3); + remove_input_defaults(input_4); + next(2); + reset(label_3); + reset(div_5); + var div_7 = sibling(div_5, 2); + var button_2 = child(div_7); var text_3 = child(button_2, true); reset(button_2); var button_3 = sibling(button_2, 2); - reset(div_5); + reset(div_7); reset(div_2); reset(div_1); template_effect(() => { set_checked(input, get(selectAll)); set_text(text_1, `${get(exportEntryCount) ?? ""} entries`); + set_checked(input_2, !get(exportUseExistingPassword)); + set_checked(input_4, get(exportUseExistingPassword)); button_2.disabled = get(exporting) || get(selectedGroupIds).length === 0; set_text(text_3, get(exporting) ? "Exporting..." : "📤 Export JSON"); }); delegated("click", div_1, () => set(showExport, false)); delegated("click", div_2, (e) => e.stopPropagation()); delegated("change", input, toggleSelectAll); + delegated("change", input_2, () => set(exportUseExistingPassword, false)); + delegated("change", input_4, () => set(exportUseExistingPassword, true)); delegated("click", button_2, handleExport); delegated("click", button_3, () => set(showExport, false)); append($$anchor, div_1); }; if_block(node, ($$render) => { - if (get(showExport)) $$render(consequent); + if (get(showExport)) $$render(consequent_1); }); - var node_1 = sibling(node, 2); - var consequent_5 = ($$anchor) => { - var div_6 = root_3$1(); - var div_7 = child(div_6); - var node_2 = sibling(child(div_7), 2); - var consequent_1 = ($$anchor) => { - var div_8 = root_4$1(); - var text_4 = child(div_8, true); - reset(div_8); + var node_2 = sibling(node, 2); + var consequent_6 = ($$anchor) => { + var div_8 = root_4$1(); + var div_9 = child(div_8); + var node_3 = sibling(child(div_9), 2); + var consequent_2 = ($$anchor) => { + var div_10 = root_5$1(); + var text_4 = child(div_10, true); + reset(div_10); template_effect(() => set_text(text_4, get(importError))); - append($$anchor, div_8); + append($$anchor, div_10); }; - if_block(node_2, ($$render) => { - if (get(importError)) $$render(consequent_1); + if_block(node_3, ($$render) => { + if (get(importError)) $$render(consequent_2); }); - var node_3 = sibling(node_2, 2); - var consequent_3 = ($$anchor) => { - var div_9 = root_5$1(); - var text_5 = child(div_9); - var node_4 = sibling(text_5); - var consequent_2 = ($$anchor) => { + var node_4 = sibling(node_3, 2); + var consequent_4 = ($$anchor) => { + var div_11 = root_6$1(); + var text_5 = child(div_11); + var node_5 = sibling(text_5); + var consequent_3 = ($$anchor) => { var text_6 = text(); template_effect(() => set_text(text_6, `(${get(importResult).skipped ?? ""} skipped)`)); append($$anchor, text_6); }; - if_block(node_4, ($$render) => { - if (get(importResult).skipped > 0) $$render(consequent_2); + if_block(node_5, ($$render) => { + if (get(importResult).skipped > 0) $$render(consequent_3); }); - reset(div_9); - template_effect(() => set_text(text_5, `✓ Imported ${get(importResult).imported.entries ?? ""} entries and ${get(importResult).imported.groups ?? ""} groups `)); - append($$anchor, div_9); - }; - var consequent_4 = ($$anchor) => { - var fragment_1 = root_7$1(); - var div_10 = sibling(first_child(fragment_1), 2); - var input_2 = sibling(child(div_10), 2); - remove_input_defaults(input_2); - reset(div_10); - var div_11 = sibling(div_10, 2); - var label_2 = child(div_11); - var input_3 = child(label_2); - remove_input_defaults(input_3); - input_3.value = input_3.__value = "merge"; - next(2); - reset(label_2); - var label_3 = sibling(label_2, 2); - var input_4 = child(label_3); - remove_input_defaults(input_4); - input_4.value = input_4.__value = "replace"; - next(2); - reset(label_3); reset(div_11); - var div_12 = sibling(div_11, 2); - var button_4 = child(div_12); + template_effect(() => set_text(text_5, `✓ Imported ${get(importResult).imported.entries ?? ""} entries and ${get(importResult).imported.groups ?? ""} groups `)); + append($$anchor, div_11); + }; + var consequent_5 = ($$anchor) => { + var fragment_1 = root_8$1(); + var div_12 = sibling(first_child(fragment_1), 2); + var input_5 = sibling(child(div_12), 2); + remove_input_defaults(input_5); + reset(div_12); + var div_13 = sibling(div_12, 2); + var label_4 = child(div_13); + var input_6 = child(label_4); + remove_input_defaults(input_6); + input_6.value = input_6.__value = "merge"; + next(2); + reset(label_4); + var label_5 = sibling(label_4, 2); + var input_7 = child(label_5); + remove_input_defaults(input_7); + input_7.value = input_7.__value = "replace"; + next(2); + reset(label_5); + reset(div_13); + var div_14 = sibling(div_13, 2); + var button_4 = child(div_14); var text_7 = child(button_4, true); reset(button_4); var button_5 = sibling(button_4, 2); - reset(div_12); + reset(div_14); template_effect(() => { button_4.disabled = get(importing); set_text(text_7, get(importing) ? "Importing..." : "📥 Import"); }); - bind_value(input_2, () => get(sourcePassword), ($$value) => set(sourcePassword, $$value)); - bind_group(binding_group, [], input_3, () => get(importMode), ($$value) => set(importMode, $$value)); - bind_group(binding_group, [], input_4, () => get(importMode), ($$value) => set(importMode, $$value)); + bind_value(input_5, () => get(sourcePassword), ($$value) => set(sourcePassword, $$value)); + bind_group(binding_group, [], input_6, () => get(importMode), ($$value) => set(importMode, $$value)); + bind_group(binding_group, [], input_7, () => get(importMode), ($$value) => set(importMode, $$value)); delegated("click", button_4, handleImportSubmit); delegated("click", button_5, () => { set(parsedFileData, null); @@ -7238,51 +7328,51 @@ function ImportExport($$anchor, $$props) { append($$anchor, fragment_1); }; var alternate = ($$anchor) => { - var fragment_2 = root_8$1(); - var div_13 = sibling(first_child(fragment_2), 2); - var label_4 = child(div_13); - var input_5 = child(label_4); - remove_input_defaults(input_5); - input_5.value = input_5.__value = "merge"; + var fragment_2 = root_9(); + var div_15 = sibling(first_child(fragment_2), 2); + var label_6 = child(div_15); + var input_8 = child(label_6); + remove_input_defaults(input_8); + input_8.value = input_8.__value = "merge"; next(2); - reset(label_4); - var label_5 = sibling(label_4, 2); - var input_6 = child(label_5); - remove_input_defaults(input_6); - input_6.value = input_6.__value = "replace"; + reset(label_6); + var label_7 = sibling(label_6, 2); + var input_9 = child(label_7); + remove_input_defaults(input_9); + input_9.value = input_9.__value = "replace"; next(2); - reset(label_5); - reset(div_13); - var div_14 = sibling(div_13, 2); - var input_7 = sibling(child(div_14), 2); - reset(div_14); - template_effect(() => input_7.disabled = get(importing)); - bind_group(binding_group, [], input_5, () => get(importMode), ($$value) => set(importMode, $$value)); - bind_group(binding_group, [], input_6, () => get(importMode), ($$value) => set(importMode, $$value)); - delegated("change", input_7, handleFileSelect); + reset(label_7); + reset(div_15); + var div_16 = sibling(div_15, 2); + var input_10 = sibling(child(div_16), 2); + reset(div_16); + template_effect(() => input_10.disabled = get(importing)); + bind_group(binding_group, [], input_8, () => get(importMode), ($$value) => set(importMode, $$value)); + bind_group(binding_group, [], input_9, () => get(importMode), ($$value) => set(importMode, $$value)); + delegated("change", input_10, handleFileSelect); append($$anchor, fragment_2); }; - if_block(node_3, ($$render) => { - if (get(importResult)) $$render(consequent_3); - else if (get(parsedFileData)) $$render(consequent_4, 1); + if_block(node_4, ($$render) => { + if (get(importResult)) $$render(consequent_4); + else if (get(parsedFileData)) $$render(consequent_5, 1); else $$render(alternate, -1); }); - var div_15 = sibling(node_3, 2); - var button_6 = child(div_15); - reset(div_15); - reset(div_7); - reset(div_6); - delegated("click", div_6, () => set(showImport, false)); - delegated("click", div_7, (e) => e.stopPropagation()); + var div_17 = sibling(node_4, 2); + var button_6 = child(div_17); + reset(div_17); + reset(div_9); + reset(div_8); + delegated("click", div_8, () => set(showImport, false)); + delegated("click", div_9, (e) => e.stopPropagation()); delegated("click", button_6, () => { set(showImport, false); set(importResult, null); set(importError, ""); }); - append($$anchor, div_6); + append($$anchor, div_8); }; - if_block(node_1, ($$render) => { - if (get(showImport)) $$render(consequent_5); + if_block(node_2, ($$render) => { + if (get(showImport)) $$render(consequent_6); }); reset(div); delegated("click", button, openExportModal); @@ -8570,6 +8660,21 @@ label { margin-bottom: 4px; } + .protection-choice.svelte-17di1i9 { + display: flex; + flex-direction: column; + gap: 4px; + margin-bottom: 16px; + } + + .protection-choice.svelte-17di1i9 .radio-label:where(.svelte-17di1i9) { + margin-bottom: 2px; + } + + .protection-choice.svelte-17di1i9 .form-group:where(.svelte-17di1i9) { + margin: 2px 0 6px 24px; + } + input[type="file"].svelte-17di1i9 { font-size: 0.85rem; padding: 8px; diff --git a/src/components/ImportExport.svelte b/src/components/ImportExport.svelte index d02cc16..05b7fe3 100644 --- a/src/components/ImportExport.svelte +++ b/src/components/ImportExport.svelte @@ -23,6 +23,7 @@ let sourcePassword = $state('') let parsedFileData = $state(null) let exportPassword = $state('') + let exportUseExistingPassword = $state(false) // Group selection for export let allGroups = $state([]) @@ -36,14 +37,22 @@ ) async function handleExport() { + if (!exportUseExistingPassword && !exportPassword.trim()) { + importError = 'Choose a new password to encrypt the export' + return + } exporting = true try { exportData = await exportSelected( selectedGroupIds.length === allGroups.length ? null : selectedGroupIds, - app.encryptionKey, - exportPassword.trim() + { + vaultKey: app.encryptionKey, + password: exportUseExistingPassword ? '' : exportPassword.trim(), + useExistingPassword: exportUseExistingPassword, + } ) exportPassword = '' + exportUseExistingPassword = false const json = JSON.stringify(exportData, null, 2) const blob = new Blob([json], { type: 'application/json' }) const url = URL.createObjectURL(blob) @@ -143,7 +152,7 @@