Compare commits

..

1 Commits

62 changed files with 6886 additions and 12347 deletions

View File

@ -1,117 +0,0 @@
name: Test, Build & Deploy
# Tests + build run on every push/PR; the deploy (WebDAV publish of the
# single-file dist/index.html) runs only on the default branch (main).
"on":
push:
branches:
- '**'
pull_request:
jobs:
test-and-build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install dependencies
run: npm ci
- name: Run tests
run: npm run test:run
- name: Build (single-file bundle)
env:
VITE_COMMIT_HASH: ${{ github.sha }}
run: npm run build
- name: Verify build output
run: test -f dist/index.html
deploy:
# Release the built app only when a commit lands on the default branch.
needs: test-and-build
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install dependencies
run: npm ci
- name: Build (single-file bundle)
env:
VITE_COMMIT_HASH: ${{ github.sha }}
run: npm run build
- name: Verify build output
run: test -f dist/index.html
# Publish index.html + PWA companion files (manifest, sandbox service
# worker, icons) to the WebDAV mirror. The pretty URL
# (…/password_manager/index.html) 401s without auth; files live at
# www/static/password_manager/<name> with basic auth.
- name: Publish to WebDAV (/password_manager)
env:
WEBDAV_USER: ${{ secrets.WEBDAV_USER }}
WEBDAV_PASS: ${{ secrets.WEBDAV_PASS }}
WEBDAV_BASE: https://files.thecookiejar.me/www/static/password_manager
run: |
set -e
if [ -z "$WEBDAV_USER" ] || [ -z "$WEBDAV_PASS" ]; then
echo "::error::WEBDAV_USER / WEBDAV_PASS repo secrets are not set."
echo "::error::Add them under Settings -> Actions -> Secrets, then re-run this job."
exit 1
fi
publish() {
local src="$1" dst="$2" ctype="$3"
# DELETE-then-PUT: the serving layer caches uploaded files, so a plain
# PUT over an existing file keeps serving the OLD copy.
curl -sS -u "$WEBDAV_USER:$WEBDAV_PASS" -X DELETE "$dst" || echo "(DELETE non-zero; may be a new file -- continuing)"
curl -sS --fail -u "$WEBDAV_USER:$WEBDAV_PASS" -X PUT -T "$src" -H "Content-Type: $ctype" "$dst"
echo "Published $src -> $dst"
}
# Ensure the icons/ subfolder exists.
curl -sS -u "$WEBDAV_USER:$WEBDAV_PASS" -X MKCOL "$WEBDAV_BASE/icons" -o /dev/null || echo "(icons dir exists or MKCOL unsupported -- continuing)"
publish dist/index.html "$WEBDAV_BASE/index.html" 'text/html'
publish dist/manifest.webmanifest "$WEBDAV_BASE/manifest.webmanifest" 'application/manifest+json'
publish dist/sw.js "$WEBDAV_BASE/sw.js" 'application/javascript; charset=utf-8'
publish dist/icons/icon-192.png "$WEBDAV_BASE/icons/icon-192.png" 'image/png'
publish dist/icons/icon-512.png "$WEBDAV_BASE/icons/icon-512.png" 'image/png'
publish dist/icons/icon-maskable-512.png "$WEBDAV_BASE/icons/icon-maskable-512.png" 'image/png'
# Pull the freshly uploaded files back and compare bytes, so a successful
# deploy isn't just "curl returned 0" but the served copy actually matches.
- name: Verify deployed files match build
env:
WEBDAV_USER: ${{ secrets.WEBDAV_USER }}
WEBDAV_PASS: ${{ secrets.WEBDAV_PASS }}
WEBDAV_BASE: https://files.thecookiejar.me/www/static/password_manager
run: |
set -e
verify() {
local src="$1" dst="$2"
curl -sS --fail -u "$WEBDAV_USER:$WEBDAV_PASS" "$dst" -o "/tmp/deployed_$(basename "$src")"
sha1sum "$src" "/tmp/deployed_$(basename "$src")"
test "$(sha1sum "$src" | cut -d' ' -f1)" = \
"$(sha1sum "/tmp/deployed_$(basename "$src")" | cut -d' ' -f1)" \
|| { echo "::error::Deployed $src does not match build"; exit 1; }
echo "Verified $src bytes match"
}
verify dist/index.html "$WEBDAV_BASE/index.html"
verify dist/manifest.webmanifest "$WEBDAV_BASE/manifest.webmanifest"
verify dist/sw.js "$WEBDAV_BASE/sw.js"
verify dist/icons/icon-192.png "$WEBDAV_BASE/icons/icon-192.png"
verify dist/icons/icon-512.png "$WEBDAV_BASE/icons/icon-512.png"
verify dist/icons/icon-maskable-512.png "$WEBDAV_BASE/icons/icon-maskable-512.png"
echo "All deployed files verified"

View File

@ -75,27 +75,16 @@ Password verification uses a test payload (random string encrypted at vault crea
## Security Notes
- Only `encryptedPassword` and `encryptedTotpSecret` are encrypted at rest; other fields (title, username, URL, notes) are plaintext in IndexedDB. Passwords and TOTP secrets are optional on an entry.
- 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 configurable inactivity timer (default 5 min).
- Clipboard auto-clears after 15 seconds.
- No browser fingerprinting or anti-keylogger protections.
## TOTP (2FA)
## Export
- `src/lib/crypto/totp.js`: native RFC 6238 TOTP via Web Crypto HMAC-SHA1 (no external lib). Exports `generateTotp(secret, {timestamp, period=30, digits=6})`, `base32Decode`, `extractSecret` (accepts bare base32 OR `otpauth://` URI), `totpRemainingSeconds`.
- Entries store an optional `encryptedTotpSecret` (AES-GCM, like passwords). EntryForm accepts a base32 secret or otpauth:// URI; EntryDetail shows a live, copyable 6-digit code with a 1s countdown + urgency color, cleaned up via `onDestroy`.
- Export/import re-key `encryptedTotpSecret` alongside passwords for sealed exports (and decrypt/re-encrypt on import), so TOTP survives migration between vaults.
- Note: `extractSecret` must strip hyphens from hyphen-grouped secrets (authenticator display style) as well as whitespace.
## Export / Import
- `exportSelected(groupIds, options)` — group IDs to export; `null`/`[]` = full export (include `''` for ungrouped). `options = { vaultKey, password = '', useExistingPassword = false }`. An explicit protection choice is required; every protected export AES-256-GCM-seals the whole payload (titles/usernames/notes included) into `{ format: 'encrypted-export', salt, data }`.
- **New password** (`options.password`): re-keys every exported entry's password to a key derived from that password + a fresh random salt (embedded in the envelope), so import needs only this password — never the vault's.
- **Reuse existing password** (`options.useExistingPassword`): seals with the vault's own key and keeps the vault's salt embedded, so import derives the key from the vault master password. No second password to create/remember.
- **No option set**: falls back to a plaintext JSON export (entries keep source-vault-encrypted passwords) for backward compatibility; the UI never offers this.
- `importAll(data, mode, password, targetKey)` detects a sealed export (`data.format === 'encrypted-export'`): `password` is whichever password opens the envelope — a separate export password OR the source vault's master password (for reuse-existing). A wrong password rejects the import (never silently skips). The entry loop decrypts with the reproduced source key and re-encrypts under `targetKey`.
- `ImportExport.svelte` fetches groups/entries on modal open and shows a checkbox list for group selection with live entry count. The export dialog forces an explicit choice via two radio options: "Use a new password" (shows a short password field, validated non-empty) or "Reuse my vault password". The import dialog's single password field is generic (export password for sealed files, source vault master for plain/old files).
- `exportSelected(groupIds)` replaces the old `exportAll()` — accepts an array of group IDs to export. Pass `null` or `[]` for a full export. Vault meta (salt, test payload) is always included for import decryption.
- `ImportExport.svelte` fetches groups/entries on modal open and shows a checkbox list for group selection with live entry count.
## Known Bug Fixes

View File

@ -34,6 +34,7 @@ The build produces a **single `dist/index.html`** file with all JavaScript, CSS,
The single-file output is handled by [`vite-plugin-singlefile`](https://www.npmjs.com/package/vite-plugin-singlefile) for JS/CSS inlining, plus a post-build script that inlines the favicon SVG and removes leftover asset files.
### Encryption Flow
```

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

10143
dist/index.html vendored

File diff suppressed because one or more lines are too long

View File

@ -1,19 +0,0 @@
{
"name": "Password Vault",
"short_name": "Vault",
"description": "Offline-first password manager. Encrypted locally in your browser; never leaves your device.",
"start_url": "./",
"scope": "./",
"display": "standalone",
"display_override": "standalone",
"orientation": "any",
"background_color": "#0f1117",
"theme_color": "#6c63ff",
"categories": ["productivity", "utilities", "security"],
"lang": "en",
"icons": [
{ "src": "./icons/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
{ "src": "./icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
{ "src": "./icons/icon-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
]
}

58
dist/sw.js vendored
View File

@ -1,58 +0,0 @@
/**
* Password Vault service worker a NETWORK SANDBOX.
*
* The vault is offline-first (single-file, zero external deps). When installed
* as a PWA this worker enforces that: it allows requests ONLY for the app's own
* same-origin static resources (index.html, manifest, icons, and the worker
* itself) and rejects every cross-origin/third-party request.
*
* What this guards against: if a page within the PWA scope tries to exfiltrate
* or inject via <img>, <script src>, fetch(), an external manifest link, etc.,
* the worker refuses it before it leaves the device.
*
* Residual boundaries covered by the CSP in index.html (which a service worker
* cannot catch): WebSockets (connect-src 'none') and user-initiated top-level
* navigation. Form POSTs are blocked by form-action 'none'. img/media that a
* site tries to load are blocked here AND by img-src 'self'.
*/
const VERSION = '1.0.0'
self.addEventListener('install', () => self.skipWaiting())
self.addEventListener('activate', () => {
// Claim control of any pages already open in this scope.
self.clients?.claim?.()
})
/** URL/string->URL normalizer (worker self.location is unreliable in type). */
const toURL = (u) => (u instanceof URL) ? u : new URL(String(u))
/** Is this an in-scope resource for the app itself? */
function isAppOwnResource(url) {
const selfLoc = toURL(self.location)
// Cross-origin is always denied.
if (url.origin !== selfLoc.origin) return false
// Same-origin is only allowed WITHIN the worker's own directory scope (the
// index.html, manifest, and icons the app ships). A same-origin request to
// another app/dir on the same server is outside the sandbox and denied.
const dir = selfLoc.pathname.slice(0, selfLoc.pathname.lastIndexOf('/') + 1)
return url.pathname.startsWith(dir)
}
/** Block cross-origin requests; let same-origin app resources through. */
self.addEventListener('fetch', (event) => {
const url = new URL(event.request.url)
if (!isAppOwnResource(url)) {
event.respondWith(Promise.resolve(new Response(null, { status: 403, statusText: 'Blocked by Vault net policy' })))
return
}
// Same-origin: pass through to network (covers reload of index.html and the
// manifest/icons). We do not cache, so the app is always the current build.
event.respondWith(fetch(event.request).catch(() => {
// Network fallback: serve a minimal offline placeholder so a launched app
// never hangs open on a dead link. The real HTML is never cached here.
return new Response('offline', { status: 200 })
}))
})
console.log(`[vault-sw] ${VERSION} active — network sandbox on`)

View File

@ -2,33 +2,8 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- Dev CSP permits the Vite HMR WebSocket + same-origin dev assets. The
production build (scripts/inline-assets.js) swaps this for the strict
sandbox CSP (connect-src 'none', form/object denied). Clickjacking
(frame-ancestors / X-Frame-Options) is an HTTP-header concern and must
be set by the serving layer, not in a meta tag. -->
<meta http-equiv="Content-Security-Policy" content="
default-src 'self';
script-src 'self' 'unsafe-inline';
style-src 'self' 'unsafe-inline';
img-src 'self' data:;
font-src 'self' data:;
media-src 'self' data:;
connect-src 'self' ws: wss:;
form-action 'self';
object-src 'none';
base-uri 'none';
manifest-src 'self'
" />
<meta name="theme-color" content="#6c63ff" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Vault" />
<link rel="icon" type="image/svg+xml" href="./favicon.svg" />
<link rel="manifest" href="./manifest.webmanifest" />
<link rel="apple-touch-icon" href="./icons/icon-192.png" />
<title>Password Vault</title>
</head>
<body>

431
package-lock.json generated
View File

@ -11,12 +11,9 @@
"idb": "^8.0.3"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^7.1.2",
"@testing-library/svelte": "^5.3.1",
"@vitest/ui": "^4.1.6",
"fake-indexeddb": "^6.2.5",
"jsdom": "^29.1.1",
"svelte": "^5.55.5",
"vite": "^8.0.12",
"vite-plugin-singlefile": "^2.3.3",
"vitest": "^4.1.6"
@ -73,41 +70,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/@babel/code-frame": {
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
"integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-validator-identifier": "^7.28.5",
"js-tokens": "^4.0.0",
"picocolors": "^1.1.1"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-validator-identifier": {
"version": "7.28.5",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
"integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/runtime": {
"version": "7.29.2",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz",
"integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@bramus/specificity": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz",
@ -313,38 +275,6 @@
}
}
},
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
"integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.0",
"@jridgewell/trace-mapping": "^0.3.24"
}
},
"node_modules/@jridgewell/remapping": {
"version": "2.3.5",
"resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
"integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/gen-mapping": "^0.3.5",
"@jridgewell/trace-mapping": "^0.3.24"
}
},
"node_modules/@jridgewell/resolve-uri": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@jridgewell/sourcemap-codec": {
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
@ -352,17 +282,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/@jridgewell/trace-mapping": {
"version": "0.3.31",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/resolve-uri": "^3.1.0",
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz",
@ -670,106 +589,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/@sveltejs/acorn-typescript": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.9.tgz",
"integrity": "sha512-lVJX6qEgs/4DOcRTpo56tmKzVPtoWAaVbL4hfO7t7NVwl9AAXzQR6cihesW1BmNMPl+bK6dreu2sOKBP2Q9CIA==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"acorn": "^8.9.0"
}
},
"node_modules/@sveltejs/vite-plugin-svelte": {
"version": "7.1.2",
"resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-7.1.2.tgz",
"integrity": "sha512-DrUBA2UXRfDmUX/ZTiEopd3X40yavsJF1FX2RygcuIScHL7o5YX1fMvoYnDhjeJQC4weCOklirpNWlcb2NiSeA==",
"dev": true,
"license": "MIT",
"dependencies": {
"deepmerge": "^4.3.1",
"magic-string": "^0.30.21",
"obug": "^2.1.0",
"vitefu": "^1.1.2"
},
"engines": {
"node": "^20.19 || ^22.12 || >=24"
},
"peerDependencies": {
"svelte": "^5.46.4",
"vite": "^8.0.0-beta.7 || ^8.0.0"
}
},
"node_modules/@testing-library/dom": {
"version": "10.4.1",
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
"integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.10.4",
"@babel/runtime": "^7.12.5",
"@types/aria-query": "^5.0.1",
"aria-query": "5.3.0",
"dom-accessibility-api": "^0.5.9",
"lz-string": "^1.5.0",
"picocolors": "1.1.1",
"pretty-format": "^27.0.2"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@testing-library/dom/node_modules/aria-query": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz",
"integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"dequal": "^2.0.3"
}
},
"node_modules/@testing-library/svelte": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/@testing-library/svelte/-/svelte-5.3.1.tgz",
"integrity": "sha512-8Ez7ZOqW5geRf9PF5rkuopODe5RGy3I9XR+kc7zHh26gBiktLaxTfKmhlGaSHYUOTQE7wFsLMN9xCJVCszw47w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@testing-library/dom": "9.x.x || 10.x.x",
"@testing-library/svelte-core": "1.0.0"
},
"engines": {
"node": ">= 10"
},
"peerDependencies": {
"svelte": "^3 || ^4 || ^5 || ^5.0.0-next.0",
"vite": "*",
"vitest": "*"
},
"peerDependenciesMeta": {
"vite": {
"optional": true
},
"vitest": {
"optional": true
}
}
},
"node_modules/@testing-library/svelte-core": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@testing-library/svelte-core/-/svelte-core-1.0.0.tgz",
"integrity": "sha512-VkUePoLV6oOYwSUvX6ShA8KLnJqZiYMIbP2JW2t0GLWLkJxKGvuH5qrrZBV/X7cXFnLGuFQEC7RheYiZOW68KQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=16"
},
"peerDependencies": {
"svelte": "^3 || ^4 || ^5 || ^5.0.0-next.0"
}
},
"node_modules/@tybys/wasm-util": {
"version": "0.10.2",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz",
@ -781,13 +600,6 @@
"tslib": "^2.4.0"
}
},
"node_modules/@types/aria-query": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/chai": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
@ -813,13 +625,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"dev": true,
"license": "MIT"
},
"node_modules/@vitest/expect": {
"version": "4.1.6",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.6.tgz",
@ -955,52 +760,6 @@
"url": "https://opencollective.com/vitest"
}
},
"node_modules/acorn": {
"version": "8.16.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"dev": true,
"license": "MIT",
"bin": {
"acorn": "bin/acorn"
},
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/ansi-styles": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/aria-query": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz",
"integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/assertion-error": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
@ -1011,16 +770,6 @@
"node": ">=12"
}
},
"node_modules/axobject-query": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
"integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/bidi-js": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
@ -1054,16 +803,6 @@
"node": ">=18"
}
},
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/convert-source-map": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
@ -1106,26 +845,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/deepmerge": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
"integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/dequal": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
"integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
@ -1136,20 +855,6 @@
"node": ">=8"
}
},
"node_modules/devalue": {
"version": "5.8.0",
"resolved": "https://registry.npmjs.org/devalue/-/devalue-5.8.0.tgz",
"integrity": "sha512-2zA9pFEsnp7vWBZbXF5JAgAq0fsUIt/1XPbRiAmRV3lp/2C3upzH+sADiyy66aFCihoLEsrQHxNM5w1gIDfsBg==",
"dev": true,
"license": "MIT"
},
"node_modules/dom-accessibility-api": {
"version": "0.5.16",
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
"dev": true,
"license": "MIT"
},
"node_modules/entities": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz",
@ -1170,31 +875,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/esm-env": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz",
"integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==",
"dev": true,
"license": "MIT"
},
"node_modules/esrap": {
"version": "2.2.7",
"resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.7.tgz",
"integrity": "sha512-Dl7o7btn2YXca1VXx+PVl+lKuZdHBm8oCFuckUxqchMvNMdHMJ/qF31wtPaVyWvFYLQePkbXJrirWzbAP6Yamw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.4.15"
},
"peerDependencies": {
"@typescript-eslint/types": "^8.2.0"
},
"peerDependenciesMeta": {
"@typescript-eslint/types": {
"optional": true
}
}
},
"node_modules/estree-walker": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
@ -1321,23 +1001,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/is-reference": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz",
"integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "^1.0.6"
}
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
"dev": true,
"license": "MIT"
},
"node_modules/jsdom": {
"version": "29.1.1",
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz",
@ -1640,13 +1303,6 @@
"url": "https://opencollective.com/parcel"
}
},
"node_modules/locate-character": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz",
"integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==",
"dev": true,
"license": "MIT"
},
"node_modules/lru-cache": {
"version": "11.3.6",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.6.tgz",
@ -1657,16 +1313,6 @@
"node": "20 || >=22"
}
},
"node_modules/lz-string": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
"dev": true,
"license": "MIT",
"bin": {
"lz-string": "bin/bin.js"
}
},
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
@ -1820,21 +1466,6 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/pretty-format": {
"version": "27.5.1",
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1",
"ansi-styles": "^5.0.0",
"react-is": "^17.0.1"
},
"engines": {
"node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
}
},
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
@ -1845,13 +1476,6 @@
"node": ">=6"
}
},
"node_modules/react-is": {
"version": "17.0.2",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
"dev": true,
"license": "MIT"
},
"node_modules/require-from-string": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
@ -1955,34 +1579,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/svelte": {
"version": "5.55.5",
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.55.5.tgz",
"integrity": "sha512-2uCs/LZ9us+AktdzYJM8OcxQ8qnPS1kpaO7syGT/MgO+6Qr1Ybl+TqPq+97u7PHqmmMlye5ZkoyXONy5mjjAbw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/remapping": "^2.3.4",
"@jridgewell/sourcemap-codec": "^1.5.0",
"@sveltejs/acorn-typescript": "^1.0.5",
"@types/estree": "^1.0.5",
"@types/trusted-types": "^2.0.7",
"acorn": "^8.12.1",
"aria-query": "5.3.1",
"axobject-query": "^4.1.0",
"clsx": "^2.1.1",
"devalue": "^5.6.4",
"esm-env": "^1.2.1",
"esrap": "^2.2.4",
"is-reference": "^3.0.3",
"locate-character": "^3.0.0",
"magic-string": "^0.30.11",
"zimmerframe": "^1.1.2"
},
"engines": {
"node": ">=18"
}
},
"node_modules/symbol-tree": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
@ -2221,26 +1817,6 @@
}
}
},
"node_modules/vitefu": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.3.tgz",
"integrity": "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==",
"dev": true,
"license": "MIT",
"workspaces": [
"tests/deps/*",
"tests/projects/*",
"tests/projects/workspace/packages/*"
],
"peerDependencies": {
"vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0"
},
"peerDependenciesMeta": {
"vite": {
"optional": true
}
}
},
"node_modules/vitest": {
"version": "4.1.6",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.6.tgz",
@ -2412,13 +1988,6 @@
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
"dev": true,
"license": "MIT"
},
"node_modules/zimmerframe": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz",
"integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==",
"dev": true,
"license": "MIT"
}
}
}

View File

@ -12,12 +12,9 @@
"test:ui": "vitest --ui"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^7.1.2",
"@testing-library/svelte": "^5.3.1",
"@vitest/ui": "^4.1.6",
"fake-indexeddb": "^6.2.5",
"jsdom": "^29.1.1",
"svelte": "^5.55.5",
"vite": "^8.0.12",
"vite-plugin-singlefile": "^2.3.3",
"vitest": "^4.1.6"

View File

@ -1,24 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

Before

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

View File

@ -1,19 +0,0 @@
{
"name": "Password Vault",
"short_name": "Vault",
"description": "Offline-first password manager. Encrypted locally in your browser; never leaves your device.",
"start_url": "./",
"scope": "./",
"display": "standalone",
"display_override": "standalone",
"orientation": "any",
"background_color": "#0f1117",
"theme_color": "#6c63ff",
"categories": ["productivity", "utilities", "security"],
"lang": "en",
"icons": [
{ "src": "./icons/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
{ "src": "./icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
{ "src": "./icons/icon-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
]
}

View File

@ -1,19 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#6c63ff"/>
<stop offset="1" stop-color="#3b2f8f"/>
</linearGradient>
</defs>
<rect x="0" y="0" width="512" height="512" rx="112" ry="112" fill="url(#bg)"/>
<!-- Padlock body -->
<ellipse cx="256" cy="300" rx="150" ry="108" fill="#e4e6f0" opacity="0.95"/>
<!-- Padlock shackle -->
<path d="M 190 200 L 210 120 Q 222 96 256 92 Q 290 96 302 120 L 322 200"
fill="none" stroke="#e4e6f0" stroke-width="40" stroke-linecap="round" opacity="0.95"/>
<!-- Keyhole -->
<circle cx="256" cy="292" r="30" fill="#0f1117"/>
<path d="M 256 322 L 256 352 L 244 352 L 268 352" fill="#0f1117"/>
<!-- Satin highlight -->
<ellipse cx="256" cy="204" rx="96" ry="14" fill="rgba(255,255,255,0.28)"/>
</svg>

Before

Width:  |  Height:  |  Size: 910 B

View File

@ -1,58 +0,0 @@
/**
* Password Vault service worker a NETWORK SANDBOX.
*
* The vault is offline-first (single-file, zero external deps). When installed
* as a PWA this worker enforces that: it allows requests ONLY for the app's own
* same-origin static resources (index.html, manifest, icons, and the worker
* itself) and rejects every cross-origin/third-party request.
*
* What this guards against: if a page within the PWA scope tries to exfiltrate
* or inject via <img>, <script src>, fetch(), an external manifest link, etc.,
* the worker refuses it before it leaves the device.
*
* Residual boundaries covered by the CSP in index.html (which a service worker
* cannot catch): WebSockets (connect-src 'none') and user-initiated top-level
* navigation. Form POSTs are blocked by form-action 'none'. img/media that a
* site tries to load are blocked here AND by img-src 'self'.
*/
const VERSION = '1.0.0'
self.addEventListener('install', () => self.skipWaiting())
self.addEventListener('activate', () => {
// Claim control of any pages already open in this scope.
self.clients?.claim?.()
})
/** URL/string->URL normalizer (worker self.location is unreliable in type). */
const toURL = (u) => (u instanceof URL) ? u : new URL(String(u))
/** Is this an in-scope resource for the app itself? */
function isAppOwnResource(url) {
const selfLoc = toURL(self.location)
// Cross-origin is always denied.
if (url.origin !== selfLoc.origin) return false
// Same-origin is only allowed WITHIN the worker's own directory scope (the
// index.html, manifest, and icons the app ships). A same-origin request to
// another app/dir on the same server is outside the sandbox and denied.
const dir = selfLoc.pathname.slice(0, selfLoc.pathname.lastIndexOf('/') + 1)
return url.pathname.startsWith(dir)
}
/** Block cross-origin requests; let same-origin app resources through. */
self.addEventListener('fetch', (event) => {
const url = new URL(event.request.url)
if (!isAppOwnResource(url)) {
event.respondWith(Promise.resolve(new Response(null, { status: 403, statusText: 'Blocked by Vault net policy' })))
return
}
// Same-origin: pass through to network (covers reload of index.html and the
// manifest/icons). We do not cache, so the app is always the current build.
event.respondWith(fetch(event.request).catch(() => {
// Network fallback: serve a minimal offline placeholder so a launched app
// never hangs open on a dead link. The real HTML is never cached here.
return new Response('offline', { status: 200 })
}))
})
console.log(`[vault-sw] ${VERSION} active — network sandbox on`)

View File

@ -30,16 +30,6 @@ if (existsSync(faviconPath)) {
console.log('[inline-assets] Inlined favicon.svg into index.html')
}
// Remove any other leftover asset files (e.g. icons.svg from Svelte compiler,
// and the source pwa-icon.svg which is only needed to regenerate the PNGs).
for (const leftover of ['icons.svg', 'pwa-icon.svg']) {
const p = join(distDir, leftover)
if (existsSync(p)) {
rmSync(p)
console.log(`[inline-assets] Removed ${leftover}`)
}
}
// Remove assets directory if it exists
const assetsDir = join(distDir, 'assets')
if (existsSync(assetsDir)) {
@ -47,43 +37,13 @@ if (existsSync(assetsDir)) {
console.log('[inline-assets] Removed assets/ directory')
}
// ---- Production network sandbox CSP -----------------------------
// Replace the dev CSP with the strict, shipped one. The app is single-file and
// offline-first, so script/style keep 'unsafe-inline' (they are inline), but
// every channel that could exfiltrate vault data is denied at the browser level:
// connect-src 'none' (no fetch/XHR/WebSocket anywhere), form-action 'none',
// object-src 'none', base-uri 'none'. (Clickjacking via frame-ancestors /
// X-Frame-Options is a header-only directive and must be set by the serving
// layer, so it is intentionally absent here.)
const indexPath = join(distDir, 'index.html')
let html = readFileSync(indexPath, 'utf8')
const PROD_CSP = [
"default-src 'self';",
"script-src 'self' 'unsafe-inline';",
"style-src 'self' 'unsafe-inline';",
"img-src 'self' data:;",
"font-src 'self' data:;",
"media-src 'self' data:;",
"connect-src 'none';",
"form-action 'none';",
"object-src 'none';",
"base-uri 'none';",
"manifest-src 'self';",
'upgrade-insecure-requests',
].join('\n ')
html = html.replace(
/<meta http-equiv="Content-Security-Policy" content="[\s\S]*?"\s*\/>/i,
`<meta http-equiv="Content-Security-Policy" content="\n ${PROD_CSP}\n " />`
)
writeFileSync(indexPath, html)
console.log('[inline-assets] Replaced dev CSP with strict production sandbox')
// ---- Keep the PWA companion files -------------------------------
// These MUST ship alongside index.html so the app is installable: the web
// manifest, the network-sandbox service worker, and the icon set.
for (const keep of ['manifest.webmanifest', 'sw.js', 'icons']) {
const p = join(distDir, keep)
if (existsSync(p)) console.log(`[inline-assets] Preserved PWA ${keep}: ${p}`)
// Remove any leftover SVG files (e.g. icons.svg from old Svelte builds)
import { readdirSync } from 'fs'
for (const file of readdirSync(distDir)) {
if (file.endsWith('.svg')) {
rmSync(join(distDir, file))
console.log(`[inline-assets] Removed ${file}`)
}
}
console.log('[inline-assets] Done — dist/ contains index.html + PWA files')
console.log('[inline-assets] Done — dist/ contains only index.html')

65
src/App.js Normal file
View File

@ -0,0 +1,65 @@
/**
* Root app component routes between LockScreen and MainLayout.
*/
import { Component } from './components/component.js'
import { LockScreen } from './components/LockScreen.js'
import { MainLayout } from './components/MainLayout.js'
import { app } from './lib/stores/app.js'
export class App extends Component {
_lockScreen = null
_mainLayout = null
mount() {
super.mount()
// Set meta tags (preserve existing <style> and <script> tags from the build)
const head = document.head
// Only remove meta/link tags, keep style/script
head.querySelectorAll('meta, link').forEach(el => el.remove())
const charset = document.createElement('meta')
charset.setAttribute('charset', 'UTF-8')
head.appendChild(charset)
const viewport = document.createElement('meta')
viewport.setAttribute('name', 'viewport')
viewport.setAttribute('content', 'width=device-width, initial-scale=1.0')
head.appendChild(viewport)
document.title = 'Password Vault'
// Subscribe to isUnlocked to swap views
this.subscribe(app, 'isUnlocked', (unlocked) => this.#swapView(unlocked))
// Initial render
this.#swapView(app.isUnlocked)
return this
}
#swapView(unlocked) {
// Destroy current view
if (this._lockScreen) { this._lockScreen.destroy(); this._lockScreen = null }
if (this._mainLayout) { this._mainLayout.destroy(); this._mainLayout = null }
// Clear container
if (this.container) this.container.innerHTML = ''
// Mount new view
if (unlocked) {
this._mainLayout = new MainLayout(this.container)
this._mainLayout.mount()
} else {
this._lockScreen = new LockScreen(this.container)
this._lockScreen.mount()
}
}
destroy() {
if (this._lockScreen) this._lockScreen.destroy()
if (this._mainLayout) this._mainLayout.destroy()
super.destroy()
}
}

View File

@ -1,16 +0,0 @@
<script>
import LockScreen from './components/LockScreen.svelte'
import MainLayout from './components/MainLayout.svelte'
import { app } from './lib/stores/app.svelte'
</script>
<svelte:head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
</svelte:head>
{#if app.isUnlocked}
<MainLayout />
{:else}
<LockScreen />
{/if}

View File

@ -0,0 +1,288 @@
/**
* EntryDetail view single entry with copy-to-clipboard, trash, and permanent delete.
*/
import { Component } from './component.js'
import { getEntryById, moveToTrash, deleteEntry } from '../lib/storage/db.js'
import { decrypt } from '../lib/crypto/crypto.js'
import { app } from '../lib/stores/app.js'
import { isTrashGroup } from '../lib/models/schema.js'
export class EntryDetail extends Component {
/** @param {{ entryId: string, onEdit: Function, onBack: Function }} props */
constructor(container, props = {}) {
super(container)
this.entryId = props.entryId
this.onEdit = props.onEdit || (() => {})
this.onBack = props.onBack || (() => {})
this.entry = null
this.passwordVisible = false
this.decryptedPassword = ''
this.loading = true
this.error = ''
this.showDeleteConfirm = false
this.showPermanentDeleteConfirm = false
this.deleting = false
this.toast = ''
this.toastTimer = null
}
mount() {
super.mount()
this.#loadEntry()
return this
}
render() {
this.el = this.ce('div', { className: 'entry-detail' })
this.#renderContent()
return this.el
}
#renderContent() {
this.el.innerHTML = ''
// Toast
if (this.toast) {
this.el.appendChild(this.ce('div', { className: 'toast', textContent: this.toast }))
}
if (this.loading) {
this.el.appendChild(this.ce('div', { className: 'loading', textContent: 'Loading...' }))
return
}
if (this.error) {
this.el.appendChild(this.ce('div', { className: 'error-banner', textContent: this.error }))
return
}
if (!this.entry) {
this.el.appendChild(this.ce('div', { className: 'empty-state', textContent: 'Entry not found' }))
return
}
const isInTrash = isTrashGroup(this.entry.groupId)
const card = this.ce('div', { className: 'detail-card' })
// Header
const header = this.ce('div', { className: 'detail-header' },
this.ce('h2', { textContent: this.entry.title }),
this.ce('div', { className: 'header-actions' },
isInTrash
? this.ce('button', { className: 'btn btn-primary btn-sm', id: 'restore-btn', textContent: '↩️ Restore' })
: this.ce('button', { className: 'btn btn-ghost btn-sm', id: 'edit-btn', textContent: '✏️ Edit' }),
isInTrash
? this.ce('button', { className: 'btn btn-danger btn-sm', id: 'perm-delete-btn', textContent: '🗑 Delete Forever' })
: this.ce('button', { className: 'btn btn-danger btn-sm', id: 'trash-btn', textContent: '🗑 Move to Trash' }),
),
)
card.appendChild(header)
// Fields
const fields = this.ce('div', { className: 'detail-fields' })
if (this.entry.username) {
fields.appendChild(this.ce('div', { className: 'detail-field' },
this.ce('span', { className: 'field-label', textContent: 'Username' }),
this.ce('div', { className: 'field-value' },
this.ce('span', { textContent: this.entry.username }),
this.ce('button', { className: 'btn btn-ghost btn-sm copy-btn', textContent: '📋', title: 'Copy username', 'data-copy': this.entry.username }),
),
))
}
// Password
fields.appendChild(this.ce('div', { className: 'detail-field' },
this.ce('span', { className: 'field-label', textContent: 'Password' }),
this.ce('div', { className: 'field-value' },
this.ce('span', { id: 'pwd-display', textContent: this.passwordVisible ? this.decryptedPassword : '••••••••••••' }),
this.ce('button', { className: 'btn btn-ghost btn-sm', id: 'toggle-pwd', textContent: this.passwordVisible ? '🙈' : '👁', title: 'Toggle visibility' }),
this.ce('button', { className: 'btn btn-ghost btn-sm copy-btn', textContent: '📋', title: 'Copy password', 'data-copy': this.decryptedPassword }),
),
))
if (this.entry.url) {
const urlField = this.ce('div', { className: 'detail-field' },
this.ce('span', { className: 'field-label', textContent: 'URL' }),
this.ce('div', { className: 'field-value' },
this.ce('a', { href: this.entry.url, target: '_blank', rel: 'noopener noreferrer', textContent: this.entry.url }),
this.ce('button', { className: 'btn btn-ghost btn-sm copy-btn', textContent: '📋', title: 'Copy URL', 'data-copy': this.entry.url }),
),
)
fields.appendChild(urlField)
}
if (this.entry.notes) {
fields.appendChild(this.ce('div', { className: 'detail-field' },
this.ce('span', { className: 'field-label', textContent: 'Notes' }),
this.ce('div', { className: 'field-value notes', textContent: this.entry.notes }),
))
}
card.appendChild(fields)
// Meta
card.appendChild(this.ce('div', { className: 'detail-meta' },
this.ce('span', { className: 'text-xs text-muted', textContent: `Created: ${new Date(this.entry.createdAt).toLocaleString()}` }),
this.ce('span', { className: 'text-xs text-muted', textContent: `Updated: ${new Date(this.entry.updatedAt).toLocaleString()}` }),
))
this.el.appendChild(card)
// Wire up buttons
const editBtn = this.q('#edit-btn')
if (editBtn) this.on(editBtn, 'click', () => this.onEdit(this.entry.id))
const restoreBtn = this.q('#restore-btn')
if (restoreBtn) this.on(restoreBtn, 'click', () => this.onEdit(this.entry.id))
const trashBtn = this.q('#trash-btn')
if (trashBtn) this.on(trashBtn, 'click', () => { this.showDeleteConfirm = true; this.#renderModal() })
const permDeleteBtn = this.q('#perm-delete-btn')
if (permDeleteBtn) this.on(permDeleteBtn, 'click', () => { this.showPermanentDeleteConfirm = true; this.#renderModal() })
// Toggle password visibility
const togglePwd = this.q('#toggle-pwd')
if (togglePwd) {
this.on(togglePwd, 'click', () => {
this.passwordVisible = !this.passwordVisible
const display = this.q('#pwd-display')
if (display) display.textContent = this.passwordVisible ? this.decryptedPassword : '••••••••••••'
togglePwd.textContent = this.passwordVisible ? '🙈' : '👁'
})
}
// Copy buttons
this.qa('[data-copy]').forEach(btn => {
this.on(btn, 'click', () => {
const text = btn.dataset.copy
this.#copyToClipboard(text, btn.title || 'Text')
})
})
}
async #loadEntry() {
this.loading = true
this.error = ''
try {
this.entry = await getEntryById(this.entryId)
if (this.entry && app.encryptionKey) {
this.decryptedPassword = await decrypt(this.entry.encryptedPassword, app.encryptionKey)
}
} catch (e) {
this.error = 'Failed to load entry: ' + e.message
}
this.loading = false
this.#renderContent()
}
#showToast(message) {
this.toast = message
if (this.toastTimer) clearTimeout(this.toastTimer)
this.toastTimer = setTimeout(() => { this.toast = '' }, 3000)
}
async #copyToClipboard(text, label) {
try {
await navigator.clipboard.writeText(text)
this.#showToast(`${label} copied (auto-clear in 15s)`)
setTimeout(async () => {
try { await navigator.clipboard.writeText('') } catch {}
}, 15000)
} catch {
const textarea = document.createElement('textarea')
textarea.value = text
textarea.style.position = 'fixed'
textarea.style.opacity = '0'
document.body.appendChild(textarea)
textarea.select()
document.execCommand('copy')
document.body.removeChild(textarea)
this.#showToast(`${label} copied`)
}
}
#renderModal() {
const existing = this.q('.ed-modal-overlay')
if (existing) existing.remove()
let title, message, actionId, actionLabel
if (this.showDeleteConfirm) {
title = 'Move to Trash'
message = `Move "${this.entry.title}" to the trash? You can restore it later.`
actionId = 'confirm-trash-btn'
actionLabel = this.deleting ? 'Moving...' : 'Move to Trash'
} else {
title = 'Delete Forever'
message = `Permanently delete "${this.entry.title}"? This cannot be undone.`
actionId = 'confirm-perm-delete-btn'
actionLabel = this.deleting ? 'Deleting...' : 'Delete Forever'
}
const overlay = this.ce('div', { className: 'ed-modal-overlay', role: 'presentation' },
this.ce('div', { className: 'ed-modal', role: 'dialog', 'aria-modal': 'true', tabindex: '-1' },
this.ce('h3', { textContent: title }),
this.ce('p', { textContent: message }),
this.ce('div', { className: 'ed-modal-actions' },
this.ce('button', { className: 'btn btn-danger', id: actionId, disabled: this.deleting, textContent: actionLabel }),
this.ce('button', { className: 'btn btn-ghost', id: 'cancel-modal-btn', textContent: 'Cancel' }),
),
),
)
this.el.appendChild(overlay)
this.on(overlay, 'click', () => {
this.showDeleteConfirm = false
this.showPermanentDeleteConfirm = false
overlay.remove()
})
this.on(overlay.querySelector('.ed-modal'), 'click', (e) => e.stopPropagation())
const actionBtn = overlay.querySelector(`#${actionId}`)
if (actionBtn) {
if (this.showDeleteConfirm) {
this.on(actionBtn, 'click', () => this.#handleMoveToTrash())
} else {
this.on(actionBtn, 'click', () => this.#handlePermanentDelete())
}
}
const cancelBtn = overlay.querySelector('#cancel-modal-btn')
if (cancelBtn) {
this.on(cancelBtn, 'click', () => {
this.showDeleteConfirm = false
this.showPermanentDeleteConfirm = false
overlay.remove()
})
}
}
async #handleMoveToTrash() {
this.deleting = true
try {
await moveToTrash(this.entryId)
this.onBack()
} catch (e) {
this.error = 'Failed to move to trash: ' + e.message
}
this.deleting = false
this.showDeleteConfirm = false
}
async #handlePermanentDelete() {
this.deleting = true
try {
await deleteEntry(this.entryId)
this.onBack()
} catch (e) {
this.error = 'Failed to permanently delete: ' + e.message
}
this.deleting = false
this.showPermanentDeleteConfirm = false
}
}

