Password export: explicit choice (new password vs reuse existing vault password)
- exportSelected(groupIds, { vaultKey, password, useExistingPassword }) replaces the
positional (groupIds, vaultKey, exportPassword) form. Protection is now an explicit
choice, never an ambiguous optional field.
- password mode: re-key entries under a fresh export-derived key (unchanged semantics).
- reuseExistingPassword mode: seal with the vault's own key, keep vault salt embedded,
so import derives the key from the vault master password. No second password required.
- ImportExport.svelte: replaces the long-placeholder free-text field with two radio
options (Use a new password / Reuse my vault password); short placeholder, with
client-side validation that a new password isn't empty.
- Cryptography importAll unchanged: derives the envelope key from the supplied password +
embedded salt, which covers both sealed modes; wrong password still rejects import.
- Tests: updated call sites to options object; added round-trip + wrong-password tests for
reuseExistingPassword.
This commit is contained in:
parent
800feb1d37
commit
a89c7811e1
11
AGENTS.md
11
AGENTS.md
@ -83,11 +83,12 @@ Password verification uses a test payload (random string encrypted at vault crea
|
|||||||
|
|
||||||
## Export / Import
|
## 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.
|
- `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 }`.
|
||||||
- **Plain export** (no export password): unchanged — entries keep their source-vault-encrypted passwords; import needs the source vault's master password.
|
- **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.
|
||||||
- **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 }`.
|
- **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.
|
||||||
- `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`.
|
- **No option set**: falls back to a plaintext JSON export (entries keep source-vault-encrypted passwords) for backward compatibility; the UI never offers this.
|
||||||
- `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).
|
- `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
|
## Known Bug Fixes
|
||||||
|
|
||||||
|
|||||||
365
dist/index.html
vendored
365
dist/index.html
vendored
@ -5587,23 +5587,47 @@ async function moveEntryToGroup(entryId, groupId) {
|
|||||||
await db.put("entries", entry);
|
await db.put("entries", entry);
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* Export data (entries + groups + meta) as a JSON object.
|
* Export data (entries + groups + meta).
|
||||||
* 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.
|
|
||||||
*
|
*
|
||||||
* @param {string[]} [groupIds] - Array of group IDs to export. If null/empty, exports everything.
|
* The caller must choose how the file is protected - an explicit choice, never
|
||||||
* Include '' to export ungrouped entries.
|
* 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<Object>}
|
* @returns {Promise<Object>}
|
||||||
*/
|
*/
|
||||||
async function exportSelected(groupIds) {
|
async function exportSelected(groupIds = null, options = {}) {
|
||||||
|
const { vaultKey = null, password = "", useExistingPassword = false } = options;
|
||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
const allEntries = await db.getAll("entries");
|
const allEntries = await db.getAll("entries");
|
||||||
const allGroups = await db.getAll("groups");
|
const allGroups = await db.getAll("groups");
|
||||||
const saltRow = await db.get("meta", "salt");
|
const saltRow = await db.get("meta", "salt");
|
||||||
const testEncryptedRow = await db.get("meta", "testEncrypted");
|
const testEncryptedRow = await db.get("meta", "testEncrypted");
|
||||||
const testPlaintextRow = await db.get("meta", "testPlaintext");
|
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,
|
version: DB_VERSION,
|
||||||
exportedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
exportedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
||||||
meta: {
|
meta: {
|
||||||
@ -5611,21 +5635,35 @@ async function exportSelected(groupIds) {
|
|||||||
testEncrypted: testEncryptedRow?.value || null,
|
testEncrypted: testEncryptedRow?.value || null,
|
||||||
testPlaintext: testPlaintextRow?.value || null
|
testPlaintext: testPlaintextRow?.value || null
|
||||||
},
|
},
|
||||||
groups: allGroups,
|
groups: allGroups.filter(pickGroup),
|
||||||
entries: allEntries
|
entries: allEntries.filter(pickEntry)
|
||||||
};
|
};
|
||||||
const entries = allEntries.filter((e) => groupIds.includes(e.groupId));
|
if (!!!(password || useExistingPassword)) return payload;
|
||||||
const groups = allGroups.filter((g) => groupIds.includes(g.id));
|
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 {
|
return {
|
||||||
version: DB_VERSION,
|
version: 1,
|
||||||
exportedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
exportedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
||||||
meta: {
|
format: "encrypted-export",
|
||||||
salt: saltRow?.value || null,
|
kdfIterations: 6e5,
|
||||||
testEncrypted: testEncryptedRow?.value || null,
|
salt: envelopeSalt,
|
||||||
testPlaintext: testPlaintextRow?.value || null
|
data: sealed
|
||||||
},
|
|
||||||
groups,
|
|
||||||
entries
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
@ -5642,6 +5680,17 @@ async function exportSelected(groupIds) {
|
|||||||
* @returns {Promise<{ imported: { entries: number, groups: number }, skipped: number }>}
|
* @returns {Promise<{ imported: { entries: number, groups: number }, skipped: number }>}
|
||||||
*/
|
*/
|
||||||
async function importAll(data, mode = "merge", sourcePassword = "", targetKey = null) {
|
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");
|
if (!data || !Array.isArray(data.entries) || !Array.isArray(data.groups)) throw new Error("Invalid import data format");
|
||||||
let sourceKey = null;
|
let sourceKey = null;
|
||||||
if (data.meta?.salt && sourcePassword) sourceKey = await deriveKey(sourcePassword, base64ToUint8Array(data.meta.salt));
|
if (data.meta?.salt && sourcePassword) sourceKey = await deriveKey(sourcePassword, base64ToUint8Array(data.meta.salt));
|
||||||
@ -5815,7 +5864,7 @@ function autofocus(node, condition = true) {
|
|||||||
}
|
}
|
||||||
//#endregion
|
//#endregion
|
||||||
//#region src/components/LockScreen.svelte
|
//#region src/components/LockScreen.svelte
|
||||||
var root_1$7 = /* @__PURE__ */ from_html(`<div class="warning-banner svelte-7sq1ct" role="alert">This HTML file is intended for offline use.</div>`);
|
var root_1$7 = /* @__PURE__ */ from_html(`<div class="warning-banner svelte-7sq1ct" role="alert">You're viewing this from a web server. For best security, download this HTML file and open it locally on your computer instead.</div>`);
|
||||||
var root_2$6 = /* @__PURE__ */ from_html(`<div class="error-banner svelte-7sq1ct" role="alert"> </div>`);
|
var root_2$6 = /* @__PURE__ */ from_html(`<div class="error-banner svelte-7sq1ct" role="alert"> </div>`);
|
||||||
var root_3$6 = /* @__PURE__ */ from_html(`<div class="form-group"><label for="confirm-password">Confirm Password</label> <input id="confirm-password" type="password" placeholder="Confirm master password" autocomplete="new-password"/></div>`);
|
var root_3$6 = /* @__PURE__ */ from_html(`<div class="form-group"><label for="confirm-password">Confirm Password</label> <input id="confirm-password" type="password" placeholder="Confirm master password" autocomplete="new-password"/></div>`);
|
||||||
var root$7 = /* @__PURE__ */ from_html(`<div class="lock-screen svelte-7sq1ct"><div class="lock-card svelte-7sq1ct"><div class="lock-icon svelte-7sq1ct">🔐</div> <h1 class="svelte-7sq1ct">Password Vault</h1> <p class="subtitle svelte-7sq1ct"> </p> <!> <!> <form class="lock-form svelte-7sq1ct"><div class="form-group"><label for="master-password">Master Password</label> <input id="master-password" type="password" placeholder="Enter master password" autocomplete="current-password"/></div> <!> <button type="submit" class="btn btn-primary w-full"> </button></form> <p class="hint svelte-7sq1ct"> </p></div></div>`);
|
var root$7 = /* @__PURE__ */ from_html(`<div class="lock-screen svelte-7sq1ct"><div class="lock-card svelte-7sq1ct"><div class="lock-icon svelte-7sq1ct">🔐</div> <h1 class="svelte-7sq1ct">Password Vault</h1> <p class="subtitle svelte-7sq1ct"> </p> <!> <!> <form class="lock-form svelte-7sq1ct"><div class="form-group"><label for="master-password">Master Password</label> <input id="master-password" type="password" placeholder="Enter master password" autocomplete="current-password"/></div> <!> <button type="submit" class="btn btn-primary w-full"> </button></form> <p class="hint svelte-7sq1ct"> </p></div></div>`);
|
||||||
@ -6008,7 +6057,7 @@ var root_2$5 = /* @__PURE__ */ from_html(`<div class="group-row svelte-181dlmc">
|
|||||||
var root_4$5 = /* @__PURE__ */ from_html(`<div class="error-banner svelte-181dlmc"> </div>`);
|
var root_4$5 = /* @__PURE__ */ from_html(`<div class="error-banner svelte-181dlmc"> </div>`);
|
||||||
var root_5$5 = /* @__PURE__ */ from_html(`<button></button>`);
|
var root_5$5 = /* @__PURE__ */ from_html(`<button></button>`);
|
||||||
var root_3$5 = /* @__PURE__ */ from_html(`<div class="modal-overlay svelte-181dlmc" role="presentation"><div class="modal svelte-181dlmc" role="dialog" aria-modal="true" aria-label="Group settings" tabindex="-1"><h3 class="svelte-181dlmc"> </h3> <!> <div class="form-group svelte-181dlmc"><label for="group-name" class="svelte-181dlmc">Group Name</label> <input id="group-name" type="text" placeholder="e.g. Work, Personal" class="svelte-181dlmc"/></div> <div class="form-group svelte-181dlmc"><span class="field-label svelte-181dlmc">Color</span> <div class="color-picker svelte-181dlmc"></div></div> <div class="modal-actions svelte-181dlmc"><button class="btn btn-primary svelte-181dlmc"> </button> <button class="btn btn-ghost svelte-181dlmc">Cancel</button></div></div></div>`);
|
var root_3$5 = /* @__PURE__ */ from_html(`<div class="modal-overlay svelte-181dlmc" role="presentation"><div class="modal svelte-181dlmc" role="dialog" aria-modal="true" aria-label="Group settings" tabindex="-1"><h3 class="svelte-181dlmc"> </h3> <!> <div class="form-group svelte-181dlmc"><label for="group-name" class="svelte-181dlmc">Group Name</label> <input id="group-name" type="text" placeholder="e.g. Work, Personal" class="svelte-181dlmc"/></div> <div class="form-group svelte-181dlmc"><span class="field-label svelte-181dlmc">Color</span> <div class="color-picker svelte-181dlmc"></div></div> <div class="modal-actions svelte-181dlmc"><button class="btn btn-primary svelte-181dlmc"> </button> <button class="btn btn-ghost svelte-181dlmc">Cancel</button></div></div></div>`);
|
||||||
var root_6$3 = /* @__PURE__ */ from_html(`<div class="modal-overlay svelte-181dlmc" role="presentation"><div class="modal svelte-181dlmc" role="dialog" aria-modal="true" aria-label="Delete group confirmation" tabindex="-1"><h3 class="svelte-181dlmc">Delete Group</h3> <p class="svelte-181dlmc">Delete "<strong class="svelte-181dlmc"> </strong>"? Entries in this group will become ungrouped.</p> <div class="modal-actions svelte-181dlmc"><button class="btn btn-danger svelte-181dlmc">Yes, delete</button> <button class="btn btn-ghost svelte-181dlmc">Cancel</button></div></div></div>`);
|
var root_6$4 = /* @__PURE__ */ from_html(`<div class="modal-overlay svelte-181dlmc" role="presentation"><div class="modal svelte-181dlmc" role="dialog" aria-modal="true" aria-label="Delete group confirmation" tabindex="-1"><h3 class="svelte-181dlmc">Delete Group</h3> <p class="svelte-181dlmc">Delete "<strong class="svelte-181dlmc"> </strong>"? Entries in this group will become ungrouped.</p> <div class="modal-actions svelte-181dlmc"><button class="btn btn-danger svelte-181dlmc">Yes, delete</button> <button class="btn btn-ghost svelte-181dlmc">Cancel</button></div></div></div>`);
|
||||||
var root$6 = /* @__PURE__ */ from_html(`<div class="sidebar-content svelte-181dlmc"><div class="sidebar-header svelte-181dlmc"><h2 class="svelte-181dlmc">🔐 Vault</h2></div> <div class="search-box svelte-181dlmc"><input type="text" placeholder="Search entries..." class="svelte-181dlmc"/></div> <nav class="groups-nav svelte-181dlmc"><button><span class="group-icon svelte-181dlmc">📋</span> <span class="group-name svelte-181dlmc">All Entries</span></button> <!></nav> <div class="trash-section svelte-181dlmc"><button><span class="group-color svelte-181dlmc"></span> <span class="group-name svelte-181dlmc"> </span></button></div> <div class="sidebar-footer svelte-181dlmc"><button class="btn btn-ghost btn-sm w-full svelte-181dlmc">+ New Group</button></div> <!> <!></div>`);
|
var root$6 = /* @__PURE__ */ from_html(`<div class="sidebar-content svelte-181dlmc"><div class="sidebar-header svelte-181dlmc"><h2 class="svelte-181dlmc">🔐 Vault</h2></div> <div class="search-box svelte-181dlmc"><input type="text" placeholder="Search entries..." class="svelte-181dlmc"/></div> <nav class="groups-nav svelte-181dlmc"><button><span class="group-icon svelte-181dlmc">📋</span> <span class="group-name svelte-181dlmc">All Entries</span></button> <!></nav> <div class="trash-section svelte-181dlmc"><button><span class="group-color svelte-181dlmc"></span> <span class="group-name svelte-181dlmc"> </span></button></div> <div class="sidebar-footer svelte-181dlmc"><button class="btn btn-ghost btn-sm w-full svelte-181dlmc">+ New Group</button></div> <!> <!></div>`);
|
||||||
function Sidebar($$anchor, $$props) {
|
function Sidebar($$anchor, $$props) {
|
||||||
push($$props, true);
|
push($$props, true);
|
||||||
@ -6218,7 +6267,7 @@ function Sidebar($$anchor, $$props) {
|
|||||||
});
|
});
|
||||||
var node_4 = sibling(node_2, 2);
|
var node_4 = sibling(node_2, 2);
|
||||||
var consequent_3 = ($$anchor) => {
|
var consequent_3 = ($$anchor) => {
|
||||||
var div_13 = root_6$3();
|
var div_13 = root_6$4();
|
||||||
var div_14 = child(div_13);
|
var div_14 = child(div_13);
|
||||||
var p = sibling(child(div_14), 2);
|
var p = sibling(child(div_14), 2);
|
||||||
var strong = sibling(child(p));
|
var strong = sibling(child(p));
|
||||||
@ -6264,9 +6313,9 @@ var root_1$6 = /* @__PURE__ */ from_html(`<div class="loading svelte-13s7gu4">Lo
|
|||||||
var root_2$4 = /* @__PURE__ */ from_html(`<div class="error-banner svelte-13s7gu4"> </div>`);
|
var root_2$4 = /* @__PURE__ */ from_html(`<div class="error-banner svelte-13s7gu4"> </div>`);
|
||||||
var root_4$4 = /* @__PURE__ */ from_html(`<button class="btn btn-primary mt-3">+ New Entry</button>`);
|
var root_4$4 = /* @__PURE__ */ from_html(`<button class="btn btn-primary mt-3">+ New Entry</button>`);
|
||||||
var root_3$4 = /* @__PURE__ */ from_html(`<div class="empty-state svelte-13s7gu4"><p class="empty-icon svelte-13s7gu4"> </p> <p class="empty-text svelte-13s7gu4"> </p> <p class="empty-hint svelte-13s7gu4"> </p> <!></div>`);
|
var root_3$4 = /* @__PURE__ */ from_html(`<div class="empty-state svelte-13s7gu4"><p class="empty-icon svelte-13s7gu4"> </p> <p class="empty-text svelte-13s7gu4"> </p> <p class="empty-hint svelte-13s7gu4"> </p> <!></div>`);
|
||||||
var root_6$2 = /* @__PURE__ */ from_html(`matching "<strong> </strong>"`, 1);
|
var root_6$3 = /* @__PURE__ */ from_html(`matching "<strong> </strong>"`, 1);
|
||||||
var root_7$4 = /* @__PURE__ */ from_html(`<th style="width: 60px" class="svelte-13s7gu4"></th>`);
|
var root_7$3 = /* @__PURE__ */ from_html(`<th style="width: 60px" class="svelte-13s7gu4"></th>`);
|
||||||
var root_9$1 = /* @__PURE__ */ from_html(`<span class="drag-handle svelte-13s7gu4" aria-hidden="true">⠿</span>`);
|
var root_9$2 = /* @__PURE__ */ from_html(`<span class="drag-handle svelte-13s7gu4" aria-hidden="true">⠿</span>`);
|
||||||
var root_10$1 = /* @__PURE__ */ from_html(`<div class="notes-tooltip svelte-13s7gu4"><span class="notes-icon svelte-13s7gu4">🔍</span> <div class="tooltip-popup svelte-13s7gu4"> </div></div>`);
|
var root_10$1 = /* @__PURE__ */ from_html(`<div class="notes-tooltip svelte-13s7gu4"><span class="notes-icon svelte-13s7gu4">🔍</span> <div class="tooltip-popup svelte-13s7gu4"> </div></div>`);
|
||||||
var root_11$1 = /* @__PURE__ */ from_html(`<span>—</span>`);
|
var root_11$1 = /* @__PURE__ */ from_html(`<span>—</span>`);
|
||||||
var root_12$1 = /* @__PURE__ */ from_html(`<td class="svelte-13s7gu4"><button class="btn btn-ghost btn-sm restore-btn svelte-13s7gu4" title="Restore entry">↩️</button></td>`);
|
var root_12$1 = /* @__PURE__ */ from_html(`<td class="svelte-13s7gu4"><button class="btn btn-ghost btn-sm restore-btn svelte-13s7gu4" title="Restore entry">↩️</button></td>`);
|
||||||
@ -6360,7 +6409,7 @@ function EntryList($$anchor, $$props) {
|
|||||||
var text_4 = child(span);
|
var text_4 = child(span);
|
||||||
var node_2 = sibling(text_4);
|
var node_2 = sibling(text_4);
|
||||||
var consequent_4 = ($$anchor) => {
|
var consequent_4 = ($$anchor) => {
|
||||||
var fragment_1 = root_6$2();
|
var fragment_1 = root_6$3();
|
||||||
var strong = sibling(first_child(fragment_1));
|
var strong = sibling(first_child(fragment_1));
|
||||||
var text_5 = child(strong, true);
|
var text_5 = child(strong, true);
|
||||||
reset(strong);
|
reset(strong);
|
||||||
@ -6378,7 +6427,7 @@ function EntryList($$anchor, $$props) {
|
|||||||
var tr = child(thead);
|
var tr = child(thead);
|
||||||
var node_3 = sibling(child(tr), 4);
|
var node_3 = sibling(child(tr), 4);
|
||||||
var consequent_5 = ($$anchor) => {
|
var consequent_5 = ($$anchor) => {
|
||||||
append($$anchor, root_7$4());
|
append($$anchor, root_7$3());
|
||||||
};
|
};
|
||||||
if_block(node_3, ($$render) => {
|
if_block(node_3, ($$render) => {
|
||||||
if (get(isTrashView)) $$render(consequent_5);
|
if (get(isTrashView)) $$render(consequent_5);
|
||||||
@ -6391,7 +6440,7 @@ function EntryList($$anchor, $$props) {
|
|||||||
var td = child(tr_1);
|
var td = child(tr_1);
|
||||||
var node_4 = child(td);
|
var node_4 = child(td);
|
||||||
var consequent_6 = ($$anchor) => {
|
var consequent_6 = ($$anchor) => {
|
||||||
append($$anchor, root_9$1());
|
append($$anchor, root_9$2());
|
||||||
};
|
};
|
||||||
if_block(node_4, ($$render) => {
|
if_block(node_4, ($$render) => {
|
||||||
if (!get(isTrashView)) $$render(consequent_6);
|
if (!get(isTrashView)) $$render(consequent_6);
|
||||||
@ -6490,10 +6539,10 @@ var root_1$5 = /* @__PURE__ */ from_html(`<div class="toast svelte-dssgjx"> </di
|
|||||||
var root_2$3 = /* @__PURE__ */ from_html(`<div class="loading svelte-dssgjx">Loading...</div>`);
|
var root_2$3 = /* @__PURE__ */ from_html(`<div class="loading svelte-dssgjx">Loading...</div>`);
|
||||||
var root_3$3 = /* @__PURE__ */ from_html(`<div class="error-banner svelte-dssgjx"> </div>`);
|
var root_3$3 = /* @__PURE__ */ from_html(`<div class="error-banner svelte-dssgjx"> </div>`);
|
||||||
var root_4$3 = /* @__PURE__ */ from_html(`<div class="empty-state svelte-dssgjx">Entry not found</div>`);
|
var root_4$3 = /* @__PURE__ */ from_html(`<div class="empty-state svelte-dssgjx">Entry not found</div>`);
|
||||||
var root_6$1 = /* @__PURE__ */ from_html(`<button class="btn btn-primary btn-sm">↩️ Restore</button> <button class="btn btn-danger btn-sm">🗑 Delete Forever</button>`, 1);
|
var root_6$2 = /* @__PURE__ */ from_html(`<button class="btn btn-primary btn-sm">↩️ Restore</button> <button class="btn btn-danger btn-sm">🗑 Delete Forever</button>`, 1);
|
||||||
var root_7$3 = /* @__PURE__ */ from_html(`<button class="btn btn-ghost btn-sm">✏️ Edit</button> <button class="btn btn-danger btn-sm">🗑 Move to Trash</button>`, 1);
|
var root_7$2 = /* @__PURE__ */ from_html(`<button class="btn btn-ghost btn-sm">✏️ Edit</button> <button class="btn btn-danger btn-sm">🗑 Move to Trash</button>`, 1);
|
||||||
var root_8$2 = /* @__PURE__ */ from_html(`<div class="detail-field"><span class="field-label svelte-dssgjx">Username</span> <div class="field-value svelte-dssgjx"><span> </span> <button class="btn btn-ghost btn-sm copy-btn svelte-dssgjx" title="Copy username">📋</button></div></div>`);
|
var root_8$2 = /* @__PURE__ */ from_html(`<div class="detail-field"><span class="field-label svelte-dssgjx">Username</span> <div class="field-value svelte-dssgjx"><span> </span> <button class="btn btn-ghost btn-sm copy-btn svelte-dssgjx" title="Copy username">📋</button></div></div>`);
|
||||||
var root_9 = /* @__PURE__ */ from_html(`<div class="detail-field"><span class="field-label svelte-dssgjx">URL</span> <div class="field-value svelte-dssgjx"><a target="_blank" rel="noopener noreferrer" class="svelte-dssgjx"> </a> <button class="btn btn-ghost btn-sm copy-btn svelte-dssgjx" title="Copy URL">📋</button></div></div>`);
|
var root_9$1 = /* @__PURE__ */ from_html(`<div class="detail-field"><span class="field-label svelte-dssgjx">URL</span> <div class="field-value svelte-dssgjx"><a target="_blank" rel="noopener noreferrer" class="svelte-dssgjx"> </a> <button class="btn btn-ghost btn-sm copy-btn svelte-dssgjx" title="Copy URL">📋</button></div></div>`);
|
||||||
var root_10 = /* @__PURE__ */ from_html(`<div class="detail-field"><span class="field-label svelte-dssgjx">Notes</span> <div class="field-value notes svelte-dssgjx"> </div></div>`);
|
var root_10 = /* @__PURE__ */ from_html(`<div class="detail-field"><span class="field-label svelte-dssgjx">Notes</span> <div class="field-value notes svelte-dssgjx"> </div></div>`);
|
||||||
var root_11 = /* @__PURE__ */ from_html(`<div class="modal-overlay svelte-dssgjx" role="presentation"><div class="modal svelte-dssgjx" role="dialog" aria-modal="true" aria-label="Move to trash confirmation" tabindex="-1"><h3 class="svelte-dssgjx">Move to Trash</h3> <p class="svelte-dssgjx">Move "<strong> </strong>" to the trash? You can restore it later.</p> <div class="modal-actions svelte-dssgjx"><button class="btn btn-danger"> </button> <button class="btn btn-ghost">Cancel</button></div></div></div>`);
|
var root_11 = /* @__PURE__ */ from_html(`<div class="modal-overlay svelte-dssgjx" role="presentation"><div class="modal svelte-dssgjx" role="dialog" aria-modal="true" aria-label="Move to trash confirmation" tabindex="-1"><h3 class="svelte-dssgjx">Move to Trash</h3> <p class="svelte-dssgjx">Move "<strong> </strong>" to the trash? You can restore it later.</p> <div class="modal-actions svelte-dssgjx"><button class="btn btn-danger"> </button> <button class="btn btn-ghost">Cancel</button></div></div></div>`);
|
||||||
var root_12 = /* @__PURE__ */ from_html(`<div class="modal-overlay svelte-dssgjx" role="presentation"><div class="modal svelte-dssgjx" role="dialog" aria-modal="true" aria-label="Permanent delete confirmation" tabindex="-1"><h3 class="svelte-dssgjx">Delete Forever</h3> <p class="svelte-dssgjx">Permanently delete "<strong> </strong>"? This cannot be undone.</p> <div class="modal-actions svelte-dssgjx"><button class="btn btn-danger"> </button> <button class="btn btn-ghost">Cancel</button></div></div></div>`);
|
var root_12 = /* @__PURE__ */ from_html(`<div class="modal-overlay svelte-dssgjx" role="presentation"><div class="modal svelte-dssgjx" role="dialog" aria-modal="true" aria-label="Permanent delete confirmation" tabindex="-1"><h3 class="svelte-dssgjx">Delete Forever</h3> <p class="svelte-dssgjx">Permanently delete "<strong> </strong>"? This cannot be undone.</p> <div class="modal-actions svelte-dssgjx"><button class="btn btn-danger"> </button> <button class="btn btn-ghost">Cancel</button></div></div></div>`);
|
||||||
@ -6610,7 +6659,7 @@ function EntryDetail($$anchor, $$props) {
|
|||||||
var div_7 = sibling(h2, 2);
|
var div_7 = sibling(h2, 2);
|
||||||
var node_2 = child(div_7);
|
var node_2 = child(div_7);
|
||||||
var consequent_4 = ($$anchor) => {
|
var consequent_4 = ($$anchor) => {
|
||||||
var fragment_1 = root_6$1();
|
var fragment_1 = root_6$2();
|
||||||
var button = first_child(fragment_1);
|
var button = first_child(fragment_1);
|
||||||
var button_1 = sibling(button, 2);
|
var button_1 = sibling(button, 2);
|
||||||
delegated("click", button, () => $$props.onEdit(get(entry).id));
|
delegated("click", button, () => $$props.onEdit(get(entry).id));
|
||||||
@ -6618,7 +6667,7 @@ function EntryDetail($$anchor, $$props) {
|
|||||||
append($$anchor, fragment_1);
|
append($$anchor, fragment_1);
|
||||||
};
|
};
|
||||||
var alternate = ($$anchor) => {
|
var alternate = ($$anchor) => {
|
||||||
var fragment_2 = root_7$3();
|
var fragment_2 = root_7$2();
|
||||||
var button_2 = first_child(fragment_2);
|
var button_2 = first_child(fragment_2);
|
||||||
var button_3 = sibling(button_2, 2);
|
var button_3 = sibling(button_2, 2);
|
||||||
delegated("click", button_2, () => $$props.onEdit(get(entry).id));
|
delegated("click", button_2, () => $$props.onEdit(get(entry).id));
|
||||||
@ -6662,7 +6711,7 @@ function EntryDetail($$anchor, $$props) {
|
|||||||
reset(div_11);
|
reset(div_11);
|
||||||
var node_4 = sibling(div_11, 2);
|
var node_4 = sibling(div_11, 2);
|
||||||
var consequent_6 = ($$anchor) => {
|
var consequent_6 = ($$anchor) => {
|
||||||
var div_13 = root_9();
|
var div_13 = root_9$1();
|
||||||
var div_14 = sibling(child(div_13), 2);
|
var div_14 = sibling(child(div_13), 2);
|
||||||
var a = child(div_14);
|
var a = child(div_14);
|
||||||
var text_7 = child(a, true);
|
var text_7 = child(a, true);
|
||||||
@ -6795,7 +6844,7 @@ var root_1$4 = /* @__PURE__ */ from_html(`<div class="loading svelte-pafazm">Loa
|
|||||||
var root_3$2 = /* @__PURE__ */ from_html(`<div class="error-banner svelte-pafazm"> </div>`);
|
var root_3$2 = /* @__PURE__ */ from_html(`<div class="error-banner svelte-pafazm"> </div>`);
|
||||||
var root_5$2 = /* @__PURE__ */ from_html(`<div class="validation-error svelte-pafazm"> </div>`);
|
var root_5$2 = /* @__PURE__ */ from_html(`<div class="validation-error svelte-pafazm"> </div>`);
|
||||||
var root_4$2 = /* @__PURE__ */ from_html(`<div class="validation-errors svelte-pafazm"></div>`);
|
var root_4$2 = /* @__PURE__ */ from_html(`<div class="validation-errors svelte-pafazm"></div>`);
|
||||||
var root_7$2 = /* @__PURE__ */ from_html(`<option> </option>`);
|
var root_7$1 = /* @__PURE__ */ from_html(`<option> </option>`);
|
||||||
var root_2$2 = /* @__PURE__ */ from_html(`<!> <form class="form-card svelte-pafazm"><!> <div class="form-group"><label for="title">Title *</label> <input id="title" type="text" placeholder="e.g. GitHub, Gmail"/></div> <div class="form-group"><label for="username">Username / Email</label> <input id="username" type="text" placeholder="username or email"/></div> <div class="form-group"><label for="password">Password *</label> <div class="password-input-group svelte-pafazm"><input id="password" placeholder="Password" class="svelte-pafazm"/> <button type="button" class="btn btn-ghost btn-sm" title="Toggle visibility"> </button> <button type="button" class="btn btn-ghost btn-sm" title="Generate password">🎲</button></div></div> <div class="form-group"><label for="url">URL</label> <input id="url" type="url" placeholder="https://example.com"/></div> <div class="form-group"><label for="group">Group</label> <select id="group"><option>No group</option><!></select></div> <div class="form-group"><label for="notes">Notes</label> <textarea id="notes" placeholder="Any additional notes..."></textarea></div> <div class="form-actions svelte-pafazm"><button type="submit" class="btn btn-primary"> </button> <button type="button" class="btn btn-ghost">Cancel</button></div></form>`, 1);
|
var root_2$2 = /* @__PURE__ */ from_html(`<!> <form class="form-card svelte-pafazm"><!> <div class="form-group"><label for="title">Title *</label> <input id="title" type="text" placeholder="e.g. GitHub, Gmail"/></div> <div class="form-group"><label for="username">Username / Email</label> <input id="username" type="text" placeholder="username or email"/></div> <div class="form-group"><label for="password">Password *</label> <div class="password-input-group svelte-pafazm"><input id="password" placeholder="Password" class="svelte-pafazm"/> <button type="button" class="btn btn-ghost btn-sm" title="Toggle visibility"> </button> <button type="button" class="btn btn-ghost btn-sm" title="Generate password">🎲</button></div></div> <div class="form-group"><label for="url">URL</label> <input id="url" type="url" placeholder="https://example.com"/></div> <div class="form-group"><label for="group">Group</label> <select id="group"><option>No group</option><!></select></div> <div class="form-group"><label for="notes">Notes</label> <textarea id="notes" placeholder="Any additional notes..."></textarea></div> <div class="form-actions svelte-pafazm"><button type="submit" class="btn btn-primary"> </button> <button type="button" class="btn btn-ghost">Cancel</button></div></form>`, 1);
|
||||||
var root$3 = /* @__PURE__ */ from_html(`<div class="entry-form"><!></div>`);
|
var root$3 = /* @__PURE__ */ from_html(`<div class="entry-form"><!></div>`);
|
||||||
function EntryForm($$anchor, $$props) {
|
function EntryForm($$anchor, $$props) {
|
||||||
@ -6943,7 +6992,7 @@ function EntryForm($$anchor, $$props) {
|
|||||||
var fragment_1 = comment();
|
var fragment_1 = comment();
|
||||||
var node_4 = first_child(fragment_1);
|
var node_4 = first_child(fragment_1);
|
||||||
var consequent_3 = ($$anchor) => {
|
var consequent_3 = ($$anchor) => {
|
||||||
var option_1 = root_7$2();
|
var option_1 = root_7$1();
|
||||||
var text_3 = child(option_1, true);
|
var text_3 = child(option_1, true);
|
||||||
reset(option_1);
|
reset(option_1);
|
||||||
var option_1_value = {};
|
var option_1_value = {};
|
||||||
@ -7006,12 +7055,13 @@ delegate(["click"]);
|
|||||||
//#endregion
|
//#endregion
|
||||||
//#region src/components/ImportExport.svelte
|
//#region src/components/ImportExport.svelte
|
||||||
var root_2$1 = /* @__PURE__ */ from_html(`<label class="checkbox-label group-checkbox svelte-17di1i9"><input type="checkbox" class="svelte-17di1i9"/> <span class="group-color-dot svelte-17di1i9"></span> <span class="group-name"> </span></label>`);
|
var root_2$1 = /* @__PURE__ */ from_html(`<label class="checkbox-label group-checkbox svelte-17di1i9"><input type="checkbox" class="svelte-17di1i9"/> <span class="group-color-dot svelte-17di1i9"></span> <span class="group-name"> </span></label>`);
|
||||||
var root_1$3 = /* @__PURE__ */ from_html(`<div class="modal-overlay svelte-17di1i9" role="presentation"><div class="modal svelte-17di1i9" role="dialog" aria-modal="true" aria-label="Export vault" tabindex="-1"><h3 class="svelte-17di1i9">Export Vault</h3> <p class="svelte-17di1i9">Select which groups to export. You'll need the source vault's master password when importing into another vault.</p> <div class="group-select-header svelte-17di1i9"><label class="checkbox-label svelte-17di1i9"><input type="checkbox" class="svelte-17di1i9"/> <span>Select all</span></label> <span class="entry-count svelte-17di1i9"> </span></div> <div class="group-select-list svelte-17di1i9"></div> <div class="modal-actions svelte-17di1i9"><button class="btn btn-primary"> </button> <button class="btn btn-ghost">Cancel</button></div></div></div>`);
|
var root_3$1 = /* @__PURE__ */ from_html(`<div class="form-group svelte-17di1i9"><input id="export-password" type="password" placeholder="Enter a new password" autocomplete="new-password" class="svelte-17di1i9"/></div>`);
|
||||||
var root_4$1 = /* @__PURE__ */ from_html(`<div class="error-banner svelte-17di1i9"> </div>`);
|
var root_1$3 = /* @__PURE__ */ from_html(`<div class="modal-overlay svelte-17di1i9" role="presentation"><div class="modal svelte-17di1i9" role="dialog" aria-modal="true" aria-label="Export vault" tabindex="-1"><h3 class="svelte-17di1i9">Export Vault</h3> <p class="svelte-17di1i9">Select which groups to export. Choose how to protect the file - either with a new password, or reuse the password that unlocks this vault.</p> <div class="group-select-header svelte-17di1i9"><label class="checkbox-label svelte-17di1i9"><input type="checkbox" class="svelte-17di1i9"/> <span>Select all</span></label> <span class="entry-count svelte-17di1i9"> </span></div> <div class="group-select-list svelte-17di1i9"></div> <div class="protection-choice svelte-17di1i9"><label class="radio-label svelte-17di1i9"><input type="radio" name="exportProtection" class="svelte-17di1i9"/> <span class="svelte-17di1i9">Use a new password</span></label> <!> <label class="radio-label svelte-17di1i9"><input type="radio" name="exportProtection" class="svelte-17di1i9"/> <span class="svelte-17di1i9">Reuse my vault password</span></label></div> <div class="modal-actions svelte-17di1i9"><button class="btn btn-primary"> </button> <button class="btn btn-ghost">Cancel</button></div></div></div>`);
|
||||||
var root_5$1 = /* @__PURE__ */ from_html(`<div class="success-banner svelte-17di1i9"> <!></div>`);
|
var root_5$1 = /* @__PURE__ */ from_html(`<div class="error-banner svelte-17di1i9"> </div>`);
|
||||||
var root_7$1 = /* @__PURE__ */ from_html(`<p class="svelte-17di1i9">File loaded. Enter the <strong>source vault's master password</strong> to decrypt and re-encrypt entries under your current vault.</p> <div class="form-group svelte-17di1i9"><label for="source-password" class="file-label svelte-17di1i9">Source vault password</label> <input id="source-password" type="password" placeholder="Enter source vault password" autocomplete="current-password" class="svelte-17di1i9"/></div> <div class="import-mode svelte-17di1i9"><label class="radio-label svelte-17di1i9"><input type="radio" name="importMode" class="svelte-17di1i9"/> <span class="svelte-17di1i9">Merge — add to existing data</span></label> <label class="radio-label svelte-17di1i9"><input type="radio" name="importMode" class="svelte-17di1i9"/> <span class="svelte-17di1i9">Replace — clear all existing data first</span></label></div> <div class="modal-actions svelte-17di1i9"><button class="btn btn-primary"> </button> <button class="btn btn-ghost">Cancel</button></div>`, 1);
|
var root_6$1 = /* @__PURE__ */ from_html(`<div class="success-banner svelte-17di1i9"> <!></div>`);
|
||||||
var root_8$1 = /* @__PURE__ */ from_html(`<p class="svelte-17di1i9">Select how to handle existing data:</p> <div class="import-mode svelte-17di1i9"><label class="radio-label svelte-17di1i9"><input type="radio" name="importMode" class="svelte-17di1i9"/> <span class="svelte-17di1i9">Merge — add to existing data</span></label> <label class="radio-label svelte-17di1i9"><input type="radio" name="importMode" class="svelte-17di1i9"/> <span class="svelte-17di1i9">Replace — clear all existing data first</span></label></div> <div class="form-group svelte-17di1i9"><label for="import-file" class="file-label svelte-17di1i9">Choose JSON file</label> <input id="import-file" type="file" accept=".json,application/json" class="svelte-17di1i9"/></div>`, 1);
|
var root_8$1 = /* @__PURE__ */ from_html(`<p class="svelte-17di1i9">File loaded. Enter the password protecting this file — for an encrypted export that is the <strong>export password</strong> you chose; for a plain export it is the <strong>source vault's master password</strong>.</p> <div class="form-group svelte-17di1i9"><label for="source-password" class="file-label svelte-17di1i9">Password for this file</label> <input id="source-password" type="password" placeholder="Enter the export or source vault password" autocomplete="current-password" class="svelte-17di1i9"/></div> <div class="import-mode svelte-17di1i9"><label class="radio-label svelte-17di1i9"><input type="radio" name="importMode" class="svelte-17di1i9"/> <span class="svelte-17di1i9">Merge — add to existing data</span></label> <label class="radio-label svelte-17di1i9"><input type="radio" name="importMode" class="svelte-17di1i9"/> <span class="svelte-17di1i9">Replace — clear all existing data first</span></label></div> <div class="modal-actions svelte-17di1i9"><button class="btn btn-primary"> </button> <button class="btn btn-ghost">Cancel</button></div>`, 1);
|
||||||
var root_3$1 = /* @__PURE__ */ from_html(`<div class="modal-overlay svelte-17di1i9" role="presentation"><div class="modal svelte-17di1i9" role="dialog" aria-modal="true" aria-label="Import vault data" tabindex="-1"><h3 class="svelte-17di1i9">Import Vault Data</h3> <!> <!> <div class="modal-actions svelte-17di1i9"><button class="btn btn-ghost">Close</button></div></div></div>`);
|
var root_9 = /* @__PURE__ */ from_html(`<p class="svelte-17di1i9">Select how to handle existing data:</p> <div class="import-mode svelte-17di1i9"><label class="radio-label svelte-17di1i9"><input type="radio" name="importMode" class="svelte-17di1i9"/> <span class="svelte-17di1i9">Merge — add to existing data</span></label> <label class="radio-label svelte-17di1i9"><input type="radio" name="importMode" class="svelte-17di1i9"/> <span class="svelte-17di1i9">Replace — clear all existing data first</span></label></div> <div class="form-group svelte-17di1i9"><label for="import-file" class="file-label svelte-17di1i9">Choose JSON file</label> <input id="import-file" type="file" accept=".json,application/json" class="svelte-17di1i9"/></div>`, 1);
|
||||||
|
var root_4$1 = /* @__PURE__ */ from_html(`<div class="modal-overlay svelte-17di1i9" role="presentation"><div class="modal svelte-17di1i9" role="dialog" aria-modal="true" aria-label="Import vault data" tabindex="-1"><h3 class="svelte-17di1i9">Import Vault Data</h3> <!> <!> <div class="modal-actions svelte-17di1i9"><button class="btn btn-ghost">Close</button></div></div></div>`);
|
||||||
var root$2 = /* @__PURE__ */ from_html(`<div class="import-export"><button class="btn btn-ghost btn-sm" title="Export">📤 Export</button> <button class="btn btn-ghost btn-sm" title="Import">📥 Import</button> <!> <!></div>`);
|
var root$2 = /* @__PURE__ */ from_html(`<div class="import-export"><button class="btn btn-ghost btn-sm" title="Export">📤 Export</button> <button class="btn btn-ghost btn-sm" title="Import">📥 Import</button> <!> <!></div>`);
|
||||||
function ImportExport($$anchor, $$props) {
|
function ImportExport($$anchor, $$props) {
|
||||||
push($$props, true);
|
push($$props, true);
|
||||||
@ -7036,15 +7086,27 @@ function ImportExport($$anchor, $$props) {
|
|||||||
let exporting = /* @__PURE__ */ state(false);
|
let exporting = /* @__PURE__ */ state(false);
|
||||||
let sourcePassword = /* @__PURE__ */ state("");
|
let sourcePassword = /* @__PURE__ */ state("");
|
||||||
let parsedFileData = /* @__PURE__ */ state(null);
|
let parsedFileData = /* @__PURE__ */ state(null);
|
||||||
|
let exportPassword = /* @__PURE__ */ state("");
|
||||||
|
let exportUseExistingPassword = /* @__PURE__ */ state(false);
|
||||||
let allGroups = /* @__PURE__ */ state(proxy([]));
|
let allGroups = /* @__PURE__ */ state(proxy([]));
|
||||||
let allEntries = /* @__PURE__ */ state(proxy([]));
|
let allEntries = /* @__PURE__ */ state(proxy([]));
|
||||||
let selectedGroupIds = /* @__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 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);
|
let exportEntryCount = /* @__PURE__ */ user_derived(() => get(allEntries).filter((e) => get(selectedGroupIds).includes(e.groupId)).length);
|
||||||
async function handleExport() {
|
async function handleExport() {
|
||||||
|
if (!get(exportUseExistingPassword) && !get(exportPassword).trim()) {
|
||||||
|
set(importError, "Choose a new password to encrypt the export");
|
||||||
|
return;
|
||||||
|
}
|
||||||
set(exporting, true);
|
set(exporting, true);
|
||||||
try {
|
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 json = JSON.stringify(get(exportData), null, 2);
|
||||||
const blob = new Blob([json], { type: "application/json" });
|
const blob = new Blob([json], { type: "application/json" });
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
@ -7109,7 +7171,7 @@ function ImportExport($$anchor, $$props) {
|
|||||||
var button = child(div);
|
var button = child(div);
|
||||||
var button_1 = sibling(button, 2);
|
var button_1 = sibling(button, 2);
|
||||||
var node = sibling(button_1, 2);
|
var node = sibling(button_1, 2);
|
||||||
var consequent = ($$anchor) => {
|
var consequent_1 = ($$anchor) => {
|
||||||
var div_1 = root_1$3();
|
var div_1 = root_1$3();
|
||||||
var div_2 = child(div_1);
|
var div_2 = child(div_1);
|
||||||
var div_3 = sibling(child(div_2), 4);
|
var div_3 = sibling(child(div_2), 4);
|
||||||
@ -7142,94 +7204,122 @@ function ImportExport($$anchor, $$props) {
|
|||||||
});
|
});
|
||||||
reset(div_4);
|
reset(div_4);
|
||||||
var div_5 = sibling(div_4, 2);
|
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);
|
var text_3 = child(button_2, true);
|
||||||
reset(button_2);
|
reset(button_2);
|
||||||
var button_3 = sibling(button_2, 2);
|
var button_3 = sibling(button_2, 2);
|
||||||
reset(div_5);
|
reset(div_7);
|
||||||
reset(div_2);
|
reset(div_2);
|
||||||
reset(div_1);
|
reset(div_1);
|
||||||
template_effect(() => {
|
template_effect(() => {
|
||||||
set_checked(input, get(selectAll));
|
set_checked(input, get(selectAll));
|
||||||
set_text(text_1, `${get(exportEntryCount) ?? ""} entries`);
|
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;
|
button_2.disabled = get(exporting) || get(selectedGroupIds).length === 0;
|
||||||
set_text(text_3, get(exporting) ? "Exporting..." : "📤 Export JSON");
|
set_text(text_3, get(exporting) ? "Exporting..." : "📤 Export JSON");
|
||||||
});
|
});
|
||||||
delegated("click", div_1, () => set(showExport, false));
|
delegated("click", div_1, () => set(showExport, false));
|
||||||
delegated("click", div_2, (e) => e.stopPropagation());
|
delegated("click", div_2, (e) => e.stopPropagation());
|
||||||
delegated("change", input, toggleSelectAll);
|
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_2, handleExport);
|
||||||
delegated("click", button_3, () => set(showExport, false));
|
delegated("click", button_3, () => set(showExport, false));
|
||||||
append($$anchor, div_1);
|
append($$anchor, div_1);
|
||||||
};
|
};
|
||||||
if_block(node, ($$render) => {
|
if_block(node, ($$render) => {
|
||||||
if (get(showExport)) $$render(consequent);
|
if (get(showExport)) $$render(consequent_1);
|
||||||
});
|
});
|
||||||
var node_1 = sibling(node, 2);
|
var node_2 = sibling(node, 2);
|
||||||
var consequent_5 = ($$anchor) => {
|
var consequent_6 = ($$anchor) => {
|
||||||
var div_6 = root_3$1();
|
var div_8 = root_4$1();
|
||||||
var div_7 = child(div_6);
|
var div_9 = child(div_8);
|
||||||
var node_2 = sibling(child(div_7), 2);
|
var node_3 = sibling(child(div_9), 2);
|
||||||
var consequent_1 = ($$anchor) => {
|
var consequent_2 = ($$anchor) => {
|
||||||
var div_8 = root_4$1();
|
var div_10 = root_5$1();
|
||||||
var text_4 = child(div_8, true);
|
var text_4 = child(div_10, true);
|
||||||
reset(div_8);
|
reset(div_10);
|
||||||
template_effect(() => set_text(text_4, get(importError)));
|
template_effect(() => set_text(text_4, get(importError)));
|
||||||
append($$anchor, div_8);
|
append($$anchor, div_10);
|
||||||
};
|
};
|
||||||
if_block(node_2, ($$render) => {
|
if_block(node_3, ($$render) => {
|
||||||
if (get(importError)) $$render(consequent_1);
|
if (get(importError)) $$render(consequent_2);
|
||||||
});
|
});
|
||||||
var node_3 = sibling(node_2, 2);
|
var node_4 = sibling(node_3, 2);
|
||||||
var consequent_3 = ($$anchor) => {
|
var consequent_4 = ($$anchor) => {
|
||||||
var div_9 = root_5$1();
|
var div_11 = root_6$1();
|
||||||
var text_5 = child(div_9);
|
var text_5 = child(div_11);
|
||||||
var node_4 = sibling(text_5);
|
var node_5 = sibling(text_5);
|
||||||
var consequent_2 = ($$anchor) => {
|
var consequent_3 = ($$anchor) => {
|
||||||
var text_6 = text();
|
var text_6 = text();
|
||||||
template_effect(() => set_text(text_6, `(${get(importResult).skipped ?? ""} skipped)`));
|
template_effect(() => set_text(text_6, `(${get(importResult).skipped ?? ""} skipped)`));
|
||||||
append($$anchor, text_6);
|
append($$anchor, text_6);
|
||||||
};
|
};
|
||||||
if_block(node_4, ($$render) => {
|
if_block(node_5, ($$render) => {
|
||||||
if (get(importResult).skipped > 0) $$render(consequent_2);
|
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);
|
reset(div_11);
|
||||||
var div_12 = sibling(div_11, 2);
|
template_effect(() => set_text(text_5, `✓ Imported ${get(importResult).imported.entries ?? ""} entries and ${get(importResult).imported.groups ?? ""} groups `));
|
||||||
var button_4 = child(div_12);
|
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);
|
var text_7 = child(button_4, true);
|
||||||
reset(button_4);
|
reset(button_4);
|
||||||
var button_5 = sibling(button_4, 2);
|
var button_5 = sibling(button_4, 2);
|
||||||
reset(div_12);
|
reset(div_14);
|
||||||
template_effect(() => {
|
template_effect(() => {
|
||||||
button_4.disabled = get(importing);
|
button_4.disabled = get(importing);
|
||||||
set_text(text_7, get(importing) ? "Importing..." : "📥 Import");
|
set_text(text_7, get(importing) ? "Importing..." : "📥 Import");
|
||||||
});
|
});
|
||||||
bind_value(input_2, () => get(sourcePassword), ($$value) => set(sourcePassword, $$value));
|
bind_value(input_5, () => get(sourcePassword), ($$value) => set(sourcePassword, $$value));
|
||||||
bind_group(binding_group, [], input_3, () => get(importMode), ($$value) => set(importMode, $$value));
|
bind_group(binding_group, [], input_6, () => get(importMode), ($$value) => set(importMode, $$value));
|
||||||
bind_group(binding_group, [], input_4, () => 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_4, handleImportSubmit);
|
||||||
delegated("click", button_5, () => {
|
delegated("click", button_5, () => {
|
||||||
set(parsedFileData, null);
|
set(parsedFileData, null);
|
||||||
@ -7238,51 +7328,51 @@ function ImportExport($$anchor, $$props) {
|
|||||||
append($$anchor, fragment_1);
|
append($$anchor, fragment_1);
|
||||||
};
|
};
|
||||||
var alternate = ($$anchor) => {
|
var alternate = ($$anchor) => {
|
||||||
var fragment_2 = root_8$1();
|
var fragment_2 = root_9();
|
||||||
var div_13 = sibling(first_child(fragment_2), 2);
|
var div_15 = sibling(first_child(fragment_2), 2);
|
||||||
var label_4 = child(div_13);
|
var label_6 = child(div_15);
|
||||||
var input_5 = child(label_4);
|
var input_8 = child(label_6);
|
||||||
remove_input_defaults(input_5);
|
remove_input_defaults(input_8);
|
||||||
input_5.value = input_5.__value = "merge";
|
input_8.value = input_8.__value = "merge";
|
||||||
next(2);
|
next(2);
|
||||||
reset(label_4);
|
reset(label_6);
|
||||||
var label_5 = sibling(label_4, 2);
|
var label_7 = sibling(label_6, 2);
|
||||||
var input_6 = child(label_5);
|
var input_9 = child(label_7);
|
||||||
remove_input_defaults(input_6);
|
remove_input_defaults(input_9);
|
||||||
input_6.value = input_6.__value = "replace";
|
input_9.value = input_9.__value = "replace";
|
||||||
next(2);
|
next(2);
|
||||||
reset(label_5);
|
reset(label_7);
|
||||||
reset(div_13);
|
reset(div_15);
|
||||||
var div_14 = sibling(div_13, 2);
|
var div_16 = sibling(div_15, 2);
|
||||||
var input_7 = sibling(child(div_14), 2);
|
var input_10 = sibling(child(div_16), 2);
|
||||||
reset(div_14);
|
reset(div_16);
|
||||||
template_effect(() => input_7.disabled = get(importing));
|
template_effect(() => input_10.disabled = get(importing));
|
||||||
bind_group(binding_group, [], input_5, () => get(importMode), ($$value) => set(importMode, $$value));
|
bind_group(binding_group, [], input_8, () => get(importMode), ($$value) => set(importMode, $$value));
|
||||||
bind_group(binding_group, [], input_6, () => get(importMode), ($$value) => set(importMode, $$value));
|
bind_group(binding_group, [], input_9, () => get(importMode), ($$value) => set(importMode, $$value));
|
||||||
delegated("change", input_7, handleFileSelect);
|
delegated("change", input_10, handleFileSelect);
|
||||||
append($$anchor, fragment_2);
|
append($$anchor, fragment_2);
|
||||||
};
|
};
|
||||||
if_block(node_3, ($$render) => {
|
if_block(node_4, ($$render) => {
|
||||||
if (get(importResult)) $$render(consequent_3);
|
if (get(importResult)) $$render(consequent_4);
|
||||||
else if (get(parsedFileData)) $$render(consequent_4, 1);
|
else if (get(parsedFileData)) $$render(consequent_5, 1);
|
||||||
else $$render(alternate, -1);
|
else $$render(alternate, -1);
|
||||||
});
|
});
|
||||||
var div_15 = sibling(node_3, 2);
|
var div_17 = sibling(node_4, 2);
|
||||||
var button_6 = child(div_15);
|
var button_6 = child(div_17);
|
||||||
reset(div_15);
|
reset(div_17);
|
||||||
reset(div_7);
|
reset(div_9);
|
||||||
reset(div_6);
|
reset(div_8);
|
||||||
delegated("click", div_6, () => set(showImport, false));
|
delegated("click", div_8, () => set(showImport, false));
|
||||||
delegated("click", div_7, (e) => e.stopPropagation());
|
delegated("click", div_9, (e) => e.stopPropagation());
|
||||||
delegated("click", button_6, () => {
|
delegated("click", button_6, () => {
|
||||||
set(showImport, false);
|
set(showImport, false);
|
||||||
set(importResult, null);
|
set(importResult, null);
|
||||||
set(importError, "");
|
set(importError, "");
|
||||||
});
|
});
|
||||||
append($$anchor, div_6);
|
append($$anchor, div_8);
|
||||||
};
|
};
|
||||||
if_block(node_1, ($$render) => {
|
if_block(node_2, ($$render) => {
|
||||||
if (get(showImport)) $$render(consequent_5);
|
if (get(showImport)) $$render(consequent_6);
|
||||||
});
|
});
|
||||||
reset(div);
|
reset(div);
|
||||||
delegated("click", button, openExportModal);
|
delegated("click", button, openExportModal);
|
||||||
@ -8570,6 +8660,21 @@ label {
|
|||||||
margin-bottom: 4px;
|
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 {
|
input[type="file"].svelte-17di1i9 {
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
|
|||||||
@ -23,6 +23,7 @@
|
|||||||
let sourcePassword = $state('')
|
let sourcePassword = $state('')
|
||||||
let parsedFileData = $state(null)
|
let parsedFileData = $state(null)
|
||||||
let exportPassword = $state('')
|
let exportPassword = $state('')
|
||||||
|
let exportUseExistingPassword = $state(false)
|
||||||
|
|
||||||
// Group selection for export
|
// Group selection for export
|
||||||
let allGroups = $state([])
|
let allGroups = $state([])
|
||||||
@ -36,14 +37,22 @@
|
|||||||
)
|
)
|
||||||
|
|
||||||
async function handleExport() {
|
async function handleExport() {
|
||||||
|
if (!exportUseExistingPassword && !exportPassword.trim()) {
|
||||||
|
importError = 'Choose a new password to encrypt the export'
|
||||||
|
return
|
||||||
|
}
|
||||||
exporting = true
|
exporting = true
|
||||||
try {
|
try {
|
||||||
exportData = await exportSelected(
|
exportData = await exportSelected(
|
||||||
selectedGroupIds.length === allGroups.length ? null : selectedGroupIds,
|
selectedGroupIds.length === allGroups.length ? null : selectedGroupIds,
|
||||||
app.encryptionKey,
|
{
|
||||||
exportPassword.trim()
|
vaultKey: app.encryptionKey,
|
||||||
|
password: exportUseExistingPassword ? '' : exportPassword.trim(),
|
||||||
|
useExistingPassword: exportUseExistingPassword,
|
||||||
|
}
|
||||||
)
|
)
|
||||||
exportPassword = ''
|
exportPassword = ''
|
||||||
|
exportUseExistingPassword = false
|
||||||
const json = JSON.stringify(exportData, null, 2)
|
const json = JSON.stringify(exportData, null, 2)
|
||||||
const blob = new Blob([json], { type: 'application/json' })
|
const blob = new Blob([json], { type: 'application/json' })
|
||||||
const url = URL.createObjectURL(blob)
|
const url = URL.createObjectURL(blob)
|
||||||
@ -143,7 +152,7 @@
|
|||||||
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
|
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
|
||||||
<div class="modal" role="dialog" aria-modal="true" aria-label="Export vault" tabindex="-1" onclick={(e) => e.stopPropagation()}>
|
<div class="modal" role="dialog" aria-modal="true" aria-label="Export vault" tabindex="-1" onclick={(e) => e.stopPropagation()}>
|
||||||
<h3>Export Vault</h3>
|
<h3>Export Vault</h3>
|
||||||
<p>Select which groups to export. Optionally set a separate password below to encrypt the file; otherwise it exports as readable JSON (passwords stay encrypted under this vault's key).</p>
|
<p>Select which groups to export. Choose how to protect the file - either with a new password, or reuse the password that unlocks this vault.</p>
|
||||||
|
|
||||||
<div class="group-select-header">
|
<div class="group-select-header">
|
||||||
<label class="checkbox-label">
|
<label class="checkbox-label">
|
||||||
@ -167,15 +176,27 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="protection-choice">
|
||||||
<label for="export-password" class="file-label">Encrypt export with a separate password (optional)</label>
|
<label class="radio-label">
|
||||||
<input
|
<input type="radio" name="exportProtection" checked={!exportUseExistingPassword} onchange={() => exportUseExistingPassword = false} />
|
||||||
id="export-password"
|
<span>Use a new password</span>
|
||||||
type="password"
|
</label>
|
||||||
bind:value={exportPassword}
|
{#if !exportUseExistingPassword}
|
||||||
placeholder="Leave a separate password, or clear for a plain JSON export"
|
<div class="form-group">
|
||||||
autocomplete="new-password"
|
<input
|
||||||
/>
|
id="export-password"
|
||||||
|
type="password"
|
||||||
|
bind:value={exportPassword}
|
||||||
|
placeholder="Enter a new password"
|
||||||
|
autocomplete="new-password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<label class="radio-label">
|
||||||
|
<input type="radio" name="exportProtection" checked={exportUseExistingPassword} onchange={() => exportUseExistingPassword = true} />
|
||||||
|
<span>Reuse my vault password</span>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="modal-actions">
|
<div class="modal-actions">
|
||||||
@ -372,6 +393,21 @@
|
|||||||
margin-bottom: 4px;
|
margin-bottom: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.protection-choice {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.protection-choice .radio-label {
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.protection-choice .form-group {
|
||||||
|
margin: 2px 0 6px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
input[type="file"] {
|
input[type="file"] {
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
|
|||||||
@ -383,26 +383,42 @@ export async function moveEntryToGroup(entryId, groupId) {
|
|||||||
// ========================
|
// ========================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Export data (entries + groups + meta) as a JSON object.
|
* Export data (entries + groups + meta).
|
||||||
*
|
*
|
||||||
* Without an export password the file is plaintext JSON: entries keep
|
* The caller must choose how the file is protected - an explicit choice, never
|
||||||
* their source-vault-encrypted passwords and the source vault's meta (salt,
|
* an ambiguous "optional" field. Wraps the whole payload (entries + groups +
|
||||||
* test payload) is included so that import can decrypt them using the SOURCE
|
* meta, incl. titles/usernames/notes) in an AES-256-GCM envelope so the file
|
||||||
* VAULT's master password (existing behaviour, unchanged).
|
* is unreadable without the unlock key:
|
||||||
*
|
*
|
||||||
* When a password is supplied the WHOLE payload is sealed with AES-256-GCM under a key
|
* - `password`: a NEW, separate password. The envelope is sealed with a key
|
||||||
* derived from that independent password. Each entry's password is first
|
* derived from this password + a fresh random salt (embedded in the
|
||||||
* re-encrypted from the vault key to the export key, and the payload's
|
* envelope), so importing needs ONLY this password - never the vault's.
|
||||||
* meta.salt points at the export salt, so importing the sealed file requires
|
* Each entry's password is re-encrypted from the vault key to the export key.
|
||||||
* ONLY the export password (never the source vault's master password). The
|
|
||||||
* envelope also protects titles, usernames, URLs and notes at rest.
|
|
||||||
*
|
*
|
||||||
* @param {string[]} [groupIds] - Group IDs to export. null/[] = export (ungrouped '').
|
* - `useExistingPassword`: reuse the SAME password that unlocks this vault.
|
||||||
* @param {CryptoKey|null} [vaultKey] - Current vault's in-memory encryption key (required when exportPassword is set).
|
* The envelope is sealed with the vault's own key and the vault's salt
|
||||||
* @param {string} [exportPassword] - Optional separate password used to seal the exported file.
|
* 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<Object>}
|
* @returns {Promise<Object>}
|
||||||
*/
|
*/
|
||||||
export async function exportSelected(groupIds, vaultKey = null, exportPassword = '') {
|
export async function exportSelected(groupIds = null, options = {}) {
|
||||||
|
const {
|
||||||
|
vaultKey = null,
|
||||||
|
password = '',
|
||||||
|
useExistingPassword = false,
|
||||||
|
} = options
|
||||||
|
|
||||||
const db = await getDb()
|
const db = await getDb()
|
||||||
const allEntries = await db.getAll('entries')
|
const allEntries = await db.getAll('entries')
|
||||||
const allGroups = await db.getAll('groups')
|
const allGroups = await db.getAll('groups')
|
||||||
@ -428,38 +444,54 @@ export async function exportSelected(groupIds, vaultKey = null, exportPassword =
|
|||||||
entries: allEntries.filter(pickEntry),
|
entries: allEntries.filter(pickEntry),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Plain export (no export password) - existing, backward-compatible behavior.
|
// Explicit choice required: pick a way to protect the file.
|
||||||
if (!exportPassword) {
|
const wantsProtection = !!(password || useExistingPassword)
|
||||||
|
if (!wantsProtection) {
|
||||||
|
// Plaintext fallback (backward compatibility, never offered by the UI).
|
||||||
return payload
|
return payload
|
||||||
}
|
}
|
||||||
|
|
||||||
// Password-protected export: re-key entries to the export key, then seal.
|
|
||||||
if (!vaultKey) {
|
if (!vaultKey) {
|
||||||
throw new Error('Vault key is required to create a password-protected export')
|
throw new Error('The vault key is required to create a protected export')
|
||||||
}
|
}
|
||||||
|
|
||||||
const exportSalt = generateSalt()
|
let sealKey
|
||||||
const exportKey = await deriveKey(exportPassword, exportSalt)
|
let envelopeSalt
|
||||||
|
if (useExistingPassword) {
|
||||||
|
// Use the vault's own key; its salt stays embedded so import can
|
||||||
|
// reproduce the same key from the master password. Entries are already
|
||||||
|
// encrypted under vaultKey, so no re-keying is needed.
|
||||||
|
sealKey = vaultKey
|
||||||
|
envelopeSalt = payload.meta.salt
|
||||||
|
} else {
|
||||||
|
// A separate password: fresh random salt + a new key. Re-key the exported
|
||||||
|
// entries from the vault key to this new key so that import needs only
|
||||||
|
// this password to unlock them.
|
||||||
|
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)
|
||||||
|
|
||||||
// Re-encrypt each entry password from the vault key to the export key.
|
for (const entry of payload.entries) {
|
||||||
for (const entry of payload.entries) {
|
if (!entry.encryptedPassword) continue
|
||||||
if (!entry.encryptedPassword) continue
|
const plaintext = await decrypt(entry.encryptedPassword, vaultKey)
|
||||||
const plaintext = await decrypt(entry.encryptedPassword, vaultKey)
|
entry.encryptedPassword = await encrypt(plaintext, sealKey)
|
||||||
entry.encryptedPassword = await encrypt(plaintext, exportKey)
|
}
|
||||||
|
// Point meta.salt at the export salt so import reproduces the export key.
|
||||||
|
payload.meta.salt = envelopeSalt
|
||||||
}
|
}
|
||||||
|
|
||||||
// Point meta.salt at the export salt so import derives the correct key.
|
|
||||||
payload.meta.salt = uint8ArrayToBase64(exportSalt)
|
|
||||||
|
|
||||||
// Seal the entire payload (also protects titles/usernames/notes at rest).
|
// Seal the entire payload (also protects titles/usernames/notes at rest).
|
||||||
const sealed = await encrypt(JSON.stringify(payload), exportKey)
|
const sealed = await encrypt(JSON.stringify(payload), sealKey)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
version: 1,
|
version: 1,
|
||||||
exportedAt: new Date().toISOString(),
|
exportedAt: new Date().toISOString(),
|
||||||
format: 'encrypted-export',
|
format: 'encrypted-export',
|
||||||
kdfIterations: 600_000,
|
kdfIterations: 600_000,
|
||||||
salt: uint8ArrayToBase64(exportSalt),
|
salt: envelopeSalt,
|
||||||
data: sealed, // encrypt() output string: { iv, ciphertext }
|
data: sealed, // encrypt() output string: { iv, ciphertext }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -633,7 +633,7 @@ describe('Export / Import', () => {
|
|||||||
|
|
||||||
it('seals the export envelope with the separate password', async () => {
|
it('seals the export envelope with the separate password', async () => {
|
||||||
const sourceKey = await setupSourceVault()
|
const sourceKey = await setupSourceVault()
|
||||||
const sealed = await exportSelected(null, sourceKey, 'exportpw')
|
const sealed = await exportSelected(null, { vaultKey: sourceKey, password: 'exportpw' })
|
||||||
expect(sealed.format).toBe('encrypted-export')
|
expect(sealed.format).toBe('encrypted-export')
|
||||||
expect(sealed.salt).toBeDefined()
|
expect(sealed.salt).toBeDefined()
|
||||||
expect(sealed.data).toBeDefined()
|
expect(sealed.data).toBeDefined()
|
||||||
@ -645,7 +645,7 @@ describe('Export / Import', () => {
|
|||||||
|
|
||||||
it('imports a sealed export into another vault using only the export password', async () => {
|
it('imports a sealed export into another vault using only the export password', async () => {
|
||||||
const sourceKey = await setupSourceVault()
|
const sourceKey = await setupSourceVault()
|
||||||
const sealed = await exportSelected(null, sourceKey, 'exportpw')
|
const sealed = await exportSelected(null, { vaultKey: sourceKey, password: 'exportpw' })
|
||||||
|
|
||||||
const targetKey = await setupTargetVault()
|
const targetKey = await setupTargetVault()
|
||||||
const result = await importAll(sealed, 'merge', 'exportpw', targetKey)
|
const result = await importAll(sealed, 'merge', 'exportpw', targetKey)
|
||||||
@ -661,7 +661,7 @@ describe('Export / Import', () => {
|
|||||||
|
|
||||||
it('rejects a sealed export when the export password is wrong', async () => {
|
it('rejects a sealed export when the export password is wrong', async () => {
|
||||||
const sourceKey = await setupSourceVault()
|
const sourceKey = await setupSourceVault()
|
||||||
const sealed = await exportSelected(null, sourceKey, 'exportpw')
|
const sealed = await exportSelected(null, { vaultKey: sourceKey, password: 'exportpw' })
|
||||||
const targetKey = await setupTargetVault()
|
const targetKey = await setupTargetVault()
|
||||||
await expect(importAll(sealed, 'merge', 'wrong-password', targetKey))
|
await expect(importAll(sealed, 'merge', 'wrong-password', targetKey))
|
||||||
.rejects.toThrow('Incorrect export password')
|
.rejects.toThrow('Incorrect export password')
|
||||||
@ -669,10 +669,35 @@ describe('Export / Import', () => {
|
|||||||
|
|
||||||
it('requires an export password to import a sealed export', async () => {
|
it('requires an export password to import a sealed export', async () => {
|
||||||
const sourceKey = await setupSourceVault()
|
const sourceKey = await setupSourceVault()
|
||||||
const sealed = await exportSelected(null, sourceKey, 'exportpw')
|
const sealed = await exportSelected(null, { vaultKey: sourceKey, password: 'exportpw' })
|
||||||
const targetKey = await setupTargetVault()
|
const targetKey = await setupTargetVault()
|
||||||
await expect(importAll(sealed, 'merge', '', targetKey))
|
await expect(importAll(sealed, 'merge', '', targetKey))
|
||||||
.rejects.toThrow('The export password is required')
|
.rejects.toThrow('The export password is required')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('seals with the vault password when reuseExistingPassword is set', async () => {
|
||||||
|
const sourceKey = await setupSourceVault()
|
||||||
|
const sealed = await exportSelected(null, { vaultKey: sourceKey, useExistingPassword: true })
|
||||||
|
expect(sealed.format).toBe('encrypted-export')
|
||||||
|
expect(JSON.stringify(sealed)).not.toContain('GitHub')
|
||||||
|
|
||||||
|
const targetKey = await setupTargetVault()
|
||||||
|
// Import with the SOURCE vault's master password.
|
||||||
|
const result = await importAll(sealed, 'merge', 'source-key', targetKey)
|
||||||
|
expect(result.skipped).toBe(0)
|
||||||
|
const entries = await getEntries()
|
||||||
|
expect(entries).toHaveLength(1)
|
||||||
|
expect(entries[0].title).toBe('GitHub')
|
||||||
|
const plain = await decrypt(entries[0].encryptedPassword, targetKey)
|
||||||
|
expect(plain).toBe('topsecret')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects a reuse-existing export with the wrong vault password', async () => {
|
||||||
|
const sourceKey = await setupSourceVault()
|
||||||
|
const sealed = await exportSelected(null, { vaultKey: sourceKey, useExistingPassword: true })
|
||||||
|
const targetKey = await setupTargetVault()
|
||||||
|
await expect(importAll(sealed, 'merge', 'not-the-password', targetKey))
|
||||||
|
.rejects.toThrow('Incorrect export password')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user