First pass

This commit is contained in:
Timothy Farrell 2026-05-11 22:32:05 +00:00
commit 742ba505c6
28 changed files with 4518 additions and 0 deletions

24
.gitignore vendored Normal file
View File

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

3
.vscode/extensions.json vendored Normal file
View File

@ -0,0 +1,3 @@
{
"recommendations": ["svelte.svelte-vscode"]
}

118
README.md Normal file
View File

@ -0,0 +1,118 @@
# Password Vault
An offline-first password manager that runs entirely in your browser. No server, no cloud, no tracking — your vault lives on your machine.
## Features
- **AES-256-GCM encryption** — All credentials encrypted with a key derived from your master password via PBKDF2 (100,000 iterations). The key exists only in memory.
- **Zero network calls** — Works from `file://` or `localhost`. No APIs, no analytics, no telemetry.
- **Group management** — Organize entries into color-coded groups. Create, rename, delete.
- **Full-text search** — Instant search across title, username, URL, and notes.
- **Password generator** — Configurable length (464), character types, custom exclusions, strength indicator.
- **Copy to clipboard** — One-click copy with 15-second auto-clear.
- **JSON import/export** — Export your entire vault as encrypted JSON. Import with merge or replace mode.
- **Auto-lock** — Vault locks automatically on tab switch, visibility change, or configurable inactivity timer.
- **Dark theme** — Responsive layout that works on desktop and mobile.
## Quick Start
```bash
npm install
npm run dev # http://localhost:5173
```
## Production Build
```bash
npm run build # → dist/ (static files)
npm run preview # test the production build locally
```
The `dist/` folder contains fully static HTML/CSS/JS that works from:
- `file://` protocol (open `dist/index.html` directly)
- Any static web server (nginx, Apache, GitHub Pages, etc.)
## Architecture
```
src/
├── App.svelte # Root — toggles LockScreen ↔ MainLayout
├── main.js # Entry point
├── components/
│ ├── LockScreen.svelte # Vault creation & password unlock
│ ├── MainLayout.svelte # Dashboard shell (sidebar + content)
│ ├── Sidebar.svelte # Group list, search, group CRUD
│ ├── EntryList.svelte # Searchable/filterable entry table
│ ├── EntryDetail.svelte # View single entry, copy, delete
│ ├── EntryForm.svelte # Create/edit entry with validation
│ ├── PasswordGenerator.svelte # Standalone generator panel
│ └── ImportExport.svelte # JSON import/export modals
├── lib/
│ ├── crypto/crypto.js # PBKDF2 key derivation, AES-GCM, generator
│ ├── models/schema.js # Data factories, validation, ID generation
│ ├── storage/db.js # IndexedDB wrapper (idb library)
│ └── stores/
│ ├── app.svelte.js # Reactive app state (lock/unlock)
│ ├── search.svelte.js # Shared search + filter state
│ └── security.svelte.js # Auto-lock timer, visibility detection
└── styles/
└── main.css # Dark theme, CSS variables, responsive
```
### Encryption Flow
```
Master Password ──PBKDF2──→ 256-bit Key ──AES-GCM──→ Encrypted Credential
(100k iters)
└── Salt stored in IndexedDB (not encrypted)
```
- The encryption key is **never persisted** — it lives only in JavaScript memory.
- A test payload (random string) is encrypted on vault creation and stored alongside the salt. On unlock, the entered password is used to derive a key and decrypt the test payload — if decryption succeeds, the password is correct.
- On tab close or auto-lock, the key is cleared from memory.
### Storage Schema (IndexedDB)
| Store | Fields |
|---|---|
| `entries` | `id`, `title`, `username`, `password` (encrypted), `url`, `notes` (encrypted), `groupId`, `createdAt`, `updatedAt` |
| `groups` | `id`, `name`, `color`, `createdAt` |
| `meta` | `salt`, `testEncrypted`, `testPlaintext` |
## Security Considerations
| Threat | Mitigation |
|---|---|
| Key persistence | Key stored only in `$state`, cleared on lock/close |
| Weak passwords | Strength indicator on generator; no minimum enforced (user's choice) |
| Clipboard leakage | Auto-clear after 15 seconds |
| Tab left open | Auto-lock on visibility change (tab switch) |
| Database tampering | All sensitive data encrypted at rest with AES-256-GCM |
| Brute force | PBKDF2 with 100,000 iterations slows offline attacks |
### Known limitations
- **No browser fingerprinting or anti-keylogger** — This is a local tool, not a hardened security appliance.
- **IndexedDB can be inspected** — Encrypted data is safe, but metadata (titles, usernames, URLs) may be visible if not encrypted. Currently only `password` and `notes` are encrypted; titles/usernames/URLs are stored in plaintext for searchability.
- **No automatic backups** — Use the JSON export feature to back up your vault regularly.
## Development
```bash
npm run dev # Start dev server with HMR
npm run build # Production build (zero warnings target)
npm run preview # Preview production build
```
### Stack
- **Svelte 5** — Runes-based reactivity (`$state`, `$derived`, `$effect`)
- **Vite 8** — Build tool and dev server
- **idb** — Promise-based IndexedDB wrapper
- **Web Crypto API** — Native browser cryptography (no external crypto libraries)
- **Vanilla CSS** — Dark theme with CSS custom properties, no preprocessors
## License
MIT

13
index.html Normal file
View File

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Password Vault</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

33
jsconfig.json Normal file
View File

@ -0,0 +1,33 @@
{
"compilerOptions": {
"moduleResolution": "bundler",
"target": "ESNext",
"module": "ESNext",
/**
* svelte-preprocess cannot figure out whether you have
* a value or a type, so tell TypeScript to enforce using
* `import type` instead of `import` for Types.
*/
"verbatimModuleSyntax": true,
"isolatedModules": true,
"resolveJsonModule": true,
/**
* To have warnings / errors of the Svelte compiler at the
* correct position, enable source maps by default.
*/
"sourceMap": true,
"esModuleInterop": true,
"types": ["vite/client"],
"skipLibCheck": true,
/**
* Typecheck JS in `.svelte` and `.js` files by default.
* Disable this if you'd like to use dynamic types.
*/
"checkJs": true
},
/**
* Use global.d.ts instead of compilerOptions.types
* to avoid limiting type declarations.
*/
"include": ["src/**/*.d.ts", "src/**/*.js", "src/**/*.svelte"]
}

1155
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

19
package.json Normal file
View File

@ -0,0 +1,19 @@
{
"name": "password-manager",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview --host 0.0.0.0"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^7.1.2",
"svelte": "^5.55.5",
"vite": "^8.0.12"
},
"dependencies": {
"idb": "^8.0.3"
}
}