View File

@ -1,446 +0,0 @@
<script>
import { onDestroy } from 'svelte'
import { getEntryById, moveToTrash, deleteEntry } from '../lib/storage/db.js'
import { decrypt } from '../lib/crypto/crypto.js'
import { generateTotp, totpRemainingSeconds } from '../lib/crypto/totp.js'
import { app } from '../lib/stores/app.svelte.js'
import { isTrashGroup } from '../lib/models/schema.js'
let { entryId, onEdit, onBack } = $props()
let entry = $state(null)
let passwordVisible = $state(false)
let decryptedPassword = $state('')
let totpCode = $state('')
let totpRemaining = $state(30)
let totalRemaining = $state(false)
let loading = $state(true)
let error = $state('')
let showDeleteConfirm = $state(false)
let showPermanentDeleteConfirm = $state(false)
let deleting = $state(false)
let toast = $state('')
const isInTrash = $derived(entry && isTrashGroup(entry.groupId))
let toastTimer = null
async function loadEntry() {
loading = true
error = ''
try {
entry = await getEntryById(entryId)
if (entry && app.encryptionKey) {
decryptedPassword = entry.encryptedPassword ? await decrypt(entry.encryptedPassword, app.encryptionKey) : ''
if (entry.encryptedTotpSecret) {
const secret = await decrypt(entry.encryptedTotpSecret, app.encryptionKey)
await refreshTotp(secret)
startTotpTimer(secret)
}
}
} catch (e) {
error = 'Failed to load entry: ' + e.message
}
loading = false
}
loadEntry()
let totpTimer = null
async function refreshTotp(secret) {
try {
totpCode = await generateTotp(secret)
totpRemaining = totpRemainingSeconds()
totalRemaining = totpRemaining <= 5
} catch (e) {
totpCode = ''
totpRemaining = 0
}
}
function startTotpTimer(secret) {
stopTotpTimer()
totpTimer = setInterval(async () => {
if (totpRemaining <= 1) await refreshTotp(secret)
else totpRemaining -= 1
totalRemaining = totpRemaining <= 5
}, 1000)
}
function stopTotpTimer() {
if (totpTimer) { clearInterval(totpTimer); totpTimer = null }
}
onDestroy(stopTotpTimer)
function showToast(message) {
toast = message
if (toastTimer) clearTimeout(toastTimer)
toastTimer = setTimeout(() => { toast = '' }, 3000)
}
async function copyToClipboard(text, label) {
try {
await navigator.clipboard.writeText(text)
showToast(`✓ ${label} copied (auto-clear in 15s)`)
// Auto-clear after 15 seconds
setTimeout(async () => {
try {
// Try to clear by writing spaces
await navigator.clipboard.writeText('')
} catch {
// Some browsers don't allow clearing clipboard
}
}, 15000)
} catch (e) {
// Fallback for browsers without clipboard API
const textarea = document.createElement('textarea')
textarea.value = text
textarea.style.position = 'fixed'
textarea.style.opacity = '0'
document.body.appendChild(textarea)
textarea.select()
document.execCommand('copy')
document.body.removeChild(textarea)
showToast(`✓ ${label} copied`)
}
}
async function handleMoveToTrash() {
deleting = true
try {
await moveToTrash(entryId)
onBack()
} catch (e) {
error = 'Failed to move to trash: ' + e.message
}
deleting = false
showDeleteConfirm = false
}
async function handlePermanentDelete() {
deleting = true
try {
await deleteEntry(entryId)
onBack()
} catch (e) {
error = 'Failed to permanently delete: ' + e.message
}
deleting = false
showPermanentDeleteConfirm = false
}
</script>
<div class="entry-detail">
<!-- Toast notification -->
{#if toast}
<div class="toast">{toast}</div>
{/if}
{#if loading}
<div class="loading">Loading...</div>
{:else if error}
<div class="error-banner">{error}</div>
{:else if !entry}
<div class="empty-state">Entry not found</div>
{:else}
<div class="detail-card">
<div class="detail-header">
<h2>{entry.title}</h2>
<div class="header-actions">
{#if isInTrash}
<button class="btn btn-primary btn-sm" onclick={() => onEdit(entry.id)}>↩️ Restore</button>
<button class="btn btn-danger btn-sm" onclick={() => showPermanentDeleteConfirm = true}>🗑 Delete Forever</button>
{:else}
<button class="btn btn-ghost btn-sm" onclick={() => onEdit(entry.id)}>✏️ Edit</button>
<button class="btn btn-danger btn-sm" onclick={() => showDeleteConfirm = true}>🗑 Move to Trash</button>
{/if}
</div>
</div>
<div class="detail-fields">
{#if entry.username}
<div class="detail-field">
<span class="field-label">Username</span>
<div class="field-value">
<span>{entry.username}</span>
<button class="btn btn-ghost btn-sm copy-btn" onclick={() => copyToClipboard(entry.username, 'Username')} title="Copy username">📋</button>
</div>
</div>
{/if}
{#if entry.encryptedPassword}
<div class="detail-field">
<span class="field-label">Password</span>
<div class="field-value">
<span>{passwordVisible ? decryptedPassword : '••••••••••••'}</span>
<button class="btn btn-ghost btn-sm" onclick={() => passwordVisible = !passwordVisible} title="Toggle visibility">
{passwordVisible ? '🙈' : '👁'}
</button>
<button class="btn btn-ghost btn-sm copy-btn" onclick={() => copyToClipboard(decryptedPassword, 'Password')} title="Copy password">📋</button>
</div>
</div>
{/if}
{#if entry.encryptedTotpSecret}
<div class="detail-field">
<span class="field-label">2FA Code (TOTP)</span>
<div class="field-value totp-value">
<span class:totp-urgent={totalRemaining} class="totp-code">
{totpCode ? String(totpCode).replace(/^(.{3})/, '$1 ') : ''}
</span>
<button class="btn btn-ghost btn-sm copy-btn" onclick={() => copyToClipboard(totpCode, '2FA code')} title="Copy 2FA code">📋</button>
</div>
<div class="totp-remaining" aria-hidden="true">
<span class="totp-dot" class:urgent={totalRemaining}></span>
<span class="text-xs text-muted">{totpRemaining}s</span>
</div>
</div>
{/if}
{#if entry.url}
<div class="detail-field">
<span class="field-label">URL</span>
<div class="field-value">
<a href={entry.url} target="_blank" rel="noopener noreferrer">{entry.url}</a>
<button class="btn btn-ghost btn-sm copy-btn" onclick={() => copyToClipboard(entry.url, 'URL')} title="Copy URL">📋</button>
</div>
</div>
{/if}
{#if entry.notes}
<div class="detail-field">
<span class="field-label">Notes</span>
<div class="field-value notes">{entry.notes}</div>
</div>
{/if}
</div>
<div class="detail-meta">
<span class="text-xs text-muted">Created: {new Date(entry.createdAt).toLocaleString()}</span>
<span class="text-xs text-muted">Updated: {new Date(entry.updatedAt).toLocaleString()}</span>
</div>
</div>
<!-- Move to Trash confirmation modal -->
{#if showDeleteConfirm}
<div class="modal-overlay" role="presentation" onclick={() => showDeleteConfirm = false}>
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
<div class="modal" role="dialog" aria-modal="true" aria-label="Move to trash confirmation" tabindex="-1" onclick={(e) => e.stopPropagation()}>
<h3>Move to Trash</h3>
<p>Move "<strong>{entry.title}</strong>" to the trash? You can restore it later.</p>
<div class="modal-actions">
<button class="btn btn-danger" onclick={handleMoveToTrash} disabled={deleting}>
{deleting ? 'Moving...' : 'Move to Trash'}
</button>
<button class="btn btn-ghost" onclick={() => showDeleteConfirm = false}>Cancel</button>
</div>
</div>
</div>
{/if}
<!-- Permanent delete confirmation modal -->
{#if showPermanentDeleteConfirm}
<div class="modal-overlay" role="presentation" onclick={() => showPermanentDeleteConfirm = false}>
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
<div class="modal" role="dialog" aria-modal="true" aria-label="Permanent delete confirmation" tabindex="-1" onclick={(e) => e.stopPropagation()}>
<h3>Delete Forever</h3>
<p>Permanently delete "<strong>{entry.title}</strong>"? This cannot be undone.</p>
<div class="modal-actions">
<button class="btn btn-danger" onclick={handlePermanentDelete} disabled={deleting}>
{deleting ? 'Deleting...' : 'Delete Forever'}
</button>
<button class="btn btn-ghost" onclick={() => showPermanentDeleteConfirm = false}>Cancel</button>
</div>
</div>
</div>
{/if}
{/if}
</div>
<style>
.loading, .empty-state {
text-align: center;
padding: 3rem;
color: var(--color-text-muted);
}
.error-banner {
padding: 12px 16px;
background: rgba(229, 72, 77, 0.15);
border: 1px solid rgba(229, 72, 77, 0.4);
border-radius: var(--radius-md);
color: var(--color-danger);
font-size: 0.85rem;
}
.toast {
position: fixed;
bottom: 20px;
right: 20px;
padding: 10px 16px;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
font-size: 0.85rem;
color: var(--color-success);
box-shadow: var(--shadow);
z-index: 1000;
animation: slideIn 200ms ease;
}
@keyframes slideIn {
from { transform: translateY(20px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
.detail-card {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
padding: 24px;
max-width: 600px;
}
.detail-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
padding-bottom: 16px;
border-bottom: 1px solid var(--color-border);
gap: 12px;
}
.detail-header h2 {
font-size: 1.25rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.header-actions {
display: flex;
gap: 8px;
flex-shrink: 0;
}
.detail-fields {
display: flex;
flex-direction: column;
gap: 16px;
}
.field-label {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text-muted);
margin-bottom: 4px;
display: block;
}
.field-value {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.95rem;
word-break: break-all;
}
.field-value.notes {
white-space: pre-wrap;
}
.field-value a {
color: var(--color-primary);
text-decoration: none;
}
.field-value a:hover {
text-decoration: underline;
}
.copy-btn {
flex-shrink: 0;
}
.totp-value .totp-code {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 1.15rem;
font-weight: 700;
letter-spacing: 0.14em;
color: var(--color-primary);
}
.totp-value .totp-code.totp-urgent {
color: var(--color-danger);
}
.totp-remaining {
display: flex;
align-items: center;
gap: 6px;
margin-top: 6px;
}
.totp-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--color-success);
display: inline-block;
}
.totp-dot.urgent {
background: var(--color-danger);
}
.detail-meta {
display: flex;
gap: 16px;
margin-top: 24px;
padding-top: 16px;
border-top: 1px solid var(--color-border);
}
/* Modal */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 200;
padding: 1rem;
}
.modal {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
padding: 24px;
max-width: 400px;
width: 100%;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
}
.modal h3 {
margin-bottom: 12px;
}
.modal p {
color: var(--color-text-muted);
font-size: 0.9rem;
margin-bottom: 20px;
}
.modal-actions {
display: flex;
gap: 8px;
}
@media (max-width: 600px) {
.header-actions {
display: none;
}
}
</style>

275
src/components/EntryForm.js Normal file
View File

@ -0,0 +1,275 @@
/**
* EntryForm create/edit credential form.
*/
import { Component } from './component.js'
import { addEntry, updateEntry, getEntryById, getGroups } from '../lib/storage/db.js'
import { encrypt, decrypt } from '../lib/crypto/crypto.js'
import { createEntry, updateEntry as updateEntryModel, validateEntry, isTrashGroup } from '../lib/models/schema.js'
import { generatePassword } from '../lib/crypto/crypto.js'
import { app } from '../lib/stores/app.js'
import { search } from '../lib/stores/search.js'
import { autofocus } from '../lib/autofocus.js'
export class EntryForm extends Component {
/** @param {{ entryId: string|null, onSave: Function, onCancel: Function }} props */
constructor(container, props = {}) {
super(container)
this.entryId = props.entryId || null
this.onSave = props.onSave || (() => {})
this.onCancel = props.onCancel || (() => {})
this.title = ''
this.username = ''
this.password = ''
this.url = ''
this.notes = ''
this.groupId = ''
this.passwordVisible = false
this.groups = []
this.loading = true
this.error = ''
this.saving = false
this.isEdit = false
this.formErrors = []
}
mount() {
super.mount()
this.#loadForm()
return this
}
render() {
this.el = this.ce('div', { className: 'entry-form' })
this.#renderContent()
return this.el
}
#renderContent() {
this.el.innerHTML = ''
if (this.loading) {
this.el.appendChild(this.ce('div', { className: 'loading', textContent: 'Loading...' }))
return
}
if (this.error && !this.isEdit) {
this.el.appendChild(this.ce('div', { className: 'error-banner', textContent: this.error }))
return
}
const form = this.ce('form', { className: 'ef-form-card', id: 'entry-form' })
// Validation errors
if (this.formErrors.length > 0) {
const errDiv = this.ce('div', { className: 'validation-errors' })
for (const err of this.formErrors) {
errDiv.appendChild(this.ce('div', { className: 'validation-error', textContent: `${err}` }))
}
form.appendChild(errDiv)
}
// Title
form.appendChild(this.ce('div', { className: 'form-group' },
this.ce('label', { htmlFor: 'title', textContent: 'Title *' }),
this.ce('input', { id: 'title', type: 'text', placeholder: 'e.g. GitHub, Gmail', value: this.title }),
))
// Username
form.appendChild(this.ce('div', { className: 'form-group' },
this.ce('label', { htmlFor: 'username', textContent: 'Username / Email' }),
this.ce('input', { id: 'username', type: 'text', placeholder: 'username or email', value: this.username }),
))
// Password
const pwdGroup = this.ce('div', { className: 'form-group' },
this.ce('label', { htmlFor: 'password', textContent: 'Password *' }),
this.ce('div', { className: 'password-input-group' },
this.ce('input', {
id: 'password',
type: this.passwordVisible ? 'text' : 'password',
placeholder: 'Password',
value: this.password,
}),
this.ce('button', { type: 'button', className: 'btn btn-ghost btn-sm', id: 'toggle-pwd', textContent: this.passwordVisible ? '🙈' : '👁', title: 'Toggle visibility' }),
this.ce('button', { type: 'button', className: 'btn btn-ghost btn-sm', id: 'generate-pwd', textContent: '🎲', title: 'Generate password' }),
),
)
form.appendChild(pwdGroup)
// URL
form.appendChild(this.ce('div', { className: 'form-group' },
this.ce('label', { htmlFor: 'url', textContent: 'URL' }),
this.ce('input', { id: 'url', type: 'url', placeholder: 'https://example.com', value: this.url }),
))
// Group
const select = this.ce('select', { id: 'group' })
const defaultOpt = this.ce('option', { value: '' }, this.text('No group'))
select.appendChild(defaultOpt)
for (const group of this.groups) {
if (isTrashGroup(group.id)) continue
const opt = this.ce('option', { value: group.id }, this.text(group.name))
if (group.id === this.groupId) opt.selected = true
select.appendChild(opt)
}
form.appendChild(this.ce('div', { className: 'form-group' },
this.ce('label', { htmlFor: 'group', textContent: 'Group' }),
select,
))
// Notes
form.appendChild(this.ce('div', { className: 'form-group' },
this.ce('label', { htmlFor: 'notes', textContent: 'Notes' }),
this.ce('textarea', { id: 'notes', placeholder: 'Any additional notes...' }),
))
// Actions
form.appendChild(this.ce('div', { className: 'ef-form-actions' },
this.ce('button', {
type: 'submit',
className: 'btn btn-primary',
disabled: this.saving,
textContent: this.saving ? 'Saving...' : (this.isEdit ? '💾 Update' : ' Create'),
}),
this.ce('button', { type: 'button', className: 'btn btn-ghost', id: 'cancel-btn', textContent: 'Cancel' }),
))
this.el.appendChild(form)
// Wire up form events
const formEl = this.q('#entry-form')
if (formEl) this.on(formEl, 'submit', this.#handleSubmit)
const cancelBtn = this.q('#cancel-btn')
if (cancelBtn) this.on(cancelBtn, 'click', () => this.onCancel())
const togglePwd = this.q('#toggle-pwd')
if (togglePwd) {
this.on(togglePwd, 'click', () => {
this.passwordVisible = !this.passwordVisible
const pwdInput = this.q('#password')
if (pwdInput) pwdInput.type = this.passwordVisible ? 'text' : 'password'
togglePwd.textContent = this.passwordVisible ? '🙈' : '👁'
})
}
const generatePwd = this.q('#generate-pwd')
if (generatePwd) {
this.on(generatePwd, 'click', () => {
this.password = generatePassword({ length: 16 })
const pwdInput = this.q('#password')
if (pwdInput) pwdInput.value = this.password
})
}
// Wire input changes
const titleInput = this.q('#title')
if (titleInput) this.on(titleInput, 'input', (e) => { this.title = e.target.value })
const usernameInput = this.q('#username')
if (usernameInput) this.on(usernameInput, 'input', (e) => { this.username = e.target.value })
const pwdInput = this.q('#password')
if (pwdInput) this.on(pwdInput, 'input', (e) => { this.password = e.target.value })
const urlInput = this.q('#url')
if (urlInput) this.on(urlInput, 'input', (e) => { this.url = e.target.value })
const notesInput = this.q('#notes')
if (notesInput) this.on(notesInput, 'input', (e) => { this.notes = e.target.value })
const groupSelect = this.q('#group')
if (groupSelect) this.on(groupSelect, 'change', (e) => { this.groupId = e.target.value })
// Autofocus title on new entries
if (!this.isEdit && titleInput) autofocus(titleInput, true)
}
async #loadForm() {
this.loading = true
try {
this.groups = await getGroups()
if (this.entryId) {
this.isEdit = true
const entry = await getEntryById(this.entryId)
if (entry) {
this.title = entry.title
this.username = entry.username
this.password = await decrypt(entry.encryptedPassword, app.encryptionKey)
this.url = entry.url || ''
this.notes = entry.notes || ''
this.groupId = entry.groupId || ''
} else {
this.error = 'Entry not found'
}
} else {
const active = search.activeGroupId
this.groupId = (active !== 'all' && active !== 'trash') ? active : ''
}
} catch (e) {
this.error = 'Failed to load form: ' + e.message
}
this.loading = false
this.#renderContent()
}
#handleSubmit = async (e) => {
e.preventDefault()
this.formErrors = []
this.error = ''
this.saving = true
// Read current values from inputs
const titleInput = this.q('#title')
if (titleInput) this.title = titleInput.value
const usernameInput = this.q('#username')
if (usernameInput) this.username = usernameInput.value
const pwdInput = this.q('#password')
if (pwdInput) this.password = pwdInput.value
const urlInput = this.q('#url')
if (urlInput) this.url = urlInput.value
const notesInput = this.q('#notes')
if (notesInput) this.notes = notesInput.value
const groupSelect = this.q('#group')
if (groupSelect) this.groupId = groupSelect.value
try {
const validation = validateEntry({ title: this.title, username: this.username, encryptedPassword: this.password })
if (!validation.valid) {
this.formErrors = validation.errors
this.saving = false
this.#renderContent()
return
}
const encryptedPassword = await encrypt(this.password, app.encryptionKey)
if (this.isEdit) {
const existing = await getEntryById(this.entryId)
const updated = updateEntryModel(existing, {
title: this.title,
username: this.username,
encryptedPassword,
url: this.url,
notes: this.notes,
groupId: this.groupId,
})
await updateEntry(updated)
} else {
const entry = createEntry({
title: this.title,
username: this.username,
encryptedPassword,
url: this.url,
notes: this.notes,
groupId: this.groupId,
})
await addEntry(entry)
}
this.onSave()
} catch (e) {
this.error = 'Failed to save: ' + e.message
}
this.saving = false
this.#renderContent()
}
}

View File

@ -1,278 +0,0 @@
<script>
import { addEntry, updateEntry, getEntryById, getGroups } from '../lib/storage/db.js'
import { encrypt, decrypt } from '../lib/crypto/crypto.js'
import { createEntry, updateEntry as updateEntryModel, validateEntry, isTrashGroup } from '../lib/models/schema.js'
import { generatePassword } from '../lib/crypto/crypto.js'
import { validateTotpSecret } from '../lib/crypto/totp.js'
import { app } from '../lib/stores/app.svelte.js'
import { search as searchStore } from '../lib/stores/search.svelte.js'
import { autofocus } from '../lib/autofocus.js'
let { entryId, onSave, onCancel } = $props()
let title = $state('')
let username = $state('')
let password = $state('')
let url = $state('')
let notes = $state('')
let groupId = $state('')
let totpSecret = $state('')
let passwordVisible = $state(false)
let groups = $state([])
let loading = $state(true)
let error = $state('')
let saving = $state(false)
let isEdit = $state(false)
let formErrors = $state([])
let totpError = $state('')
async function loadForm() {
loading = true
try {
groups = await getGroups()
if (entryId) {
isEdit = true
const entry = await getEntryById(entryId)
if (entry) {
title = entry.title
username = entry.username
password = entry.encryptedPassword ? await decrypt(entry.encryptedPassword, app.encryptionKey) : ''
totpSecret = entry.encryptedTotpSecret ? await decrypt(entry.encryptedTotpSecret, app.encryptionKey) : ''
url = entry.url || ''
notes = entry.notes || ''
groupId = entry.groupId || ''
} else {
error = 'Entry not found'
}
} else {
// Default to the currently active group if it's a real group
const active = searchStore.activeGroupId
groupId = (active !== 'all' && active !== 'trash') ? active : ''
}
} catch (e) {
error = 'Failed to load form: ' + e.message
}
loading = false
}
loadForm()
async function handleSubmit() {
formErrors = []
error = ''
saving = true
try {
const validation = validateEntry({ title, username, encryptedPassword: password })
if (!validation.valid) {
formErrors = validation.errors
saving = false
return
}
// TOTP secret is optional, but if provided it must be valid.
const cleanTotp = totpSecret.trim()
if (cleanTotp) {
const totpCheck = validateTotpSecret(cleanTotp)
if (!totpCheck.valid) {
totpError = totpCheck.error
saving = false
return
}
}
const encryptedPassword = password ? await encrypt(password, app.encryptionKey) : ''
// Accept raw base32 or an otpauth:// URI; store encrypted only when given.
const encryptedTotpSecret = cleanTotp ? await encrypt(cleanTotp, app.encryptionKey) : ''
if (isEdit) {
const existing = await getEntryById(entryId)
const updated = updateEntryModel(existing, {
title,
username,
encryptedPassword,
encryptedTotpSecret,
url,
notes,
groupId,
})
await updateEntry(updated)
} else {
const entry = createEntry({
title,
username,
encryptedPassword,
encryptedTotpSecret,
url,
notes,
groupId,
})
await addEntry(entry)
}
onSave()
} catch (e) {
error = 'Failed to save: ' + e.message
}
saving = false
}
</script>
<div class="entry-form">
{#if loading}
<div class="loading">Loading...</div>
{:else}
{#if error}
<div class="error-banner">{error}</div>
{/if}
<form class="form-card" onsubmit={(e) => { e.preventDefault(); handleSubmit(); }}>
{#if formErrors.length > 0}
<div class="validation-errors">
{#each formErrors as err}
<div class="validation-error">{err}</div>
{/each}
</div>
{/if}
<div class="form-group">
<label for="title">Title *</label>
<input id="title" type="text" bind:value={title} placeholder="e.g. GitHub, Gmail" use:autofocus={!isEdit} />
</div>
<div class="form-group">
<label for="username">Username / Email</label>
<input id="username" type="text" bind:value={username} placeholder="username or email" />
</div>
<div class="form-group">
<label for="password">Password</label>
<div class="password-input-group">
<input
id="password"
type={passwordVisible ? 'text' : 'password'}
bind:value={password}
placeholder="Password"
/>
<button type="button" class="btn btn-ghost btn-sm" onclick={() => passwordVisible = !passwordVisible} title="Toggle visibility">
{passwordVisible ? '🙈' : '👁'}
</button>
<button type="button" class="btn btn-ghost btn-sm" onclick={() => password = generatePassword({ length: 16 })} title="Generate password">
🎲
</button>
</div>
</div>
<div class="form-group">
<label for="totp">TOTP Secret (2FA) — optional</label>
<input
id="totp"
type="text"
bind:value={totpSecret}
oninput={() => totpError = ''}
class:input-error={totpError}
placeholder="Base32 secret or otpauth:// URI (e.g. JBSWY3DPEHPK3PXP)"
autocomplete="off"
spellcheck="false"
/>
{#if totpError}
<p class="field-error">{totpError}</p>
{/if}
</div>
<div class="form-group">
<label for="url">URL</label>
<input id="url" type="url" bind:value={url} placeholder="https://example.com" />
</div>
<div class="form-group">
<label for="group">Group</label>
<select id="group" bind:value={groupId}>
<option value="">No group</option>
{#each groups as group}
{#if !isTrashGroup(group.id)}
<option value={group.id}>{group.name}</option>
{/if}
{/each}
</select>
</div>
<div class="form-group">
<label for="notes">Notes</label>
<textarea id="notes" bind:value={notes} placeholder="Any additional notes..."></textarea>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary" disabled={saving}>
{saving ? 'Saving...' : (isEdit ? '💾 Update' : ' Create')}
</button>
<button type="button" class="btn btn-ghost" onclick={onCancel}>Cancel</button>
</div>
</form>
{/if}
</div>
<style>
.loading {
text-align: center;
padding: 3rem;
color: var(--color-text-muted);
}
.error-banner {
padding: 12px 16px;
background: rgba(229, 72, 77, 0.15);
border: 1px solid rgba(229, 72, 77, 0.4);
border-radius: var(--radius-md);
color: var(--color-danger);
font-size: 0.85rem;
margin-bottom: 16px;
}
.form-card {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
padding: 24px;
max-width: 500px;
}
.validation-errors {
margin-bottom: 16px;
padding: 12px;
background: rgba(251, 191, 36, 0.1);
border: 1px solid rgba(251, 191, 36, 0.3);
border-radius: var(--radius-md);
}
.validation-error {
font-size: 0.85rem;
color: var(--color-warning);
}
.input-error {
border-color: var(--color-danger) !important;
}
.field-error {
font-size: 0.8rem;
color: var(--color-danger);
margin-top: 4px;
}
.password-input-group {
display: flex;
gap: 8px;
}
.password-input-group input {
flex: 1;
}
.form-actions {
display: flex;
gap: 8px;
margin-top: 20px;
}
</style>

212
src/components/EntryList.js Normal file
View File

@ -0,0 +1,212 @@
/**
* EntryList credential entries grid with search/filter support.
*/
import { Component } from './component.js'
import { getEntries, searchEntries, restoreEntry, TRASH_GROUP_ID } from '../lib/storage/db.js'
import { search } from '../lib/stores/search.js'
export class EntryList extends Component {
entries = []
loading = true
error = ''
resultCount = 0
dragging = false
/** @param {{ onSelect: Function, onAdd: Function }} props */
constructor(container, props = {}) {
super(container)
this.onSelect = props.onSelect || (() => {})
this.onAdd = props.onAdd || (() => {})
}
mount() {
super.mount()
// Subscribe to reactive changes
this.subscribe(search, 'debouncedQuery', () => this.#loadEntries())
this.subscribe(search, 'activeGroupId', () => this.#loadEntries())
this.subscribe(search, 'refreshTrigger', () => this.#loadEntries())
// Initial load (subscribers only fire on change, not on mount)
this.#loadEntries()
return this
}
render() {
this.el = this.ce('div', { className: 'entry-list' })
this.#renderContent()
return this.el
}
#renderContent() {
this.el.innerHTML = ''
const isTrashView = search.activeGroupId === 'trash'
if (this.loading) {
this.el.appendChild(this.ce('div', { className: 'loading', textContent: 'Loading entries...' }))
return
}
if (this.error) {
this.el.appendChild(this.ce('div', { className: 'error-banner', textContent: this.error }))
return
}
if (this.entries.length === 0) {
const emptyState = this.ce('div', { className: 'empty-state' })
const icon = search.query ? '🔍' : (isTrashView ? '🗑' : '🔑')
const text = search.query ? 'No results found' : (isTrashView ? 'Trash is empty' : 'No entries yet')
const hint = search.query
? 'Try a different search term'
: (isTrashView ? 'Deleted entries will appear here' : 'Add your first login credential to get started')
emptyState.appendChild(this.ce('p', { className: 'empty-icon', textContent: icon }))
emptyState.appendChild(this.ce('p', { className: 'empty-text', textContent: text }))
emptyState.appendChild(this.ce('p', { className: 'empty-hint', textContent: hint }))
if (!search.query && !isTrashView) {
const addBtn = this.ce('button', { className: 'btn btn-primary mt-3', textContent: '+ New Entry' })
this.on(addBtn, 'click', () => this.onAdd())
emptyState.appendChild(addBtn)
}
this.el.appendChild(emptyState)
return
}
// Results info
const info = this.ce('div', { className: 'results-info' })
const countSpan = this.ce('span', { className: 'text-sm text-muted' })
countSpan.appendChild(this.text(`${this.resultCount} entr${this.resultCount === 1 ? 'y' : 'ies'}`))
if (search.query) {
countSpan.appendChild(this.text(' matching "'))
const strong = document.createElement('strong')
strong.textContent = this.#escapeHtml(search.query)
countSpan.appendChild(strong)
countSpan.appendChild(this.text('"'))
}
info.appendChild(countSpan)
this.el.appendChild(info)
// Table
const table = this.ce('table', { className: 'entries-table' },
this.ce('thead', null,
this.ce('tr', null,
this.ce('th', { textContent: 'Title' }),
this.ce('th', { textContent: 'Username' }),
this.ce('th', { textContent: 'URL' }),
this.ce('th', { textContent: 'Notes' }),
isTrashView ? this.ce('th', { style: 'width: 60px' }) : null,
),
),
this.ce('tbody'),
)
const tbody = table.querySelector('tbody')
for (const entry of this.entries) {
const tr = this.ce('tr', {
className: `entry-row${this.dragging ? ' dragging' : ''}`,
draggable: !isTrashView,
})
// Title cell
const tdTitle = this.ce('td')
if (!isTrashView) {
tdTitle.appendChild(this.ce('span', { className: 'drag-handle', 'aria-hidden': 'true', textContent: '⠿' }))
}
tdTitle.appendChild(this.ce('span', { className: 'entry-title', textContent: entry.title }))
tr.appendChild(tdTitle)
// Username cell
tr.appendChild(this.ce('td', null,
this.ce('span', { className: 'entry-username', textContent: entry.username || '—' }),
))
// URL cell
tr.appendChild(this.ce('td', null,
this.ce('span', { className: 'entry-url truncate', textContent: entry.url || '—' }),
))
// Notes cell
const tdNotes = this.ce('td')
if (entry.notes) {
const tooltip = this.ce('div', { className: 'notes-tooltip' },
this.ce('span', { className: 'notes-icon', title: entry.notes, textContent: '🔍' }),
this.ce('div', { className: 'tooltip-popup', textContent: entry.notes }),
)
tdNotes.appendChild(tooltip)
} else {
tdNotes.appendChild(this.ce('span', { textContent: '—' }))
}
tr.appendChild(tdNotes)
// Restore button (trash view)
if (isTrashView) {
const tdRestore = this.ce('td')
const eid = entry.id
const restoreBtn = this.ce('button', { className: 'btn btn-ghost btn-sm restore-btn', title: 'Restore entry', textContent: '↩️' })
this.on(restoreBtn, 'click', (e) => { e.stopPropagation(); this.#handleRestore(eid) })
tdRestore.appendChild(restoreBtn)
tr.appendChild(tdRestore)
}
// Row click → select entry
const eid = entry.id
this.on(tr, 'click', () => this.onSelect(eid))
// Drag events
if (!isTrashView) {
this.on(tr, 'dragstart', (e) => {
this.dragging = true
e.dataTransfer.setData('text/plain', eid)
e.dataTransfer.effectAllowed = 'move'
})
this.on(tr, 'dragend', () => { this.dragging = false })
}
tbody.appendChild(tr)
}
this.el.appendChild(table)
}
async #loadEntries() {
this.loading = true
this.error = ''
try {
const query = search.debouncedQuery.trim()
const groupId = search.activeGroupId
const resolvedGroupId = groupId === 'trash' ? TRASH_GROUP_ID : groupId
if (query) {
const options = resolvedGroupId !== 'all' ? { groupId: resolvedGroupId } : {}
this.entries = await searchEntries(query, options)
} else if (resolvedGroupId !== 'all') {
this.entries = await getEntries({ groupId: resolvedGroupId })
} else {
this.entries = (await getEntries()).filter(e => e.groupId !== TRASH_GROUP_ID)
}
this.resultCount = this.entries.length
} catch (e) {
this.error = 'Failed to load entries: ' + e.message
}
this.loading = false
this.#renderContent()
}
async #handleRestore(entryId) {
try {
await restoreEntry(entryId)
search.refresh()
} catch (e) {
this.error = 'Failed to restore: ' + e.message
this.#renderContent()
}
}
#escapeHtml(str) {
const div = document.createElement('div')
div.textContent = str
return div.innerHTML
}
}

View File

@ -1,309 +0,0 @@
<script>
import { getEntries, searchEntries, restoreEntry, TRASH_GROUP_ID } from '../lib/storage/db.js'
import { search as searchStore } from '../lib/stores/search.svelte.js'
let entries = $state([])
let loading = $state(true)
let error = $state('')
let resultCount = $state(0)
let dragging = $state(false)
let { onSelect, onAdd } = $props()
const isTrashView = $derived(searchStore.activeGroupId === 'trash')
async function loadEntries() {
loading = true
error = ''
try {
const query = searchStore.debouncedQuery.trim()
const groupId = searchStore.activeGroupId
// Resolve 'trash' alias to the real Trash group ID for DB queries
const resolvedGroupId = groupId === 'trash' ? TRASH_GROUP_ID : groupId
if (query) {
// Search with optional group filter
const options = resolvedGroupId !== 'all' ? { groupId: resolvedGroupId } : {}
entries = await searchEntries(query, options)
} else if (resolvedGroupId !== 'all') {
// Filter by group only
entries = await getEntries({ groupId: resolvedGroupId })
} else {
// Show all (excluding trashed entries)
entries = (await getEntries()).filter(e => e.groupId !== TRASH_GROUP_ID)
}
resultCount = entries.length
} catch (e) {
error = 'Failed to load entries: ' + e.message
}
loading = false
}
async function handleRestore(entryId) {
try {
await restoreEntry(entryId)
searchStore.refresh()
} catch (e) {
error = 'Failed to restore: ' + e.message
}
}
// Reload when debounced search query, active group filter, or refresh trigger changes
$effect(() => {
searchStore.debouncedQuery
searchStore.activeGroupId
searchStore.refreshTrigger
loadEntries()
})
</script>
<div class="entry-list">
{#if loading}
<div class="loading">Loading entries...</div>
{:else if error}
<div class="error-banner">{error}</div>
{:else if entries.length === 0}
<div class="empty-state">
<p class="empty-icon">{searchStore.query ? '🔍' : (isTrashView ? '🗑' : '🔑')}</p>
<p class="empty-text">{searchStore.query ? 'No results found' : (isTrashView ? 'Trash is empty' : 'No entries yet')}</p>
<p class="empty-hint">
{searchStore.query
? 'Try a different search term'
: (isTrashView ? 'Deleted entries will appear here' : 'Add your first login credential to get started')}
</p>
{#if !searchStore.query && !isTrashView}
<button class="btn btn-primary mt-3" onclick={onAdd}>+ New Entry</button>
{/if}
</div>
{:else}
<div class="results-info">
<span class="text-sm text-muted">
{resultCount} entr{resultCount === 1 ? 'y' : 'ies'}
{#if searchStore.query}
matching "<strong>{searchStore.query}</strong>"
{/if}
</span>
</div>
<table class="entries-table">
<thead>
<tr>
<th>Title</th>
<th>Username</th>
<th>URL</th>
<th>Notes</th>
{#if isTrashView}
<th style="width: 60px"></th>
{/if}
</tr>
</thead>
<tbody>
{#each entries as entry (entry.id)}
<tr
draggable={!isTrashView}
onclick={() => onSelect(entry.id)}
ondragstart={(e) => { if (!isTrashView) { dragging = true; e.dataTransfer.setData('text/plain', entry.id); e.dataTransfer.effectAllowed = 'move'; } }}
ondragend={() => { dragging = false; }}
class="entry-row {dragging ? 'dragging' : ''}"
>
<td>
{#if !isTrashView}
<span class="drag-handle" aria-hidden="true"></span>
{/if}
<span class="entry-title">{entry.title}</span>
</td>
<td>
<span class="entry-username">{entry.username || '—'}</span>
</td>
<td>
<span class="entry-url truncate">{entry.url || '—'}</span>
</td>
<td>
{#if entry.notes}
<div class="notes-tooltip">
<span class="notes-icon" title="{entry.notes}">🔍</span>
<div class="tooltip-popup">{entry.notes}</div>
</div>
{:else}
<span></span>
{/if}
</td>
{#if isTrashView}
<td>
<button class="btn btn-ghost btn-sm restore-btn" onclick={(e) => { e.stopPropagation(); handleRestore(entry.id); }} title="Restore entry">↩️</button>
</td>
{/if}
</tr>
{/each}
</tbody>
</table>
{/if}
</div>
<style>
.loading, .empty-state {
text-align: center;
padding: 3rem 1rem;
color: var(--color-text-muted);
}
.error-banner {
padding: 12px 16px;
background: rgba(229, 72, 77, 0.15);
border: 1px solid rgba(229, 72, 77, 0.4);
border-radius: var(--radius-md);
color: var(--color-danger);
font-size: 0.85rem;
}
.empty-icon {
font-size: 3rem;
margin-bottom: 0.5rem;
}
.empty-text {
font-size: 1.1rem;
font-weight: 500;
color: var(--color-text);
}
.empty-hint {
font-size: 0.85rem;
color: var(--color-text-muted);
}
.results-info {
padding: 8px 0;
margin-bottom: 8px;
}
.entries-table {
width: 100%;
border-collapse: collapse;
}
.entries-table th {
text-align: left;
padding: 8px 12px;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text-muted);
border-bottom: 1px solid var(--color-border);
}
.entry-row {
cursor: grab;
transition: background-color 150ms, opacity 150ms;
}
.entry-row:active {
cursor: grabbing;
}
.entry-row:hover {
background: var(--color-surface-hover);
}
.entry-row.dragging {
opacity: 0.35;
}
.entry-row td {
padding: 10px 12px;
font-size: 0.875rem;
border-bottom: 1px solid var(--color-border);
}
.drag-handle {
color: var(--color-text-muted);
opacity: 0.3;
margin-right: 6px;
font-size: 0.9rem;
user-select: none;
transition: opacity 150ms;
}
.entry-row:hover .drag-handle {
opacity: 0.7;
}
.restore-btn {
font-size: 0.85rem;
padding: 4px 6px;
}
.entry-title {
font-weight: 500;
}
.entry-username {
color: var(--color-text-muted);
}
.entry-url {
color: var(--color-text-muted);
max-width: 200px;
}
.notes-icon {
color: var(--color-text-muted);
cursor: help;
font-size: 0.95rem;
opacity: 0.5;
transition: opacity 150ms;
}
.notes-icon:hover {
opacity: 1;
}
.notes-tooltip {
position: relative;
display: inline-block;
}
.tooltip-popup {
visibility: hidden;
opacity: 0;
position: absolute;
bottom: calc(100% + 6px);
left: 0;
z-index: 100;
background: var(--color-surface, #1e1e2e);
color: var(--color-text, #cdd6f4);
border: 1px solid var(--color-border, #45475a);
border-radius: var(--radius-md, 8px);
padding: 8px 12px;
font-size: 0.8rem;
min-width: 150px;
max-width: 300px;
white-space: pre-wrap;
word-break: break-word;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
pointer-events: none;
transition: opacity 150ms, visibility 150ms;
}
.notes-tooltip:hover .tooltip-popup {
visibility: visible;
opacity: 1;
}
@media (max-width: 768px) {
.entries-table th:nth-child(4),
.entry-row td:nth-child(4) {
display: none;
}
}
@media (max-width: 600px) {
.entries-table th:nth-child(3),
.entry-row td:nth-child(3) {
display: none;
}
}
</style>

View File

@ -0,0 +1,354 @@
/**
* ImportExport export with group selection + import with source password.
*/
import { Component } from './component.js'
import { exportSelected, importAll, getGroups, getEntries } from '../lib/storage/db.js'
import { search } from '../lib/stores/search.js'
import { app } from '../lib/stores/app.js'
import { isTrashGroup } from '../lib/models/schema.js'
export class ImportExport extends Component {
showExport = false
showImport = false
importMode = 'merge'
importResult = null
importError = ''
importing = false
exporting = false
sourcePassword = ''
parsedFileData = null
allGroups = []
allEntries = []
selectedGroupIds = []
get selectAll() {
return this.allGroups.length > 0 && this.allGroups.every(g => this.selectedGroupIds.includes(g.id))
}
get exportEntryCount() {
return this.allEntries.filter(e => this.selectedGroupIds.includes(e.groupId)).length
}
mount() {
super.mount()
return this
}
render() {
this.el = this.ce('div', { className: 'import-export' },
this.ce('button', { className: 'btn btn-ghost btn-sm', id: 'export-btn', title: 'Export', textContent: '📤 Export' }),
this.ce('button', { className: 'btn btn-ghost btn-sm', id: 'import-btn', title: 'Import', textContent: '📥 Import' }),
)
this.on(this.q('#export-btn'), 'click', () => this.#openExportModal())
this.on(this.q('#import-btn'), 'click', () => { this.showImport = true; this.#renderImportModal() })
return this.el
}
async #openExportModal() {
const groups = (await getGroups()).filter(g => !isTrashGroup(g.id))
this.allGroups = [{ id: '', name: 'Ungrouped', color: '#6b7280' }, ...groups]
this.allEntries = await getEntries()
this.selectedGroupIds = this.allGroups.map(g => g.id)
this.showExport = true
this.#renderExportModal()
}
#renderExportModal() {
const existing = this.q('.ie-modal-overlay')
if (existing) existing.remove()
const overlay = this.ce('div', { className: 'ie-modal-overlay', role: 'presentation' })
const modal = this.ce('div', { className: 'ie-modal', role: 'dialog', 'aria-modal': 'true', 'aria-label': 'Export vault', tabindex: '-1' })
modal.appendChild(this.ce('h3', { textContent: 'Export Vault' }))
modal.appendChild(this.ce('p', { textContent: "Select which groups to export. You'll need the source vault's master password when importing into another vault." }))
// Group select header
const header = this.ce('div', { className: 'group-select-header' },
this.ce('label', { className: 'checkbox-label' },
this.ce('input', { type: 'checkbox', id: 'select-all-checkbox', checked: this.selectAll }),
this.ce('span', { textContent: 'Select all' }),
),
this.ce('span', { className: 'entry-count', id: 'export-count', textContent: `${this.exportEntryCount} entries` }),
)
modal.appendChild(header)
// Group list
const list = this.ce('div', { className: 'group-select-list' })
for (const group of this.allGroups) {
const label = this.ce('label', { className: 'checkbox-label group-checkbox' },
this.ce('input', {
type: 'checkbox',
'data-group-id': group.id,
checked: this.selectedGroupIds.includes(group.id),
}),
this.ce('span', { className: 'group-color-dot', style: `background-color: ${group.color || '#6c63ff'}` }),
this.ce('span', { className: 'group-name', textContent: group.name }),
)
list.appendChild(label)
}
modal.appendChild(list)
// Actions
const actions = this.ce('div', { className: 'ie-modal-actions' },
this.ce('button', { className: 'btn btn-primary', id: 'do-export-btn', disabled: this.exporting || this.selectedGroupIds.length === 0, textContent: this.exporting ? 'Exporting...' : '📤 Export JSON' }),
this.ce('button', { className: 'btn btn-ghost', id: 'cancel-export-btn', textContent: 'Cancel' }),
)
modal.appendChild(actions)
overlay.appendChild(modal)
this.el.appendChild(overlay)
this.on(overlay, 'click', () => { this.showExport = false; overlay.remove() })
this.on(modal, 'click', (e) => e.stopPropagation())
// Select all
const selectAllCb = overlay.querySelector('#select-all-checkbox')
if (selectAllCb) {
this.on(selectAllCb, 'change', () => this.#toggleSelectAll())
}
// Individual checkboxes
overlay.querySelectorAll('.group-select-list input[data-group-id]').forEach(cb => {
this.on(cb, 'change', () => {
const gid = cb.dataset.groupId
this.#toggleGroup(gid)
})
})
// Export button
const exportBtn = overlay.querySelector('#do-export-btn')
if (exportBtn) this.on(exportBtn, 'click', () => this.#handleExport())
// Cancel
const cancelBtn = overlay.querySelector('#cancel-export-btn')
if (cancelBtn) this.on(cancelBtn, 'click', () => { this.showExport = false; overlay.remove() })
}
#toggleSelectAll() {
if (this.selectAll) {
this.selectedGroupIds = []
} else {
this.selectedGroupIds = this.allGroups.map(g => g.id)
}
this.#renderExportModal()
}
#toggleGroup(groupId) {
if (this.selectedGroupIds.includes(groupId)) {
this.selectedGroupIds = this.selectedGroupIds.filter(id => id !== groupId)
} else {
this.selectedGroupIds = [...this.selectedGroupIds, groupId]
}
// Update select-all checkbox and count
const overlay = this.q('.ie-modal-overlay')
if (overlay) {
const selectAllCb = overlay.querySelector('#select-all-checkbox')
if (selectAllCb) selectAllCb.checked = this.selectAll
const countEl = overlay.querySelector('#export-count')
if (countEl) countEl.textContent = `${this.exportEntryCount} entries`
const exportBtn = overlay.querySelector('#do-export-btn')
if (exportBtn) exportBtn.disabled = this.exporting || this.selectedGroupIds.length === 0
}
}
async #handleExport() {
this.exporting = true
try {
const exportData = await exportSelected(
this.selectedGroupIds.length === this.allGroups.length ? null : this.selectedGroupIds
)
const json = JSON.stringify(exportData, null, 2)
const blob = new Blob([json], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `password-vault-export-${new Date().toISOString().slice(0, 10)}.json`
a.click()
URL.revokeObjectURL(url)
this.showExport = false
} catch (e) {
this.importError = 'Export failed: ' + e.message
}
this.exporting = false
}
#renderImportModal() {
const existing = this.q('.ie-modal-overlay')
if (existing) existing.remove()
const overlay = this.ce('div', { className: 'ie-modal-overlay', role: 'presentation' })
const modal = this.ce('div', { className: 'ie-modal', role: 'dialog', 'aria-modal': 'true', 'aria-label': 'Import vault data', tabindex: '-1' })
modal.appendChild(this.ce('h3', { textContent: 'Import Vault Data' }))
if (this.importError) {
modal.appendChild(this.ce('div', { className: 'ie-error-banner', textContent: this.importError }))
}
if (this.importResult) {
const msg = `✓ Imported ${this.importResult.imported.entries} entries and ${this.importResult.imported.groups} groups` +
(this.importResult.skipped > 0 ? ` (${this.importResult.skipped} skipped)` : '')
modal.appendChild(this.ce('div', { className: 'success-banner', textContent: msg }))
} else if (this.parsedFileData) {
modal.appendChild(this.ce('p', {},
this.text('File loaded. Enter the '),
this.ce('strong', { textContent: "source vault's master password" }),
this.text(' to decrypt and re-encrypt entries under your current vault.'),
))
modal.appendChild(this.ce('div', { className: 'form-group' },
this.ce('label', { htmlFor: 'source-password', className: 'file-label', textContent: 'Source vault password' }),
this.ce('input', { id: 'source-password', type: 'password', placeholder: 'Enter source vault password', autocomplete: 'current-password', value: this.sourcePassword }),
))
// Import mode radios
const modeDiv = this.ce('div', { className: 'import-mode' })
const mergeRadio = this.ce('label', { className: 'radio-label' },
this.ce('input', { type: 'radio', name: 'importMode', value: 'merge', checked: this.importMode === 'merge' }),
this.ce('span', { textContent: 'Merge — add to existing data' }),
)
const replaceRadio = this.ce('label', { className: 'radio-label' },
this.ce('input', { type: 'radio', name: 'importMode', value: 'replace', checked: this.importMode === 'replace' }),
this.ce('span', { textContent: 'Replace — clear all existing data first' }),
)
modeDiv.appendChild(mergeRadio)
modeDiv.appendChild(replaceRadio)
modal.appendChild(modeDiv)
const actions = this.ce('div', { className: 'ie-modal-actions' },
this.ce('button', { className: 'btn btn-primary', id: 'do-import-btn', disabled: this.importing, textContent: this.importing ? 'Importing...' : '📥 Import' }),
this.ce('button', { className: 'btn btn-ghost', id: 'cancel-import-btn', textContent: 'Cancel' }),
)
modal.appendChild(actions)
overlay.appendChild(modal)
this.el.appendChild(overlay)
this.on(overlay, 'click', () => { this.showImport = false; overlay.remove() })
this.on(modal, 'click', (e) => e.stopPropagation())
const srcPwdInput = overlay.querySelector('#source-password')
if (srcPwdInput) this.on(srcPwdInput, 'input', (e) => { this.sourcePassword = e.target.value })
overlay.querySelectorAll('input[name="importMode"]').forEach(radio => {
this.on(radio, 'change', (e) => { this.importMode = e.target.value })
})
const importBtn = overlay.querySelector('#do-import-btn')
if (importBtn) this.on(importBtn, 'click', () => this.#handleImportSubmit())
const cancelBtn = overlay.querySelector('#cancel-import-btn')
if (cancelBtn) this.on(cancelBtn, 'click', () => {
this.parsedFileData = null
this.sourcePassword = ''
overlay.remove()
})
} else {
modal.appendChild(this.ce('p', { textContent: 'Select how to handle existing data:' }))
const modeDiv = this.ce('div', { className: 'import-mode' })
const mergeRadio = this.ce('label', { className: 'radio-label' },
this.ce('input', { type: 'radio', name: 'importMode', value: 'merge', checked: this.importMode === 'merge' }),
this.ce('span', { textContent: 'Merge — add to existing data' }),
)
const replaceRadio = this.ce('label', { className: 'radio-label' },
this.ce('input', { type: 'radio', name: 'importMode', value: 'replace', checked: this.importMode === 'replace' }),
this.ce('span', { textContent: 'Replace — clear all existing data first' }),
)
modeDiv.appendChild(mergeRadio)
modeDiv.appendChild(replaceRadio)
modal.appendChild(modeDiv)
modal.appendChild(this.ce('div', { className: 'form-group' },
this.ce('label', { htmlFor: 'import-file', className: 'file-label', textContent: 'Choose JSON file' }),
this.ce('input', { id: 'import-file', type: 'file', accept: '.json,application/json', disabled: this.importing }),
))
const closeActions = this.ce('div', { className: 'ie-modal-actions' },
this.ce('button', { className: 'btn btn-ghost', id: 'close-import-btn', textContent: 'Close' }),
)
modal.appendChild(closeActions)
overlay.appendChild(modal)
this.el.appendChild(overlay)
this.on(overlay, 'click', () => { this.showImport = false; overlay.remove() })
this.on(modal, 'click', (e) => e.stopPropagation())
overlay.querySelectorAll('input[name="importMode"]').forEach(radio => {
this.on(radio, 'change', (e) => { this.importMode = e.target.value })
})
const fileInput = overlay.querySelector('#import-file')
if (fileInput) this.on(fileInput, 'change', (e) => this.#handleFileSelect(e))
const closeBtn = overlay.querySelector('#close-import-btn')
if (closeBtn) this.on(closeBtn, 'click', () => {
this.showImport = false
this.importResult = null
this.importError = ''
overlay.remove()
})
}
}
async #handleFileSelect(event) {
const file = event.target.files[0]
if (!file) return
this.importError = ''
this.importResult = null
this.sourcePassword = ''
try {
const text = await file.text()
const data = JSON.parse(text)
if (!data.entries || !data.groups) {
this.importError = 'Invalid file format — missing entries or groups data'
this.#renderImportModal()
return
}
this.parsedFileData = data
} catch (e) {
this.importError = 'Failed to parse file: ' + e.message
}
event.target.value = ''
this.#renderImportModal()
}
async #handleImportSubmit() {
if (!this.parsedFileData) return
const srcPwdInput = this.q('#source-password')
if (srcPwdInput) this.sourcePassword = srcPwdInput.value
if (!this.sourcePassword.trim()) {
this.importError = 'Source vault password is required'
this.#renderImportModal()
return
}
this.importing = true
this.importError = ''
this.importResult = null
try {
this.importResult = await importAll(this.parsedFileData, this.importMode, this.sourcePassword, app.encryptionKey)
this.sourcePassword = ''
this.parsedFileData = null
search.refresh()
} catch (e) {
this.importError = 'Import failed: ' + e.message
}
this.importing = false
this.#renderImportModal()
}
}

View File

@ -1,499 +0,0 @@
<script>
import { exportSelected, importAll, getGroups, getEntries } from '../lib/storage/db.js'
import { search as searchStore } from '../lib/stores/search.svelte.js'
import { app } from '../lib/stores/app.svelte.js'
import { isTrashGroup } from '../lib/models/schema.js'
let showExport = $state(false)
async function openExportModal() {
const groups = (await getGroups()).filter(g => !isTrashGroup(g.id))
allGroups = [{ id: '', name: 'Ungrouped', color: '#6b7280' }, ...groups]
allEntries = await getEntries()
selectedGroupIds = allGroups.map(g => g.id)
showExport = true
}
let showImport = $state(false)
let importMode = $state('merge') // 'merge' or 'replace'
let importResult = $state(null)
let importError = $state('')
let importing = $state(false)
let exportData = $state(null)
let exporting = $state(false)
let sourcePassword = $state('')
let parsedFileData = $state(null)
let exportPassword = $state('')
let exportUseExistingPassword = $state(false)
// Group selection for export
let allGroups = $state([])
let allEntries = $state([])
let selectedGroupIds = $state([])
let selectAll = $derived(
allGroups.length > 0 && allGroups.every(g => selectedGroupIds.includes(g.id))
)
let exportEntryCount = $derived(
allEntries.filter(e => selectedGroupIds.includes(e.groupId)).length
)
async function handleExport() {
if (!exportUseExistingPassword && !exportPassword.trim()) {
importError = 'Choose a new password to encrypt the export'
return
}
exporting = true
try {
exportData = await exportSelected(
selectedGroupIds.length === allGroups.length ? null : selectedGroupIds,
{
vaultKey: app.encryptionKey,
password: exportUseExistingPassword ? '' : exportPassword.trim(),
useExistingPassword: exportUseExistingPassword,
}
)
exportPassword = ''
exportUseExistingPassword = false
const json = JSON.stringify(exportData, null, 2)
const blob = new Blob([json], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `password-vault-export-${new Date().toISOString().slice(0, 10)}.json`
a.click()
URL.revokeObjectURL(url)
showExport = false
} catch (e) {
importError = 'Export failed: ' + e.message
}
exporting = false
}
function toggleSelectAll() {
if (selectAll) {
selectedGroupIds = []
} else {
selectedGroupIds = allGroups.map(g => g.id)
}
}
function toggleGroup(groupId) {
if (selectedGroupIds.includes(groupId)) {
selectedGroupIds = selectedGroupIds.filter(id => id !== groupId)
} else {
selectedGroupIds = [...selectedGroupIds, groupId]
}
}
async function handleFileSelect(event) {
const file = event.target.files[0]
if (!file) return
importError = ''
importResult = null
sourcePassword = ''
try {
const text = await file.text()
const data = JSON.parse(text)
if (!data.entries || !data.groups) {
importError = 'Invalid file format — missing entries or groups data'
return
}
parsedFileData = data
} catch (e) {
importError = 'Failed to parse file: ' + e.message
}
// Reset file input
event.target.value = ''
}
async function handleImportSubmit() {
if (!parsedFileData) return
if (!sourcePassword.trim()) {
importError = 'Source vault password is required'
return
}
importing = true
importError = ''
importResult = null
try {
const result = await importAll(parsedFileData, importMode, sourcePassword, app.encryptionKey)
importResult = result
sourcePassword = ''
parsedFileData = null
searchStore.refresh()
} catch (e) {
importError = 'Import failed: ' + e.message
}
importing = false
}
</script>
<div class="import-export">
<!-- Export button -->
<button class="btn btn-ghost btn-sm" onclick={openExportModal} title="Export">
📤 Export
</button>
<!-- Import button -->
<button class="btn btn-ghost btn-sm" onclick={() => showImport = true} title="Import">
📥 Import
</button>
<!-- Export modal -->
{#if showExport}
<div class="modal-overlay" role="presentation" onclick={() => showExport = false}>
<!-- 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()}>
<h3>Export Vault</h3>
<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">
<label class="checkbox-label">
<input type="checkbox" checked={selectAll} onchange={toggleSelectAll} />
<span>Select all</span>
</label>
<span class="entry-count">{exportEntryCount} entries</span>
</div>
<div class="group-select-list">
{#each allGroups as group}
<label class="checkbox-label group-checkbox">
<input
type="checkbox"
checked={selectedGroupIds.includes(group.id)}
onchange={() => toggleGroup(group.id)}
/>
<span class="group-color-dot" style="background-color: {group.color || '#6c63ff'}"></span>
<span class="group-name">{group.name}</span>
</label>
{/each}
</div>
<div class="protection-choice">
<label class="radio-label">
<input type="radio" name="exportProtection" checked={!exportUseExistingPassword} onchange={() => exportUseExistingPassword = false} />
<span>Use a new password</span>
</label>
{#if !exportUseExistingPassword}
<div class="form-group">
<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 class="modal-actions">
<button class="btn btn-primary" onclick={handleExport} disabled={exporting || selectedGroupIds.length === 0}>
{exporting ? 'Exporting...' : '📤 Export JSON'}
</button>
<button class="btn btn-ghost" onclick={() => showExport = false}>Cancel</button>
</div>
</div>
</div>
{/if}
<!-- Import modal -->
{#if showImport}
<div class="modal-overlay" role="presentation" onclick={() => showImport = false}>
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
<div class="modal" role="dialog" aria-modal="true" aria-label="Import vault data" tabindex="-1" onclick={(e) => e.stopPropagation()}>
<h3>Import Vault Data</h3>
{#if importError}
<div class="error-banner">{importError}</div>
{/if}
{#if importResult}
<div class="success-banner">
✓ Imported {importResult.imported.entries} entries and {importResult.imported.groups} groups
{#if importResult.skipped > 0}
({importResult.skipped} skipped)
{/if}
</div>
{:else if parsedFileData}
<p>File loaded. Enter the password protecting this file &mdash; 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">
<label for="source-password" class="file-label">Password for this file</label>
<input
id="source-password"
type="password"
bind:value={sourcePassword}
placeholder="Enter the export or source vault password"
autocomplete="current-password"
/>
</div>
<div class="import-mode">
<label class="radio-label">
<input type="radio" name="importMode" value="merge" bind:group={importMode} />
<span>Merge — add to existing data</span>
</label>
<label class="radio-label">
<input type="radio" name="importMode" value="replace" bind:group={importMode} />
<span>Replace — clear all existing data first</span>
</label>
</div>
<div class="modal-actions">
<button class="btn btn-primary" onclick={handleImportSubmit} disabled={importing}>
{importing ? 'Importing...' : '📥 Import'}
</button>
<button class="btn btn-ghost" onclick={() => { parsedFileData = null; sourcePassword = ''; }}>Cancel</button>
</div>
{:else}
<p>Select how to handle existing data:</p>
<div class="import-mode">
<label class="radio-label">
<input type="radio" name="importMode" value="merge" bind:group={importMode} />
<span>Merge — add to existing data</span>
</label>
<label class="radio-label">
<input type="radio" name="importMode" value="replace" bind:group={importMode} />
<span>Replace — clear all existing data first</span>
</label>
</div>
<div class="form-group">
<label for="import-file" class="file-label">Choose JSON file</label>
<input
id="import-file"
type="file"
accept=".json,application/json"
onchange={handleFileSelect}
disabled={importing}
/>
</div>
{/if}
<div class="modal-actions">
<button class="btn btn-ghost" onclick={() => {
showImport = false
importResult = null
importError = ''
}}>Close</button>
</div>
</div>
</div>
{/if}
</div>
<style>
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 200;
padding: 1rem;
}
.modal {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
padding: 24px;
max-width: 420px;
width: 100%;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
}
.modal h3 {
margin-bottom: 12px;
}
.modal p {
color: var(--color-text-muted);
font-size: 0.9rem;
margin-bottom: 16px;
}
.modal-actions {
display: flex;
gap: 8px;
margin-top: 16px;
}
.error-banner {
padding: 10px 14px;
background: rgba(229, 72, 77, 0.15);
border: 1px solid rgba(229, 72, 77, 0.4);
border-radius: var(--radius-md);
color: var(--color-danger);
font-size: 0.85rem;
margin-bottom: 12px;
}
.success-banner {
padding: 10px 14px;
background: rgba(52, 211, 153, 0.15);
border: 1px solid rgba(52, 211, 153, 0.4);
border-radius: var(--radius-md);
color: var(--color-success);
font-size: 0.85rem;
margin-bottom: 12px;
}
.import-mode {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 16px;
}
.radio-label {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
cursor: pointer;
transition: border-color 150ms, background-color 150ms;
}
.radio-label:hover {
border-color: var(--color-primary);
background: var(--color-surface-hover);
}
.radio-label input[type="radio"] {
accent-color: var(--color-primary);
cursor: pointer;
}
.radio-label span {
font-size: 0.85rem;
}
.file-label {
font-size: 0.8rem;
font-weight: 500;
color: var(--color-text-muted);
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"] {
font-size: 0.85rem;
padding: 8px;
}
.form-group {
margin-bottom: 16px;
}
.form-group input[type="password"] {
width: 100%;
padding: 8px 12px;
font-size: 0.85rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-surface);
color: var(--color-text);
box-sizing: border-box;
}
.form-group input[type="password"]:focus {
outline: none;
border-color: var(--color-primary);
}
/* Group selection for export */
.group-select-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
border-bottom: 1px solid var(--color-border);
margin-bottom: 4px;
}
.group-select-header .entry-count {
font-size: 0.8rem;
color: var(--color-text-muted);
}
.group-select-list {
max-height: 240px;
overflow-y: auto;
margin-bottom: 8px;
}
.group-select-list::-webkit-scrollbar {
width: 6px;
}
.group-select-list::-webkit-scrollbar-track {
background: transparent;
}
.group-select-list::-webkit-scrollbar-thumb {
background: var(--color-border);
border-radius: 3px;
}
.checkbox-label {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
font-size: 0.85rem;
}
.checkbox-label input[type="checkbox"] {
accent-color: var(--color-primary);
cursor: pointer;
}
.group-checkbox {
padding: 6px 12px;
border-radius: var(--radius-md);
transition: background-color 150ms;
}
.group-checkbox:hover {
background: var(--color-surface-hover);
}
.group-color-dot {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
}
</style>

View File

@ -0,0 +1,194 @@
/**
* LockScreen master password setup + unlock UI.
*/
import { Component } from './component.js'
import { app } from '../lib/stores/app.js'
import { deriveKey, createTestPayload, verifyPassword } from '../lib/crypto/crypto.js'
import { saveVaultMeta, loadVaultMeta, isVaultInitialized, ensureTrashGroup } from '../lib/storage/db.js'
import { startAutoLock } from '../lib/stores/security.js'
import { settings } from '../lib/stores/settings.js'
import { autofocus } from '../lib/autofocus.js'
export class LockScreen extends Component {
masterPassword = ''
confirmPassword = ''
error = ''
loading = false
isSetup = false
mount() {
super.mount()
this.#checkVault()
return this
}
render() {
const notLocal = typeof window !== 'undefined' && window.location.protocol !== 'file:'
this.el = this.ce('div', { className: 'lock-screen' },
this.ce('div', { className: 'lock-card' },
this.ce('div', { className: 'lock-icon', textContent: '🔐' }),
this.ce('h1', { textContent: 'Password Vault' }),
this.ce('p', { className: 'subtitle', textContent: 'Unlock your vault' }),
notLocal ? this.ce('div', { className: 'warning-banner', role: 'alert', textContent: 'This HTML file is intended for offline use.' }) : null,
null, // error-banner placeholder
this.#buildForm(),
this.ce('p', { className: 'hint', textContent: 'Your data is encrypted with AES-256-GCM. Key is stored only in memory.' }),
),
)
// Store references to dynamic elements
this._subtitle = this.q('.subtitle')
this._confirmGroup = this.q('.confirm-group')
this._submitBtn = this.q('.submit-btn')
this._passwordInput = this.q('#master-password')
this._confirmInput = this.q('#confirm-password')
this._hint = this.q('.hint')
autofocus(this._passwordInput, true)
// Wire input listeners so this.masterPassword / this.confirmPassword stay in sync
if (this._passwordInput) {
this.on(this._passwordInput, 'input', (e) => { this.masterPassword = e.target.value })
}
if (this._confirmInput) {
this.on(this._confirmInput, 'input', (e) => { this.confirmPassword = e.target.value })
}
return this.el
}
#buildForm() {
return this.ce('form', { className: 'lock-form', id: 'lock-form' },
this.ce('div', { className: 'form-group' },
this.ce('label', { htmlFor: 'master-password', textContent: 'Master Password' }),
this.ce('input', { id: 'master-password', type: 'password', placeholder: 'Enter master password', autocomplete: 'current-password' }),
),
this.ce('div', { className: 'form-group confirm-group' },
this.ce('label', { htmlFor: 'confirm-password', textContent: 'Confirm Password' }),
this.ce('input', { id: 'confirm-password', type: 'password', placeholder: 'Confirm master password', autocomplete: 'new-password' }),
),
this.ce('button', { type: 'submit', className: 'btn btn-primary w-full submit-btn', textContent: 'Unlock' }),
)
}
#updateUI() {
if (this._subtitle) {
this._subtitle.textContent = this.isSetup ? 'Create your vault' : 'Unlock your vault'
}
if (this._confirmGroup) {
this._confirmGroup.style.display = this.isSetup ? '' : 'none'
}
if (this._submitBtn) {
this._submitBtn.textContent = this.loading ? 'Processing...' : (this.isSetup ? 'Create Vault' : 'Unlock')
this._submitBtn.disabled = this.loading
}
if (this._passwordInput) {
this._passwordInput.disabled = this.loading
}
if (this._confirmInput) {
this._confirmInput.disabled = this.loading
}
// Error banner
if (this.error) {
if (!this._errorBanner) {
const banner = this.ce('div', { className: 'error-banner', role: 'alert', textContent: this.error })
const form = this.q('#lock-form')
form?.parentNode?.insertBefore(banner, form)
this._errorBanner = banner
} else {
this._errorBanner.textContent = this.error
}
} else if (this._errorBanner) {
this._errorBanner.remove()
this._errorBanner = null
}
if (this._hint) {
this._hint.textContent = this.isSetup
? 'Your master password encrypts all data locally. It cannot be recovered if lost.'
: 'Your data is encrypted with AES-256-GCM. Key is stored only in memory.'
}
}
#checkVault() {
isVaultInitialized().then(init => {
this.isSetup = !init
this.#updateUI()
})
}
#handleSubmit = async (e) => {
e.preventDefault()
this.error = ''
this.loading = true
this.#updateUI()
try {
if (this.isSetup) {
if (!this.masterPassword || this.masterPassword.length < 4) {
this.error = 'Password must be at least 4 characters'
this.loading = false
this.#updateUI()
return
}
if (this.masterPassword !== this.confirmPassword) {
this.error = 'Passwords do not match'
this.loading = false
this.#updateUI()
return
}
const { salt, testEncrypted, testPlaintext } = await createTestPayload(this.masterPassword)
app.salt = salt
const key = await deriveKey(this.masterPassword, salt)
app.encryptionKey = key
await saveVaultMeta(salt, testEncrypted, testPlaintext)
await ensureTrashGroup()
await settings.load()
app.isUnlocked = true
startAutoLock()
} else {
const meta = await loadVaultMeta()
if (!meta.salt || !meta.testEncrypted || !meta.testPlaintext) {
this.error = 'Vault data corrupted'
this.loading = false
this.#updateUI()
return
}
const key = await deriveKey(this.masterPassword, meta.salt)
const isValid = await verifyPassword(this.masterPassword, meta.salt, meta.testEncrypted, meta.testPlaintext)
if (!isValid) {
this.error = 'Incorrect password'
this.loading = false
this.#updateUI()
return
}
app.salt = meta.salt
app.encryptionKey = key
await settings.load()
app.isUnlocked = true
startAutoLock()
}
} catch (err) {
console.error(err)
this.error = 'An error occurred: ' + err.message
}
this.loading = false
this.masterPassword = ''
this.confirmPassword = ''
if (this._passwordInput) this._passwordInput.value = ''
if (this._confirmInput) this._confirmInput.value = ''
this.#updateUI()
}
afterMount() {
const form = this.q('#lock-form')
if (form) this.on(form, 'submit', this.#handleSubmit)
}
}

View File

@ -1,263 +0,0 @@
<script>
import { app } from '../lib/stores/app.svelte.js'
import { deriveKey, createTestPayload, verifyPassword } from '../lib/crypto/crypto.js'
import { saveVaultMeta, loadVaultMeta, isVaultInitialized, ensureTrashGroup, getSetting } from '../lib/storage/db.js'
import { startAutoLock } from '../lib/stores/security.svelte.js'
import { settings } from '../lib/stores/settings.svelte.js'
import { autofocus } from '../lib/autofocus.js'
let masterPassword = $state('')
let confirmPassword = $state('')
let error = $state('')
let loading = $state(false)
let isSetup = $state(false)
let notLocal = $derived(typeof window !== 'undefined' && window.location.protocol !== 'file:')
// Check if vault was already created
async function checkVault() {
isSetup = !(await isVaultInitialized())
}
checkVault()
// Visibility of the web-server warning is driven by the persisted setting
// (dismissedLocalWarning). It is loaded directly on mount so the banner
// reflects the user's stored choice immediately when the lock screen shows,
// before any unlock happens. `warningReady` avoids a one-frame flash while
// the value is read from IndexedDB.
let warningReady = $state(false)
async function loadWarningSetting() {
try {
settings.dismissedLocalWarning = Boolean(await getSetting('dismissedLocalWarning'))
} catch (e) {
console.warn('Failed to load warning setting:', e)
}
warningReady = true
}
loadWarningSetting()
async function dismissLocalWarning() {
settings.dismissedLocalWarning = true
warningReady = true
try { await settings.save() } catch (e) { console.warn('Failed to persist warning dismissal:', e) }
}
async function handleSubmit() {
error = ''
loading = true
try {
if (isSetup) {
// First-time setup: create vault
if (!masterPassword || masterPassword.length < 4) {
error = 'Password must be at least 4 characters'
loading = false
return
}
if (masterPassword !== confirmPassword) {
error = 'Passwords do not match'
loading = false
return
}
const { salt, testEncrypted, testPlaintext } = await createTestPayload(masterPassword)
app.salt = salt
const key = await deriveKey(masterPassword, salt)
app.encryptionKey = key
await saveVaultMeta(salt, testEncrypted, testPlaintext)
await ensureTrashGroup()
await settings.load()
app.isUnlocked = true
startAutoLock()
} else {
// Unlock: verify password against stored test payload
const meta = await loadVaultMeta()
if (!meta.salt || !meta.testEncrypted || !meta.testPlaintext) {
error = 'Vault data corrupted'
loading = false
return
}
const key = await deriveKey(masterPassword, meta.salt)
const isValid = await verifyPassword(masterPassword, meta.salt, meta.testEncrypted, meta.testPlaintext)
if (!isValid) {
error = 'Incorrect password'
loading = false
return
}
app.salt = meta.salt
app.encryptionKey = key
await settings.load()
app.isUnlocked = true
startAutoLock()
}
} catch (e) {
console.error(e)
error = 'An error occurred: ' + e.message
}
loading = false
masterPassword = ''
confirmPassword = ''
}
</script>
<div class="lock-screen">
<div class="lock-card">
<div class="lock-icon">🔐</div>
<h1>Password Vault</h1>
<p class="subtitle">{isSetup ? 'Create your vault' : 'Unlock your vault'}</p>
{#if notLocal && warningReady && !settings.dismissedLocalWarning}
<div class="warning-banner" role="alert">
<span class="warning-text">You're viewing this from a web server. For best security, download this HTML file and open it locally on your computer instead.</span>
<button class="warning-dismiss" onclick={dismissLocalWarning} aria-label="Dismiss warning"></button>
</div>
{/if}
{#if error}
<div class="error-banner" role="alert">{error}</div>
{/if}
<form onsubmit={(e) => { e.preventDefault(); handleSubmit(); }} class="lock-form">
<div class="form-group">
<label for="master-password">Master Password</label>
<input
id="master-password"
type="password"
bind:value={masterPassword}
placeholder="Enter master password"
autocomplete="current-password"
use:autofocus
disabled={loading}
/>
</div>
{#if isSetup}
<div class="form-group">
<label for="confirm-password">Confirm Password</label>
<input
id="confirm-password"
type="password"
bind:value={confirmPassword}
placeholder="Confirm master password"
autocomplete="new-password"
disabled={loading}
/>
</div>
{/if}
<button type="submit" class="btn btn-primary w-full" disabled={loading}>
{loading ? 'Processing...' : (isSetup ? 'Create Vault' : 'Unlock')}
</button>
</form>
<p class="hint">
{isSetup
? 'Your master password encrypts all data locally. It cannot be recovered if lost.'
: 'Your data is encrypted with AES-256-GCM. Key is stored only in memory.'}
</p>
</div>
</div>
<style>
.lock-screen {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
padding: 1rem;
}
.lock-card {
width: 100%;
max-width: 400px;
padding: 2.5rem 2rem;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow);
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
}
.lock-icon {
font-size: 3rem;
line-height: 1;
}
h1 {
font-size: 1.5rem;
text-align: center;
}
.subtitle {
color: var(--color-text-muted);
font-size: 0.9rem;
text-align: center;
}
.lock-form {
width: 100%;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.error-banner {
width: 100%;
padding: 10px 14px;
background: rgba(229, 72, 77, 0.15);
border: 1px solid rgba(229, 72, 77, 0.4);
border-radius: var(--radius-md);
color: var(--color-danger);
font-size: 0.85rem;
text-align: center;
}
.warning-banner {
width: 100%;
display: flex;
align-items: flex-start;
gap: 8px;
padding: 10px 10px 10px 14px;
background: rgba(255, 193, 7, 0.15);
border: 1px solid rgba(230, 168, 0, 0.5);
border-radius: var(--radius-md);
color: #b8860b;
font-size: 0.85rem;
}
.warning-text {
flex: 1;
text-align: left;
}
.warning-dismiss {
flex-shrink: 0;
border: none;
background: transparent;
color: inherit;
font-size: 0.9rem;
line-height: 1;
cursor: pointer;
padding: 0 2px;
opacity: 0.7;
}
.warning-dismiss:hover {
opacity: 1;
}
.hint {
font-size: 0.75rem;
color: var(--color-text-muted);
text-align: center;
line-height: 1.4;
margin-top: 0.5rem;
}
</style>

View File

@ -0,0 +1,298 @@
/**
* MainLayout shell: sidebar + content area with view routing.
*/
import { Component } from './component.js'
import { app } from '../lib/stores/app.js'
import { search } from '../lib/stores/search.js'
import { TRASH_GROUP_NAME } from '../lib/models/schema.js'
import { emptyTrash } from '../lib/storage/db.js'
import { Sidebar } from './Sidebar.js'
import { EntryList } from './EntryList.js'
import { EntryDetail } from './EntryDetail.js'
import { EntryForm } from './EntryForm.js'
import { ImportExport } from './ImportExport.js'
import { SettingsDialog } from './SettingsDialog.js'
export class MainLayout extends Component {
sidebarOpen = false
viewMode = 'list' // 'list' | 'detail' | 'form' | 'settings'
selectedEntryId = null
showEmptyTrashConfirm = false
emptyingTrash = false
// Child component instances
_sidebar = null
_importExport = null
_contentComponent = null
get isTrashView() {
return search.activeGroupId === 'trash'
}
mount() {
super.mount()
// Subscribe to app lock — unmount everything and emit event
this.subscribe(app, 'isUnlocked', (unlocked) => {
if (!unlocked) this.emitLock()
})
return this
}
render() {
this.el = this.ce('div', { className: 'app-shell' })
// Mobile header
this.el.appendChild(this.ce('div', { className: 'mobile-header' },
this.ce('button', { className: 'btn btn-ghost btn-sm', id: 'menu-btn', textContent: '☰ Menu' }),
this.ce('span', { className: 'mobile-title', textContent: 'Password Vault' }),
this.ce('button', { className: 'btn btn-ghost btn-sm', id: 'mobile-lock-btn', title: 'Lock', textContent: '🔒' }),
))
// Sidebar
const aside = this.ce('aside', { className: 'sidebar', id: 'sidebar' })
this.el.appendChild(aside)
// Main content
const main = this.ce('main', { className: 'main-content' },
this.ce('div', { className: 'top-bar', id: 'top-bar' }),
this.ce('div', { className: 'content-area', id: 'content-area' }),
)
this.el.appendChild(main)
// Wire mobile header buttons
this.on(this.q('#menu-btn'), 'click', () => { this.sidebarOpen = !this.sidebarOpen; this.#updateSidebar() })
this.on(this.q('#mobile-lock-btn'), 'click', () => app.lockVault())
// Mount sidebar
this._sidebar = new Sidebar(this.q('#sidebar'))
this._sidebar.mount()
// Initial render
this.#renderTopBar()
this.#navigate()
return this.el
}
#updateSidebar() {
const sidebar = this.q('#sidebar')
if (sidebar) {
sidebar.classList.toggle('open', this.sidebarOpen)
}
// Overlay
let overlay = this.q('.sidebar-overlay')
if (this.sidebarOpen && !overlay) {
overlay = this.ce('button', { className: 'sidebar-overlay', 'aria-label': 'Close menu' })
this.on(overlay, 'click', () => { this.sidebarOpen = false; this.#updateSidebar() })
this.el.appendChild(overlay)
} else if (!this.sidebarOpen && overlay) {
overlay.remove()
}
}
#renderTopBar() {
const topBar = this.q('#top-bar')
if (!topBar) return
topBar.innerHTML = ''
// Back button
if (this.viewMode !== 'list') {
const backBtn = this.ce('button', { className: 'btn btn-ghost btn-sm', id: 'back-btn', textContent: '← Back' })
this.on(backBtn, 'click', () => this.#handleBack())
topBar.appendChild(backBtn)
}
// Title
const titleDiv = this.ce('div', { className: 'top-bar-title' })
let titleText
switch (this.viewMode) {
case 'list': titleText = this.isTrashView ? TRASH_GROUP_NAME : 'All Entries'; break
case 'detail': titleText = 'Entry Details'; break
case 'form': titleText = this.selectedEntryId ? 'Edit Entry' : 'New Entry'; break
case 'settings': titleText = 'Settings'; break
default: titleText = 'Password Vault'
}
titleDiv.appendChild(this.ce('h1', { textContent: titleText }))
topBar.appendChild(titleDiv)
// Actions
const actions = this.ce('div', { className: 'top-bar-actions' })
if (this.viewMode === 'list' && this.isTrashView) {
actions.appendChild(this.ce('button', {
className: 'btn btn-danger btn-sm',
id: 'empty-trash-btn',
disabled: this.emptyingTrash,
textContent: this.emptyingTrash ? 'Emptying...' : '🗑 Empty Trash',
}))
}
if (this.viewMode === 'list' && !this.isTrashView) {
actions.appendChild(this.ce('button', {
className: 'btn btn-primary btn-sm',
id: 'new-entry-btn',
textContent: '+ New Entry',
}))
}
// ImportExport buttons
if (!this._importExport) {
this._importExport = new ImportExport(actions)
this._importExport.mount()
}
actions.appendChild(this.ce('button', { className: 'btn btn-ghost btn-sm', id: 'settings-btn', title: 'Settings', textContent: '⚙️' }))
actions.appendChild(this.ce('button', { className: 'btn btn-ghost btn-sm', id: 'lock-btn', title: 'Lock vault', textContent: '🔒' }))
topBar.appendChild(actions)
// Wire action buttons
const emptyTrashBtn = this.q('#empty-trash-btn')
if (emptyTrashBtn) this.on(emptyTrashBtn, 'click', () => { this.showEmptyTrashConfirm = true; this.#renderEmptyTrashModal() })
const newEntryBtn = this.q('#new-entry-btn')
if (newEntryBtn) this.on(newEntryBtn, 'click', () => this.#goForm(null))
const settingsBtn = this.q('#settings-btn')
if (settingsBtn) this.on(settingsBtn, 'click', () => this.#goSettings())
const lockBtn = this.q('#lock-btn')
if (lockBtn) this.on(lockBtn, 'click', () => app.lockVault())
}
#navigate() {
// Destroy previous content component
if (this._contentComponent) {
this._contentComponent.destroy()
this._contentComponent = null
}
const contentArea = this.q('#content-area')
if (!contentArea) return
switch (this.viewMode) {
case 'list':
this._contentComponent = new EntryList(contentArea, {
onSelect: (id) => this.#goDetail(id),
onAdd: () => this.#goForm(null),
})
break
case 'detail':
if (this.selectedEntryId) {
this._contentComponent = new EntryDetail(contentArea, {
entryId: this.selectedEntryId,
onEdit: (id) => this.#goForm(id),
onBack: () => this.#goList(),
})
}
break
case 'form':
this._contentComponent = new EntryForm(contentArea, {
entryId: this.selectedEntryId,
onSave: () => this.#goList(),
onCancel: () => this.#handleBack(),
})
break
case 'settings':
this._contentComponent = new SettingsDialog(contentArea, {
onBack: () => this.#goList(),
})
break
}
if (this._contentComponent) this._contentComponent.mount()
this.#renderTopBar()
}
#goList() {
this.viewMode = 'list'
this.selectedEntryId = null
this.sidebarOpen = false
this.#updateSidebar()
this.#navigate()
}
#goDetail(entryId) {
this.selectedEntryId = entryId
this.viewMode = 'detail'
this.sidebarOpen = false
this.#updateSidebar()
this.#navigate()
}
#goForm(entryId) {
this.selectedEntryId = entryId
this.viewMode = 'form'
this.sidebarOpen = false
this.#updateSidebar()
this.#navigate()
}
#goSettings() {
this.viewMode = 'settings'
this.sidebarOpen = false
this.#updateSidebar()
this.#navigate()
}
#handleBack() {
this.#goList()
}
#renderEmptyTrashModal() {
const existing = this.q('.ml-modal-overlay')
if (existing) existing.remove()
const overlay = this.ce('div', { className: 'ml-modal-overlay', role: 'presentation' })
const modal = this.ce('div', { className: 'ml-modal', role: 'dialog', 'aria-modal': 'true', 'aria-label': 'Empty trash confirmation', tabindex: '-1' })
modal.appendChild(this.ce('h3', { textContent: 'Empty Trash' }))
modal.appendChild(this.ce('p', { textContent: 'Permanently delete all entries from the trash? This cannot be undone.' }))
const actions = this.ce('div', { className: 'ml-modal-actions' },
this.ce('button', { className: 'btn btn-danger', id: 'confirm-empty-trash', disabled: this.emptyingTrash, textContent: this.emptyingTrash ? 'Emptying...' : 'Yes, empty trash' }),
this.ce('button', { className: 'btn btn-ghost', id: 'cancel-empty-trash', textContent: 'Cancel' }),
)
modal.appendChild(actions)
overlay.appendChild(modal)
this.el.appendChild(overlay)
this.on(overlay, 'click', () => { this.showEmptyTrashConfirm = false; overlay.remove() })
this.on(modal, 'click', (e) => e.stopPropagation())
const confirmBtn = overlay.querySelector('#confirm-empty-trash')
if (confirmBtn) this.on(confirmBtn, 'click', () => this.#handleEmptyTrash())
const cancelBtn = overlay.querySelector('#cancel-empty-trash')
if (cancelBtn) this.on(cancelBtn, 'click', () => { this.showEmptyTrashConfirm = false; overlay.remove() })
}
async #handleEmptyTrash() {
this.emptyingTrash = true
try {
await emptyTrash()
search.activeGroupId = 'all'
this.showEmptyTrashConfirm = false
this.#goList()
} catch (e) {
console.error('Failed to empty trash:', e)
}
this.emptyingTrash = false
}
emitLock() {
this.destroy()
}
destroy() {
if (this._sidebar) this._sidebar.destroy()
if (this._importExport) this._importExport.destroy()
if (this._contentComponent) this._contentComponent.destroy()
super.destroy()
}
}

View File

@ -1,328 +0,0 @@
<script>
import { app } from '../lib/stores/app.svelte.js'
import { search as searchStore } from '../lib/stores/search.svelte.js'
import { TRASH_GROUP_NAME } from '../lib/models/schema.js'
import { emptyTrash } from '../lib/storage/db.js'
import Sidebar from './Sidebar.svelte'
import EntryList from './EntryList.svelte'
import EntryDetail from './EntryDetail.svelte'
import EntryForm from './EntryForm.svelte'
import ImportExport from './ImportExport.svelte'
import SettingsDialog from './SettingsDialog.svelte'
let sidebarOpen = $state(false)
let viewMode = $state('list') // 'list' | 'detail' | 'form' | 'settings'
let selectedEntryId = $state(null)
let showEmptyTrashConfirm = $state(false)
let emptyingTrash = $state(false)
const isTrashView = $derived(searchStore.activeGroupId === 'trash')
function goList() {
viewMode = 'list'
selectedEntryId = null
sidebarOpen = false
}
function goDetail(entryId) {
selectedEntryId = entryId
viewMode = 'detail'
sidebarOpen = false
}
function goForm(entryId = null) {
selectedEntryId = entryId
viewMode = 'form'
sidebarOpen = false
}
function goSettings() {
viewMode = 'settings'
sidebarOpen = false
}
function handleBack() {
if (viewMode === 'form' || viewMode === 'settings') {
goList()
} else {
goList()
}
}
async function handleEmptyTrash() {
emptyingTrash = true
try {
await emptyTrash()
searchStore.activeGroupId = 'all'
showEmptyTrashConfirm = false
} catch (e) {
console.error('Failed to empty trash:', e)
}
emptyingTrash = false
}
function handleLock() {
app.lockVault()
}
</script>
<div class="app-shell">
<!-- Mobile header -->
<div class="mobile-header">
<button class="btn btn-ghost btn-sm" onclick={() => sidebarOpen = !sidebarOpen}>
☰ Menu
</button>
<span class="mobile-title">Password Vault</span>
<button class="btn btn-ghost btn-sm" onclick={handleLock} title="Lock">🔒</button>
</div>
<!-- Sidebar overlay for mobile -->
{#if sidebarOpen}
<button class="sidebar-overlay" onclick={() => sidebarOpen = false} aria-label="Close menu"></button>
{/if}
<!-- Sidebar -->
<aside class="sidebar {sidebarOpen ? 'open' : ''}">
<Sidebar />
</aside>
<!-- Main content -->
<main class="main-content">
<!-- Top bar -->
<div class="top-bar">
{#if viewMode !== 'list'}
<button class="btn btn-ghost btn-sm" onclick={handleBack}> Back</button>
{/if}
<div class="top-bar-title">
{#if viewMode === 'list'}
<h1>{searchStore.activeGroupId === 'trash' ? TRASH_GROUP_NAME : 'All Entries'}</h1>
{:else if viewMode === 'detail'}
<h1>Entry Details</h1>
{:else if viewMode === 'form'}
<h1>{selectedEntryId ? 'Edit Entry' : 'New Entry'}</h1>
{:else if viewMode === 'settings'}
<h1>Settings</h1>
{/if}
</div>
<div class="top-bar-actions">
{#if viewMode === 'list' && isTrashView}
<button class="btn btn-danger btn-sm" onclick={() => showEmptyTrashConfirm = true} disabled={emptyingTrash}>
{emptyingTrash ? 'Emptying...' : '🗑 Empty Trash'}
</button>
{/if}
{#if viewMode === 'list' && !isTrashView}
<button class="btn btn-primary btn-sm" onclick={() => goForm(null)}>+ New Entry</button>
{/if}
<ImportExport />
<button class="btn btn-ghost btn-sm" onclick={goSettings} title="Settings">⚙️</button>
<button class="btn btn-ghost btn-sm" onclick={handleLock} title="Lock vault">🔒</button>
</div>
</div>
<!-- Content area -->
<div class="content-area">
{#if viewMode === 'list'}
<EntryList onSelect={goDetail} onAdd={() => goForm(null)} />
{:else if viewMode === 'detail' && selectedEntryId}
<EntryDetail
entryId={selectedEntryId}
onEdit={() => goForm(selectedEntryId)}
onBack={goList}
/>
{:else if viewMode === 'form'}
<EntryForm
entryId={selectedEntryId}
onSave={goList}
onCancel={handleBack}
/>
{:else if viewMode === 'settings'}
<SettingsDialog onBack={goList} />
{/if}
</div>
</main>
<!-- Empty trash confirmation -->
{#if showEmptyTrashConfirm}
<div class="modal-overlay" role="presentation" onclick={() => showEmptyTrashConfirm = false}>
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
<div class="modal" role="dialog" aria-modal="true" aria-label="Empty trash confirmation" tabindex="-1" onclick={(e) => e.stopPropagation()}>
<h3>Empty Trash</h3>
<p>Permanently delete all entries from the trash? This cannot be undone.</p>
<div class="modal-actions">
<button class="btn btn-danger" onclick={handleEmptyTrash} disabled={emptyingTrash}>
{emptyingTrash ? 'Emptying...' : 'Yes, empty trash'}
</button>
<button class="btn btn-ghost" onclick={() => showEmptyTrashConfirm = false}>Cancel</button>
</div>
</div>
</div>
{/if}
</div>
<style>
.app-shell {
display: flex;
min-height: 100vh;
}
/* Mobile header */
.mobile-header {
display: none;
align-items: center;
justify-content: space-between;
padding: 10px 16px;
background: var(--color-surface);
border-bottom: 1px solid var(--color-border);
position: sticky;
top: 0;
z-index: 100;
}
.mobile-title {
font-weight: 600;
font-size: 0.95rem;
}
/* Sidebar */
.sidebar {
width: 260px;
min-width: 260px;
background: var(--color-sidebar);
border-right: 1px solid var(--color-border);
height: 100vh;
position: sticky;
top: 0;
overflow-y: auto;
display: flex;
flex-direction: column;
}
/* Main content */
.main-content {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
}
.top-bar {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 20px;
background: var(--color-surface);
border-bottom: 1px solid var(--color-border);
position: sticky;
top: 0;
z-index: 10;
}
.top-bar-title {
flex: 1;
min-width: 0;
}
.top-bar-title h1 {
font-size: 1.1rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.top-bar-actions {
display: flex;
gap: 8px;
align-items: center;
}
.content-area {
flex: 1;
padding: 20px;
overflow-y: auto;
}
/* Sidebar overlay (mobile) */
.sidebar-overlay {
display: none;
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 49;
border: none;
cursor: pointer;
padding: 0;
margin: 0;
width: 100%;
height: 100%;
}
/* Modal */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 200;
padding: 1rem;
}
.modal {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
padding: 24px;
max-width: 380px;
width: 100%;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
}
.modal h3 {
margin-bottom: 16px;
}
.modal p {
color: var(--color-text-muted);
font-size: 0.9rem;
margin-bottom: 20px;
}
.modal-actions {
display: flex;
gap: 8px;
}
/* Responsive */
@media (max-width: 768px) {
.mobile-header {
display: flex;
}
.sidebar {
position: fixed;
top: 0;
left: 0;
bottom: 0;
z-index: 50;
transform: translateX(-100%);
transition: transform 200ms ease;
}
.sidebar.open {
transform: translateX(0);
}
.sidebar-overlay {
display: block;
}
.content-area {
padding: 12px;
}
.top-bar {
padding: 10px 12px;
}
}
</style>

View File

@ -0,0 +1,122 @@
/**
* SettingsDialog auto-lock and tab-switch settings.
*/
import { Component } from './component.js'
import { settings } from '../lib/stores/settings.js'
import { startAutoLock } from '../lib/stores/security.js'
export class SettingsDialog extends Component {
/** @param {{ onBack: Function }} props */
constructor(container, props = {}) {
super(container)
this.onBack = props.onBack || (() => {})
this.minutes = settings.autoLockMinutes
this.lockOnTabSwitch = settings.lockOnTabSwitch
this.saving = false
}
mount() {
super.mount()
// Sync local values
this.minutes = settings.autoLockMinutes
this.lockOnTabSwitch = settings.lockOnTabSwitch
return this
}
render() {
this.el = this.ce('div', { className: 'settings-panel' })
this.#renderContent()
return this.el
}
#renderContent() {
this.el.innerHTML = ''
const form = this.ce('form', { className: 'sd-form-card', id: 'settings-form' },
this.ce('h3', { textContent: 'Settings' }),
// Auto-lock minutes
this.ce('div', { className: 'form-group' },
this.ce('label', { htmlFor: 'auto-lock-minutes', textContent: 'Auto-lock after' }),
this.#buildMinuteSelect(),
this.ce('p', { className: 'text-muted text-xs mt-1', id: 'lock-hint' }),
),
// Lock on tab switch
this.ce('div', { className: 'form-group' },
this.ce('label', { className: 'toggle-label', htmlFor: 'lock-tab-switch' },
this.ce('input', { id: 'lock-tab-switch', type: 'checkbox', checked: this.lockOnTabSwitch }),
this.ce('span', { className: 'toggle-track' },
this.ce('span', { className: 'toggle-thumb' }),
),
this.ce('span', { className: 'toggle-text', textContent: 'Lock when tab loses focus' }),
),
this.ce('p', { className: 'text-muted text-xs mt-1', id: 'tab-hint' }),
),
// Actions
this.ce('div', { className: 'sd-form-actions' },
this.ce('button', { type: 'submit', className: 'btn btn-primary', disabled: this.saving, id: 'save-settings-btn', textContent: this.saving ? 'Saving...' : 'Save' }),
this.ce('button', { type: 'button', className: 'btn btn-ghost', id: 'cancel-settings-btn', textContent: 'Cancel' }),
),
)
this.el.appendChild(form)
// Update hints
const lockHint = this.q('#lock-hint')
if (lockHint) lockHint.textContent = `Vault locks after ${this.minutes} ${this.minutes === 1 ? 'minute' : 'minutes'} of inactivity.`
const tabHint = this.q('#tab-hint')
if (tabHint) tabHint.textContent = this.lockOnTabSwitch
? 'The vault locks immediately when you switch to another tab.'
: 'The vault stays unlocked even when you switch tabs.'
// Wire events
const formEl = this.q('#settings-form')
if (formEl) this.on(formEl, 'submit', this.#handleSave)
const cancelBtn = this.q('#cancel-settings-btn')
if (cancelBtn) this.on(cancelBtn, 'click', () => this.onBack())
const minutesSelect = this.q('#auto-lock-minutes')
if (minutesSelect) this.on(minutesSelect, 'change', (e) => {
this.minutes = Number(e.target.value)
if (lockHint) lockHint.textContent = `Vault locks after ${this.minutes} ${this.minutes === 1 ? 'minute' : 'minutes'} of inactivity.`
})
const tabCheckbox = this.q('#lock-tab-switch')
if (tabCheckbox) this.on(tabCheckbox, 'change', (e) => {
this.lockOnTabSwitch = e.target.checked
if (tabHint) tabHint.textContent = this.lockOnTabSwitch
? 'The vault locks immediately when you switch to another tab.'
: 'The vault stays unlocked even when you switch tabs.'
})
}
#buildMinuteSelect() {
const select = this.ce('select', { id: 'auto-lock-minutes' })
for (const m of [1, 5, 10, 15, 30, 60]) {
const opt = this.ce('option', { value: m }, this.text(`${m} ${m === 1 ? 'minute' : 'minutes'}`))
if (m === this.minutes) opt.selected = true
select.appendChild(opt)
}
return select
}
#handleSave = async (e) => {
e.preventDefault()
this.saving = true
try {
settings.autoLockMinutes = this.minutes
settings.lockOnTabSwitch = this.lockOnTabSwitch
await settings.save()
startAutoLock()
} catch (err) {
console.error('Failed to save settings:', err)
}
this.saving = false
this.onBack()
}
}

View File

@ -1,172 +0,0 @@
<script>
import { settings } from '../lib/stores/settings.svelte.js'
import { startAutoLock } from '../lib/stores/security.svelte.js'
let { onBack } = $props()
// Local copies so the user can cancel without losing values
let minutes = $state(settings.autoLockMinutes)
let lockOnTabSwitch = $state(settings.lockOnTabSwitch)
// Inverse of dismissedLocalWarning: on = show the web-server security banner.
let showLocalWarning = $state(!settings.dismissedLocalWarning)
let saving = $state(false)
const minuteOptions = [1, 5, 10, 15, 30, 60]
async function handleSave() {
saving = true
try {
settings.autoLockMinutes = minutes
settings.lockOnTabSwitch = lockOnTabSwitch
settings.dismissedLocalWarning = !showLocalWarning
await settings.save()
startAutoLock()
} catch (e) {
console.error('Failed to save settings:', e)
}
saving = false
onBack()
}
// Sync local values on mount
$effect(() => {
minutes = settings.autoLockMinutes
lockOnTabSwitch = settings.lockOnTabSwitch
showLocalWarning = !settings.dismissedLocalWarning
})
</script>
<div class="settings-panel">
<form class="form-card" onsubmit={(e) => { e.preventDefault(); handleSave(); }}>
<h3>Settings</h3>
<div class="form-group">
<label for="auto-lock-minutes">Auto-lock after</label>
<select id="auto-lock-minutes" bind:value={minutes}>
{#each minuteOptions as m}
<option value={m}>{m} {m === 1 ? 'minute' : 'minutes'}</option>
{/each}
</select>
<p class="text-muted text-xs mt-1">
Vault locks after {minutes} {minutes === 1 ? 'minute' : 'minutes'} of inactivity.
</p>
</div>
<div class="form-group">
<label class="toggle-label" for="lock-tab-switch">
<input
id="lock-tab-switch"
type="checkbox"
bind:checked={lockOnTabSwitch}
/>
<span class="toggle-track">
<span class="toggle-thumb"></span>
</span>
<span class="toggle-text">Lock when tab loses focus</span>
</label>
<p class="text-muted text-xs mt-1">
{lockOnTabSwitch
? 'The vault locks immediately when you switch to another tab.'
: 'The vault stays unlocked even when you switch tabs.'}
</p>
</div>
<div class="form-group">
<label class="toggle-label" for="show-local-warning">
<input
id="show-local-warning"
type="checkbox"
bind:checked={showLocalWarning}
/>
<span class="toggle-track">
<span class="toggle-thumb"></span>
</span>
<span class="toggle-text">Show web-server warning</span>
</label>
<p class="text-muted text-xs mt-1">
{showLocalWarning
? 'Shows a reminder on the lock screen to use the vault from a downloaded local file.'
: 'The lock-screen web-server warning banner is hidden.'}
</p>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary" disabled={saving}>
{saving ? 'Saving...' : 'Save'}
</button>
<button type="button" class="btn btn-ghost" onclick={onBack}>Cancel</button>
</div>
</form>
</div>
<style>
.settings-panel {
max-width: 500px;
}
.form-card {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
padding: 24px;
}
.form-card h3 {
margin-bottom: 16px;
}
.form-actions {
display: flex;
gap: 8px;
margin-top: 20px;
}
.toggle-label {
display: flex;
align-items: center;
gap: 10px;
cursor: pointer;
margin-bottom: 0;
}
.toggle-label input[type="checkbox"] {
position: absolute;
opacity: 0;
width: 0;
height: 0;
}
.toggle-track {
width: 40px;
height: 22px;
background: var(--color-border);
border-radius: 11px;
position: relative;
transition: background-color 150ms;
flex-shrink: 0;
}
.toggle-track .toggle-thumb {
position: absolute;
top: 3px;
left: 3px;
width: 16px;
height: 16px;
background: #fff;
border-radius: 50%;
transition: transform 150ms;
}
.toggle-label input:checked + .toggle-track {
background: var(--color-primary);
}
.toggle-label input:checked + .toggle-track .toggle-thumb {
transform: translateX(18px);
}
.toggle-text {
font-size: 0.875rem;
color: var(--color-text);
}
</style>

398
src/components/Sidebar.js Normal file
View File

@ -0,0 +1,398 @@
/**
* Sidebar group list + search bar + group management.
*/
import { Component } from './component.js'
import { getGroups, addGroup, updateGroup, deleteGroup, moveEntryToGroup, ensureTrashGroup } from '../lib/storage/db.js'
import { createGroup, validateGroup, isTrashGroup, TRASH_GROUP_NAME, TRASH_GROUP_COLOR, GROUP_COLORS } from '../lib/models/schema.js'
import { search } from '../lib/stores/search.js'
import { autofocus } from '../lib/autofocus.js'
export class Sidebar extends Component {
groups = []
// Group management state
showGroupForm = false
editingGroupId = null
groupName = ''
groupColor = '#6c63ff'
groupError = ''
showDeleteGroupConfirm = null
dragOverGroupId = null
droppedGroupId = null
mount() {
super.mount()
// Subscribe to store changes
this.subscribe(search, 'refreshTrigger', () => this.#loadData())
this.subscribe(search, 'activeGroupId', () => {
this.#renderGroups()
this.#updateTrashButton()
})
// Event delegation on the nav for all group interactions
const nav = this.q('.groups-nav')
if (nav) {
// Click: select group or handle action buttons
this.on(nav, 'click', (e) => {
const editBtn = e.target.closest('.group-action-btn[title="Edit group"]')
if (editBtn) {
e.stopPropagation()
const row = editBtn.closest('.group-row')
const groupId = row?.querySelector('[data-group-id]')?.dataset.groupId
const group = this.groups.find(g => g.id === groupId)
if (group) this.#openGroupForm(group)
return
}
const delBtn = e.target.closest('.group-action-btn[title="Delete group"]')
if (delBtn) {
e.stopPropagation()
const row = delBtn.closest('.group-row')
const groupId = row?.querySelector('[data-group-id]')?.dataset.groupId
if (groupId) {
this.showDeleteGroupConfirm = groupId
this.#renderDeleteModal()
}
return
}
// Group selection
const groupBtn = e.target.closest('.group-item[data-group-id]')
if (groupBtn) {
search.activeGroupId = groupBtn.dataset.groupId
return
}
// All entries button
if (e.target.closest('#all-entries-btn')) {
search.activeGroupId = 'all'
}
})
// Drag-and-drop via delegation
this.on(nav, 'dragover', (e) => {
const btn = e.target.closest('.group-item[data-group-id]')
if (btn) {
const gid = btn.dataset.groupId
if (this.#canDrop(gid)) {
e.preventDefault()
e.dataTransfer.dropEffect = 'move'
this.dragOverGroupId = gid
btn.classList.add('drag-over')
}
}
})
this.on(nav, 'dragleave', (e) => {
const btn = e.target.closest('.group-item[data-group-id]')
if (btn && this.dragOverGroupId === btn.dataset.groupId) {
this.dragOverGroupId = null
btn.classList.remove('drag-over')
}
})
this.on(nav, 'drop', (e) => {
e.preventDefault()
const btn = e.target.closest('.group-item[data-group-id]')
if (btn) {
btn.classList.remove('drag-over')
const gid = btn.dataset.groupId
if (this.#canDrop(gid)) {
const entryId = e.dataTransfer.getData('text/plain')
if (entryId) this.#handleDrop(gid, entryId)
}
}
this.dragOverGroupId = null
})
}
this.#loadData()
return this
}
render() {
this.el = this.ce('div', { className: 'sidebar-content' },
this.ce('div', { className: 'sidebar-header' },
this.ce('h2', { textContent: '🔐 Vault' }),
),
this.ce('div', { className: 'search-box' },
this.ce('input', { id: 'sidebar-search', type: 'text', placeholder: 'Search entries...' }),
),
this.ce('nav', { className: 'groups-nav' }), // populated dynamically
this.ce('div', { className: 'trash-section' },
this.ce('button', { className: 'group-item trash-btn', id: 'trash-btn' },
this.ce('span', { className: 'group-color', style: `background-color: ${TRASH_GROUP_COLOR}` }),
this.ce('span', { className: 'group-name', textContent: TRASH_GROUP_NAME }),
),
),
this.ce('div', { className: 'sidebar-footer' },
this.ce('button', { className: 'btn btn-ghost btn-sm w-full', id: 'new-group-btn', textContent: '+ New Group' }),
),
)
// Wire search input
const searchInput = this.q('#sidebar-search')
if (searchInput) {
this.on(searchInput, 'input', (e) => search.setSearchQuery(e.target.value))
}
// Wire trash button
const trashBtn = this.q('#trash-btn')
if (trashBtn) {
this.on(trashBtn, 'click', () => { search.activeGroupId = 'trash' })
}
// Wire new group button
const newGroupBtn = this.q('#new-group-btn')
if (newGroupBtn) {
this.on(newGroupBtn, 'click', () => this.#openGroupForm(null))
}
this.#renderGroups()
this.#updateTrashButton()
return this.el
}
#renderGroups() {
const nav = this.q('.groups-nav')
if (!nav) return
// Clear existing
nav.innerHTML = ''
// "All Entries" button
const allBtn = this.ce('button', {
className: `group-item${search.activeGroupId === 'all' ? ' active' : ''}`,
id: 'all-entries-btn',
},
this.ce('span', { className: 'group-icon', textContent: '📋' }),
this.ce('span', { className: 'group-name', textContent: 'All Entries' }),
)
nav.appendChild(allBtn)
// Group items
for (const group of this.groups) {
if (isTrashGroup(group.id)) continue
const row = this.ce('div', { className: 'group-row' })
const btn = this.ce('button', {
className: `group-item${search.activeGroupId === group.id ? ' active' : ''}`,
'data-group-id': group.id,
},
this.ce('span', { className: 'group-color', style: `background-color: ${group.color || '#6c63ff'}` }),
this.ce('span', { className: 'group-name', textContent: group.name }),
this.ce('span', { className: 'drop-icon', textContent: '📥' }),
)
row.appendChild(btn)
// Group actions (edit, delete) — handled via event delegation on .groups-nav
const actions = this.ce('div', { className: 'group-actions' })
actions.appendChild(this.ce('button', { className: 'group-action-btn', title: 'Edit group', textContent: '✏️' }))
actions.appendChild(this.ce('button', { className: 'group-action-btn', title: 'Delete group', textContent: '🗑' }))
row.appendChild(actions)
nav.appendChild(row)
}
}
#renderDeleteModal() {
// Remove existing modal
const existing = this.q('.sb-modal-overlay')
if (existing) existing.remove()
if (!this.showDeleteGroupConfirm) return
const group = this.groups.find(g => g.id === this.showDeleteGroupConfirm)
if (!group) return
const overlay = this.ce('div', { className: 'sb-modal-overlay', role: 'presentation' },
this.ce('div', { className: 'sb-modal', role: 'dialog', 'aria-modal': 'true', 'aria-label': 'Delete group confirmation', tabindex: '-1' },
this.ce('h3', { textContent: 'Delete Group' }),
this.ce('p', {},
this.text(`Delete "`),
this.ce('strong', { textContent: group.name }),
this.text(`"? Entries in this group will become ungrouped.`),
),
this.ce('div', { className: 'sb-modal-actions' },
this.ce('button', { className: 'btn btn-danger', id: 'confirm-delete-group-btn', textContent: 'Yes, delete' }),
this.ce('button', { className: 'btn btn-ghost', id: 'cancel-delete-group-btn', textContent: 'Cancel' }),
),
),
)
// Append overlay first so querySelector finds the elements
this.el.appendChild(overlay)
this.on(overlay, 'click', () => {
this.showDeleteGroupConfirm = null
overlay.remove()
})
this.on(overlay.querySelector('.sb-modal'), 'click', (e) => e.stopPropagation())
this.on(overlay.querySelector('#confirm-delete-group-btn'), 'click', () => this.#confirmDeleteGroup(this.showDeleteGroupConfirm))
this.on(overlay.querySelector('#cancel-delete-group-btn'), 'click', () => {
this.showDeleteGroupConfirm = null
overlay.remove()
})
}
#renderGroupFormModal() {
// Remove existing modal
const existing = this.q('.sb-modal-overlay')
if (existing) existing.remove()
if (!this.showGroupForm) return
const overlay = this.ce('div', { className: 'sb-modal-overlay', role: 'presentation' },
this.ce('div', { className: 'sb-modal', role: 'dialog', 'aria-modal': 'true', 'aria-label': 'Group settings', tabindex: '-1' },
this.ce('h3', { textContent: this.editingGroupId ? 'Edit Group' : 'New Group' }),
this.groupError ? this.ce('div', { className: 'ie-error-banner', textContent: this.groupError }) : null,
this.ce('div', { className: 'form-group' },
this.ce('label', { htmlFor: 'group-name', textContent: 'Group Name' }),
this.ce('input', { id: 'group-name', type: 'text', placeholder: 'e.g. Work, Personal', value: this.groupName }),
),
this.ce('div', { className: 'form-group' },
this.ce('span', { className: 'field-label', textContent: 'Color' }),
this.#buildColorPicker(),
),
this.ce('div', { className: 'sb-modal-actions' },
this.ce('button', { className: 'btn btn-primary', id: 'save-group-btn', textContent: this.editingGroupId ? 'Update' : 'Create' }),
this.ce('button', { className: 'btn btn-ghost', id: 'cancel-group-btn', textContent: 'Cancel' }),
),
),
)
// Append overlay first so querySelector finds the elements
this.el.appendChild(overlay)
this.on(overlay, 'click', () => {
this.showGroupForm = false
overlay.remove()
})
this.on(overlay.querySelector('.sb-modal'), 'click', (e) => e.stopPropagation())
this.on(overlay.querySelector('#save-group-btn'), 'click', () => this.#saveGroup())
this.on(overlay.querySelector('#cancel-group-btn'), 'click', () => {
this.showGroupForm = false
overlay.remove()
})
// Wire group name input
const nameInput = overlay.querySelector('#group-name')
if (nameInput) {
this.groupName = nameInput.value
this.on(nameInput, 'input', (e) => { this.groupName = e.target.value })
if (!this.editingGroupId) autofocus(nameInput, true)
}
}
#buildColorPicker() {
const picker = this.ce('div', { className: 'color-picker' })
for (const color of GROUP_COLORS) {
const swatch = this.ce('button', {
className: `color-swatch${this.groupColor === color ? ' selected' : ''}`,
style: `background-color: ${color}`,
title: color,
type: 'button',
})
const c = color
this.on(swatch, 'click', () => {
this.groupColor = c
this.qa('.color-swatch').forEach(s => s.classList.remove('selected'))
swatch.classList.add('selected')
})
picker.appendChild(swatch)
}
return picker
}
#openGroupForm(group) {
if (group) {
this.editingGroupId = group.id
this.groupName = group.name
this.groupColor = group.color || '#6c63ff'
} else {
this.editingGroupId = null
this.groupName = ''
this.groupColor = GROUP_COLORS[Math.floor(Math.random() * GROUP_COLORS.length)]
}
this.groupError = ''
this.showGroupForm = true
this.#renderGroupFormModal()
}
async #saveGroup() {
this.groupError = ''
const nameInput = this.q('#group-name')
if (nameInput) this.groupName = nameInput.value
const validation = validateGroup(this.groupName)
if (!validation.valid) {
this.groupError = validation.errors[0]
this.#renderGroupFormModal()
return
}
try {
if (this.editingGroupId) {
const existing = this.groups.find(g => g.id === this.editingGroupId)
const updated = { ...existing, name: this.groupName.trim(), color: this.groupColor }
await updateGroup(updated)
} else {
const group = createGroup(this.groupName, this.groupColor)
await addGroup(group)
}
this.showGroupForm = false
const overlay = this.q('.sb-modal-overlay')
if (overlay) overlay.remove()
await this.#loadData()
} catch (e) {
this.groupError = 'Failed to save group: ' + e.message
this.#renderGroupFormModal()
}
}
async #confirmDeleteGroup(groupId) {
try {
await deleteGroup(groupId)
if (search.activeGroupId === groupId) {
search.activeGroupId = 'all'
}
this.showDeleteGroupConfirm = null
const overlay = this.q('.sb-modal-overlay')
if (overlay) overlay.remove()
await this.#loadData()
} catch (e) {
this.groupError = 'Failed to delete group: ' + e.message
}
}
#updateTrashButton() {
const trashBtn = this.q('#trash-btn')
if (trashBtn) {
trashBtn.classList.toggle('active', search.activeGroupId === 'trash')
}
}
async #handleDrop(groupId, entryId) {
try {
await moveEntryToGroup(entryId, groupId)
this.droppedGroupId = groupId
// Flash the dropped group
const btn = this.q(`[data-group-id="${groupId}"]`)
if (btn) {
btn.classList.add('dropped')
setTimeout(() => btn.classList.remove('dropped'), 600)
}
await this.#loadData()
search.refresh()
} catch (e) {
// silent fail
}
}
#canDrop(groupId) {
return groupId !== search.activeGroupId && !isTrashGroup(groupId)
}
async #loadData() {
await ensureTrashGroup()
this.groups = await getGroups()
this.#renderGroups()
}
}

View File

@ -1,436 +0,0 @@
<script>
import { getGroups, addGroup, updateGroup, deleteGroup, moveEntryToGroup, ensureTrashGroup } from '../lib/storage/db.js'
import { createGroup, validateGroup, isTrashGroup, TRASH_GROUP_NAME, TRASH_GROUP_COLOR, GROUP_COLORS } from '../lib/models/schema.js'
import { search as searchStore } from '../lib/stores/search.svelte.js'
import { autofocus } from '../lib/autofocus.js'
let groups = $state([])
// Group management state
let showGroupForm = $state(false)
let editingGroupId = $state(null)
let groupName = $state('')
let groupColor = $state('#6c63ff')
let groupError = $state('')
let showDeleteGroupConfirm = $state(null) // groupId being confirmed for deletion
let deletingGroup = $derived(groups.find(g => g.id === showDeleteGroupConfirm))
// Drag-and-drop state
let dragOverGroupId = $state(null)
let droppedGroupId = $state(null)
async function handleDrop(groupId, entryId) {
try {
await moveEntryToGroup(entryId, groupId)
droppedGroupId = groupId
setTimeout(() => { droppedGroupId = null }, 600)
await loadData()
searchStore.refresh()
} catch (e) {
// silent fail
}
}
function canDrop(groupId) {
return groupId !== searchStore.activeGroupId && !isTrashGroup(groupId)
}
async function loadData() {
await ensureTrashGroup()
groups = await getGroups()
}
// Initial load + refresh after import/export
$effect(() => {
searchStore.refreshTrigger
loadData()
})
function openGroupForm(group = null) {
if (group) {
editingGroupId = group.id
groupName = group.name
groupColor = group.color || '#6c63ff'
} else {
editingGroupId = null
groupName = ''
groupColor = GROUP_COLORS[Math.floor(Math.random() * GROUP_COLORS.length)]
}
groupError = ''
showGroupForm = true
}
async function saveGroup() {
groupError = ''
const validation = validateGroup(groupName)
if (!validation.valid) {
groupError = validation.errors[0]
return
}
try {
if (editingGroupId) {
const existing = groups.find(g => g.id === editingGroupId)
const updated = { ...existing, name: groupName.trim(), color: groupColor }
await updateGroup(updated)
} else {
const group = createGroup(groupName, groupColor)
await addGroup(group)
}
showGroupForm = false
await loadData()
} catch (e) {
groupError = 'Failed to save group: ' + e.message
}
}
async function confirmDeleteGroup(groupId) {
try {
await deleteGroup(groupId)
if (searchStore.activeGroupId === groupId) {
searchStore.activeGroupId = 'all'
}
showDeleteGroupConfirm = null
await loadData()
} catch (e) {
groupError = 'Failed to delete group: ' + e.message
}
}
</script>
<div class="sidebar-content">
<div class="sidebar-header">
<h2>🔐 Vault</h2>
</div>
<div class="search-box">
<input
type="text"
placeholder="Search entries..."
value={searchStore.query}
oninput={(e) => searchStore.setSearchQuery(e.target.value)}
/>
</div>
<nav class="groups-nav">
<button
class="group-item {searchStore.activeGroupId === 'all' ? 'active' : ''}"
onclick={() => searchStore.activeGroupId = 'all'}
>
<span class="group-icon">📋</span>
<span class="group-name">All Entries</span>
</button>
{#each groups as group}
{#if !isTrashGroup(group.id)}
<div class="group-row">
<button
class="group-item {searchStore.activeGroupId === group.id ? 'active' : ''} {dragOverGroupId === group.id ? 'drag-over' : ''} {droppedGroupId === group.id ? 'dropped' : ''}"
onclick={() => searchStore.activeGroupId = group.id}
ondragover={(e) => { if (canDrop(group.id)) { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; dragOverGroupId = group.id; } }}
ondragleave={() => { if (dragOverGroupId === group.id) dragOverGroupId = null; }}
ondrop={(e) => { e.preventDefault(); dragOverGroupId = null; if (canDrop(group.id)) { const entryId = e.dataTransfer.getData('text/plain'); if (entryId) handleDrop(group.id, entryId); } }}
>
<span class="group-color" style="background-color: {group.color || '#6c63ff'}"></span>
<span class="group-name">{group.name}</span>
<span class="drop-icon">📥</span>
</button>
<div class="group-actions">
<button class="group-action-btn" onclick={() => openGroupForm(group)} title="Edit group">✏️</button>
<button class="group-action-btn" onclick={() => showDeleteGroupConfirm = group.id} title="Delete group">🗑</button>
</div>
</div>
{/if}
{/each}
</nav>
<!-- Trash pinned to bottom -->
<div class="trash-section">
<button
class="group-item {searchStore.activeGroupId === 'trash' ? 'active' : ''}"
onclick={() => searchStore.activeGroupId = 'trash'}
>
<span class="group-color" style="background-color: {TRASH_GROUP_COLOR}"></span>
<span class="group-name">{TRASH_GROUP_NAME}</span>
</button>
</div>
<div class="sidebar-footer">
<button class="btn btn-ghost btn-sm w-full" onclick={() => openGroupForm(null)}>+ New Group</button>
</div>
<!-- Group form modal -->
{#if showGroupForm}
<div class="modal-overlay" role="presentation" onclick={() => showGroupForm = false}>
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
<div class="modal" role="dialog" aria-modal="true" aria-label="Group settings" tabindex="-1" onclick={(e) => e.stopPropagation()}>
<h3>{editingGroupId ? 'Edit Group' : 'New Group'}</h3>
{#if groupError}
<div class="error-banner">{groupError}</div>
{/if}
<div class="form-group">
<label for="group-name">Group Name</label>
<input id="group-name" type="text" bind:value={groupName} placeholder="e.g. Work, Personal" use:autofocus={!editingGroupId} />
</div>
<div class="form-group">
<span class="field-label">Color</span>
<div class="color-picker">
{#each GROUP_COLORS as color}
<button
class="color-swatch {groupColor === color ? 'selected' : ''}"
style="background-color: {color}"
onclick={() => groupColor = color}
title={color}
></button>
{/each}
</div>
</div>
<div class="modal-actions">
<button class="btn btn-primary" onclick={saveGroup}>
{editingGroupId ? 'Update' : 'Create'}
</button>
<button class="btn btn-ghost" onclick={() => showGroupForm = false}>Cancel</button>
</div>
</div>
</div>
{/if}
<!-- Delete group confirmation -->
{#if deletingGroup}
<div class="modal-overlay" role="presentation" onclick={() => showDeleteGroupConfirm = null}>
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
<div class="modal" role="dialog" aria-modal="true" aria-label="Delete group confirmation" tabindex="-1" onclick={(e) => e.stopPropagation()}>
<h3>Delete Group</h3>
<p>Delete "<strong>{deletingGroup.name}</strong>"? Entries in this group will become ungrouped.</p>
<div class="modal-actions">
<button class="btn btn-danger" onclick={() => confirmDeleteGroup(deletingGroup.id)}>Yes, delete</button>
<button class="btn btn-ghost" onclick={() => showDeleteGroupConfirm = null}>Cancel</button>
</div>
</div>
</div>
{/if}
</div>
<style>
.sidebar-content {
display: flex;
flex-direction: column;
height: 100%;
}
.sidebar-header {
padding: 16px;
border-bottom: 1px solid var(--color-border);
}
.sidebar-header h2 {
font-size: 1rem;
font-weight: 600;
}
.search-box {
padding: 12px 16px;
}
.search-box input {
padding: 8px 10px;
font-size: 0.85rem;
}
.groups-nav {
flex: 1;
overflow-y: auto;
padding: 8px;
}
.group-row {
display: flex;
align-items: center;
}
.group-item {
display: flex;
align-items: center;
gap: 10px;
flex: 1;
min-width: 0;
padding: 8px 12px;
border: none;
border-radius: var(--radius-md);
background: transparent;
color: var(--color-text-muted);
font-size: 0.875rem;
cursor: pointer;
transition: background-color 150ms, color 150ms;
text-align: left;
}
.group-item:hover {
background: var(--color-surface-hover);
color: var(--color-text);
}
.group-item.active {
background: rgba(108, 99, 255, 0.15);
color: var(--color-primary);
}
.group-item.drag-over {
background: rgba(108, 99, 255, 0.2);
border: 2px dashed var(--color-primary);
outline: 2px solid rgba(108, 99, 255, 0.25);
outline-offset: -4px;
transform: scale(1.02);
}
.group-item.drag-over .drop-icon {
opacity: 1;
transform: scale(1.2);
}
.group-item.dropped {
animation: dropFlash 600ms ease;
}
@keyframes dropFlash {
0% { background: rgba(52, 211, 153, 0.35); }
100% { background: transparent; }
}
.drop-icon {
opacity: 0;
font-size: 0.85rem;
transition: opacity 150ms, transform 150ms;
flex-shrink: 0;
}
.group-icon {
font-size: 1rem;
}
.group-color {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
}
.group-name {
flex: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.group-actions {
display: flex;
gap: 2px;
padding-right: 4px;
opacity: 0;
transition: opacity 150ms;
}
.group-row:hover .group-actions {
opacity: 1;
}
.trash-section {
padding: 8px;
border-top: 1px solid var(--color-border);
}
.group-action-btn {
background: none;
border: none;
cursor: pointer;
font-size: 0.75rem;
padding: 4px;
border-radius: var(--radius-sm);
transition: background-color 150ms;
}
.group-action-btn:hover {
background: var(--color-surface-hover);
}
.sidebar-footer {
padding: 12px 16px;
border-top: 1px solid var(--color-border);
}
/* Modal */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 200;
padding: 1rem;
}
.modal {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
padding: 24px;
max-width: 380px;
width: 100%;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
}
.modal h3 {
margin-bottom: 16px;
}
.modal p {
color: var(--color-text-muted);
font-size: 0.9rem;
margin-bottom: 20px;
}
.modal-actions {
display: flex;
gap: 8px;
}
.error-banner {
padding: 8px 12px;
background: rgba(229, 72, 77, 0.15);
border: 1px solid rgba(229, 72, 77, 0.4);
border-radius: var(--radius-md);
color: var(--color-danger);
font-size: 0.85rem;
margin-bottom: 12px;
}
/* Color picker */
.color-picker {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.color-swatch {
width: 28px;
height: 28px;
border-radius: 50%;
border: 2px solid transparent;
cursor: pointer;
transition: transform 150ms, border-color 150ms;
}
.color-swatch:hover {
transform: scale(1.15);
}
.color-swatch.selected {
border-color: #fff;
transform: scale(1.15);
}
</style>

174
src/components/component.js Normal file
View File

@ -0,0 +1,174 @@
/**
* Minimal base class for custom vanilla-JS components.
*
* Subclasses override:
* - render() returns a DOM element (or fragment)
* - destroy() cleanup (optional, called on unmount)
*
* Helpers:
* - this.el root DOM element
* - this.container mount target
* - this.subscribe(store, prop, fn) reactive binding
* - this.q(sel) querySelector on this.el
* - this.qa(sel) querySelectorAll on this.el
* - this.html(html) set innerHTML (use sparingly; prefer createElement for interactive DOM)
* - this.on(el, event, fn) addEventListener with auto-cleanup
*/
export class Component {
/** @param {HTMLElement} container — parent to mount into */
constructor(container) {
this.container = container
this.el = null
this._listeners = [] // { el, event, fn }
this._unsubs = [] // store unsubscribe fns
}
/** Render and mount the component. */
mount() {
this.el = this.render()
if (this.el) {
this.container.appendChild(this.el)
this.afterMount?.()
}
return this
}
/** Override in subclass. Must return a DOM node. */
render() {
return document.createElement('div')
}
/** Called after the element is appended to the DOM. Override in subclass. */
afterMount() {}
/** Remove the component and clean up. */
destroy() {
// Unsubscribe from stores
this._unsubs.forEach(fn => fn())
this._unsubs = []
// Remove event listeners
this._listeners.forEach(({ el, event, fn }) => {
el.removeEventListener(event, fn)
})
this._listeners = []
if (this.el && this.el.parentNode) {
this.el.parentNode.removeChild(this.el)
}
this.el = null
}
/** Subscribe to a store property. Auto-unsubscribed on destroy. */
subscribe(store, prop, fn) {
const unsub = store.onChange(prop, fn)
this._unsubs.push(unsub)
return unsub
}
/** Add an event listener with auto-cleanup. */
on(target, event, fn, options) {
target.addEventListener(event, fn, options)
this._listeners.push({ el: target, event, fn })
return fn
}
/** Convenience: querySelector on this.el */
q(sel) {
return this.el?.querySelector(sel)
}
/** Convenience: querySelectorAll on this.el */
qa(sel) {
return this.el?.querySelectorAll(sel) || []
}
/** Set innerHTML. Warning: loses event listeners on replaced children. */
html(htmlStr) {
if (this.el) this.el.innerHTML = htmlStr
}
/**
* Create an element with tag, attributes, and children.
* @param {string} tag
* @param {Object} [attrs] { className, id, style, textContent, innerHTML, ...dataset, ...rest }
* @param {...*} children DOM nodes, strings, or arrays
* @returns {HTMLElement}
*/
ce(tag, attrs, ...children) {
const el = document.createElement(tag)
attrs = attrs || {}
if (attrs.className) el.className = attrs.className
if (attrs.id) el.id = attrs.id
if (attrs.style) {
if (typeof attrs.style === 'string') el.style.cssText = attrs.style
else Object.assign(el.style, attrs.style)
}
if (attrs.title) el.title = attrs.title
if (attrs.disabled !== undefined) el.disabled = attrs.disabled
if (attrs.checked !== undefined) el.checked = attrs.checked
if (attrs.value !== undefined) el.value = attrs.value
if (attrs.type) el.type = attrs.type
if (attrs.placeholder) el.placeholder = attrs.placeholder
if (attrs.autocomplete) el.autocomplete = attrs.autocomplete
if (attrs.draggable !== undefined) el.draggable = attrs.draggable
if (attrs.role) el.setAttribute('role', attrs.role)
if (attrs['aria-modal']) el.setAttribute('aria-modal', attrs['aria-modal'])
if (attrs['aria-label']) el.setAttribute('aria-label', attrs['aria-label'])
if (attrs['aria-hidden']) el.setAttribute('aria-hidden', attrs['aria-hidden'])
if (attrs['aria-labelledby']) el.setAttribute('aria-labelledby', attrs['aria-labelledby'])
if (attrs.tabindex !== undefined) el.tabIndex = attrs.tabindex
if (attrs.href) el.href = attrs.href
if (attrs.target) el.target = attrs.target
if (attrs.rel) el.rel = attrs.rel
if (attrs.accept) el.accept = attrs.accept
if (attrs.name) el.name = attrs.name
// data-* attributes (e.g. 'data-group-id': 'abc')
for (const key in attrs) {
if (Object.prototype.hasOwnProperty.call(attrs, key) && key.startsWith('data-') && key.length > 5) {
el.dataset[key.slice(5)] = attrs[key]
}
}
// dataset object (e.g. { dataset: { groupId: 'abc' } })
if (attrs.dataset) Object.assign(el.dataset, attrs.dataset)
// innerHTML (if provided, skip children)
if (attrs.innerHTML) {
el.innerHTML = attrs.innerHTML
} else if (attrs.textContent) {
el.textContent = attrs.textContent
} else {
this._appendChildren(el, children.flat())
}
return el
}
_appendChildren(parent, children) {
for (const child of children) {
if (child === null || child === undefined) continue
if (typeof child === 'string' || typeof child === 'number') {
parent.appendChild(document.createTextNode(String(child)))
} else if (child instanceof Node) {
parent.appendChild(child)
} else if (Array.isArray(child)) {
this._appendChildren(parent, child)
}
}
}
/**
* Create a text node helper.
*/
text(str) {
return document.createTextNode(String(str))
}
/**
* Create a fragment with children.
*/
frag(...children) {
const f = document.createDocumentFragment()
this._appendChildren(f, children.flat())
return f
}
}

View File

@ -1,9 +1,9 @@
/**
* Action that autofocuses an element after mount when the condition is truthy.
* Focus an element after mount when the condition is truthy.
* Uses a microtask to ensure the element is fully rendered.
*/
export function autofocus(node, condition = true) {
if (condition) {
queueMicrotask(() => node.focus())
export function autofocus(el, condition = true) {
if (el && condition) {
queueMicrotask(() => el.focus())
}
}

View File

@ -1,159 +0,0 @@
/**
* TOTP (Time-based One-Time Password) RFC 6238 / RFC 4226.
*
* Implemented with the browser's native Web Crypto API (HMAC-SHA1), so no
* external crypto dependency. A base32 secret yields 6-digit codes that change
* every 30 seconds, matching common 2FA authenticator apps.
*/
const DEFAULT_PERIOD = 30
const DEFAULT_DIGITS = 6
const BASE32_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'
/**
* Decode a base32 string (RFC 4648) into bytes. Accepts whitespace (from
* authenticator-exported secrets). Throws on invalid characters.
*
* @param {string} base32
* @returns {Uint8Array}
*/
export function base32Decode(base32) {
const clean = String(base32).toUpperCase().replace(/[\s=]/g, '')
if (!clean) return new Uint8Array(0)
let bits = 0
let value = 0
const bytes = []
for (const ch of clean) {
const idx = BASE32_ALPHABET.indexOf(ch)
if (idx === -1) throw new Error(`Invalid base32 character: "${ch}"`)
value = (value << 5) | idx
bits += 5
if (bits >= 8) {
bytes.push((value >>> (bits - 8)) & 0xff)
bits -= 8
}
}
return new Uint8Array(bytes)
}
/**
* Extract a raw base32 secret from the user input, accepting either a bare
* base32 string or an otpauth:// URI (which embeds `secret=`).
*
* @param {string} input
* @returns {string} Base32 secret (uppercased, no whitespace)
*/
export function extractSecret(input) {
const text = String(input || '').trim()
if (!text) return ''
if (/^otpauth:\/\//i.test(text)) {
try {
const m = text.match(/[?&]secret=([^&]+)/i)
if (m) return m[1].toUpperCase().replace(/[^A-Z2-7]/g, '')
} catch {
/* fall through */
}
}
// Bare base32 (optionally hyphen-grouped as authenticators display it).
return text.toUpperCase().replace(/[\s-]/g, '')
}
/**
* Validate a TOTP secret entered by the user (bare base32 or otpauth:// URI).
*
* The secret is optional an empty string validates as OK. When provided, it
* must decode to a valid base32 key of at least `minBytes` bytes so garbage,
* typos, or wrong-format input is rejected before it is stored.
*
* @param {string} input
* @param {Object} [opts]
* @param {number} [opts.minBytes=10] - Minimum decoded secret length (RFC 6238
* recommends >= 80 bits / 10 bytes; most real 2FA secrets exceed this).
* @returns {{ valid: boolean, error: string }}
*/
export function validateTotpSecret(input, { minBytes = 10 } = {}) {
const text = String(input || '').trim()
if (!text) return { valid: true, error: '' }
let secret
try {
secret = extractSecret(text)
} catch {
return { valid: false, error: 'Could not read the TOTP secret.' }
}
if (!secret) {
return { valid: false, error: 'No TOTP secret found. Paste a base32 code or an otpauth:// link.' }
}
let bytes
try {
bytes = base32Decode(secret)
} catch (e) {
return { valid: false, error: `Invalid TOTP secret: ${e.message}` }
}
if (bytes.length === 0) {
return { valid: false, error: 'The TOTP secret is empty.' }
}
if (bytes.length < minBytes) {
return { valid: false, error: `TOTP secret is too short (needs at least ${minBytes} characters decoded).` }
}
return { valid: true, error: '' }
}
/**
* Generate the 8-byte big-endian counter for a Unix timestamp.
* @param {number} counter
* @returns {Uint8Array}
*/
function counterBytes(counter) {
const buf = new Uint8Array(8)
for (let i = 7; i >= 0; i--) {
buf[i] = counter & 0xff
counter = Math.floor(counter / 256)
}
return buf
}
/**
* Compute a TOTP code for a secret at a given Unix timestamp.
*
* @param {string} secret - Base32 secret (SCHEME uri also accepted via extractSecret)
* @param {Object} [opts]
* @param {number} [opts.timestamp=Date.now()/1000] - Unix seconds
* @param {number} [opts.period=30]
* @param {number} [opts.digits=6]
* @returns {Promise<string>} Zero-padded code.
*/
export async function generateTotp(secret, { timestamp = Math.floor(Date.now() / 1000), period = DEFAULT_PERIOD, digits = DEFAULT_DIGITS } = {}) {
const keyBytes = base32Decode(extractSecret(secret))
if (keyBytes.length === 0) {
throw new Error('TOTP secret is empty or invalid')
}
const counter = Math.floor(timestamp / period)
const key = await crypto.subtle.importKey('raw', keyBytes, { name: 'HMAC', hash: 'SHA-1' }, false, ['sign'])
const sig = new Uint8Array(await crypto.subtle.sign('HMAC', key, counterBytes(counter)))
// Dynamic truncation (RFC 4226 section 5.3)
const offset = sig[sig.length - 1] & 0x0f
const bin = ((sig[offset] & 0x7f) << 24) | (sig[offset + 1] << 16) | (sig[offset + 2] << 8) | sig[offset + 3]
const code = bin % Math.pow(10, digits)
return code.toString().padStart(digits, '0')
}
/**
* Seconds remaining before the current TOTP code expires.
* @param {Object} [opts]
* @param {number} [opts.period=30]
* @param {number} [opts.timestamp=Date.now()/1000]
* @returns {number} 1..period
*/
export function totpRemainingSeconds({ period = DEFAULT_PERIOD, timestamp = Math.floor(Date.now() / 1000) } = {}) {
return period - (timestamp % period)
}

View File

@ -30,8 +30,7 @@ export function generateId() {
* @property {string} id - Unique identifier
* @property {string} title - Display name (e.g. "GitHub", "Gmail")
* @property {string} [username] - Login username or email (optional)
* @property {string} [encryptedPassword] - AES-GCM encrypted password blob (JSON string); optional
* @property {string} [encryptedTotpSecret] - AES-GCM encrypted TOTP base32 secret (JSON string); optional
* @property {string} encryptedPassword - AES-GCM encrypted password blob (JSON string)
* @property {string} [url] - Website URL
* @property {string} [notes] - Free-form notes
* @property {string} [groupId] - Reference to a Group id (empty string = no group)
@ -46,8 +45,7 @@ export function generateId() {
* @param {Object} data
* @param {string} data.title
* @param {string} [data.username]
* @param {string} [data.encryptedPassword] - Must already be encrypted (optional; empty string = no password)
* @param {string} [data.encryptedTotpSecret] - Must already be encrypted (optional; empty = no TOTP)
* @param {string} data.encryptedPassword - Must already be encrypted
* @param {string} [data.url]
* @param {string} [data.notes]
* @param {string} [data.groupId]
@ -61,7 +59,6 @@ export function createEntry(data) {
title: data.title.trim(),
username: data.username?.trim() || '',
encryptedPassword: data.encryptedPassword,
encryptedTotpSecret: data.encryptedTotpSecret,
url: data.url?.trim() || '',
notes: data.notes?.trim() || '',
groupId: data.groupId || '',
@ -84,7 +81,6 @@ export function updateEntry(existing, data) {
title: data.title !== undefined ? data.title.trim() : existing.title,
username: data.username !== undefined ? (data.username?.trim() || '') : existing.username,
encryptedPassword: data.encryptedPassword !== undefined ? data.encryptedPassword : existing.encryptedPassword,
encryptedTotpSecret: data.encryptedTotpSecret !== undefined ? data.encryptedTotpSecret : existing.encryptedTotpSecret,
url: data.url !== undefined ? data.url.trim() : existing.url,
notes: data.notes !== undefined ? data.notes.trim() : existing.notes,
groupId: data.groupId !== undefined ? data.groupId : existing.groupId,
@ -131,7 +127,8 @@ export function createGroup(name, color) {
export function validateEntry(data) {
const errors = []
if (!data.title || !data.title.trim()) errors.push('Title is required')
// Password is optional — an entry may store no password at all.
if (!data.encryptedPassword) errors.push('Password is required')
return { valid: errors.length === 0, errors }
}

View File

@ -1,26 +0,0 @@
/**
* PWA bootstrap for the Password Vault.
*
* Registers the service worker (relative path, so it works both in dev at /
* and in production under /password_manager/). The worker doubles as a network
* sandbox (see public/sw.js): once installed, it blocks all cross-origin and
* out-of-app requests. CSP in index.html backs it up (WebSockets/form/etc.).
*
* Registration is best-effort: it never blocks or breaks the app.
*/
export async function registerServiceWorker() {
if (!('serviceWorker' in navigator) || !window.isSecureContext) return false
try {
const reg = await navigator.serviceWorker.register('./sw.js')
try {
await reg.update()
} catch {
/* first install may have nothing to update */
}
return true
} catch (e) {
console.warn('[vault] service worker registration failed:', e)
return false
}
}

View File

@ -12,14 +12,7 @@
*/
import { openDB } from 'idb'
import {
deriveKey,
decrypt,
encrypt,
generateSalt,
base64ToUint8Array,
uint8ArrayToBase64,
} from '../crypto/crypto.js'
import { deriveKey, decrypt, encrypt, base64ToUint8Array } from '../crypto/crypto.js'
import { TRASH_GROUP_ID, createTrashGroup, isTrashGroup } from '../models/schema.js'
// Re-export for convenience
@ -383,42 +376,16 @@ export async function moveEntryToGroup(entryId, groupId) {
// ========================
/**
* Export data (entries + groups + meta).
* Export data (entries + groups + meta) as a JSON object.
* Entries remain encrypted with the source vault's key. The import function
* requires the source vault's master password to decrypt and re-encrypt
* entries under the target vault's key.
*
* The caller must choose how the file is protected - an explicit choice, never
* an ambiguous "optional" field. Wraps the whole payload (entries + groups +
* meta, incl. titles/usernames/notes) in an AES-256-GCM envelope so the file
* is unreadable without the unlock key:
*
* - `password`: a NEW, separate password. The envelope is sealed with a key
* derived from this password + a fresh random salt (embedded in the
* envelope), so importing needs ONLY this password - never the vault's.
* Each entry's password is re-encrypted from the vault key to the export key.
*
* - `useExistingPassword`: reuse the SAME password that unlocks this vault.
* The envelope is sealed with the vault's own key and the vault's salt
* stays embedded, so import derives that key from the master password. No
* new password needs creating or remembering.
*
* Supplying neither password nor useExistingPassword falls back to a plaintext
* JSON export (entries keep their source-vault-encrypted passwords) - kept for
* programmatic/backward compatibility, though the UI always chooses one of the
* two protected modes.
*
* @param {string[]} [groupIds] - Group IDs to export. null/[] = full (include '' for ungrouped).
* @param {Object} [options]
* @param {CryptoKey|null} [options.vaultKey] - Current vault's in-memory encryption key (required to seal).
* @param {string} [options.password] - Optional NEW separate password to seal with.
* @param {boolean} [options.useExistingPassword] - Seal with the vault's own key instead of a new password.
* @param {string[]} [groupIds] - Array of group IDs to export. If null/empty, exports everything.
* Include '' to export ungrouped entries.
* @returns {Promise<Object>}
*/
export async function exportSelected(groupIds = null, options = {}) {
const {
vaultKey = null,
password = '',
useExistingPassword = false,
} = options
export async function exportSelected(groupIds) {
const db = await getDb()
const allEntries = await db.getAll('entries')
const allGroups = await db.getAll('groups')
@ -428,11 +395,8 @@ export async function exportSelected(groupIds = null, options = {}) {
const testPlaintextRow = await db.get('meta', 'testPlaintext')
// If no groups selected, export everything
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 = {
if (!groupIds || groupIds.length === 0) {
return {
version: DB_VERSION,
exportedAt: new Date().toISOString(),
meta: {
@ -440,69 +404,28 @@ export async function exportSelected(groupIds = null, options = {}) {
testEncrypted: testEncryptedRow?.value || null,
testPlaintext: testPlaintextRow?.value || null,
},
groups: allGroups.filter(pickGroup),
entries: allEntries.filter(pickEntry),
groups: allGroups,
entries: allEntries,
}
}
// Explicit choice required: pick a way to protect the file.
const wantsProtection = !!(password || useExistingPassword)
if (!wantsProtection) {
// Plaintext fallback (backward compatibility, never offered by the UI).
return payload
}
if (!vaultKey) {
throw new Error('The vault key is required to create a protected export')
}
let sealKey
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)
for (const entry of payload.entries) {
if (entry.encryptedPassword) {
const plaintext = await decrypt(entry.encryptedPassword, vaultKey)
entry.encryptedPassword = await encrypt(plaintext, sealKey)
}
if (entry.encryptedTotpSecret) {
const secret = await decrypt(entry.encryptedTotpSecret, vaultKey)
entry.encryptedTotpSecret = await encrypt(secret, sealKey)
}
}
// Point meta.salt at the export salt so import reproduces the export key.
payload.meta.salt = envelopeSalt
}
// Seal the entire payload (also protects titles/usernames/notes at rest).
const sealed = await encrypt(JSON.stringify(payload), sealKey)
const entries = allEntries.filter(e => groupIds.includes(e.groupId))
const groups = allGroups.filter(g => groupIds.includes(g.id))
return {
version: 1,
version: DB_VERSION,
exportedAt: new Date().toISOString(),
format: 'encrypted-export',
kdfIterations: 600_000,
salt: envelopeSalt,
data: sealed, // encrypt() output string: { iv, ciphertext }
meta: {
salt: saltRow?.value || null,
testEncrypted: testEncryptedRow?.value || null,
testPlaintext: testPlaintextRow?.value || null,
},
groups,
entries,
}
}
/**
* Import data from a previously exported JSON object.
*
@ -517,24 +440,6 @@ export async function exportSelected(groupIds = null, options = {}) {
* @returns {Promise<{ imported: { entries: number, groups: number }, skipped: number }>}
*/
export async function importAll(data, mode = 'merge', sourcePassword = '', targetKey = null) {
// Accept a password-protected (sealed) export. `sourcePassword` here is the
// EXPORT password - it may differ from any vault's master password. If the
// wrong password is entered, decryption fails and import is rejected, rather
// than silently skipping entries.
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')
}
@ -573,27 +478,17 @@ export async function importAll(data, mode = 'merge', sourcePassword = '', targe
try {
let reencryptedEntry = { ...entry }
// An entry is only skippable if it actually needs re-keying (has an
// encrypted password or TOTP secret) but we lack the keys to do so.
const hasEncrypted = !!(entry.encryptedPassword || entry.encryptedTotpSecret)
if (sourceKey && targetKey && hasEncrypted) {
if (entry.encryptedPassword) {
if (sourceKey && targetKey && entry.encryptedPassword) {
// Decrypt password with source key
const plaintext = await decrypt(entry.encryptedPassword, sourceKey)
// Re-encrypt under target vault's key
reencryptedEntry.encryptedPassword = await encrypt(plaintext, targetKey)
}
if (entry.encryptedTotpSecret) {
const secret = await decrypt(entry.encryptedTotpSecret, sourceKey)
reencryptedEntry.encryptedTotpSecret = await encrypt(secret, targetKey)
}
} else if (!sourceKey || !targetKey) {
// Can't re-encrypt — require a password/secret, else nothing to do.
if (hasEncrypted) {
console.warn('Skipping entry (missing source password or target):', entry.title)
// Can't re-encrypt — skip this entry with a warning
console.warn('Skipping entry (missing source password or target key):', entry.title)
skipped++
continue
}
}
await db.put('entries', reencryptedEntry)
importedEntries++

27
src/lib/stores/app.js Normal file
View File

@ -0,0 +1,27 @@
/**
* App-level reactive state.
*/
import { Store } from './store.js'
import { stopAutoLock } from './security.js'
export class AppStore extends Store {
constructor() {
super({
isUnlocked: false,
encryptionKey: null,
salt: null,
})
}
/**
* Lock the vault clear the key from memory.
*/
lockVault() {
stopAutoLock()
this.encryptionKey = null
this.isUnlocked = false
}
}
export const app = new AppStore()

View File

@ -1,22 +0,0 @@
/**
* App-level reactive state using Svelte 5 runes.
*/
import { stopAutoLock } from './security.svelte.js'
export class AppStore {
isUnlocked = $state(false)
encryptionKey = $state(null)
salt = $state(null)
/**
* Lock the vault clear the key from memory.
*/
lockVault() {
stopAutoLock()
this.encryptionKey = null
this.isUnlocked = false
}
}
export const app = new AppStore()

View File

@ -3,17 +3,22 @@
* Shared between Sidebar and EntryList for coordinated filtering.
*/
import { TRASH_GROUP_ID } from '../models/schema.js'
import { Store } from './store.js'
const DEBOUNCE_MS = 300
export class SearchStore {
query = $state('') // raw input value — bound to the search input
debouncedQuery = $state('') // debounced value — used for actual search
activeGroupId = $state('all') // 'all', 'trash', or a group id
refreshTrigger = $state(0) // incremented to force a re-fetch
export class SearchStore extends Store {
#debounceTimer = null
constructor() {
super({
query: '', // raw input value — bound to the search input
debouncedQuery: '', // debounced value — used for actual search
activeGroupId: 'all', // 'all', 'trash', or a group id
refreshTrigger: 0, // incremented to force a re-fetch
})
}
/**
* Update the search query with debouncing.
* Call this from the input handler instead of setting `query` directly.

View File

@ -2,8 +2,8 @@
* Security utilities: auto-lock timer, visibility change detection, cleanup.
*/
import { app } from './app.svelte.js'
import { settings } from './settings.svelte.js'
import { app } from './app.js'
import { settings } from './settings.js'
let autoLockTimer = null
@ -80,4 +80,3 @@ export function stopAutoLock() {
document.removeEventListener('visibilitychange', handleVisibilityChange)
window.removeEventListener('beforeunload', clearKeyOnExit)
}

View File

@ -5,13 +5,16 @@
* page reloads. Defaults are used until the user explicitly saves.
*/
import { Store } from './store.js'
import { getSetting, saveSetting } from '../storage/db.js'
export class SettingsStore {
autoLockMinutes = $state(5)
lockOnTabSwitch = $state(true)
// User dismissed the "running from a web server" warning banner on the lock screen.
dismissedLocalWarning = $state(false)
export class SettingsStore extends Store {
constructor() {
super({
autoLockMinutes: 5,
lockOnTabSwitch: true,
})
}
/**
* Load persisted settings from IndexedDB.
@ -20,11 +23,9 @@ export class SettingsStore {
async load() {
const minutes = await getSetting('autoLockMinutes')
const tabSwitch = await getSetting('lockOnTabSwitch')
const dismissedWarning = await getSetting('dismissedLocalWarning')
this.autoLockMinutes = minutes != null ? Number(minutes) : 5
this.lockOnTabSwitch = tabSwitch != null ? Boolean(tabSwitch) : true
this.dismissedLocalWarning = dismissedWarning != null ? Boolean(dismissedWarning) : false
}
/**
@ -33,7 +34,6 @@ export class SettingsStore {
async save() {
await saveSetting('autoLockMinutes', this.autoLockMinutes)
await saveSetting('lockOnTabSwitch', this.lockOnTabSwitch)
await saveSetting('dismissedLocalWarning', this.dismissedLocalWarning)
}
}

104
src/lib/stores/store.js Normal file
View File

@ -0,0 +1,104 @@
/**
* Minimal reactive store subscriber/callback pattern.
*
* Usage:
* const store = new Store({ count: 0, name: '' })
* store.onChange('count', v => console.log('count is now', v))
* store.set('count', 5)
* store.get('count') // 5
* // or via proxy: store.count = 5; console.log(store.count)
*/
export class Store {
#data
#subscribers = {} // prop -> Set<fn>
/** @param {Record<string, any>} initial */
constructor(initial) {
this.#data = { ...initial }
// Build a live proxy so `store.prop` reads/writes go through get/set
const self = this
const proxy = new Proxy({}, {
get(_, prop) {
if (prop === '_data') return self.#data
if (prop in self.#data) return self.#data[prop]
return undefined
},
set(_, prop, value) {
if (prop in self.#data) {
const old = self.#data[prop]
self.#data[prop] = value
if (old !== value) self.#emit(prop, value)
return true
}
return false
},
})
// Attach proxy methods onto the instance so `store.set(...)` works
// but property access goes through the proxy. We do this by
// forwarding store.get/set/onChange through the instance and
// exposing the proxy as `store.$` for direct property binding.
this.$ = proxy
// Also make direct property access on the instance work by
// defining getters/setters dynamically. We update them when
// new props are added.
this.#syncDescriptors()
}
/** @param {string} prop @param {*} value */
set(prop, value) {
const old = this.#data[prop]
this.#data[prop] = value
if (old !== value) this.#emit(prop, value)
this.#syncDescriptors()
}
/** @param {string} prop @returns {*} */
get(prop) {
return this.#data[prop]
}
/** @param {string} prop @param {Function} fn @returns {Function} unsubscribe */
onChange(prop, fn) {
if (!this.#subscribers[prop]) this.#subscribers[prop] = new Set()
this.#subscribers[prop].add(fn)
return () => this.#subscribers[prop].delete(fn)
}
/** @param {string} prop @param {Function} fn */
onChangeOnce(prop, fn) {
const unsub = this.onChange(prop, (...args) => {
fn(...args)
unsub()
})
return unsub
}
/** @param {string} prop @param {*} value */
#emit(prop, value) {
const fns = this.#subscribers[prop]
if (fns) {
const snap = [...fns]
snap.forEach(fn => {
try { fn(value) } catch (e) { console.error('Store subscriber error:', e) }
})
}
}
// Keep instance-level property access in sync with internal data
#syncDescriptors() {
for (const key of Object.keys(this.#data)) {
if (Object.getOwnPropertyDescriptor(this, key)) continue
Object.defineProperty(this, key, {
get: () => this.#data[key],
set: (value) => {
const old = this.#data[key]
this.#data[key] = value
if (old !== value) this.#emit(key, value)
},
enumerable: true,
configurable: true,
})
}
}
}

View File

@ -1,16 +1,8 @@
import { mount } from 'svelte'
import './styles/main.css'
import App from './App.svelte'
import { registerServiceWorker } from './lib/pwa.js'
import { App } from './App.js'
// Report the baked-in commit hash on startup (handy for verifying a deploy).
console.info({ commit_hash: __VAULT_COMMIT__ })
// Register the PWA service worker / network sandbox (no-op where unsupported).
registerServiceWorker()
const app = mount(App, {
target: document.getElementById('app'),
})
const root = document.getElementById('app')
const app = new App(root)
app.mount()
export default app

File diff suppressed because it is too large Load Diff

View File

@ -1,2 +0,0 @@
/** @type {import("@sveltejs/vite-plugin-svelte").SvelteConfig} */
export default {}

View File

@ -1,117 +0,0 @@
import { describe, it, expect } from 'vitest'
import { generateTotp, totpRemainingSeconds, base32Decode, extractSecret, validateTotpSecret } from '../../../src/lib/crypto/totp.js'
// RFC 6238 test vectors (Appendix B, SHA-1) use the ASCII secret
// "12345678901234567890" whose base32 is GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ.
const RFC_SECRET = 'GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ'
describe('base32Decode', () => {
it('decodes the RFC secret to the expected ASCII bytes', () => {
const bytes = base32Decode(RFC_SECRET)
expect(String.fromCharCode(...bytes)).toBe('12345678901234567890')
})
it('ignores whitespace and padding', () => {
expect(base32Decode('JBSWY3DP EHPK3PXP').length).toBe(10)
expect(base32Decode('JBSWY3DPEHPK3PXP==').length).toBe(10)
})
it('throws on invalid characters', () => {
// '0','1','8','9' are not valid base32
expect(() => base32Decode('ABC0')).toThrow(/Invalid base32/)
})
})
describe('extractSecret', () => {
it('passes through a bare base32 secret (normalized)', () => {
expect(extractSecret('jbs-wy3dpehpk3pxp')).toBe('JBSWY3DPEHPK3PXP')
})
it('pulls the secret out of an otpauth:// URI', () => {
expect(extractSecret('otpauth://totp/Example:alice?secret=JBSWY3DPEHPK3PXP&issuer=Example'))
.toBe('JBSWY3DPEHPK3PXP')
})
it('returns empty string for empty input', () => {
expect(extractSecret('')).toBe('')
expect(extractSecret(' ')).toBe('')
})
})
describe('generateTotp (RFC 6238 vectors)', () => {
it('matches the RFC 6238 SHA-1 vectors at 6 digits', async () => {
const vectors = [
[59, '287082'],
[1111111109, '081804'],
[1111111111, '050471'],
[1234567890, '005924'],
[2000000000, '279037'],
[20000000000, '353130'],
]
for (const [t, expected] of vectors) {
await expect(generateTotp(RFC_SECRET, { timestamp: t })).resolves.toBe(expected)
}
})
it('supports custom digit counts', async () => {
// 8-digit vector at t=59 is 94287082
await expect(generateTotp(RFC_SECRET, { timestamp: 59, digits: 8 })).resolves.toBe('94287082')
})
it('rejects an empty/invalid secret', async () => {
await expect(generateTotp('')).rejects.toThrow(/empty or invalid/)
await expect(generateTotp('!!!!')).rejects.toThrow()
})
it('changes over time', async () => {
const a = await generateTotp(RFC_SECRET, { timestamp: 30 })
const b = await generateTotp(RFC_SECRET, { timestamp: 90 })
// 30 and 90 map to counters 1 and 3 — codes differ.
expect(a).not.toBe(b)
})
})
describe('validateTotpSecret', () => {
const GOOD = 'JBSWY3DPEHPK3PXP' // valid 10-byte base32 secret
it('accepts a blank secret (optional)', () => {
expect(validateTotpSecret('').valid).toBe(true)
expect(validateTotpSecret(' ').valid).toBe(true)
})
it('accepts a valid bare base32 secret', () => {
expect(validateTotpSecret(GOOD).valid).toBe(true)
})
it('accepts hyphen-grouped base32 and otpauth:// URIs', () => {
expect(validateTotpSecret('JBSW-Y3DP-EHPK-3PXP').valid).toBe(true)
expect(validateTotpSecret(`otpauth://totp/Example:alice?secret=${GOOD}&issuer=Example`).valid).toBe(true)
})
it('rejects invalid base32 characters', () => {
const r = validateTotpSecret('ABC9012345678901')
expect(r.valid).toBe(false)
expect(r.error).toMatch(/Invalid TOTP secret/)
})
it('rejects a secret that is too short', () => {
const r = validateTotpSecret('ABCDEF') // 4 decoded bytes < minBytes
expect(r.valid).toBe(false)
expect(r.error).toMatch(/too short/)
})
it('rejects an otpauth:// URI with no secret parameter', () => {
const r = validateTotpSecret('otpauth://totp/x?issuer=y')
expect(r.valid).toBe(false)
})
})
describe('totpRemainingSeconds', () => {
it('returns period for exact boundary', () => {
expect(totpRemainingSeconds({ timestamp: 0, period: 30 })).toBe(30)
})
it('counts down within a period', () => {
expect(totpRemainingSeconds({ timestamp: 5, period: 30 })).toBe(25)
expect(totpRemainingSeconds({ timestamp: 29, period: 30 })).toBe(1)
})
})

View File

@ -50,20 +50,6 @@ describe('createEntry', () => {
expect(entry.updatedAt).toBe(entry.createdAt)
})
it('should store an encrypted TOTP secret', () => {
const entry = createEntry({
title: 'GitHub',
encryptedPassword: 'encrypted-blob',
encryptedTotpSecret: 'encrypted-totp',
})
expect(entry.encryptedTotpSecret).toBe('encrypted-totp')
})
it('should default encryptedTotpSecret to undefined when not provided', () => {
const entry = createEntry({ title: 'GitHub' })
expect(entry.encryptedTotpSecret).toBeUndefined()
})
it('should trim title and optional fields', () => {
const entry = createEntry({
title: ' GitHub ',
@ -190,22 +176,16 @@ describe('validateEntry', () => {
expect(result.errors).toContain('Title is required')
})
it('should pass with no encryptedPassword (password optional)', () => {
it('should fail with missing encryptedPassword', () => {
const result = validateEntry({ title: 'GitHub' })
expect(result.valid).toBe(true)
expect(result.errors).toEqual([])
expect(result.valid).toBe(false)
expect(result.errors).toContain('Password is required')
})
it('should pass with an empty password string', () => {
const result = validateEntry({ title: 'GitHub', encryptedPassword: '' })
expect(result.valid).toBe(true)
})
it('should report only the title error when password is missing (not required)', () => {
it('should report multiple errors', () => {
const result = validateEntry({ title: '' })
expect(result.valid).toBe(false)
expect(result.errors.length).toBe(1)
expect(result.errors).toContain('Title is required')
expect(result.errors.length).toBe(2)
})
})

View File

@ -25,7 +25,7 @@ import {
importAll,
TRASH_GROUP_ID,
} from '../../../src/lib/storage/db.js'
import { generateSalt, deriveKey, encrypt, decrypt } from '../../../src/lib/crypto/crypto.js'
import { generateSalt, deriveKey, encrypt } from '../../../src/lib/crypto/crypto.js'
import { createEntry, createGroup, createTrashGroup } from '../../../src/lib/models/schema.js'
const DB_NAME = 'password-vault'
@ -601,103 +601,4 @@ describe('Export / Import', () => {
expect(result.skipped).toBe(1)
expect(result.imported.entries).toBe(0)
})
describe('password-protected encrypted export/import', () => {
async function setupSourceVault() {
await clearAllData()
const salt = generateSalt()
const key = await deriveKey('source-key', salt)
const testPlaintext = 'vault_test_src'
const testEncrypted = await encrypt(testPlaintext, key)
await saveVaultMeta(salt, testEncrypted, testPlaintext)
const group = createGroup('Work')
await addGroup(group)
const enc = await encrypt('topsecret', key)
await addEntry(createEntry({
title: 'GitHub',
encryptedPassword: enc,
groupId: group.id,
}))
return key
}
async function setupTargetVault() {
await clearAllData()
const salt = generateSalt()
const key = await deriveKey('target-key', salt)
const testPlaintext = 'vault_test_tgt'
const testEncrypted = await encrypt(testPlaintext, key)
await saveVaultMeta(salt, testEncrypted, testPlaintext)
return key
}
it('seals the export envelope with the separate password', async () => {
const sourceKey = await setupSourceVault()
const sealed = await exportSelected(null, { vaultKey: sourceKey, password: 'exportpw' })
expect(sealed.format).toBe('encrypted-export')
expect(sealed.salt).toBeDefined()
expect(sealed.data).toBeDefined()
expect(sealed.groups).toBeUndefined()
expect(sealed.entries).toBeUndefined()
// The plaintext payload (incl. entry title) must not leak into the envelope
expect(JSON.stringify(sealed)).not.toContain('GitHub')
})
it('imports a sealed export into another vault using only the export password', async () => {
const sourceKey = await setupSourceVault()
const sealed = await exportSelected(null, { vaultKey: sourceKey, password: 'exportpw' })
const targetKey = await setupTargetVault()
const result = await importAll(sealed, 'merge', 'exportpw', targetKey)
expect(result.skipped).toBe(0)
expect(result.imported.entries).toBe(1)
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 sealed export when the export password is wrong', async () => {
const sourceKey = await setupSourceVault()
const sealed = await exportSelected(null, { vaultKey: sourceKey, password: 'exportpw' })
const targetKey = await setupTargetVault()
await expect(importAll(sealed, 'merge', 'wrong-password', targetKey))
.rejects.toThrow('Incorrect export password')
})
it('requires an export password to import a sealed export', async () => {
const sourceKey = await setupSourceVault()
const sealed = await exportSelected(null, { vaultKey: sourceKey, password: 'exportpw' })
const targetKey = await setupTargetVault()
await expect(importAll(sealed, 'merge', '', targetKey))
.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')
})
})
})

View File

@ -1,5 +1,5 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { app } from '../../../src/lib/stores/app.svelte.js'
import { app } from '../../../src/lib/stores/app.js'
describe('AppStore', () => {
beforeEach(() => {

View File

@ -1,5 +1,5 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { search } from '../../../src/lib/stores/search.svelte.js'
import { search } from '../../../src/lib/stores/search.js'
describe('SearchStore', () => {
beforeEach(() => {

View File

@ -1,7 +1,7 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { startAutoLock, stopAutoLock } from '../../../src/lib/stores/security.svelte.js'
import { app } from '../../../src/lib/stores/app.svelte.js'
import { settings } from '../../../src/lib/stores/settings.svelte.js'
import { startAutoLock, stopAutoLock } from '../../../src/lib/stores/security.js'
import { app } from '../../../src/lib/stores/app.js'
import { settings } from '../../../src/lib/stores/settings.js'
// Reset singleton state before each test
function resetState() {

View File

@ -1,5 +1,5 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { settings } from '../../../src/lib/stores/settings.svelte.js'
import { settings } from '../../../src/lib/stores/settings.js'
import { saveSetting, getSetting } from '../../../src/lib/storage/db.js'
beforeEach(() => {

View File

@ -1,50 +0,0 @@
import { describe, it, expect } from 'vitest'
/**
* Canonical network-sandbox decision logic for the Password Vault PWA.
* Mirrors `isAppOwnResource` in public/sw.js. It decides whether a request is
* permitted: only SAME-ORIGIN requests within the worker's OWN directory scope
* are allowed; everything else (cross-origin, or same-origin but a different
* app/dir on the same server) is denied.
*/
function isAppOwnResource(requestUrl, selfUrl) {
const req = new URL(requestUrl)
const self = new URL(selfUrl)
if (req.origin !== self.origin) return false
const dir = self.pathname.slice(0, self.pathname.lastIndexOf('/') + 1)
return req.pathname.startsWith(dir)
}
// The deployed worker lives under /password_manager/ -> selfUrl is its own URL.
const SELF = 'https://thecookiejar.me/password_manager/sw.js'
describe('Vault network-sandbox policy', () => {
it('allows the app is own in-scope static resources', () => {
expect(isAppOwnResource('https://thecookiejar.me/password_manager/index.html', SELF)).toBe(true)
expect(isAppOwnResource('https://thecookiejar.me/password_manager/manifest.webmanifest', SELF)).toBe(true)
expect(isAppOwnResource('https://thecookiejar.me/password_manager/icons/icon-192.png', SELF)).toBe(true)
expect(isAppOwnResource('https://thecookiejar.me/password_manager/sw.js', SELF)).toBe(true)
})
it('denies any cross-origin request (third-party exfiltration)', () => {
expect(isAppOwnResource('https://evil.example.com/collect', SELF)).toBe(false)
expect(isAppOwnResource('https://api.open-meteo.com/v1/forecast', SELF)).toBe(false)
expect(isAppOwnResource('https://google.com/', SELF)).toBe(false)
expect(isAppOwnResource('https://thecookiejar.me.evil.com/', SELF)).toBe(false)
})
it('denies same-origin requests outside the app directory scope', () => {
// Same server, different app/vault dir -> outside the sandbox.
expect(isAppOwnResource('https://thecookiejar.me/weather/index.html', SELF)).toBe(false)
expect(isAppOwnResource('https://thecookiejar.me/static/switch.css', SELF)).toBe(false)
expect(isAppOwnResource('https://thecookiejar.me/', SELF)).toBe(false)
})
it('treats a different scheme (http vs https) as a different origin', () => {
expect(isAppOwnResource('http://thecookiejar.me/password_manager/index.html', SELF)).toBe(false)
})
it('treats a different port as a different origin', () => {
expect(isAppOwnResource('https://thecookiejar.me:8443/password_manager/index.html', SELF)).toBe(false)
})
})

View File

@ -1,27 +1,9 @@
import { execSync } from 'node:child_process'
import { defineConfig } from 'vite'
import { svelte } from '@sveltejs/vite-plugin-svelte'
import { viteSingleFile } from 'vite-plugin-singlefile'
// https://vite.dev/config/
// Bake the commit hash into the bundle at build time so a deployed app can
// report exactly which commit produced it (logged via console.info at startup).
// CI sets VITE_COMMIT_HASH=${{ github.sha }}; local builds fall back to git HEAD.
function getCommitHash() {
if (process.env.VITE_COMMIT_HASH) return process.env.VITE_COMMIT_HASH
try {
return execSync('git rev-parse HEAD').toString().trim()
} catch {
return 'unknown'
}
}
export default defineConfig(({ command }) => ({
define: {
__VAULT_COMMIT__: JSON.stringify(getCommitHash()),
},
plugins: [
svelte(),
...(command === 'build'
? [viteSingleFile({
inlineStyles: true,
@ -43,8 +25,5 @@ export default defineConfig(({ command }) => ({
globals: true,
setupFiles: ['./tests/setup.js'],
include: ['src/**/*.test.js', 'tests/**/*.test.js'],
// PBKDF2 (600k iterations) derivations are slow; don't let the default 5s
// per-test timeout flake the suite on slower CI hosts.
testTimeout: 30000,
},
}))