Compare commits

..

3 Commits

8 changed files with 159 additions and 386 deletions

72
AGENTS.md Normal file
View File

@ -0,0 +1,72 @@
# Password Vault — Agent Context
## Project Overview
An offline-first, single-file password manager built with **Svelte 5** (runes-based reactivity). All data is encrypted in-browser with AES-256-GCM and stored in IndexedDB. The production build is a single `dist/index.html` with zero network dependencies — it works from `file://`, USB, or any static server.
## Architecture
```
src/
├── App.svelte # Root component — routes between LockScreen, MainLayout
├── main.js # Entry point
├── components/
│ ├── LockScreen.svelte # Master password setup + unlock UI
│ ├── MainLayout.svelte # Shell: sidebar + content area
│ ├── Sidebar.svelte # Group list + search bar
│ ├── EntryList.svelte # Credential entries grid
│ ├── EntryForm.svelte # Create/edit credential form
│ ├── EntryDetail.svelte # View single entry (copy password/username)
│ ├── PasswordGenerator.svelte# Configurable password generator
│ └── ImportExport.svelte # JSON import/export with merge/replace
├── lib/
│ ├── crypto/crypto.js # Web Crypto API: PBKDF2 key derivation, AES-GCM encrypt/decrypt, password generator
│ ├── storage/db.js # IndexedDB layer (idb wrapper): entries, groups, meta stores
│ ├── models/schema.js # Data models: CredentialEntry, Group; validation; ID generation
│ └── stores/
│ ├── app.svelte.js # AppStore: $state(isUnlocked, encryptionKey, salt)
│ ├── security.svelte.js # Auto-lock timer, visibility change, beforeunload cleanup
│ └── search.svelte.js # Reactive search state
```
## Key Design Decisions
- **Svelte 5 runes** — Use `$state`, `$derived`, `$effect`. Props-based event passing (no Svelte events).
- **No external crypto libraries** — Uses the browser's native Web Crypto API exclusively.
- **Key never persisted** — Encryption key lives only in `$state` memory; cleared on lock, tab switch, or page close.
- **Single-file build**`vite-plugin-singlefile` inlines all JS/CSS; post-build script inlines favicon.
- **IndexedDB via `idb`** — Three stores: `entries`, `groups`, `meta`. Only `encryptedPassword` is encrypted at rest; titles, usernames, URLs, and notes are plaintext for searchability.
- **PBKDF2 key derivation** — 600,000 iterations, SHA-256, 256-bit AES-GCM key.
## Encryption Flow
```
Master Password → PBKDF2 (600k iters, 16-byte salt) → AES-256-GCM Key → encrypt/decrypt credentials
```
Password verification uses a test payload (random string encrypted at vault creation). On unlock, the entered password derives a key that must successfully decrypt the test payload.
## Scripts
| Command | Description |
|---|---|
| `npm run dev` | Vite dev server with HMR on `:5173` |
| `npm run build` | Production build → `dist/index.html` (single self-contained file) |
| `npm run preview` | Preview production build |
| `npm run test` | Vitest (watch mode) |
| `npm run test:run` | Vitest (single run) |
## Testing
- Framework: **Vitest** with **jsdom** environment
- Test setup: `tests/setup.js` (fake-indexeddb polyfill)
- Test files: `tests/lib/stores/*.test.js`
- Run with `npm run test` or `npm run test:run`
## Security Notes
- Only `encryptedPassword` is encrypted at rest; other fields (title, username, URL, notes) are plaintext in IndexedDB.
- `testPlaintext` for password verification is stored unencrypted in the `meta` store.
- Auto-lock triggers on tab visibility change and 5-minute inactivity.
- Clipboard auto-clears after 15 seconds.
- No browser fingerprinting or anti-keylogger protections.

247
dist/index.html vendored
View File