1
public/favicon.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

24
public/icons.svg Normal file
View File

@ -0,0 +1,24 @@
<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>

After

Width:  |  Height:  |  Size: 4.9 KiB

16
src/App.svelte Normal file
View File

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

View File

@ -0,0 +1,323 @@
<script>
import { getEntryById, deleteEntry } from '../lib/storage/db.js'
import { decrypt } from '../lib/crypto/crypto.js'
import { app } from '../lib/stores/app.svelte.js'
let { entryId, onEdit, onBack } = $props()
let entry = $state(null)
let passwordVisible = $state(false)
let decryptedPassword = $state('')
let loading = $state(true)
let error = $state('')
let showDeleteConfirm = $state(false)
let deleting = $state(false)
let toast = $state('')
let toastTimer = null
async function loadEntry() {
loading = true
error = ''
try {
entry = await getEntryById(entryId)
if (entry && app.encryptionKey) {
decryptedPassword = await decrypt(entry.encryptedPassword, app.encryptionKey)
}
} catch (e) {
error = 'Failed to load entry: ' + e.message
}
loading = false
}
loadEntry()
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 handleDelete() {
deleting = true
try {
await deleteEntry(entryId)
onBack()
} catch (e) {
error = 'Failed to delete: ' + e.message
}
deleting = false
showDeleteConfirm = 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">
<button class="btn btn-ghost btn-sm" onclick={() => onEdit(entry.id)}>✏️ Edit</button>
<button class="btn btn-danger btn-sm" onclick={() => showDeleteConfirm = true}>🗑 Delete</button>
</div>
</div>
<div class="detail-fields">
<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>
<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 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>
<!-- Delete 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="Delete confirmation" tabindex="-1" onclick={(e) => e.stopPropagation()}>
<h3>Delete Entry</h3>
<p>Are you sure you want to delete "<strong>{entry.title}</strong>"? This cannot be undone.</p>
<div class="modal-actions">
<button class="btn btn-danger" onclick={handleDelete} disabled={deleting}>
{deleting ? 'Deleting...' : 'Yes, delete'}
</button>
<button class="btn btn-ghost" onclick={() => showDeleteConfirm = 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;
}
.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>

View File

@ -0,0 +1,225 @@
<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 } from '../lib/models/schema.js'
import { generatePassword } from '../lib/crypto/crypto.js'
import { app } from '../lib/stores/app.svelte.js'
import PasswordGenerator from './PasswordGenerator.svelte'
let { entryId, onSave, onCancel } = $props()
let title = $state('')
let username = $state('')
let password = $state('')
let url = $state('')
let notes = $state('')
let groupId = $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([])
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 = await decrypt(entry.encryptedPassword, app.encryptionKey)
url = entry.url || ''
notes = entry.notes || ''
groupId = entry.groupId || ''
} else {
error = 'Entry not found'
}
}
} 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
}
const encryptedPassword = await encrypt(password, app.encryptionKey)
if (isEdit) {
const existing = await getEntryById(entryId)
const updated = updateEntryModel(existing, {
title,
username,
encryptedPassword,
url,
notes,
groupId,
})
await updateEntry(updated)
} else {
const entry = createEntry({
title,
username,
encryptedPassword,
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" submitHandler={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" />
</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="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}
<option value={group.id}>{group.name}</option>
{/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);
}
.password-input-group {
display: flex;
gap: 8px;
}
.password-input-group input {
flex: 1;
}
.form-actions {
display: flex;
gap: 8px;
margin-top: 20px;
}
</style>

View File

@ -0,0 +1,189 @@
<script>
import { getEntries, searchEntries } 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 { onSelect, onAdd } = $props()
async function loadEntries() {
loading = true
error = ''
try {
const query = searchStore.query.trim()
const groupId = searchStore.activeGroupId
if (query) {
// Search with optional group filter
const options = groupId !== 'all' ? { groupId } : {}
entries = await searchEntries(query, options)
} else if (groupId !== 'all') {
// Filter by group only
entries = await getEntries({ groupId })
} else {
// Show all
entries = await getEntries()
}
resultCount = entries.length
} catch (e) {
error = 'Failed to load entries: ' + e.message
}
loading = false
}
loadEntries()
// Reload when search or filter changes (polling-based reactivity)
const intervalId = setInterval(loadEntries, 1000)
$effect(() => {
return () => clearInterval(intervalId)
})
</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 ? '🔍' : '🔑'}</p>
<p class="empty-text">{searchStore.query ? 'No results found' : 'No entries yet'}</p>
<p class="empty-hint">
{searchStore.query
? 'Try a different search term'
: 'Add your first login credential to get started'}
</p>
{#if !searchStore.query}
<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>
</tr>
</thead>
<tbody>
{#each entries as entry (entry.id)}
<tr onclick={() => onSelect(entry.id)} class="entry-row">
<td>
<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>
</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: pointer;
transition: background-color 150ms;
}
.entry-row:hover {
background: var(--color-surface-hover);
}
.entry-row td {
padding: 10px 12px;
font-size: 0.875rem;
border-bottom: 1px solid var(--color-border);
}
.entry-title {
font-weight: 500;
}
.entry-username {
color: var(--color-text-muted);
}
.entry-url {
color: var(--color-text-muted);
max-width: 200px;
}
@media (max-width: 600px) {
.entries-table th:nth-child(3),
.entry-row td:nth-child(3) {
display: none;
}
}
</style>

View File

@ -0,0 +1,247 @@
<script>
import { exportAll, importAll } from '../lib/storage/db.js'
let showExport = $state(false)
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)
async function handleExport() {
exporting = true
try {
exportData = await exportAll()
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
}
async function handleImport(event) {
const file = event.target.files[0]
if (!file) return
importing = true
importError = ''
importResult = null
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'
importing = false
return
}
const result = await importAll(data, importMode)
importResult = result
} catch (e) {
importError = 'Import failed: ' + e.message
}
importing = false
// Reset file input
event.target.value = ''
}
</script>
<div class="import-export">
<!-- Export button -->
<button class="btn btn-ghost btn-sm" onclick={() => showExport = true} 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>All entries and groups will be exported as encrypted JSON. You'll need your master password to import them later.</p>
<div class="modal-actions">
<button class="btn btn-primary" onclick={handleExport} disabled={exporting}>
{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}
<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={handleImport}
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;
}
input[type="file"] {
font-size: 0.85rem;
padding: 8px;
}
</style>

