password_manager/src/components/ImportExport.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>