@ -5756,13 +5756,12 @@ var SearchStore = class {
var search = new SearchStore(); var search = new SearchStore();
//#endregion //#endregion
//#region src/components/Sidebar.svelte //#region src/components/Sidebar.svelte
var root_1$7 = /* @__PURE__ */ from_html(`<div role="button" tabindex="0"><button><span class="group-color svelte-181dlmc"></span> <span class="group-name svelte-181dlmc"> </span></button> <div class="group-actions svelte-181dlmc"><button class="group-action-btn svelte-181dlmc" title="Edit group">✏️</button> <button class="group-action-btn svelte-181dlmc" title="Delete group">🗑</button></div></div>`); var root_1$7 = /* @__PURE__ */ from_html(`<div class="group-row svelte-181dlmc"><button><span class="group-color svelte-181dlmc"></span> <span class="group-name svelte-181dlmc"> </span></button> <div class="group-actions svelte-181dlmc"><button class="group-action-btn svelte-181dlmc" title="Edit group">✏️</button> <button class="group-action-btn svelte-181dlmc" title="Delete group">🗑</button></div></div>`);
var root_3$5 = /* @__PURE__ */ from_html(`<div class="error-banner svelte-181dlmc"> </div>`); var root_3$5 = /* @__PURE__ */ from_html(`<div class="error-banner svelte-181dlmc"> </div>`);
var root_4$5 = /* @__PURE__ */ from_html(`<button></button>`); var root_4$5 = /* @__PURE__ */ from_html(`<button></button>`);
var root_2$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"><label for="group-name">Group Name</label> <input id="group-name" type="text" placeholder="e.g. Work, Personal"/></div> <div class="form-group"><span class="field-label">Color</span> <div class="color-picker svelte-181dlmc"></div></div> <div class="modal-actions svelte-181dlmc"><button class="btn btn-primary"> </button> <button class="btn btn-ghost">Cancel</button></div></div></div>`); var root_2$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"><label for="group-name">Group Name</label> <input id="group-name" type="text" placeholder="e.g. Work, Personal"/></div> <div class="form-group"><span class="field-label">Color</span> <div class="color-picker svelte-181dlmc"></div></div> <div class="modal-actions svelte-181dlmc"><button class="btn btn-primary"> </button> <button class="btn btn-ghost">Cancel</button></div></div></div>`);
var root_5$4 = /* @__PURE__ */ from_html(`<div class="drop-indicator svelte-181dlmc">Drop here to move</div>`); var root_5$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> </strong>"? Entries in this group will become ungrouped.</p> <div class="modal-actions svelte-181dlmc"><button class="btn btn-danger">Yes, delete</button> <button class="btn btn-ghost">Cancel</button></div></div></div>`);
var root_6$5 = /* @__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> </strong>"? Entries in this group will become ungrouped.</p> <div class="modal-actions svelte-181dlmc"><button class="btn btn-danger">Yes, delete</button> <button class="btn btn-ghost">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="sidebar-footer svelte-181dlmc"><button class="btn btn-ghost btn-sm w-full">+ 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"><div role="button" tabindex="0" aria-label="Drop here to remove from group"><button><span class="group-icon svelte-181dlmc">📋</span> <span class="group-name svelte-181dlmc">All Entries</span></button></div> <!></nav> <div class="sidebar-footer svelte-181dlmc"><button class="btn btn-ghost btn-sm w-full">+ New Group</button></div> <!> <!> <!></div>`);
function Sidebar($$anchor, $$props) { function Sidebar($$anchor, $$props) {
push($$props, true); push($$props, true);
let groups = /* @__PURE__ */ state(proxy([])); let groups = /* @__PURE__ */ state(proxy([]));
@ -5774,7 +5773,6 @@ function Sidebar($$anchor, $$props) {
let groupError = /* @__PURE__ */ state(""); let groupError = /* @__PURE__ */ state("");
let showDeleteGroupConfirm = /* @__PURE__ */ state(null); let showDeleteGroupConfirm = /* @__PURE__ */ state(null);
let deletingGroup = /* @__PURE__ */ user_derived(() => get(groups).find((g) => g.id === get(showDeleteGroupConfirm))); let deletingGroup = /* @__PURE__ */ user_derived(() => get(groups).find((g) => g.id === get(showDeleteGroupConfirm)));
let dropTargetGroupId = /* @__PURE__ */ state(null);
const GROUP_COLORS = [ const GROUP_COLORS = [
"#6c63ff", "#6c63ff",
"#e5484d", "#e5484d",
@ -5844,111 +5842,65 @@ function Sidebar($$anchor, $$props) {
set(groupError, "Failed to delete group: " + e.message); set(groupError, "Failed to delete group: " + e.message);
} }
} }
function onDragOverGroup(e, groupId) {
e.preventDefault();
e.dataTransfer.dropEffect = "move";
set(dropTargetGroupId, groupId, true);
}
function onDragLeaveGroup() {
set(dropTargetGroupId, null);
}
function resetDropTarget() {
set(dropTargetGroupId, null);
}
async function onDropGroup(groupId, e) {
e.preventDefault();
set(dropTargetGroupId, groupId, true);
const entryId = e.dataTransfer.getData("text/plain");
if (!entryId) return;
try {
const entry = await getEntryById(entryId);
if (!entry) return;
const newGroupId = groupId === "all" ? "" : groupId;
await updateEntry({
...entry,
groupId: newGroupId,
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
});
await loadData();
resetDropTarget();
search.refresh();
} catch (err) {
console.error("Failed to move entry:", err);
resetDropTarget();
}
}
var div = root$6(); var div = root$6();
var div_1 = sibling(child(div), 2); var div_1 = sibling(child(div), 2);
var input = child(div_1); var input = child(div_1);
remove_input_defaults(input); remove_input_defaults(input);
reset(div_1); reset(div_1);
var nav = sibling(div_1, 2); var nav = sibling(div_1, 2);
var div_2 = child(nav); var button = child(nav);
var button = child(div_2); each(sibling(button, 2), 17, () => get(groups), index, ($$anchor, group) => {
reset(div_2); var div_2 = root_1$7();
each(sibling(div_2, 2), 17, () => get(groups), index, ($$anchor, group) => { var button_1 = child(div_2);
var div_3 = root_1$7();
var button_1 = child(div_3);
var span = child(button_1); var span = child(button_1);
var span_1 = sibling(span, 2); var span_1 = sibling(span, 2);
var text = child(span_1, true); var text = child(span_1, true);
reset(span_1); reset(span_1);
reset(button_1); reset(button_1);
var div_4 = sibling(button_1, 2); var div_3 = sibling(button_1, 2);
var button_2 = child(div_4); var button_2 = child(div_3);
var button_3 = sibling(button_2, 2); var button_3 = sibling(button_2, 2);
reset(div_4);
reset(div_3); reset(div_3);
reset(div_2);
template_effect(() => { template_effect(() => {
set_class(div_3, 1, `group-row ${get(dropTargetGroupId) === get(group).id ? "drop-target" : ""}`, "svelte-181dlmc");
set_attribute(div_3, "aria-label", `Drop here to move to ${get(group).name}`);
set_class(button_1, 1, `group-item ${search.activeGroupId === get(group).id ? "active" : ""}`, "svelte-181dlmc"); set_class(button_1, 1, `group-item ${search.activeGroupId === get(group).id ? "active" : ""}`, "svelte-181dlmc");
set_style(span, `background-color: ${(get(group).color || "#6c63ff") ?? ""}`); set_style(span, `background-color: ${(get(group).color || "#6c63ff") ?? ""}`);
set_text(text, get(group).name); set_text(text, get(group).name);
}); });
event("dragover", div_3, (e) => onDragOverGroup(e, get(group).id)); delegated("click", button_1, () => search.activeGroupId = get(group).id);
event("dragleave", div_3, onDragLeaveGroup);
event("drop", div_3, (e) => onDropGroup(get(group).id, e));
delegated("pointerup", button_1, () => {
if (get(dropTargetGroupId) === get(group).id) {
resetDropTarget();
return;
}
search.activeGroupId = get(group).id;
});
delegated("click", button_2, () => openGroupForm(get(group))); delegated("click", button_2, () => openGroupForm(get(group)));
delegated("click", button_3, () => set(showDeleteGroupConfirm, get(group).id, true)); delegated("click", button_3, () => set(showDeleteGroupConfirm, get(group).id, true));
append($$anchor, div_3); append($$anchor, div_2);
}); });
reset(nav); reset(nav);
var div_5 = sibling(nav, 2); var div_4 = sibling(nav, 2);
var button_4 = child(div_5); var button_4 = child(div_4);
reset(div_5); reset(div_4);
var node_1 = sibling(div_5, 2); var node_1 = sibling(div_4, 2);
var consequent_1 = ($$anchor) => { var consequent_1 = ($$anchor) => {
var div_6 = root_2$5(); var div_5 = root_2$5();
var div_7 = child(div_6); var div_6 = child(div_5);
var h3 = child(div_7); var h3 = child(div_6);
var text_1 = child(h3, true); var text_1 = child(h3, true);
reset(h3); reset(h3);
var node_2 = sibling(h3, 2); var node_2 = sibling(h3, 2);
var consequent = ($$anchor) => { var consequent = ($$anchor) => {
var div_8 = root_3$5(); var div_7 = root_3$5();
var text_2 = child(div_8, true); var text_2 = child(div_7, true);
reset(div_8); reset(div_7);
template_effect(() => set_text(text_2, get(groupError))); template_effect(() => set_text(text_2, get(groupError)));
append($$anchor, div_8); append($$anchor, div_7);
}; };
if_block(node_2, ($$render) => { if_block(node_2, ($$render) => {
if (get(groupError)) $$render(consequent); if (get(groupError)) $$render(consequent);
}); });
var div_9 = sibling(node_2, 2); var div_8 = sibling(node_2, 2);
var input_1 = sibling(child(div_9), 2); var input_1 = sibling(child(div_8), 2);
remove_input_defaults(input_1); remove_input_defaults(input_1);
reset(div_9); reset(div_8);
var div_10 = sibling(div_9, 2); var div_9 = sibling(div_8, 2);
var div_11 = sibling(child(div_10), 2); var div_10 = sibling(child(div_9), 2);
each(div_11, 21, () => GROUP_COLORS, index, ($$anchor, color) => { each(div_10, 21, () => GROUP_COLORS, index, ($$anchor, color) => {
var button_5 = root_4$5(); var button_5 = root_4$5();
template_effect(() => { template_effect(() => {
set_class(button_5, 1, `color-swatch ${get(groupColor) === get(color) ? "selected" : ""}`, "svelte-181dlmc"); set_class(button_5, 1, `color-swatch ${get(groupColor) === get(color) ? "selected" : ""}`, "svelte-181dlmc");
@ -5958,85 +5910,68 @@ function Sidebar($$anchor, $$props) {
delegated("click", button_5, () => set(groupColor, get(color), true)); delegated("click", button_5, () => set(groupColor, get(color), true));
append($$anchor, button_5); append($$anchor, button_5);
}); });
reset(div_11);
reset(div_10); reset(div_10);
var div_12 = sibling(div_10, 2); reset(div_9);
var button_6 = child(div_12); var div_11 = sibling(div_9, 2);
var button_6 = child(div_11);
var text_3 = child(button_6, true); var text_3 = child(button_6, true);
reset(button_6); reset(button_6);
var button_7 = sibling(button_6, 2); var button_7 = sibling(button_6, 2);
reset(div_12); reset(div_11);
reset(div_7);
reset(div_6); reset(div_6);
reset(div_5);
template_effect(() => { template_effect(() => {
set_text(text_1, get(editingGroupId) ? "Edit Group" : "New Group"); set_text(text_1, get(editingGroupId) ? "Edit Group" : "New Group");
set_text(text_3, get(editingGroupId) ? "Update" : "Create"); set_text(text_3, get(editingGroupId) ? "Update" : "Create");
}); });
delegated("click", div_6, () => set(showGroupForm, false)); delegated("click", div_5, () => set(showGroupForm, false));
delegated("click", div_7, (e) => e.stopPropagation()); delegated("click", div_6, (e) => e.stopPropagation());
bind_value(input_1, () => get(groupName), ($$value) => set(groupName, $$value)); bind_value(input_1, () => get(groupName), ($$value) => set(groupName, $$value));
delegated("click", button_6, saveGroup); delegated("click", button_6, saveGroup);
delegated("click", button_7, () => set(showGroupForm, false)); delegated("click", button_7, () => set(showGroupForm, false));
append($$anchor, div_6); append($$anchor, div_5);
}; };
if_block(node_1, ($$render) => { if_block(node_1, ($$render) => {
if (get(showGroupForm)) $$render(consequent_1); if (get(showGroupForm)) $$render(consequent_1);
}); });
var node_3 = sibling(node_1, 2); var node_3 = sibling(node_1, 2);
var consequent_2 = ($$anchor) => { var consequent_2 = ($$anchor) => {
append($$anchor, root_5$4()); var div_12 = root_5$4();
}; var div_13 = child(div_12);
if_block(node_3, ($$render) => { var p = sibling(child(div_13), 2);
if (get(dropTargetGroupId)) $$render(consequent_2);
});
var node_4 = sibling(node_3, 2);
var consequent_3 = ($$anchor) => {
var div_14 = root_6$5();
var div_15 = child(div_14);
var p = sibling(child(div_15), 2);
var strong = sibling(child(p)); var strong = sibling(child(p));
var text_4 = child(strong, true); var text_4 = child(strong, true);
reset(strong); reset(strong);
next(); next();
reset(p); reset(p);
var div_16 = sibling(p, 2); var div_14 = sibling(p, 2);
var button_8 = child(div_16); var button_8 = child(div_14);
var button_9 = sibling(button_8, 2); var button_9 = sibling(button_8, 2);
reset(div_16);
reset(div_15);
reset(div_14); reset(div_14);
reset(div_13);
reset(div_12);
template_effect(() => set_text(text_4, get(deletingGroup).name)); template_effect(() => set_text(text_4, get(deletingGroup).name));
delegated("click", div_14, () => set(showDeleteGroupConfirm, null)); delegated("click", div_12, () => set(showDeleteGroupConfirm, null));
delegated("click", div_15, (e) => e.stopPropagation()); delegated("click", div_13, (e) => e.stopPropagation());
delegated("click", button_8, () => confirmDeleteGroup(get(deletingGroup).id)); delegated("click", button_8, () => confirmDeleteGroup(get(deletingGroup).id));
delegated("click", button_9, () => set(showDeleteGroupConfirm, null)); delegated("click", button_9, () => set(showDeleteGroupConfirm, null));
append($$anchor, div_14); append($$anchor, div_12);
}; };
if_block(node_4, ($$render) => { if_block(node_3, ($$render) => {
if (get(deletingGroup)) $$render(consequent_3); if (get(deletingGroup)) $$render(consequent_2);
}); });
reset(div); reset(div);
template_effect(() => { template_effect(() => {
set_value(input, search.query); set_value(input, search.query);
set_class(div_2, 1, `group-wrapper ${get(dropTargetGroupId) === "all" ? "drop-target" : ""}`, "svelte-181dlmc");
set_class(button, 1, `group-item ${search.activeGroupId === "all" ? "active" : ""}`, "svelte-181dlmc"); set_class(button, 1, `group-item ${search.activeGroupId === "all" ? "active" : ""}`, "svelte-181dlmc");
}); });
event("Input", input, (e) => search.query = e.target.value); event("Input", input, (e) => search.query = e.target.value);
event("dragover", div_2, (e) => onDragOverGroup(e, "all")); delegated("click", button, () => search.activeGroupId = "all");
event("dragleave", div_2, onDragLeaveGroup);
event("drop", div_2, (e) => onDropGroup("all", e));
delegated("pointerup", button, () => {
if (get(dropTargetGroupId) === "all") {
resetDropTarget();
return;
}
search.activeGroupId = "all";
});
delegated("click", button_4, () => openGroupForm(null)); delegated("click", button_4, () => openGroupForm(null));
append($$anchor, div); append($$anchor, div);
pop(); pop();
} }
delegate(["pointerup", "click"]); delegate(["click"]);
//#endregion //#endregion
//#region src/components/EntryList.svelte //#region src/components/EntryList.svelte
var root_1$6 = /* @__PURE__ */ from_html(`<div class="loading svelte-13s7gu4">Loading entries...</div>`); var root_1$6 = /* @__PURE__ */ from_html(`<div class="loading svelte-13s7gu4">Loading entries...</div>`);
@ -6044,7 +5979,7 @@ var root_2$4 = /* @__PURE__ */ from_html(`<div class="error-banner svelte-13s7gu
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$4 = /* @__PURE__ */ from_html(`matching "<strong> </strong>"`, 1); var root_6$4 = /* @__PURE__ */ from_html(`matching "<strong> </strong>"`, 1);
var root_7$2 = /* @__PURE__ */ from_html(`<tr draggable="true"><td class="svelte-13s7gu4"><span class="drag-handle svelte-13s7gu4" title="Drag to move to another group"></span> <span class="entry-title svelte-13s7gu4"> </span></td><td class="svelte-13s7gu4"><span class="entry-username svelte-13s7gu4"> </span></td><td class="svelte-13s7gu4"><span class="entry-url truncate svelte-13s7gu4"> </span></td></tr>`); var root_7$2 = /* @__PURE__ */ from_html(`<tr class="entry-row svelte-13s7gu4"><td class="svelte-13s7gu4"><span class="entry-title svelte-13s7gu4"> </span></td><td class="svelte-13s7gu4"><span class="entry-username svelte-13s7gu4"> </span></td><td class="svelte-13s7gu4"><span class="entry-url truncate svelte-13s7gu4"> </span></td></tr>`);
var root_5$3 = /* @__PURE__ */ from_html(`<div class="results-info svelte-13s7gu4"><span class="text-sm text-muted"> <!></span></div> <table class="entries-table svelte-13s7gu4"><thead><tr><th class="svelte-13s7gu4">Title</th><th class="svelte-13s7gu4">Username</th><th class="svelte-13s7gu4">URL</th></tr></thead><tbody></tbody></table>`, 1); var root_5$3 = /* @__PURE__ */ from_html(`<div class="results-info svelte-13s7gu4"><span class="text-sm text-muted"> <!></span></div> <table class="entries-table svelte-13s7gu4"><thead><tr><th class="svelte-13s7gu4">Title</th><th class="svelte-13s7gu4">Username</th><th class="svelte-13s7gu4">URL</th></tr></thead><tbody></tbody></table>`, 1);
var root$5 = /* @__PURE__ */ from_html(`<div class="entry-list"><!></div>`); var root$5 = /* @__PURE__ */ from_html(`<div class="entry-list"><!></div>`);
function EntryList($$anchor, $$props) { function EntryList($$anchor, $$props) {
@ -6053,13 +5988,6 @@ function EntryList($$anchor, $$props) {
let loading = /* @__PURE__ */ state(true); let loading = /* @__PURE__ */ state(true);
let error = /* @__PURE__ */ state(""); let error = /* @__PURE__ */ state("");
let resultCount = /* @__PURE__ */ state(0); let resultCount = /* @__PURE__ */ state(0);
let draggedEntryId = /* @__PURE__ */ state(null);
function handleDragStart(entryId) {
set(draggedEntryId, entryId, true);
}
function handleDragEnd() {
set(draggedEntryId, null);
}
async function loadEntries() { async function loadEntries() {
set(loading, true); set(loading, true);
set(error, ""); set(error, "");
@ -6148,7 +6076,7 @@ function EntryList($$anchor, $$props) {
each(tbody, 21, () => get(entries), (entry) => entry.id, ($$anchor, entry) => { each(tbody, 21, () => get(entries), (entry) => entry.id, ($$anchor, entry) => {
var tr = root_7$2(); var tr = root_7$2();
var td = child(tr); var td = child(tr);
var span_1 = sibling(child(td), 2); var span_1 = child(td);
var text_6 = child(span_1, true); var text_6 = child(span_1, true);
reset(span_1); reset(span_1);
reset(td); reset(td);
@ -6164,18 +6092,11 @@ function EntryList($$anchor, $$props) {
reset(td_2); reset(td_2);
reset(tr); reset(tr);
template_effect(() => { template_effect(() => {
set_class(tr, 1, `entry-row ${get(draggedEntryId) === get(entry).id ? "dragging" : ""}`, "svelte-13s7gu4");
set_text(text_6, get(entry).title); set_text(text_6, get(entry).title);
set_text(text_7, get(entry).username); set_text(text_7, get(entry).username);
set_text(text_8, get(entry).url || "—"); set_text(text_8, get(entry).url || "—");
}); });
delegated("click", tr, () => $$props.onSelect(get(entry).id)); delegated("click", tr, () => $$props.onSelect(get(entry).id));
event("DragStart", tr, (e) => {
e.dataTransfer.setData("text/plain", get(entry).id);
e.dataTransfer.effectAllowed = "move";
handleDragStart(get(entry).id);
});
event("DragEnd", tr, handleDragEnd);
append($$anchor, tr); append($$anchor, tr);
}); });
reset(tbody); reset(tbody);
@ -7340,25 +7261,6 @@ label {
color: var(--color-primary); color: var(--color-primary);
} }
.group-wrapper.drop-target.svelte-181dlmc .group-item:where(.svelte-181dlmc),
.group-row.drop-target.svelte-181dlmc .group-item:where(.svelte-181dlmc) {
background: rgba(108, 99, 255, 0.3);
color: var(--color-primary);
}
.group-wrapper.drop-target.svelte-181dlmc,
.group-row.drop-target.svelte-181dlmc {
background: rgba(108, 99, 255, 0.12);
border: 2px dashed var(--color-primary);
border-radius: var(--radius-md);
box-shadow: 0 0 12px rgba(108, 99, 255, 0.3);
}
.group-wrapper.svelte-181dlmc {
border-radius: var(--radius-md);
transition: background-color 150ms;
}
.group-icon.svelte-181dlmc { .group-icon.svelte-181dlmc {
font-size: 1rem; font-size: 1rem;
} }
@ -7408,27 +7310,6 @@ label {
border-top: 1px solid var(--color-border); border-top: 1px solid var(--color-border);
} }
.drop-indicator.svelte-181dlmc {
position: fixed;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
padding: 8px 16px;
background: var(--color-primary);
color: #fff;
border-radius: var(--radius-md);
font-size: 0.8rem;
font-weight: 500;
z-index: 100;
pointer-events: none;
animation: svelte-181dlmc-fadeIn 150ms ease;
}
@keyframes svelte-181dlmc-fadeIn {
from { opacity: 0; transform: translateX(-50%) translateY(10px); }
to { opacity: 1; transform: translateX(-50%) translateY(0); }
}
/* Modal */ /* Modal */
.modal-overlay.svelte-181dlmc { .modal-overlay.svelte-181dlmc {
position: fixed; position: fixed;
@ -7554,7 +7435,7 @@ label {
} }
.entry-row.svelte-13s7gu4 { .entry-row.svelte-13s7gu4 {
cursor: grab; cursor: pointer;
transition: background-color 150ms; transition: background-color 150ms;
} }
@ -7562,32 +7443,12 @@ label {
background: var(--color-surface-hover); background: var(--color-surface-hover);
} }
.entry-row.dragging.svelte-13s7gu4 {
opacity: 0.4;
}
.entry-row.svelte-13s7gu4:active {
cursor: grabbing;
}
.entry-row.svelte-13s7gu4 td:where(.svelte-13s7gu4) { .entry-row.svelte-13s7gu4 td:where(.svelte-13s7gu4) {
padding: 10px 12px; padding: 10px 12px;
font-size: 0.875rem; font-size: 0.875rem;
border-bottom: 1px solid var(--color-border); border-bottom: 1px solid var(--color-border);
} }
.drag-handle.svelte-13s7gu4 {
color: var(--color-text-muted);
margin-right: 6px;
user-select: none;
opacity: 0.5;
transition: opacity 150ms;
}
.entry-row.svelte-13s7gu4:hover .drag-handle:where(.svelte-13s7gu4) {
opacity: 1;
}
.entry-title.svelte-13s7gu4 { .entry-title.svelte-13s7gu4 {
font-weight: 500; font-weight: 500;
} }

View File

@ -9,16 +9,6 @@
let { onSelect, onAdd } = $props() let { onSelect, onAdd } = $props()
let draggedEntryId = $state(null)
function handleDragStart(entryId) {
draggedEntryId = entryId
}
function handleDragEnd() {
draggedEntryId = null
}
async function loadEntries() { async function loadEntries() {
loading = true loading = true
error = '' error = ''
@ -92,19 +82,8 @@
</thead> </thead>
<tbody> <tbody>
{#each entries as entry (entry.id)} {#each entries as entry (entry.id)}
<tr <tr onclick={() => onSelect(entry.id)} class="entry-row">
onclick={() => onSelect(entry.id)}
class="entry-row {draggedEntryId === entry.id ? 'dragging' : ''}"
draggable="true"
onDragStart={(e) => {
e.dataTransfer.setData('text/plain', entry.id)
e.dataTransfer.effectAllowed = 'move'
handleDragStart(entry.id)
}}
onDragEnd={handleDragEnd}
>
<td> <td>
<span class="drag-handle" title="Drag to move to another group"></span>
<span class="entry-title">{entry.title}</span> <span class="entry-title">{entry.title}</span>
</td> </td>
<td> <td>
@ -174,7 +153,7 @@
} }
.entry-row { .entry-row {
cursor: grab; cursor: pointer;
transition: background-color 150ms; transition: background-color 150ms;
} }
@ -182,32 +161,12 @@
background: var(--color-surface-hover); background: var(--color-surface-hover);
} }
.entry-row.dragging {
opacity: 0.4;
}
.entry-row:active {
cursor: grabbing;
}
.entry-row td { .entry-row td {
padding: 10px 12px; padding: 10px 12px;
font-size: 0.875rem; font-size: 0.875rem;
border-bottom: 1px solid var(--color-border); border-bottom: 1px solid var(--color-border);
} }
.drag-handle {
color: var(--color-text-muted);
margin-right: 6px;
user-select: none;
opacity: 0.5;
transition: opacity 150ms;
}
.entry-row:hover .drag-handle {
opacity: 1;
}
.entry-title { .entry-title {
font-weight: 500; font-weight: 500;
} }

View File

@ -95,6 +95,7 @@
bind:value={masterPassword} bind:value={masterPassword}
placeholder="Enter master password" placeholder="Enter master password"
autocomplete="current-password" autocomplete="current-password"
autofocus
disabled={loading} disabled={loading}
/> />
</div> </div>

View File

@ -1,5 +1,5 @@
<script> <script>
import { getGroups, addGroup, updateGroup, deleteGroup, getEntryCountsByGroup, getEntryById, updateEntry as updateEntryDb } from '../lib/storage/db.js' import { getGroups, addGroup, updateGroup, deleteGroup, getEntryCountsByGroup } from '../lib/storage/db.js'
import { createGroup, validateGroup } from '../lib/models/schema.js' import { createGroup, validateGroup } from '../lib/models/schema.js'
import { search as searchStore } from '../lib/stores/search.svelte.js' import { search as searchStore } from '../lib/stores/search.svelte.js'
@ -14,7 +14,6 @@
let groupError = $state('') let groupError = $state('')
let showDeleteGroupConfirm = $state(null) // groupId being confirmed for deletion let showDeleteGroupConfirm = $state(null) // groupId being confirmed for deletion
let deletingGroup = $derived(groups.find(g => g.id === showDeleteGroupConfirm)) let deletingGroup = $derived(groups.find(g => g.id === showDeleteGroupConfirm))
let dropTargetGroupId = $state(null) // groupId currently being hovered during drag
const GROUP_COLORS = [ const GROUP_COLORS = [
'#6c63ff', '#e5484d', '#34d399', '#fbbf24', '#3b82f6', '#6c63ff', '#e5484d', '#34d399', '#fbbf24', '#3b82f6',
@ -84,50 +83,6 @@
groupError = 'Failed to delete group: ' + e.message groupError = 'Failed to delete group: ' + e.message
} }
} }
// Drag-and-drop: move entry to a group
function onDragOverGroup(e, groupId) {
e.preventDefault()
e.dataTransfer.dropEffect = 'move'
dropTargetGroupId = groupId
}
function onDragLeaveGroup() {
dropTargetGroupId = null
}
function resetDropTarget() {
dropTargetGroupId = null
}
async function onDropGroup(groupId, e) {
e.preventDefault()
dropTargetGroupId = groupId // signal to onpointerup that a drop occurred here
const entryId = e.dataTransfer.getData('text/plain')
if (!entryId) return
try {
const entry = await getEntryById(entryId)
if (!entry) return
// If dropping on "All Entries" (no group), clear the groupId
const newGroupId = groupId === 'all' ? '' : groupId
const updated = {
...entry,
groupId: newGroupId,
updatedAt: new Date().toISOString(),
}
await updateEntryDb(updated)
await loadData()
resetDropTarget()
// Force the entry list to re-fetch so the moved entry appears immediately.
searchStore.refresh()
} catch (err) {
console.error('Failed to move entry:', err)
resetDropTarget()
}
}
</script> </script>
<div class="sidebar-content"> <div class="sidebar-content">
@ -140,54 +95,24 @@
type="text" type="text"
placeholder="Search entries..." placeholder="Search entries..."
value={searchStore.query} value={searchStore.query}
oninput={(e) => searchStore.query = e.target.value} onInput={(e) => searchStore.query = e.target.value}
/> />
</div> </div>
<nav class="groups-nav"> <nav class="groups-nav">
<div <button
class="group-wrapper {dropTargetGroupId === 'all' ? 'drop-target' : ''}" class="group-item {searchStore.activeGroupId === 'all' ? 'active' : ''}"
role="button" onclick={() => searchStore.activeGroupId = 'all'}
tabindex="0"
aria-label="Drop here to remove from group"
ondragover={(e) => onDragOverGroup(e, 'all')}
ondragleave={onDragLeaveGroup}
ondrop={(e) => onDropGroup('all', e)}
> >
<button <span class="group-icon">📋</span>
class="group-item {searchStore.activeGroupId === 'all' ? 'active' : ''}" <span class="group-name">All Entries</span>
onclick={() => { </button>
if (dropTargetGroupId === 'all') {
resetDropTarget()
return
}
searchStore.activeGroupId = 'all'
}}
>
<span class="group-icon">📋</span>
<span class="group-name">All Entries</span>
</button>
</div>
{#each groups as group} {#each groups as group}
<div <div class="group-row">
class="group-row {dropTargetGroupId === group.id ? 'drop-target' : ''}"
role="button"
tabindex="0"
aria-label={`Drop here to move to ${group.name}`}
ondragover={(e) => onDragOverGroup(e, group.id)}
ondragleave={onDragLeaveGroup}
ondrop={(e) => onDropGroup(group.id, e)}
>
<button <button
class="group-item {searchStore.activeGroupId === group.id ? 'active' : ''}" class="group-item {searchStore.activeGroupId === group.id ? 'active' : ''}"
onclick={() => { onclick={() => searchStore.activeGroupId = group.id}
if (dropTargetGroupId === group.id) {
resetDropTarget()
return
}
searchStore.activeGroupId = group.id
}}
> >
<span class="group-color" style="background-color: {group.color || '#6c63ff'}"></span> <span class="group-color" style="background-color: {group.color || '#6c63ff'}"></span>
<span class="group-name">{group.name}</span> <span class="group-name">{group.name}</span>
@ -244,11 +169,6 @@
</div> </div>
{/if} {/if}
<!-- Entry moved toast -->
{#if dropTargetGroupId}
<div class="drop-indicator">Drop here to move</div>
{/if}
<!-- Delete group confirmation --> <!-- Delete group confirmation -->
{#if deletingGroup} {#if deletingGroup}
<div class="modal-overlay" role="presentation" onclick={() => showDeleteGroupConfirm = null}> <div class="modal-overlay" role="presentation" onclick={() => showDeleteGroupConfirm = null}>
@ -329,25 +249,6 @@
color: var(--color-primary); color: var(--color-primary);
} }
.group-wrapper.drop-target .group-item,
.group-row.drop-target .group-item {
background: rgba(108, 99, 255, 0.3);
color: var(--color-primary);
}
.group-wrapper.drop-target,
.group-row.drop-target {
background: rgba(108, 99, 255, 0.12);
border: 2px dashed var(--color-primary);
border-radius: var(--radius-md);
box-shadow: 0 0 12px rgba(108, 99, 255, 0.3);
}
.group-wrapper {
border-radius: var(--radius-md);
transition: background-color 150ms;
}
.group-icon { .group-icon {
font-size: 1rem; font-size: 1rem;
} }
@ -397,27 +298,6 @@
border-top: 1px solid var(--color-border); border-top: 1px solid var(--color-border);
} }
.drop-indicator {
position: fixed;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
padding: 8px 16px;
background: var(--color-primary);
color: #fff;
border-radius: var(--radius-md);
font-size: 0.8rem;
font-weight: 500;
z-index: 100;
pointer-events: none;
animation: fadeIn 150ms ease;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateX(-50%) translateY(10px); }
to { opacity: 1; transform: translateX(-50%) translateY(0); }
}
/* Modal */ /* Modal */
.modal-overlay { .modal-overlay {
position: fixed; position: fixed;

View File

@ -9,7 +9,7 @@
import { generateId } from '../models/schema.js' import { generateId } from '../models/schema.js'
const PBKDF2_ITERATIONS = 100_000 const PBKDF2_ITERATIONS = 600_000
const SALT_LENGTH = 16 // bytes const SALT_LENGTH = 16 // bytes
const IV_LENGTH = 12 // bytes (recommended for AES-GCM) const IV_LENGTH = 12 // bytes (recommended for AES-GCM)
@ -135,7 +135,7 @@ export async function createTestPayload(masterPassword) {
// --- Utility: Uint8Array ↔ Base64 --- // --- Utility: Uint8Array ↔ Base64 ---
function uint8ArrayToBase64(buffer) { export function uint8ArrayToBase64(buffer) {
const bytes = new Uint8Array(buffer) const bytes = new Uint8Array(buffer)
let binary = '' let binary = ''
for (let i = 0; i < bytes.byteLength; i++) { for (let i = 0; i < bytes.byteLength; i++) {
@ -144,7 +144,7 @@ function uint8ArrayToBase64(buffer) {
return btoa(binary) return btoa(binary)
} }
function base64ToUint8Array(base64) { export function base64ToUint8Array(base64) {
const binary = atob(base64) const binary = atob(base64)
const bytes = new Uint8Array(binary.length) const bytes = new Uint8Array(binary.length)
for (let i = 0; i < binary.length; i++) { for (let i = 0; i < binary.length; i++) {
@ -189,10 +189,21 @@ export function generatePassword({
throw new Error('Password charset is empty — enable at least one character type') throw new Error('Password charset is empty — enable at least one character type')
} }
const randomValues = crypto.getRandomValues(new Uint8Array(length)) const charsetLength = charset.length
const maxValid = 256 - (256 % charsetLength)
const randomBytes = new Uint8Array(length * 2)
let password = '' let password = ''
for (let i = 0; i < length; i++) { let byteIdx = 0
password += charset[randomValues[i] % charset.length]
while (password.length < length) {
if (byteIdx >= randomBytes.length) {
crypto.getRandomValues(randomBytes)
byteIdx = 0
}
const byte = randomBytes[byteIdx++]
if (byte < maxValid) {
password += charset[byte % charsetLength]
}
} }
return password return password
} }

View File

@ -11,7 +11,9 @@
*/ */
export function generateId() { export function generateId() {
const timestamp = Date.now().toString(36) const timestamp = Date.now().toString(36)
const random = Math.random().toString(36).slice(2, 10) const randomBytes = new Uint8Array(4)
crypto.getRandomValues(randomBytes)
const random = Array.from(randomBytes).map(b => b.toString(16).padStart(2, '0')).join('').slice(0, 8)
return `${timestamp}_${random}` return `${timestamp}_${random}`
} }

View File

@ -310,19 +310,6 @@ export async function exportAll() {
} }
} }
/**
* Convert a base64 string back to Uint8Array.
* @param {string} base64
* @returns {Uint8Array}
*/
function base64ToUint8Array(base64) {
const binary = atob(base64)
const bytes = new Uint8Array(binary.length)
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i)
}
return bytes
}
/** /**
* Import data from a previously exported JSON object. * Import data from a previously exported JSON object.