View File

@ -0,0 +1,193 @@
<script>
import { app } from '../lib/stores/app.svelte.js'
import { deriveKey, createTestPayload, verifyPassword } from '../lib/crypto/crypto.js'
import { saveVaultMeta, loadVaultMeta, isVaultInitialized } from '../lib/storage/db.js'
import { startAutoLock } from '../lib/stores/security.svelte.js'
let masterPassword = $state('')
let confirmPassword = $state('')
let error = $state('')
let loading = $state(false)
let isSetup = $state(false)
// Check if vault was already created
async function checkVault() {
isSetup = !(await isVaultInitialized())
}
checkVault()
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)
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
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 error}
<div class="error-banner" role="alert">{error}</div>
{/if}
<form submitHandler={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"
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;
}
.hint {
font-size: 0.75rem;
color: var(--color-text-muted);
text-align: center;
line-height: 1.4;
margin-top: 0.5rem;
}
</style>

View File

@ -0,0 +1,239 @@
<script>
import { app } from '../lib/stores/app.svelte.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'
let sidebarOpen = $state(false)
let viewMode = $state('list') // 'list' | 'detail' | 'form'
let selectedEntryId = $state(null)
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 handleBack() {
if (viewMode === 'form') {
goDetail(selectedEntryId)
} else {
goList()
}
}
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 on:back={handleBack} on:goList={goList} />
</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>All Entries</h1>
{:else if viewMode === 'detail'}
<h1>Entry Details</h1>
{:else if viewMode === 'form'}
<h1>{selectedEntryId ? 'Edit Entry' : 'New Entry'}</h1>
{/if}
</div>
<div class="top-bar-actions">
{#if viewMode === 'list'}
<button class="btn btn-primary btn-sm" onclick={() => goForm(null)}>+ New Entry</button>
{/if}
<ImportExport />
<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 on:select={goDetail} on:add={() => goForm(null)} />
{:else if viewMode === 'detail' && selectedEntryId}
<EntryDetail
entryId={selectedEntryId}
on:edit={() => goForm(selectedEntryId)}
on:back={goList}
/>
{:else if viewMode === 'form'}
<EntryForm
entryId={selectedEntryId}
on:save={goList}
on:cancel={handleBack}
/>
{/if}
</div>
</main>
</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%;
}
/* Responsive */
@media (max-width: 768px) {
.mobile-header {
display: flex;
}
.sidebar {
position: fixed;
top: 0;
left: 0;
bottom: 0;
z-index: 50;
transform: translateX(-100%);
transition: transform 200ms ease;
}
.sidebar.open {
transform: translateX(0);
}
.sidebar-overlay {
display: block;
}
.content-area {
padding: 12px;
}
.top-bar {
padding: 10px 12px;
}
}
</style>

View File

