447 lines
12 KiB
Svelte
447 lines
12 KiB
Svelte
<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)
|
|
|
|
// 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() {
|
|
exporting = true
|
|
try {
|
|
exportData = await exportSelected(selectedGroupIds.length === allGroups.length ? null : 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)
|
|
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. You'll need the source vault's master password when importing into another 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="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 <strong>source vault's master password</strong> to decrypt and re-encrypt entries under your current vault.</p>
|
|
|
|
<div class="form-group">
|
|
<label for="source-password" class="file-label">Source vault password</label>
|
|
<input
|
|
id="source-password"
|
|
type="password"
|
|
bind:value={sourcePassword}
|
|
placeholder="Enter 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;
|
|
}
|
|
|
|
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>
|