12345import { createMemo, createSignal, Show } from "solid-js";67import { sendMessage } from "../App";8import { PeachWordmark } from "../components/PeachMark";9import {10 parseImportSource,11 type ImportParseResult,12} from "../../shared/import";13import type { VaultEntry } from "../../shared/types";14import { CJKImportView } from "./CJKImport";1516interface ImportProps {17 mode?: "existing" | "onboarding";18 replacesExistingVault?: boolean;19 onBack: () => void;20 onImport: () => void | Promise<void>;21}2223type ImportMergeMode = "add" | "replace";2425export function ImportView(props: ImportProps) {26 const [showCjkImport, setShowCjkImport] = createSignal(false);27 return (28 <Show29 when={showCjkImport()}30 fallback={31 <MainImportView32 mode={props.mode}33 replacesExistingVault={props.replacesExistingVault}34 onBack={props.onBack}35 onImport={props.onImport}36 onOpenCjk={() => setShowCjkImport(true)}37 />38 }39 >40 <CJKImportView41 mode="existing"42 onBack={() => setShowCjkImport(false)}43 onDone={async () => {44 setShowCjkImport(false);45 await props.onImport();46 }}47 />48 </Show>49 );50}5152function MainImportView(props: ImportProps & { onOpenCjk: () => void }) {53 const mode = () => props.mode ?? "existing";5455 const [preview, setPreview] = createSignal<ImportParseResult | null>(null);56 const [rawImport, setRawImport] = createSignal<string | null>(null);57 const [backupPassword, setBackupPassword] = createSignal("");58 const [masterPassword, setMasterPassword] = createSignal("");59 const [masterConfirm, setMasterConfirm] = createSignal("");60 const [importing, setImporting] = createSignal(false);61 const [error, setError] = createSignal<string | null>(null);62 const [mergeMode, setMergeMode] = createSignal<ImportMergeMode>("add");63 const [awaitingReplaceConfirm, setAwaitingReplaceConfirm] = createSignal(false);64 const [importProgress, setImportProgress] = createSignal<{ current: number; total: number } | null>(null);65 const [duplicateWarning, setDuplicateWarning] = createSignal<number>(0);66 const [existingCount, setExistingCount] = createSignal<number>(0);6768 const needsBackupPassword = createMemo(() => preview()?.requiresPassword ?? false);69 const needsMasterPassword = createMemo(() => mode() === "onboarding");70 const masterPasswordReady = createMemo(71 () =>72 masterPassword().length >= 8 && masterPassword() === masterConfirm()73 );7475 async function refreshExistingCount() {76 if (mode() !== "existing") return;77 const res = await sendMessage({ type: "GET_ENTRIES" });78 const list = (res as { ok: boolean; data?: VaultEntry[] }).data ?? [];79 setExistingCount(list.length);80 }8182 void refreshExistingCount();8384 async function parseSource(text: string, password?: string) {85 try {86 const result = await parseImportSource(text, password);87 setPreview(result);88 setError(null);89 if (result.format === "unknown") {90 setError(91 "We couldn't recognise this file format. Peach supports: Peach JSON backups, Bitwarden JSON/CSV, Proton Pass JSON/CSV, 1Password JSON, and generic browser CSV exports."92 );93 }94 return result;95 } catch (err) {96 const message = err instanceof Error ? err.message : String(err);97 setPreview(null);98 setError(getImportErrorCopy(message));99 return null;100 }101 }102103 async function handleFileSelect(event: Event) {104 const file = (event.target as HTMLInputElement).files?.[0];105 if (!file) return;106107 setPreview(null);108 setRawImport(null);109 setBackupPassword("");110 setError(null);111 setAwaitingReplaceConfirm(false);112113 const text = await file.text();114 setRawImport(text);115 await parseSource(text);116 }117118 async function handleUnlockBackup() {119 const raw = rawImport();120 if (!raw || !backupPassword().trim()) return;121 await parseSource(raw, backupPassword());122 }123124 async function countDuplicates(incoming: ImportParseResult["entries"]): Promise<number> {125 if (incoming.length === 0) return 0;126 const res = await sendMessage({ type: "GET_ENTRIES" });127 const existing = ((res as { ok: boolean; data?: VaultEntry[] }).data ?? []).filter((e) => !e.deletedAt);128 const keyOf = (title?: string, url?: string, username?: string) =>129 `${(title ?? "").toLowerCase()}|${(url ?? "").toLowerCase()}|${(username ?? "").toLowerCase()}`;130 const existingKeys = new Set(existing.map((e) => keyOf(e.title, e.url, e.username)));131 let dupes = 0;132 for (const entry of incoming) {133 const u = "username" in entry ? entry.username : undefined;134 const url = "url" in entry ? entry.url : undefined;135 if (existingKeys.has(keyOf(entry.title, url, u))) dupes += 1;136 }137 return dupes;138 }139140 async function handleImport() {141 const currentPreview = preview();142 if (!currentPreview) return;143144 if (needsBackupPassword() && !backupPassword().trim()) {145 setError("Enter the backup password used for this export.");146 return;147 }148149 if (needsMasterPassword()) {150 if (masterPassword().length < 8) {151 setError("Master password must be at least 8 characters.");152 return;153 }154 if (!masterPasswordReady()) {155 setError("Master passwords do not match.");156 return;157 }158 }159160 if (mode() === "existing" && mergeMode() === "replace" && !awaitingReplaceConfirm()) {161 setAwaitingReplaceConfirm(true);162 return;163 }164165 setImporting(true);166 setError(null);167 setDuplicateWarning(0);168 setImportProgress(null);169170 try {171 172 173 174 if (currentPreview.entries.length === 0) {175 throw new Error("NO_ENTRIES_IMPORTED");176 }177178 if (needsMasterPassword()) {179 const createResponse = await sendMessage({180 type: "CREATE_VAULT",181 payload: { password: masterPassword() },182 });183 if (!(createResponse as { ok: boolean }).ok) {184 throw new Error("CREATE_VAULT_FAILED");185 }186 }187188 if (mode() === "existing" && mergeMode() === "replace") {189 const clearRes = await sendMessage({ type: "CLEAR_VAULT_ENTRIES" });190 if (!(clearRes as { ok: boolean }).ok) {191 throw new Error("CLEAR_VAULT_FAILED");192 }193 }194195 let dupes = 0;196 if (mode() === "existing" && mergeMode() === "add") {197 dupes = await countDuplicates(currentPreview.entries);198 }199200 let imported = 0;201 setImportProgress({ current: 0, total: currentPreview.entries.length });202203 const batchRes = await sendMessage({204 type: "BATCH_ADD_ENTRIES",205 payload: { entries: currentPreview.entries },206 });207208 if ((batchRes as { ok: boolean }).ok) {209 imported = ((batchRes as { ok: boolean; data?: VaultEntry[] }).data ?? []).length;210 setImportProgress({ current: imported, total: currentPreview.entries.length });211 } else {212 213 for (const entry of currentPreview.entries) {214 const response = await sendMessage({215 type: "ADD_ENTRY",216 payload: entry,217 });218 if ((response as { ok: boolean }).ok) imported += 1;219 setImportProgress({ current: imported, total: currentPreview.entries.length });220 }221 }222223 if (imported === 0) {224 throw new Error("NO_ENTRIES_IMPORTED");225 }226227 setDuplicateWarning(dupes);228 await props.onImport();229 } catch (err) {230 const message = err instanceof Error ? err.message : String(err);231 setError(getImportErrorCopy(message));232 setImporting(false);233 setImportProgress(null);234 return;235 }236237 setImporting(false);238 setImportProgress(null);239 }240241 return (242 <div class={mode() === "onboarding" ? "peach-lock-view" : "peach-overlay-view ov-enter-right"}>243 <Show244 when={mode() === "onboarding"}245 fallback={246 <div class="peach-overlay-header">247 <button248 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"249 onClick={props.onBack}250 aria-label="Back"251 >252 <svg253 width="7"254 height="12"255 viewBox="0 0 7 12"256 fill="none"257 stroke="currentColor"258 stroke-width="1.1"259 stroke-linecap="round"260 stroke-linejoin="round"261 >262 <path d="M6 1L1 6L6 11" />263 </svg>264 </button>265 <span class="peach-overlay-title">import</span>266 <div class="peach-overlay-spacer"></div>267 </div>268 }269 >270 <div class="peach-lock-view-header">271 <PeachWordmark class="peach-wordmark-wrap" />272 <button273 class="peach-lock-restore-link"274 onClick={props.onBack}275 disabled={importing()}276 >277 ‹ back278 </button>279 </div>280 </Show>281282 <div class="peach-settings-body">283 <div class="peach-settings-group peach-restore-group">284 <p class="peach-settings-label">285 {mode() === "onboarding" ? "bring data into peach" : "import passwords"}286 </p>287 <p class="peach-restore-copy">288 {mode() === "onboarding"289 ? "Import a Peach backup, FIDO credential-exchange export, Proton Pass export, Bitwarden export, or a common browser CSV. We'll create a fresh local vault first."290 : "Supports Peach encrypted backups, FIDO credential-exchange passkeys, Proton Pass, Bitwarden, and common browser CSV exports."}291 </p>292293 <Show when={mode() === "onboarding" && props.replacesExistingVault}>294 <div class="peach-restore-warning">295 Restoring now replaces the locked local vault on this device with296 a fresh one from your backup or export file.297 </div>298 </Show>299300 <div class="peach-provider-chips">301 <span class="peach-provider-chip">Peach JSON</span>302 <span class="peach-provider-chip">FIDO passkeys</span>303 <span class="peach-provider-chip">Proton Pass CSV/JSON</span>304 <span class="peach-provider-chip">Bitwarden CSV/JSON</span>305 <span class="peach-provider-chip">1Password JSON</span>306 <span class="peach-provider-chip">Chrome / Edge / Safari CSV</span>307 </div>308 </div>309310 <Show when={mode() === "existing"}>311 <div class="peach-settings-group">312 <p class="peach-settings-label">restore from CJK phrase</p>313 <p class="peach-restore-copy">314 Use a Peach CJK backup phrase instead of a file.315 </p>316 <button317 class="peach-btn peach-btn--subtle peach-btn--sm"318 onClick={props.onOpenCjk}319 >320 Restore from CJK phrase321 </button>322 </div>323 </Show>324325 <div class="peach-settings-group">326 <p class="peach-settings-label">select file</p>327 <input328 class="peach-restore-file"329 type="file"330 accept=".csv,.json"331 onChange={(event) => void handleFileSelect(event)}332 />333 </div>334335 <Show when={needsBackupPassword()}>336 <div class="peach-settings-group">337 <p class="peach-settings-label">unlock backup</p>338 <p class="peach-restore-copy">339 Enter the password used when you created this encrypted Peach JSON340 backup.341 </p>342 <input343 class="peach-restore-input"344 type="password"345 placeholder="backup password"346 value={backupPassword()}347 onInput={(event) =>348 setBackupPassword((event.target as HTMLInputElement).value)349 }350 onBlur={() => { if (backupPassword().trim()) void handleUnlockBackup(); }}351 onKeyDown={(event) => {352 if (event.key === "Enter") {353 event.preventDefault();354 void handleUnlockBackup();355 }356 }}357 />358 <div class="peach-settings-actions">359 <button360 class="peach-btn peach-btn--subtle peach-btn--sm"361 onClick={() => void handleUnlockBackup()}362 disabled={!backupPassword().trim()}363 >364 Decrypt →365 </button>366 </div>367 </div>368 </Show>369370 <Show when={preview() && !needsBackupPassword() && preview()!.format !== "unknown"}>371 <div class="peach-settings-group">372 <p class="peach-settings-label">{preview()!.label}</p>373 <div class="peach-import-summary">374 Found <strong>{preview()!.entries.length}</strong>{" "}375 entr{preview()!.entries.length === 1 ? "y" : "ies"}376 {preview()!.skippedItems > 0377 ? `. ${preview()!.skippedItems} skipped because no password was present.`378 : "."}379 </div>380381 <div class="peach-import-preview">382 {preview()!.entries.slice(0, 8).map((entry) => (383 <div class="peach-import-preview-row">384 <span class="peach-import-preview-title">{entry.title}</span>385 <span class="peach-import-preview-meta">386 {entry.type === "passkey"387 ? `${entry.userName || "passkey"} • ${entry.rpId || "site"}`388 : entry.type === "note"389 ? "secure note"390 : entry.username || entry.url || "password item"}391 </span>392 </div>393 ))}394 <Show when={preview()!.entries.length > 8}>395 <div class="peach-import-preview-more">396 ...and {preview()!.entries.length - 8} more397 </div>398 </Show>399 </div>400 </div>401 </Show>402403 <Show when={mode() === "existing" && preview() && !needsBackupPassword() && preview()!.format !== "unknown" && preview()!.entries.length > 0}>404 <div class="peach-settings-group">405 <p class="peach-settings-label">merge or replace</p>406 <label class="peach-import-choice">407 <input408 type="radio"409 name="merge-mode"410 value="add"411 checked={mergeMode() === "add"}412 onChange={() => { setMergeMode("add"); setAwaitingReplaceConfirm(false); }}413 />414 <span class="peach-import-choice-main">415 <span class="peach-import-choice-label">Add to existing vault</span>416 <span class="peach-import-choice-sub">417 This will add {preview()!.entries.length} entries to your existing {existingCount()}.418 </span>419 </span>420 </label>421 <label class="peach-import-choice">422 <input423 type="radio"424 name="merge-mode"425 value="replace"426 checked={mergeMode() === "replace"}427 onChange={() => setMergeMode("replace")}428 />429 <span class="peach-import-choice-main">430 <span class="peach-import-choice-label">Replace vault</span>431 <span class="peach-import-choice-sub">432 This will replace your {existingCount()} entries with {preview()!.entries.length} imported ones.433 </span>434 </span>435 </label>436 <Show when={awaitingReplaceConfirm()}>437 <div class="peach-restore-warning" style="margin-top:6px">438 Replace forever? This cannot be undone.439 </div>440 </Show>441 </div>442 </Show>443444 <Show when={needsMasterPassword() && preview() && !needsBackupPassword() && preview()!.format !== "unknown"}>445 <div class="peach-settings-group">446 <p class="peach-settings-label">new master password</p>447 <p class="peach-restore-copy">448 This password unlocks the Peach vault created on this device after449 import. It is separate from any export password.450 </p>451 <div class="peach-auth-grid">452 <input453 class="peach-restore-input"454 type="password"455 placeholder="new master password"456 value={masterPassword()}457 onInput={(event) =>458 setMasterPassword((event.target as HTMLInputElement).value)459 }460 />461 <input462 class="peach-restore-input"463 type="password"464 placeholder="confirm master password"465 value={masterConfirm()}466 onInput={(event) =>467 setMasterConfirm((event.target as HTMLInputElement).value)468 }469 />470 </div>471 </div>472 </Show>473474 <Show when={error()}>475 <div class="peach-settings-group">476 <div class="peach-restore-error">{error()}</div>477 </div>478 </Show>479480 <Show when={importing() && importProgress()}>481 <div class="peach-settings-group">482 <div class="peach-import-summary">483 Importing... [{importProgress()!.current} / {importProgress()!.total}]484 </div>485 </div>486 </Show>487488 <Show when={duplicateWarning() > 0 && !importing()}>489 <div class="peach-settings-group">490 <div class="peach-restore-copy">491 {duplicateWarning()} {duplicateWarning() === 1 ? "entry appears" : "entries appear"} to be duplicates of existing ones. They were still imported — you can review and delete them in your vault.492 </div>493 </div>494 </Show>495496 <Show when={preview() && !needsBackupPassword() && preview()!.format !== "unknown"}>497 <div class="peach-settings-group">498 <div class="peach-settings-actions">499 <button500 class="peach-btn peach-btn--subtle peach-btn--sm"501 onClick={() => void handleImport()}502 disabled={503 importing() ||504 preview()!.entries.length === 0 ||505 (needsMasterPassword() && !masterPasswordReady())506 }507 >508 {importing()509 ? "importing..."510 : mode() === "onboarding"511 ? `Create vault + import ${preview()!.entries.length}`512 : mergeMode() === "replace"513 ? awaitingReplaceConfirm()514 ? "Yes, replace forever"515 : `Replace vault with ${preview()!.entries.length}`516 : `Import ${preview()!.entries.length}`}517 </button>518 <button519 class="peach-btn peach-btn--ghost peach-btn--sm"520 onClick={props.onBack}521 disabled={importing()}522 >523 Cancel524 </button>525 </div>526 </div>527 </Show>528 </div>529 </div>530 );531}532533function getImportErrorCopy(error: string): string {534 switch (error) {535 case "UNSUPPORTED_IMPORT_FORMAT":536 return "Unrecognised file format. Use CSV, Bitwarden JSON, Proton Pass JSON/CSV, or a Peach encrypted backup.";537 case "INVALID_EXPORT_PASSWORD":538 return "Incorrect backup password. This is the password you chose when you exported the Peach JSON backup.";539 case "INVALID_PEACH_EXPORT":540 return "This Peach backup file looks corrupted or incomplete.";541 case "CREATE_VAULT_FAILED":542 return "Peach couldn't create a new local vault before importing.";543 case "CLEAR_VAULT_FAILED":544 return "Peach couldn't clear the existing vault before replacing it.";545 case "NO_ENTRIES_IMPORTED":546 return "No importable password entries were found in that file.";547 default:548 return error;549 }550}