@ -0,0 +1,301 @@
<script>
import { generatePassword } from '../lib/crypto/crypto.js'
let length = $state(16)
let uppercase = $state(true)
let lowercase = $state(true)
let digits = $state(true)
let symbols = $state(true)
let exclude = $state('')
let generated = $state('')
let strength = $state('')
let toast = $state('')
let toastTimer = null
// Generate on mount and whenever settings change
// $effect auto-tracks all reactive reads
$effect(() => {
// Reading these values registers them as dependencies
const _l = length
const _u = uppercase
const _lo = lowercase
const _d = digits
const _s = symbols
const _e = exclude
regenerate()
})
function regenerate() {
try {
generated = generatePassword({ length, uppercase, lowercase, digits, symbols, exclude })
strength = calculateStrength(generated)
} catch (e) {
generated = ''
strength = ''
}
}
function calculateStrength(password) {
if (!password) return { label: '', color: '' }
let score = 0
if (password.length >= 8) score++
if (password.length >= 12) score++
if (password.length >= 16) score++
if (/[a-z]/.test(password) && /[A-Z]/.test(password)) score++
if (/\d/.test(password)) score++
if (/[^a-zA-Z0-9]/.test(password)) score++
if (score <= 2) return { label: 'Weak', color: 'var(--color-danger)' }
if (score <= 4) return { label: 'Moderate', color: 'var(--color-warning)' }
return { label: 'Strong', color: 'var(--color-success)' }
}
function showToast(message) {
toast = message
if (toastTimer) clearTimeout(toastTimer)
toastTimer = setTimeout(() => { toast = '' }, 2000)
}
async function copyPassword() {
try {
await navigator.clipboard.writeText(generated)
showToast('✓ Password copied to clipboard')
} catch {
// Fallback
const textarea = document.createElement('textarea')
textarea.value = generated
textarea.style.position = 'fixed'
textarea.style.opacity = '0'
document.body.appendChild(textarea)
textarea.select()
document.execCommand('copy')
document.body.removeChild(textarea)
showToast('✓ Password copied')
}
}
</script>
<div class="password-generator">
{#if toast}
<div class="toast">{toast}</div>
{/if}
<div class="generator-card">
<h3>🔑 Password Generator</h3>
<!-- Generated password display -->
<div class="generated-display">
<code class="generated-password">{generated}</code>
<div class="display-actions">
<span class="strength-badge" style="color: {strength.color}">{strength.label}</span>
<button class="btn btn-ghost btn-sm" onclick={copyPassword} title="Copy">📋 Copy</button>
<button class="btn btn-ghost btn-sm" onclick={regenerate} title="Regenerate">🔄</button>
</div>
</div>
<!-- Length slider -->
<div class="form-group">
<div class="slider-header">
<span class="field-label">Length</span>
<span class="slider-value">{length}</span>
</div>
<input
type="range"
min="4"
max="64"
bind:value={length}
class="length-slider"
/>
<div class="slider-marks">
<span>4</span>
<span>16</span>
<span>32</span>
<span>64</span>
</div>
</div>
<!-- Character types -->
<div class="checkbox-group">
<label class="checkbox-label">
<input type="checkbox" bind:checked={uppercase} />
<span>Uppercase (A-Z)</span>
</label>
<label class="checkbox-label">
<input type="checkbox" bind:checked={lowercase} />
<span>Lowercase (a-z)</span>
</label>
<label class="checkbox-label">
<input type="checkbox" bind:checked={digits} />
<span>Numbers (0-9)</span>
</label>
<label class="checkbox-label">
<input type="checkbox" bind:checked={symbols} />
<span>Symbols (!@#$...)</span>
</label>
</div>
<!-- Exclude characters -->
<div class="form-group">
<label for="exclude-chars">Exclude characters</label>
<input
id="exclude-chars"
type="text"
bind:value={exclude}
placeholder="e.g. 0OIl1"
/>
</div>
</div>
</div>
<style>
.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;
}
.generator-card {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
padding: 20px;
max-width: 450px;
}
.generator-card h3 {
margin-bottom: 16px;
}
.generated-display {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px;
background: var(--color-input-bg);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
margin-bottom: 16px;
flex-wrap: wrap;
}
.generated-password {
font-family: 'SF Mono', 'Fira Code', 'Consolas', monospace;
font-size: 1rem;
word-break: break-all;
flex: 1;
min-width: 0;
}
.display-actions {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.strength-badge {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
}
.field-label {
font-size: 0.8rem;
font-weight: 500;
color: var(--color-text-muted);
}
.slider-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 4px;
}
.slider-value {
font-size: 0.85rem;
font-weight: 600;
color: var(--color-primary);
}
.length-slider {
width: 100%;
height: 6px;
-webkit-appearance: none;
appearance: none;
background: var(--color-border);
border-radius: 3px;
outline: none;
border: none;
padding: 0;
}
.length-slider::-webkit-slider-thumb {
-webkit-appearance: none;
width: 18px;
height: 18px;
border-radius: 50%;
background: var(--color-primary);
cursor: pointer;
}
.length-slider::-moz-range-thumb {
width: 18px;
height: 18px;
border-radius: 50%;
background: var(--color-primary);
cursor: pointer;
border: none;
}
.slider-marks {
display: flex;
justify-content: space-between;
font-size: 0.7rem;
color: var(--color-text-muted);
margin-top: 4px;
padding: 0 2px;
}
.checkbox-group {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
margin-bottom: 16px;
}
.checkbox-label {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.85rem;
cursor: pointer;
padding: 6px 8px;
border-radius: var(--radius-sm);
transition: background-color 150ms;
}
.checkbox-label:hover {
background: var(--color-surface-hover);
}
.checkbox-label input[type="checkbox"] {
width: 16px;
height: 16px;
accent-color: var(--color-primary);
cursor: pointer;
}
.checkbox-label span {
flex: 1;
}
</style>

View File

@ -0,0 +1,373 @@
<script>
import { getGroups, addGroup, updateGroup, deleteGroup, getEntryCountsByGroup } from '../lib/storage/db.js'
import { createGroup, validateGroup } from '../lib/models/schema.js'
import { search } from '../lib/stores/search.svelte.js'
let groups = $state([])
let entryCounts = $state(new Map())
// 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))
const GROUP_COLORS = [
'#6c63ff', '#e5484d', '#34d399', '#fbbf24', '#3b82f6',
'#ec4899', '#8b5cf6', '#14b8a6', '#f97316', '#06b6d4',
'#a855f7', '#ef4444', '#22c55e', '#eab308', '#6366f1',
]
async function loadData() {
const [g, counts] = await Promise.all([getGroups(), getEntryCountsByGroup()])
groups = g
entryCounts = counts
}
loadData()
// Reload periodically
const intervalId = setInterval(loadData, 3000)
$effect(() => {
return () => clearInterval(intervalId)
})
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 (search.activeGroupId === groupId) {
search.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={search.query}
onInput={(e) => search.query = e.target.value}
/>
</div>
<nav class="groups-nav">
<button
class="group-item {search.activeGroupId === 'all' ? 'active' : ''}"
onclick={() => search.activeGroupId = 'all'}
>
<span class="group-icon">📋</span>
<span class="group-name">All Entries</span>
</button>
{#each groups as group}
<div class="group-row">
<button
class="group-item {search.activeGroupId === group.id ? 'active' : ''}"
onclick={() => search.activeGroupId = group.id}
>
<span class="group-color" style="background-color: {group.color || '#6c63ff'}"></span>
<span class="group-name">{group.name}</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>
{/each}
</nav>
<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" />
</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-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;
}
.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>

196
src/lib/crypto/crypto.js Normal file
View File

@ -0,0 +1,196 @@
/**
* Crypto module Web Crypto API wrapper.
*
* Uses PBKDF2 for key derivation and AES-GCM for symmetric encryption.
* All operations are async and use the browser's native Web Crypto API.
*
* The derived encryption key is kept in memory only never written to disk.
*/
const PBKDF2_ITERATIONS = 100_000
const SALT_LENGTH = 16 // bytes
const IV_LENGTH = 12 // bytes (recommended for AES-GCM)
/**
* Generate a random salt.
* @returns {Uint8Array}
*/
export function generateSalt() {
return crypto.getRandomValues(new Uint8Array(SALT_LENGTH))
}
/**
* Derive an AES-GCM encryption key from a master password and salt.
*
* @param {string} masterPassword
* @param {Uint8Array} salt
* @returns {Promise<CryptoKey>}
*/
export async function deriveKey(masterPassword, salt) {
// Step 1: Import the password as raw key material
const keyMaterial = await crypto.subtle.importKey(
'raw',
new TextEncoder().encode(masterPassword),
'PBKDF2',
false,
['deriveKey']
)
// Step 2: Derive an AES-GCM key using PBKDF2
return crypto.subtle.deriveKey(
{
name: 'PBKDF2',
salt,
iterations: PBKDF2_ITERATIONS,
hash: 'SHA-256',
},
keyMaterial,
{ name: 'AES-GCM', length: 256 },
false, // not extractable — stays in memory
['encrypt', 'decrypt']
)
}
/**
* Encrypt a plaintext string.
*
* @param {string} plaintext
* @param {CryptoKey} key
* @returns {Promise<string>} JSON string containing { iv, ciphertext }
* (salt is stored separately in the app store)
*/
export async function encrypt(plaintext, key) {
const iv = crypto.getRandomValues(new Uint8Array(IV_LENGTH))
const encoded = new TextEncoder().encode(plaintext)
const ciphertext = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv },
key,
encoded
)
// Return as JSON with base64-encoded iv and ciphertext
return JSON.stringify({
iv: uint8ArrayToBase64(iv),
ciphertext: uint8ArrayToBase64(new Uint8Array(ciphertext)),
})
}
/**
* Decrypt an encrypted blob back to plaintext.
*
* @param {string} encryptedJson - JSON string from encrypt()
* @param {CryptoKey} key
* @returns {Promise<string>}
*/
export async function decrypt(encryptedJson, key) {
const { iv, ciphertext } = JSON.parse(encryptedJson)
const ciphertextBuffer = base64ToUint8Array(ciphertext)
const ivBuffer = base64ToUint8Array(iv)
const decrypted = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv: ivBuffer },
key,
ciphertextBuffer
)
return new TextDecoder().decode(decrypted)
}
/**
* Verify that a master password is correct by attempting to decrypt a test payload.
*
* @param {string} masterPassword
* @param {Uint8Array} salt
* @param {string} testEncrypted - A known encrypted test string
* @param {string} testPlaintext - The expected plaintext
* @returns {Promise<boolean>}
*/
export async function verifyPassword(masterPassword, salt, testEncrypted, testPlaintext) {
try {
const key = await deriveKey(masterPassword, salt)
const decrypted = await decrypt(testEncrypted, key)
return decrypted === testPlaintext
} catch {
return false
}
}
/**
* Create a test payload for password verification on first setup.
*
* @param {string} masterPassword
* @returns {Promise<{ salt: Uint8Array, testEncrypted: string }>}
*/
export async function createTestPayload(masterPassword) {
const salt = generateSalt()
const key = await deriveKey(masterPassword, salt)
const testPlaintext = 'vault_test_' + generateId()
const testEncrypted = await encrypt(testPlaintext, key)
return { salt, testEncrypted, testPlaintext }
}
// --- Utility: Uint8Array ↔ Base64 ---
function uint8ArrayToBase64(buffer) {
const bytes = new Uint8Array(buffer)
let binary = ''
for (let i = 0; i < bytes.byteLength; i++) {
binary += String.fromCharCode(bytes[i])
}
return btoa(binary)
}
function base64ToUint8Array(base64) {
const binary = atob(base64)
const bytes = new Uint8Array(binary.length)
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i)
}
return bytes
}
/**
* Generate a random password.
*
* @param {Object} options
* @param {number} [options.length=16]
* @param {boolean} [options.uppercase=true]
* @param {boolean} [options.lowercase=true]
* @param {boolean} [options.digits=true]
* @param {boolean} [options.symbols=true]
* @param {string} [options.exclude='']
* @returns {string}
*/
export function generatePassword({
length = 16,
uppercase = true,
lowercase = true,
digits = true,
symbols = true,
exclude = '',
} = {}) {
let charset = ''
if (uppercase) charset += 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'
if (lowercase) charset += 'abcdefghijklmnopqrstuvwxyz'
if (digits) charset += '0123456789'
if (symbols) charset += '!@#$%^&*()_+-=[]{}|;:,.<>?'
// Remove excluded characters
if (exclude) {
const excludeSet = new Set(exclude.split(''))
charset = charset.split('').filter(c => !excludeSet.has(c)).join('')
}
if (!charset) {
throw new Error('Password charset is empty — enable at least one character type')
}
const randomValues = crypto.getRandomValues(new Uint8Array(length))
let password = ''
for (let i = 0; i < length; i++) {
password += charset[randomValues[i] % charset.length]
}
return password
}

