Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7a02051b14 |
@ -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"
|
|
||||||
19
AGENTS.md
@ -75,27 +75,16 @@ Password verification uses a test payload (random string encrypted at vault crea
|
|||||||
|
|
||||||
## Security Notes
|
## 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.
|
- `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).
|
- Auto-lock triggers on tab visibility change and configurable inactivity timer (default 5 min).
|
||||||
- Clipboard auto-clears after 15 seconds.
|
- Clipboard auto-clears after 15 seconds.
|
||||||
- No browser fingerprinting or anti-keylogger protections.
|
- 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`.
|
- `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.
|
||||||
- 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`.
|
- `ImportExport.svelte` fetches groups/entries on modal open and shows a checkbox list for group selection with live entry count.
|
||||||
- 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).
|
|
||||||
|
|
||||||
## Known Bug Fixes
|
## Known Bug Fixes
|
||||||
|
|
||||||
|
|||||||
@ -33,6 +33,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.
|
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
|
### Encryption Flow
|
||||||
|
|
||||||
|
|||||||
BIN
dist/icons/icon-192.png
vendored
|
Before Width: | Height: | Size: 20 KiB |
BIN
dist/icons/icon-512.png
vendored
|
Before Width: | Height: | Size: 60 KiB |
BIN
dist/icons/icon-maskable-512.png
vendored
|
Before Width: | Height: | Size: 26 KiB |
11273
dist/index.html
vendored
19
dist/manifest.webmanifest
vendored
@ -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
@ -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`)
|
|
||||||
27
index.html
@ -2,33 +2,8 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<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" />
|
<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>
|
<title>Password Vault</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
431
package-lock.json
generated
@ -11,12 +11,9 @@
|
|||||||
"idb": "^8.0.3"
|
"idb": "^8.0.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@sveltejs/vite-plugin-svelte": "^7.1.2",
|
|
||||||
"@testing-library/svelte": "^5.3.1",
|
|
||||||
"@vitest/ui": "^4.1.6",
|
"@vitest/ui": "^4.1.6",
|
||||||
"fake-indexeddb": "^6.2.5",
|
"fake-indexeddb": "^6.2.5",
|
||||||
"jsdom": "^29.1.1",
|
"jsdom": "^29.1.1",
|
||||||
"svelte": "^5.55.5",
|
|
||||||
"vite": "^8.0.12",
|
"vite": "^8.0.12",
|
||||||
"vite-plugin-singlefile": "^2.3.3",
|
"vite-plugin-singlefile": "^2.3.3",
|
||||||
"vitest": "^4.1.6"
|
"vitest": "^4.1.6"
|
||||||
@ -73,41 +70,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@bramus/specificity": {
|
||||||
"version": "2.4.2",
|
"version": "2.4.2",
|
||||||
"resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz",
|
"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": {
|
"node_modules/@jridgewell/sourcemap-codec": {
|
||||||
"version": "1.5.5",
|
"version": "1.5.5",
|
||||||
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
|
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
|
||||||
@ -352,17 +282,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@napi-rs/wasm-runtime": {
|
||||||
"version": "1.1.4",
|
"version": "1.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz",
|
||||||
@ -670,106 +589,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@tybys/wasm-util": {
|
||||||
"version": "0.10.2",
|
"version": "0.10.2",
|
||||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz",
|
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz",
|
||||||
@ -781,13 +600,6 @@
|
|||||||
"tslib": "^2.4.0"
|
"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": {
|
"node_modules/@types/chai": {
|
||||||
"version": "5.2.3",
|
"version": "5.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
|
||||||
@ -813,13 +625,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@vitest/expect": {
|
||||||
"version": "4.1.6",
|
"version": "4.1.6",
|
||||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.6.tgz",
|
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.6.tgz",
|
||||||
@ -955,52 +760,6 @@
|
|||||||
"url": "https://opencollective.com/vitest"
|
"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": {
|
"node_modules/assertion-error": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
|
||||||
@ -1011,16 +770,6 @@
|
|||||||
"node": ">=12"
|
"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": {
|
"node_modules/bidi-js": {
|
||||||
"version": "1.0.3",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
|
||||||
@ -1054,16 +803,6 @@
|
|||||||
"node": ">=18"
|
"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": {
|
"node_modules/convert-source-map": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
|
||||||
@ -1106,26 +845,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/detect-libc": {
|
||||||
"version": "2.1.2",
|
"version": "2.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||||
@ -1136,20 +855,6 @@
|
|||||||
"node": ">=8"
|
"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": {
|
"node_modules/entities": {
|
||||||
"version": "8.0.0",
|
"version": "8.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz",
|
||||||
@ -1170,31 +875,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/estree-walker": {
|
||||||
"version": "3.0.3",
|
"version": "3.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
|
||||||
@ -1321,23 +1001,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/jsdom": {
|
||||||
"version": "29.1.1",
|
"version": "29.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz",
|
||||||
@ -1640,13 +1303,6 @@
|
|||||||
"url": "https://opencollective.com/parcel"
|
"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": {
|
"node_modules/lru-cache": {
|
||||||
"version": "11.3.6",
|
"version": "11.3.6",
|
||||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.6.tgz",
|
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.6.tgz",
|
||||||
@ -1657,16 +1313,6 @@
|
|||||||
"node": "20 || >=22"
|
"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": {
|
"node_modules/magic-string": {
|
||||||
"version": "0.30.21",
|
"version": "0.30.21",
|
||||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||||
@ -1820,21 +1466,6 @@
|
|||||||
"node": "^10 || ^12 || >=14"
|
"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": {
|
"node_modules/punycode": {
|
||||||
"version": "2.3.1",
|
"version": "2.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||||
@ -1845,13 +1476,6 @@
|
|||||||
"node": ">=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": {
|
"node_modules/require-from-string": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
||||||
@ -1955,34 +1579,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/symbol-tree": {
|
||||||
"version": "3.2.4",
|
"version": "3.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
|
"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": {
|
"node_modules/vitest": {
|
||||||
"version": "4.1.6",
|
"version": "4.1.6",
|
||||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.6.tgz",
|
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.6.tgz",
|
||||||
@ -2412,13 +1988,6 @@
|
|||||||
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
|
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -12,12 +12,9 @@
|
|||||||
"test:ui": "vitest --ui"
|
"test:ui": "vitest --ui"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@sveltejs/vite-plugin-svelte": "^7.1.2",
|
|
||||||
"@testing-library/svelte": "^5.3.1",
|
|
||||||
"@vitest/ui": "^4.1.6",
|
"@vitest/ui": "^4.1.6",
|
||||||
"fake-indexeddb": "^6.2.5",
|
"fake-indexeddb": "^6.2.5",
|
||||||
"jsdom": "^29.1.1",
|
"jsdom": "^29.1.1",
|
||||||
"svelte": "^5.55.5",
|
|
||||||
"vite": "^8.0.12",
|
"vite": "^8.0.12",
|
||||||
"vite-plugin-singlefile": "^2.3.3",
|
"vite-plugin-singlefile": "^2.3.3",
|
||||||
"vitest": "^4.1.6"
|
"vitest": "^4.1.6"
|
||||||
|
|||||||
@ -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 |
|
Before Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 26 KiB |
@ -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" }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@ -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 |
58
public/sw.js
@ -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`)
|
|
||||||
@ -30,16 +30,6 @@ if (existsSync(faviconPath)) {
|
|||||||
console.log('[inline-assets] Inlined favicon.svg into index.html')
|
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
|
// Remove assets directory if it exists
|
||||||
const assetsDir = join(distDir, 'assets')
|
const assetsDir = join(distDir, 'assets')
|
||||||
if (existsSync(assetsDir)) {
|
if (existsSync(assetsDir)) {
|
||||||
@ -47,43 +37,13 @@ if (existsSync(assetsDir)) {
|
|||||||
console.log('[inline-assets] Removed assets/ directory')
|
console.log('[inline-assets] Removed assets/ directory')
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Production network sandbox CSP -----------------------------
|
// Remove any leftover SVG files (e.g. icons.svg from old Svelte builds)
|
||||||
// Replace the dev CSP with the strict, shipped one. The app is single-file and
|
import { readdirSync } from 'fs'
|
||||||
// offline-first, so script/style keep 'unsafe-inline' (they are inline), but
|
for (const file of readdirSync(distDir)) {
|
||||||
// every channel that could exfiltrate vault data is denied at the browser level:
|
if (file.endsWith('.svg')) {
|
||||||
// connect-src 'none' (no fetch/XHR/WebSocket anywhere), form-action 'none',
|
rmSync(join(distDir, file))
|
||||||
// object-src 'none', base-uri 'none'. (Clickjacking via frame-ancestors /
|
console.log(`[inline-assets] Removed ${file}`)
|
||||||
// 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}`)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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}
|
|
||||||
288
src/components/EntryDetail.js
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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>
|
|
||||||
354
src/components/ImportExport.js
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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 — 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>
|
|
||||||
194
src/components/LockScreen.js
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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>
|
|
||||||
298
src/components/MainLayout.js
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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>
|
|
||||||
122
src/components/SettingsDialog.js
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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.
|
* Uses a microtask to ensure the element is fully rendered.
|
||||||
*/
|
*/
|
||||||
export function autofocus(node, condition = true) {
|
export function autofocus(el, condition = true) {
|
||||||
if (condition) {
|
if (el && condition) {
|
||||||
queueMicrotask(() => node.focus())
|
queueMicrotask(() => el.focus())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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)
|
|
||||||
}
|
|
||||||
@ -30,8 +30,7 @@ export function generateId() {
|
|||||||
* @property {string} id - Unique identifier
|
* @property {string} id - Unique identifier
|
||||||
* @property {string} title - Display name (e.g. "GitHub", "Gmail")
|
* @property {string} title - Display name (e.g. "GitHub", "Gmail")
|
||||||
* @property {string} [username] - Login username or email (optional)
|
* @property {string} [username] - Login username or email (optional)
|
||||||
* @property {string} [encryptedPassword] - AES-GCM encrypted password blob (JSON string); optional
|
* @property {string} encryptedPassword - AES-GCM encrypted password blob (JSON string)
|
||||||
* @property {string} [encryptedTotpSecret] - AES-GCM encrypted TOTP base32 secret (JSON string); optional
|
|
||||||
* @property {string} [url] - Website URL
|
* @property {string} [url] - Website URL
|
||||||
* @property {string} [notes] - Free-form notes
|
* @property {string} [notes] - Free-form notes
|
||||||
* @property {string} [groupId] - Reference to a Group id (empty string = no group)
|
* @property {string} [groupId] - Reference to a Group id (empty string = no group)
|
||||||
@ -46,8 +45,7 @@ export function generateId() {
|
|||||||
* @param {Object} data
|
* @param {Object} data
|
||||||
* @param {string} data.title
|
* @param {string} data.title
|
||||||
* @param {string} [data.username]
|
* @param {string} [data.username]
|
||||||
* @param {string} [data.encryptedPassword] - Must already be encrypted (optional; empty string = no password)
|
* @param {string} data.encryptedPassword - Must already be encrypted
|
||||||
* @param {string} [data.encryptedTotpSecret] - Must already be encrypted (optional; empty = no TOTP)
|
|
||||||
* @param {string} [data.url]
|
* @param {string} [data.url]
|
||||||
* @param {string} [data.notes]
|
* @param {string} [data.notes]
|
||||||
* @param {string} [data.groupId]
|
* @param {string} [data.groupId]
|
||||||
@ -61,7 +59,6 @@ export function createEntry(data) {
|
|||||||
title: data.title.trim(),
|
title: data.title.trim(),
|
||||||
username: data.username?.trim() || '',
|
username: data.username?.trim() || '',
|
||||||
encryptedPassword: data.encryptedPassword,
|
encryptedPassword: data.encryptedPassword,
|
||||||
encryptedTotpSecret: data.encryptedTotpSecret,
|
|
||||||
url: data.url?.trim() || '',
|
url: data.url?.trim() || '',
|
||||||
notes: data.notes?.trim() || '',
|
notes: data.notes?.trim() || '',
|
||||||
groupId: data.groupId || '',
|
groupId: data.groupId || '',
|
||||||
@ -84,7 +81,6 @@ export function updateEntry(existing, data) {
|
|||||||
title: data.title !== undefined ? data.title.trim() : existing.title,
|
title: data.title !== undefined ? data.title.trim() : existing.title,
|
||||||
username: data.username !== undefined ? (data.username?.trim() || '') : existing.username,
|
username: data.username !== undefined ? (data.username?.trim() || '') : existing.username,
|
||||||
encryptedPassword: data.encryptedPassword !== undefined ? data.encryptedPassword : existing.encryptedPassword,
|
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,
|
url: data.url !== undefined ? data.url.trim() : existing.url,
|
||||||
notes: data.notes !== undefined ? data.notes.trim() : existing.notes,
|
notes: data.notes !== undefined ? data.notes.trim() : existing.notes,
|
||||||
groupId: data.groupId !== undefined ? data.groupId : existing.groupId,
|
groupId: data.groupId !== undefined ? data.groupId : existing.groupId,
|
||||||
@ -131,7 +127,8 @@ export function createGroup(name, color) {
|
|||||||
export function validateEntry(data) {
|
export function validateEntry(data) {
|
||||||
const errors = []
|
const errors = []
|
||||||
if (!data.title || !data.title.trim()) errors.push('Title is required')
|
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 }
|
return { valid: errors.length === 0, errors }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -12,14 +12,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { openDB } from 'idb'
|
import { openDB } from 'idb'
|
||||||
import {
|
import { deriveKey, decrypt, encrypt, base64ToUint8Array } from '../crypto/crypto.js'
|
||||||
deriveKey,
|
|
||||||
decrypt,
|
|
||||||
encrypt,
|
|
||||||
generateSalt,
|
|
||||||
base64ToUint8Array,
|
|
||||||
uint8ArrayToBase64,
|
|
||||||
} from '../crypto/crypto.js'
|
|
||||||
import { TRASH_GROUP_ID, createTrashGroup, isTrashGroup } from '../models/schema.js'
|
import { TRASH_GROUP_ID, createTrashGroup, isTrashGroup } from '../models/schema.js'
|
||||||
|
|
||||||
// Re-export for convenience
|
// 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
|
* @param {string[]} [groupIds] - Array of group IDs to export. If null/empty, exports everything.
|
||||||
* an ambiguous "optional" field. Wraps the whole payload (entries + groups +
|
* Include '' to export ungrouped entries.
|
||||||
* meta, incl. titles/usernames/notes) in an AES-256-GCM envelope so the file
|
|
||||||
* is unreadable without the unlock key:
|
|
||||||
*
|
|
||||||
* - `password`: a NEW, separate password. The envelope is sealed with a key
|
|
||||||
* derived from this password + a fresh random salt (embedded in the
|
|
||||||
* envelope), so importing needs ONLY this password - never the vault's.
|
|
||||||
* Each entry's password is re-encrypted from the vault key to the export key.
|
|
||||||
*
|
|
||||||
* - `useExistingPassword`: reuse the SAME password that unlocks this vault.
|
|
||||||
* The envelope is sealed with the vault's own key and the vault's salt
|
|
||||||
* stays embedded, so import derives that key from the master password. No
|
|
||||||
* new password needs creating or remembering.
|
|
||||||
*
|
|
||||||
* Supplying neither password nor useExistingPassword falls back to a plaintext
|
|
||||||
* JSON export (entries keep their source-vault-encrypted passwords) - kept for
|
|
||||||
* programmatic/backward compatibility, though the UI always chooses one of the
|
|
||||||
* two protected modes.
|
|
||||||
*
|
|
||||||
* @param {string[]} [groupIds] - Group IDs to export. null/[] = full (include '' for ungrouped).
|
|
||||||
* @param {Object} [options]
|
|
||||||
* @param {CryptoKey|null} [options.vaultKey] - Current vault's in-memory encryption key (required to seal).
|
|
||||||
* @param {string} [options.password] - Optional NEW separate password to seal with.
|
|
||||||
* @param {boolean} [options.useExistingPassword] - Seal with the vault's own key instead of a new password.
|
|
||||||
* @returns {Promise<Object>}
|
* @returns {Promise<Object>}
|
||||||
*/
|
*/
|
||||||
export async function exportSelected(groupIds = null, options = {}) {
|
export async function exportSelected(groupIds) {
|
||||||
const {
|
|
||||||
vaultKey = null,
|
|
||||||
password = '',
|
|
||||||
useExistingPassword = false,
|
|
||||||
} = options
|
|
||||||
|
|
||||||
const db = await getDb()
|
const db = await getDb()
|
||||||
const allEntries = await db.getAll('entries')
|
const allEntries = await db.getAll('entries')
|
||||||
const allGroups = await db.getAll('groups')
|
const allGroups = await db.getAll('groups')
|
||||||
@ -428,11 +395,24 @@ export async function exportSelected(groupIds = null, options = {}) {
|
|||||||
const testPlaintextRow = await db.get('meta', 'testPlaintext')
|
const testPlaintextRow = await db.get('meta', 'testPlaintext')
|
||||||
|
|
||||||
// If no groups selected, export everything
|
// If no groups selected, export everything
|
||||||
const full = !groupIds || groupIds.length === 0
|
if (!groupIds || groupIds.length === 0) {
|
||||||
const pickEntry = full ? () => true : e => groupIds.includes(e.groupId)
|
return {
|
||||||
const pickGroup = full ? () => true : g => groupIds.includes(g.id)
|
version: DB_VERSION,
|
||||||
|
exportedAt: new Date().toISOString(),
|
||||||
|
meta: {
|
||||||
|
salt: saltRow?.value || null,
|
||||||
|
testEncrypted: testEncryptedRow?.value || null,
|
||||||
|
testPlaintext: testPlaintextRow?.value || null,
|
||||||
|
},
|
||||||
|
groups: allGroups,
|
||||||
|
entries: allEntries,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const payload = {
|
const entries = allEntries.filter(e => groupIds.includes(e.groupId))
|
||||||
|
const groups = allGroups.filter(g => groupIds.includes(g.id))
|
||||||
|
|
||||||
|
return {
|
||||||
version: DB_VERSION,
|
version: DB_VERSION,
|
||||||
exportedAt: new Date().toISOString(),
|
exportedAt: new Date().toISOString(),
|
||||||
meta: {
|
meta: {
|
||||||
@ -440,69 +420,12 @@ export async function exportSelected(groupIds = null, options = {}) {
|
|||||||
testEncrypted: testEncryptedRow?.value || null,
|
testEncrypted: testEncryptedRow?.value || null,
|
||||||
testPlaintext: testPlaintextRow?.value || null,
|
testPlaintext: testPlaintextRow?.value || null,
|
||||||
},
|
},
|
||||||
groups: allGroups.filter(pickGroup),
|
groups,
|
||||||
entries: allEntries.filter(pickEntry),
|
entries,
|
||||||
}
|
|
||||||
|
|
||||||
// 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)
|
|
||||||
|
|
||||||
return {
|
|
||||||
version: 1,
|
|
||||||
exportedAt: new Date().toISOString(),
|
|
||||||
format: 'encrypted-export',
|
|
||||||
kdfIterations: 600_000,
|
|
||||||
salt: envelopeSalt,
|
|
||||||
data: sealed, // encrypt() output string: { iv, ciphertext }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Import data from a previously exported JSON object.
|
* 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 }>}
|
* @returns {Promise<{ imported: { entries: number, groups: number }, skipped: number }>}
|
||||||
*/
|
*/
|
||||||
export async function importAll(data, mode = 'merge', sourcePassword = '', targetKey = null) {
|
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)) {
|
if (!data || !Array.isArray(data.entries) || !Array.isArray(data.groups)) {
|
||||||
throw new Error('Invalid import data format')
|
throw new Error('Invalid import data format')
|
||||||
}
|
}
|
||||||
@ -573,26 +478,16 @@ export async function importAll(data, mode = 'merge', sourcePassword = '', targe
|
|||||||
try {
|
try {
|
||||||
let reencryptedEntry = { ...entry }
|
let reencryptedEntry = { ...entry }
|
||||||
|
|
||||||
// An entry is only skippable if it actually needs re-keying (has an
|
if (sourceKey && targetKey && entry.encryptedPassword) {
|
||||||
// encrypted password or TOTP secret) but we lack the keys to do so.
|
// Decrypt password with source key
|
||||||
const hasEncrypted = !!(entry.encryptedPassword || entry.encryptedTotpSecret)
|
const plaintext = await decrypt(entry.encryptedPassword, sourceKey)
|
||||||
|
// Re-encrypt under target vault's key
|
||||||
if (sourceKey && targetKey && hasEncrypted) {
|
reencryptedEntry.encryptedPassword = await encrypt(plaintext, targetKey)
|
||||||
if (entry.encryptedPassword) {
|
|
||||||
const plaintext = await decrypt(entry.encryptedPassword, sourceKey)
|
|
||||||
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) {
|
} else if (!sourceKey || !targetKey) {
|
||||||
// Can't re-encrypt — require a password/secret, else nothing to do.
|
// Can't re-encrypt — skip this entry with a warning
|
||||||
if (hasEncrypted) {
|
console.warn('Skipping entry (missing source password or target key):', entry.title)
|
||||||
console.warn('Skipping entry (missing source password or target):', entry.title)
|
skipped++
|
||||||
skipped++
|
continue
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await db.put('entries', reencryptedEntry)
|
await db.put('entries', reencryptedEntry)
|
||||||
|
|||||||
27
src/lib/stores/app.js
Normal 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()
|
||||||
@ -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()
|
|
||||||
@ -3,17 +3,22 @@
|
|||||||
* Shared between Sidebar and EntryList for coordinated filtering.
|
* 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
|
const DEBOUNCE_MS = 300
|
||||||
|
|
||||||
export class SearchStore {
|
export class SearchStore extends Store {
|
||||||
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
|
|
||||||
#debounceTimer = null
|
#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.
|
* Update the search query with debouncing.
|
||||||
* Call this from the input handler instead of setting `query` directly.
|
* Call this from the input handler instead of setting `query` directly.
|
||||||
@ -2,8 +2,8 @@
|
|||||||
* Security utilities: auto-lock timer, visibility change detection, cleanup.
|
* Security utilities: auto-lock timer, visibility change detection, cleanup.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { app } from './app.svelte.js'
|
import { app } from './app.js'
|
||||||
import { settings } from './settings.svelte.js'
|
import { settings } from './settings.js'
|
||||||
|
|
||||||
let autoLockTimer = null
|
let autoLockTimer = null
|
||||||
|
|
||||||
@ -80,4 +80,3 @@ export function stopAutoLock() {
|
|||||||
document.removeEventListener('visibilitychange', handleVisibilityChange)
|
document.removeEventListener('visibilitychange', handleVisibilityChange)
|
||||||
window.removeEventListener('beforeunload', clearKeyOnExit)
|
window.removeEventListener('beforeunload', clearKeyOnExit)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -5,13 +5,16 @@
|
|||||||
* page reloads. Defaults are used until the user explicitly saves.
|
* page reloads. Defaults are used until the user explicitly saves.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { Store } from './store.js'
|
||||||
import { getSetting, saveSetting } from '../storage/db.js'
|
import { getSetting, saveSetting } from '../storage/db.js'
|
||||||
|
|
||||||
export class SettingsStore {
|
export class SettingsStore extends Store {
|
||||||
autoLockMinutes = $state(5)
|
constructor() {
|
||||||
lockOnTabSwitch = $state(true)
|
super({
|
||||||
// User dismissed the "running from a web server" warning banner on the lock screen.
|
autoLockMinutes: 5,
|
||||||
dismissedLocalWarning = $state(false)
|
lockOnTabSwitch: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load persisted settings from IndexedDB.
|
* Load persisted settings from IndexedDB.
|
||||||
@ -20,11 +23,9 @@ export class SettingsStore {
|
|||||||
async load() {
|
async load() {
|
||||||
const minutes = await getSetting('autoLockMinutes')
|
const minutes = await getSetting('autoLockMinutes')
|
||||||
const tabSwitch = await getSetting('lockOnTabSwitch')
|
const tabSwitch = await getSetting('lockOnTabSwitch')
|
||||||
const dismissedWarning = await getSetting('dismissedLocalWarning')
|
|
||||||
|
|
||||||
this.autoLockMinutes = minutes != null ? Number(minutes) : 5
|
this.autoLockMinutes = minutes != null ? Number(minutes) : 5
|
||||||
this.lockOnTabSwitch = tabSwitch != null ? Boolean(tabSwitch) : true
|
this.lockOnTabSwitch = tabSwitch != null ? Boolean(tabSwitch) : true
|
||||||
this.dismissedLocalWarning = dismissedWarning != null ? Boolean(dismissedWarning) : false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -33,7 +34,6 @@ export class SettingsStore {
|
|||||||
async save() {
|
async save() {
|
||||||
await saveSetting('autoLockMinutes', this.autoLockMinutes)
|
await saveSetting('autoLockMinutes', this.autoLockMinutes)
|
||||||
await saveSetting('lockOnTabSwitch', this.lockOnTabSwitch)
|
await saveSetting('lockOnTabSwitch', this.lockOnTabSwitch)
|
||||||
await saveSetting('dismissedLocalWarning', this.dismissedLocalWarning)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
104
src/lib/stores/store.js
Normal 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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
16
src/main.js
@ -1,16 +1,8 @@
|
|||||||
import { mount } from 'svelte'
|
|
||||||
import './styles/main.css'
|
import './styles/main.css'
|
||||||
import App from './App.svelte'
|
import { App } from './App.js'
|
||||||
import { registerServiceWorker } from './lib/pwa.js'
|
|
||||||
|
|
||||||
// Report the baked-in commit hash on startup (handy for verifying a deploy).
|
const root = document.getElementById('app')
|
||||||
console.info({ commit_hash: __VAULT_COMMIT__ })
|
const app = new App(root)
|
||||||
|
app.mount()
|
||||||
// Register the PWA service worker / network sandbox (no-op where unsupported).
|
|
||||||
registerServiceWorker()
|
|
||||||
|
|
||||||
const app = mount(App, {
|
|
||||||
target: document.getElementById('app'),
|
|
||||||
})
|
|
||||||
|
|
||||||
export default app
|
export default app
|
||||||
|
|||||||
1061
src/styles/main.css
@ -1,2 +0,0 @@
|
|||||||
/** @type {import("@sveltejs/vite-plugin-svelte").SvelteConfig} */
|
|
||||||
export default {}
|
|
||||||
@ -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)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@ -50,20 +50,6 @@ describe('createEntry', () => {
|
|||||||
expect(entry.updatedAt).toBe(entry.createdAt)
|
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', () => {
|
it('should trim title and optional fields', () => {
|
||||||
const entry = createEntry({
|
const entry = createEntry({
|
||||||
title: ' GitHub ',
|
title: ' GitHub ',
|
||||||
@ -190,22 +176,16 @@ describe('validateEntry', () => {
|
|||||||
expect(result.errors).toContain('Title is required')
|
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' })
|
const result = validateEntry({ title: 'GitHub' })
|
||||||
expect(result.valid).toBe(true)
|
expect(result.valid).toBe(false)
|
||||||
expect(result.errors).toEqual([])
|
expect(result.errors).toContain('Password is required')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should pass with an empty password string', () => {
|
it('should report multiple errors', () => {
|
||||||
const result = validateEntry({ title: 'GitHub', encryptedPassword: '' })
|
|
||||||
expect(result.valid).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should report only the title error when password is missing (not required)', () => {
|
|
||||||
const result = validateEntry({ title: '' })
|
const result = validateEntry({ title: '' })
|
||||||
expect(result.valid).toBe(false)
|
expect(result.valid).toBe(false)
|
||||||
expect(result.errors.length).toBe(1)
|
expect(result.errors.length).toBe(2)
|
||||||
expect(result.errors).toContain('Title is required')
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@ -25,7 +25,7 @@ import {
|
|||||||
importAll,
|
importAll,
|
||||||
TRASH_GROUP_ID,
|
TRASH_GROUP_ID,
|
||||||
} from '../../../src/lib/storage/db.js'
|
} 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'
|
import { createEntry, createGroup, createTrashGroup } from '../../../src/lib/models/schema.js'
|
||||||
|
|
||||||
const DB_NAME = 'password-vault'
|
const DB_NAME = 'password-vault'
|
||||||
@ -601,103 +601,4 @@ describe('Export / Import', () => {
|
|||||||
expect(result.skipped).toBe(1)
|
expect(result.skipped).toBe(1)
|
||||||
expect(result.imported.entries).toBe(0)
|
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')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect, beforeEach } from 'vitest'
|
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', () => {
|
describe('AppStore', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect, beforeEach } from 'vitest'
|
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', () => {
|
describe('SearchStore', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||||
import { startAutoLock, stopAutoLock } from '../../../src/lib/stores/security.svelte.js'
|
import { startAutoLock, stopAutoLock } from '../../../src/lib/stores/security.js'
|
||||||
import { app } from '../../../src/lib/stores/app.svelte.js'
|
import { app } from '../../../src/lib/stores/app.js'
|
||||||
import { settings } from '../../../src/lib/stores/settings.svelte.js'
|
import { settings } from '../../../src/lib/stores/settings.js'
|
||||||
|
|
||||||
// Reset singleton state before each test
|
// Reset singleton state before each test
|
||||||
function resetState() {
|
function resetState() {
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
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'
|
import { saveSetting, getSetting } from '../../../src/lib/storage/db.js'
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
|||||||
@ -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)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@ -1,27 +1,9 @@
|
|||||||
import { execSync } from 'node:child_process'
|
|
||||||
import { defineConfig } from 'vite'
|
import { defineConfig } from 'vite'
|
||||||
import { svelte } from '@sveltejs/vite-plugin-svelte'
|
|
||||||
import { viteSingleFile } from 'vite-plugin-singlefile'
|
import { viteSingleFile } from 'vite-plugin-singlefile'
|
||||||
|
|
||||||
// https://vite.dev/config/
|
// 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 }) => ({
|
export default defineConfig(({ command }) => ({
|
||||||
define: {
|
|
||||||
__VAULT_COMMIT__: JSON.stringify(getCommitHash()),
|
|
||||||
},
|
|
||||||
plugins: [
|
plugins: [
|
||||||
svelte(),
|
|
||||||
...(command === 'build'
|
...(command === 'build'
|
||||||
? [viteSingleFile({
|
? [viteSingleFile({
|
||||||
inlineStyles: true,
|
inlineStyles: true,
|
||||||
@ -43,8 +25,5 @@ export default defineConfig(({ command }) => ({
|
|||||||
globals: true,
|
globals: true,
|
||||||
setupFiles: ['./tests/setup.js'],
|
setupFiles: ['./tests/setup.js'],
|
||||||
include: ['src/**/*.test.js', 'tests/**/*.test.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,
|
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|||||||