136
src/lib/models/schema.js Normal file
View File

@ -0,0 +1,136 @@
/**
* Data model definitions and factory functions.
*
* All IDs are generated with a simple ULID-like timestamp + random suffix
* to keep things sortable and collision-resistant without external deps.
*/
/**
* Generate a unique ID (timestamp-based, sortable).
* @returns {string}
*/
export function generateId() {
const timestamp = Date.now().toString(36)
const random = Math.random().toString(36).slice(2, 10)
return `${timestamp}_${random}`
}
/**
* @typedef {Object} CredentialEntry
* @property {string} id - Unique identifier
* @property {string} title - Display name (e.g. "GitHub", "Gmail")
* @property {string} username - Login username or email
* @property {string} encryptedPassword - AES-GCM encrypted password blob (JSON string)
* @property {string} [url] - Website URL
* @property {string} [notes] - Free-form notes
* @property {string} [groupId] - Reference to a Group id (empty string = no group)
* @property {string[]} [tags] - Free-form tags
* @property {string} createdAt - ISO timestamp
* @property {string} updatedAt - ISO timestamp
*/
/**
* Create a new CredentialEntry.
*
* @param {Object} data
* @param {string} data.title
* @param {string} data.username
* @param {string} data.encryptedPassword - Must already be encrypted
* @param {string} [data.url]
* @param {string} [data.notes]
* @param {string} [data.groupId]
* @param {string[]} [data.tags]
* @returns {CredentialEntry}
*/
export function createEntry(data) {
const now = new Date().toISOString()
return {
id: generateId(),
title: data.title.trim(),
username: data.username.trim(),
encryptedPassword: data.encryptedPassword,
url: data.url?.trim() || '',
notes: data.notes?.trim() || '',
groupId: data.groupId || '',
tags: data.tags || [],
createdAt: now,
updatedAt: now,
}
}
/**
* Update an existing entry, preserving id and createdAt.
*
* @param {CredentialEntry} existing
* @param {Object} data - Fields to update
* @returns {CredentialEntry}
*/
export function updateEntry(existing, data) {
return {
...existing,
title: data.title !== undefined ? data.title.trim() : existing.title,
username: data.username !== undefined ? data.username.trim() : existing.username,
encryptedPassword: data.encryptedPassword !== undefined ? data.encryptedPassword : existing.encryptedPassword,
url: data.url !== undefined ? data.url.trim() : existing.url,
notes: data.notes !== undefined ? data.notes.trim() : existing.notes,
groupId: data.groupId !== undefined ? data.groupId : existing.groupId,
tags: data.tags !== undefined ? data.tags : existing.tags,
updatedAt: new Date().toISOString(),
}
}
/**
* @typedef {Object} Group
* @property {string} id - Unique identifier
* @property {string} name - Display name
* @property {string} [color] - Hex color for UI accent
* @property {string} createdAt - ISO timestamp
*/
const GROUP_COLORS = [
'#6c63ff', '#e5484d', '#34d399', '#fbbf24', '#3b82f6',
'#ec4899', '#8b5cf6', '#14b8a6', '#f97316', '#06b6d4',
]
/**
* Create a new Group.
*
* @param {string} name
* @param {string} [color]
* @returns {Group}
*/
export function createGroup(name, color) {
return {
id: generateId(),
name: name.trim(),
color: color || GROUP_COLORS[Math.floor(Math.random() * GROUP_COLORS.length)],
createdAt: new Date().toISOString(),
}
}
/**
* Validate a credential entry form submission.
*
* @param {Object} data
* @returns {{ valid: boolean, errors: string[] }}
*/
export function validateEntry(data) {
const errors = []
if (!data.title || !data.title.trim()) errors.push('Title is required')
if (!data.username || !data.username.trim()) errors.push('Username is required')
if (!data.encryptedPassword) errors.push('Password is required')
return { valid: errors.length === 0, errors }
}
/**
* Validate a group name.
*
* @param {string} name
* @returns {{ valid: boolean, errors: string[] }}
*/
export function validateGroup(name) {
const errors = []
if (!name || !name.trim()) errors.push('Group name is required')
else if (name.trim().length > 50) errors.push('Group name must be 50 characters or less')
return { valid: errors.length === 0, errors }
}

370
src/lib/storage/db.js Normal file
View File

@ -0,0 +1,370 @@
/**
* IndexedDB storage layer using the `idb` wrapper.
*
* Database: "password-vault"
* - Object store "entries": stores CredentialEntry objects
* - Object store "groups": stores Group objects
* - Object store "meta": stores app metadata (salt, test payload, version)
*
* All passwords are stored as encrypted blobs (encryptedPassword field).
* The encryption key is never stored only the salt and a test payload
* for password verification.
*/
import { openDB } from 'idb'
const DB_NAME = 'password-vault'
const DB_VERSION = 1
/**
* Open (or create) the database.
* @returns {Promise<IDBPDatabase>}
*/
async function getDb() {
return openDB(DB_NAME, DB_VERSION, {
upgrade(db) {
// Entries store — indexed by groupId for fast group filtering
if (!db.objectStoreNames.contains('entries')) {
const entryStore = db.createObjectStore('entries', { keyPath: 'id' })
entryStore.createIndex('groupId', 'groupId')
entryStore.createIndex('updatedAt', 'updatedAt')
}
// Groups store
if (!db.objectStoreNames.contains('groups')) {
db.createObjectStore('groups', { keyPath: 'id' })
}
// Meta store — single key-value pairs for app settings
if (!db.objectStoreNames.contains('meta')) {
db.createObjectStore('meta', { keyPath: 'key' })
}
},
})
}
// ========================
// Meta (salt, test payload, version)
// ========================
/**
* Store the vault's salt and test payload (used for password verification).
*
* @param {Uint8Array} salt
* @param {string} testEncrypted
* @param {string} testPlaintext
*/
export async function saveVaultMeta(salt, testEncrypted, testPlaintext) {
const db = await getDb()
const tx = db.transaction('meta', 'readwrite')
// Store salt as base64
let binary = ''
for (let i = 0; i < salt.byteLength; i++) {
binary += String.fromCharCode(salt[i])
}
const saltBase64 = btoa(binary)
await tx.store.put({ key: 'salt', value: saltBase64 })
await tx.store.put({ key: 'testEncrypted', value: testEncrypted })
await tx.store.put({ key: 'testPlaintext', value: testPlaintext })
await tx.store.put({ key: 'dbVersion', value: DB_VERSION })
await tx.done
}
/**
* Load the vault's salt and test payload.
*
* @returns {Promise<{ salt: Uint8Array|null, testEncrypted: string|null, testPlaintext: string|null }>}
*/
export async function loadVaultMeta() {
const db = await getDb()
const tx = db.transaction('meta', 'readonly')
const store = tx.store
const saltRow = await store.get('salt')
const testEncryptedRow = await store.get('testEncrypted')
const testPlaintextRow = await store.get('testPlaintext')
let salt = null
if (saltRow?.value) {
const binary = atob(saltRow.value)
const bytes = new Uint8Array(binary.length)
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i)
}
salt = bytes
}
return {
salt,
testEncrypted: testEncryptedRow?.value || null,
testPlaintext: testPlaintextRow?.value || null,
}
}
/**
* Check if the vault has been initialized (has a salt stored).
* @returns {Promise<boolean>}
*/
export async function isVaultInitialized() {
const meta = await loadVaultMeta()
return meta.salt !== null
}
// ========================
// Groups
// ========================
/**
* @typedef {import('../models/schema.js').Group} Group
*/
/**
* Add a group.
* @param {Group} group
* @returns {Promise<void>}
*/
export async function addGroup(group) {
const db = await getDb()
await db.put('groups', group)
}
/**
* Update a group.
* @param {Group} group
* @returns {Promise<void>}
*/
export async function updateGroup(group) {
const db = await getDb()
await db.put('groups', group)
}
/**
* Delete a group (does NOT delete associated entries they become ungrouped).
* @param {string} groupId
* @returns {Promise<void>}
*/
export async function deleteGroup(groupId) {
const db = await getDb()
await db.delete('groups', groupId)
}
/**
* Get all groups, sorted by creation date.
* @returns {Promise<Group[]>}
*/
export async function getGroups() {
const db = await getDb()
const tx = db.transaction('groups', 'readonly')
const all = await tx.store.getAll()
return all.sort((a, b) => a.createdAt.localeCompare(b.createdAt))
}
/**
* Get a single group by ID.
* @param {string} groupId
* @returns {Promise<Group | undefined>}
*/
export async function getGroupById(groupId) {
const db = await getDb()
return db.get('groups', groupId)
}
// ========================
// Entries
// ========================
/**
* @typedef {import('../models/schema.js').CredentialEntry} CredentialEntry
*/
/**
* Add an entry.
* @param {CredentialEntry} entry
* @returns {Promise<void>}
*/
export async function addEntry(entry) {
const db = await getDb()
await db.put('entries', entry)
}
/**
* Update an entry.
* @param {CredentialEntry} entry
* @returns {Promise<void>}
*/
export async function updateEntry(entry) {
const db = await getDb()
await db.put('entries', entry)
}
/**
* Delete an entry.
* @param {string} entryId
* @returns {Promise<void>}
*/
export async function deleteEntry(entryId) {
const db = await getDb()
await db.delete('entries', entryId)
}
/**
* Get a single entry by ID.
* @param {string} entryId
* @returns {Promise<CredentialEntry | undefined>}
*/
export async function getEntryById(entryId) {
const db = await getDb()
return db.get('entries', entryId)
}
/**
* Get all entries. Optionally filter by groupId.
* Results sorted by updatedAt descending (most recent first).
*
* @param {Object} [options]
* @param {string} [options.groupId] - Filter by group (empty string = ungrouped)
* @returns {Promise<CredentialEntry[]>}
*/
export async function getEntries(options = {}) {
const db = await getDb()
let entries
if (options.groupId !== undefined) {
const index = db.transaction('entries').store.index('groupId')
entries = await index.getAll(options.groupId)
} else {
entries = await db.getAll('entries')
}
return entries.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))
}
/**
* Search entries by query string (matches title, username, url, notes).
*
* @param {string} query
* @param {Object} [options]
* @param {string} [options.groupId]
* @returns {Promise<CredentialEntry[]>}
*/
export async function searchEntries(query, options = {}) {
const entries = await getEntries(options)
const lower = query.toLowerCase()
return entries.filter(e =>
e.title.toLowerCase().includes(lower) ||
e.username.toLowerCase().includes(lower) ||
(e.url && e.url.toLowerCase().includes(lower)) ||
(e.notes && e.notes.toLowerCase().includes(lower))
)
}
/**
* Count entries per group.
* @returns {Promise<Map<string, number>>}
*/
export async function getEntryCountsByGroup() {
const db = await getDb()
const all = await db.getAll('entries')
const counts = new Map()
for (const entry of all) {
const gid = entry.groupId || ''
counts.set(gid, (counts.get(gid) || 0) + 1)
}
return counts
}
// ========================
// Import / Export
// ========================
/**
* Export all data (entries + groups + meta) as a JSON object.
* Entries remain encrypted the importer needs the same master password.
*
* @returns {Promise<Object>}
*/
export async function exportAll() {
const db = await getDb()
const entries = await db.getAll('entries')
const groups = await db.getAll('groups')
const saltRow = await db.get('meta', 'salt')
const testEncryptedRow = await db.get('meta', 'testEncrypted')
const testPlaintextRow = await db.get('meta', 'testPlaintext')
return {
version: DB_VERSION,
exportedAt: new Date().toISOString(),
meta: {
salt: saltRow?.value || null,
testEncrypted: testEncryptedRow?.value || null,
testPlaintext: testPlaintextRow?.value || null,
},
groups,
entries,
}
}
/**
* Import data from a previously exported JSON object.
*
* @param {Object} data
* @param {'merge'|'replace'} mode - 'merge' adds to existing, 'replace' clears first
* @returns {Promise<{ imported: { entries: number, groups: number }, skipped: number }>}
*/
export async function importAll(data, mode = 'merge') {
if (!data || !Array.isArray(data.entries) || !Array.isArray(data.groups)) {
throw new Error('Invalid import data format')
}
const db = await getDb()
if (mode === 'replace') {
await db.clear('entries')
await db.clear('groups')
// Clear meta so user can re-setup
await db.clear('meta')
}
let skipped = 0
let importedEntries = 0
let importedGroups = 0
// Import groups
for (const group of data.groups) {
try {
await db.put('groups', group)
importedGroups++
} catch {
skipped++
}
}
// Import entries
for (const entry of data.entries) {
try {
await db.put('entries', entry)
importedEntries++
} catch {
skipped++
}
}
// Restore meta if present
if (data.meta?.salt) {
await db.put('meta', { key: 'salt', value: data.meta.salt })
}
if (data.meta?.testEncrypted) {
await db.put('meta', { key: 'testEncrypted', value: data.meta.testEncrypted })
}
if (data.meta?.testPlaintext) {
await db.put('meta', { key: 'testPlaintext', value: data.meta.testPlaintext })
}
return {
imported: { entries: importedEntries, groups: importedGroups },
skipped,
}
}

View File

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

View File

@ -0,0 +1,16 @@
/**
* Search and filter state.
* Shared between Sidebar and EntryList for coordinated filtering.
*/
export class SearchStore {
query = $state('')
activeGroupId = $state('all') // 'all' or a group id
clear() {
this.query = ''
this.activeGroupId = 'all'
}
}
export const search = new SearchStore()

View File

@ -0,0 +1,82 @@
/**
* Security utilities: auto-lock timer, visibility change detection, cleanup.
*/
import { app } from './app.svelte.js'
const DEFAULT_AUTO_LOCK_MINUTES = 5
let autoLockTimer = null
let autoLockMinutes = 5
/**
* Start the auto-lock timer. Resets every time the user interacts.
* @param {number} minutes - Auto-lock after N minutes of inactivity
*/
export function startAutoLock(minutes = DEFAULT_AUTO_LOCK_MINUTES) {
autoLockMinutes = minutes
resetAutoLock()
// Listen for user activity to reset the timer
const events = ['mousedown', 'keydown', 'scroll', 'touchstart']
const handler = () => resetAutoLock()
events.forEach(evt => window.addEventListener(evt, handler, { passive: true }))
// Listen for visibility change (user switches tabs)
document.addEventListener('visibilitychange', handleVisibilityChange)
// Listen for beforeunload to clear the key
window.addEventListener('beforeunload', clearKeyOnExit)
// Store cleanup function
window.__vaultCleanup = () => {
events.forEach(evt => window.removeEventListener(evt, handler))
document.removeEventListener('visibilitychange', handleVisibilityChange)
window.removeEventListener('beforeunload', clearKeyOnExit)
if (autoLockTimer) clearTimeout(autoLockTimer)
}
}
/**
* Reset the auto-lock timer.
*/
function resetAutoLock() {
if (autoLockTimer) clearTimeout(autoLockTimer)
autoLockTimer = setTimeout(() => {
app.lockVault()
}, autoLockMinutes * 60 * 1000)
}
/**
* Handle visibility change lock when user switches away from tab.
*/
function handleVisibilityChange() {
if (document.hidden && app.isUnlocked) {
app.lockVault()
}
}
/**
* Clear the encryption key when the page is closing.
*/
function clearKeyOnExit() {
app.encryptionKey = null
}
/**
* Stop auto-lock (e.g., when manually locking).
*/
export function stopAutoLock() {
if (autoLockTimer) {
clearTimeout(autoLockTimer)
autoLockTimer = null
}
}
/**
* Check if Web Crypto API is available.
* @returns {boolean}
*/
export function isCryptoAvailable() {
return typeof crypto !== 'undefined' && crypto.subtle !== undefined
}

9
src/main.js Normal file
View File

@ -0,0 +1,9 @@
import { mount } from 'svelte'
import './styles/main.css'
import App from './App.svelte'
const app = mount(App, {
target: document.getElementById('app'),
})
export default app

179
src/styles/main.css Normal file
View File

@ -0,0 +1,179 @@
/* ===== CSS Reset & Base ===== */
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
:root {
--color-bg: #0f1117;
--color-surface: #1a1d27;
--color-surface-hover: #242836;
--color-border: #2e3345;
--color-text: #e4e6f0;
--color-text-muted: #8b8fa3;
--color-primary: #6c63ff;
--color-primary-hover: #5a52d9;
--color-danger: #e5484d;
--color-danger-hover: #c93a3f;
--color-success: #34d399;
--color-warning: #fbbf24;
--color-input-bg: #161822;
--color-sidebar: #13151d;
--radius-sm: 4px;
--radius-md: 8px;
--radius-lg: 12px;
--shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
--transition: 150ms ease;
}
html {
font-size: 16px;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background-color: var(--color-bg);
color: var(--color-text);
line-height: 1.5;
min-height: 100vh;
}
#app {
min-height: 100vh;
}
/* ===== Typography ===== */
h1 { font-size: 1.5rem; font-weight: 600; }
h2 { font-size: 1.25rem; font-weight: 600; }
h3 { font-size: 1.1rem; font-weight: 600; }
/* ===== Buttons ===== */
.btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 16px;
border: none;
border-radius: var(--radius-md);
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: background-color var(--transition), opacity var(--transition);
text-decoration: none;
white-space: nowrap;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-primary {
background-color: var(--color-primary);
color: #fff;
}
.btn-primary:hover:not(:disabled) {
background-color: var(--color-primary-hover);
}
.btn-danger {
background-color: var(--color-danger);
color: #fff;
}
.btn-danger:hover:not(:disabled) {
background-color: var(--color-danger-hover);
}
.btn-ghost {
background: transparent;
color: var(--color-text-muted);
border: 1px solid var(--color-border);
}
.btn-ghost:hover:not(:disabled) {
background-color: var(--color-surface-hover);
color: var(--color-text);
}
.btn-sm {
padding: 4px 10px;
font-size: 0.8rem;
}
/* ===== Inputs ===== */
input[type="text"],
input[type="password"],
input[type="url"],
input[type="email"],
input[type="number"],
textarea,
select {
width: 100%;
padding: 10px 12px;
background-color: var(--color-input-bg);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
color: var(--color-text);
font-size: 0.875rem;
font-family: inherit;
transition: border-color var(--transition);
outline: none;
}
input:focus,
textarea:focus,
select:focus {
border-color: var(--color-primary);
}
input::placeholder,
textarea::placeholder {
color: var(--color-text-muted);
}
textarea {
resize: vertical;
min-height: 80px;
}
label {
display: block;
font-size: 0.8rem;
font-weight: 500;
color: var(--color-text-muted);
margin-bottom: 4px;
}
.form-group {
margin-bottom: 16px;
}
/* ===== Utility ===== */
.text-muted { color: var(--color-text-muted); }
.text-sm { font-size: 0.8rem; }
.text-xs { font-size: 0.75rem; }
.mt-1 { margin-top: 4px; }
.mt-2 { margin-top: 8px; }
.mt-3 { margin-top: 12px; }
.mt-4 { margin-top: 16px; }
.mb-2 { margin-bottom: 8px; }
.mb-4 { margin-bottom: 16px; }
.flex { display: flex; }
.flex-col { flex-direction: column; }
.items-center { align-items: center; }
.justify-between { justify-content: space-between; }
.gap-1 { gap: 4px; }
.gap-2 { gap: 8px; }
.gap-3 { gap: 12px; }
.gap-4 { gap: 16px; }
.w-full { width: 100%; }
.truncate {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

2
svelte.config.js Normal file
View File

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

10
vite.config.js Normal file
View File

@ -0,0 +1,10 @@
import { defineConfig } from 'vite'
import { svelte } from '@sveltejs/vite-plugin-svelte'
// https://vite.dev/config/
export default defineConfig({
plugins: [svelte()],
preview: {
allowedHosts: ["dev.thecookiejar.me"]
}
})