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 228 if (currentPreview.licenseKey) {229 await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key: currentPreview.licenseKey } }).catch(() => {});230 }231232 setDuplicateWarning(dupes);233 await props.onImport();234 } catch (err) {235 const message = err instanceof Error ? err.message : String(err);236 setError(getImportErrorCopy(message));237 setImporting(false);238 setImportProgress(null);239 return;240 }241242 setImporting(false);243 setImportProgress(null);244 }245246 return (247 <div class={mode() === "onboarding" ? "peach-lock-view" : "peach-overlay-view ov-enter-right"}>248 <Show249 when={mode() === "onboarding"}250 fallback={251 <div class="peach-overlay-header">252 <button253 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"254 onClick={props.onBack}255 aria-label="Back"256 >257 <svg258 width="7"259 height="12"260 viewBox="0 0 7 12"261 fill="none"262 stroke="currentColor"263 stroke-width="1.1"264 stroke-linecap="round"265 stroke-linejoin="round"266 >267 <path d="M6 1L1 6L6 11" />268 </svg>269 </button>270 <span class="peach-overlay-title">import</span>271 <div class="peach-overlay-spacer"></div>272 </div>273 }274 >275 <div class="peach-lock-view-header">276 <PeachWordmark class="peach-wordmark-wrap" />277 <button278 class="peach-lock-restore-link"279 onClick={props.onBack}280 disabled={importing()}281 >282 ‹ back283 </button>284 </div>285 </Show>286287 <div class="peach-settings-body">288 <div class="peach-settings-group peach-restore-group">289 <p class="peach-settings-label">290 {mode() === "onboarding" ? "bring data into peach" : "import passwords"}291 </p>292 <p class="peach-restore-copy">293 {mode() === "onboarding"294 ? "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."295 : "Supports Peach encrypted backups, FIDO credential-exchange passkeys, Proton Pass, Bitwarden, and common browser CSV exports."}296 </p>297298 <Show when={mode() === "onboarding" && props.replacesExistingVault}>299 <div class="peach-restore-warning">300 Restoring now replaces the locked local vault on this device with301 a fresh one from your backup or export file.302 </div>303 </Show>304305 <div class="peach-provider-chips">306 <span class="peach-provider-chip">Peach JSON</span>307 <span class="peach-provider-chip">FIDO passkeys</span>308 <span class="peach-provider-chip">Proton Pass CSV/JSON</span>309 <span class="peach-provider-chip">Bitwarden CSV/JSON</span>310 <span class="peach-provider-chip">1Password JSON</span>311 <span class="peach-provider-chip">Chrome / Edge / Safari CSV</span>312 </div>313 </div>314315 <Show when={mode() === "existing"}>316 <div class="peach-settings-group">317 <p class="peach-settings-label">restore from CJK phrase</p>318 <p class="peach-restore-copy">319 Use a Peach CJK backup phrase instead of a file.320 </p>321 <button322 class="peach-btn peach-btn--subtle peach-btn--sm"323 onClick={props.onOpenCjk}324 >325 Restore from CJK phrase326 </button>327 </div>328 </Show>329330 <div class="peach-settings-group">331 <p class="peach-settings-label">select file</p>332 <input333 class="peach-restore-file"334 type="file"335 accept=".csv,.json"336 onChange={(event) => void handleFileSelect(event)}337 />338 </div>339340 <Show when={needsBackupPassword()}>341 <div class="peach-settings-group">342 <p class="peach-settings-label">unlock backup</p>343 <p class="peach-restore-copy">344 Enter the password used when you created this encrypted Peach JSON345 backup.346 </p>347 <input348 class="peach-restore-input"349 type="password"350 placeholder="backup password"351 value={backupPassword()}352 onInput={(event) =>353 setBackupPassword((event.target as HTMLInputElement).value)354 }355 onBlur={() => { if (backupPassword().trim()) void handleUnlockBackup(); }}356 onKeyDown={(event) => {357 if (event.key === "Enter") {358 event.preventDefault();359 void handleUnlockBackup();360 }361 }}362 />363 <div class="peach-settings-actions">364 <button365 class="peach-btn peach-btn--subtle peach-btn--sm"366 onClick={() => void handleUnlockBackup()}367 disabled={!backupPassword().trim()}368 >369 Decrypt →370 </button>371 </div>372 </div>373 </Show>374375 <Show when={preview() && !needsBackupPassword() && preview()!.format !== "unknown"}>376 <div class="peach-settings-group">377 <p class="peach-settings-label">{preview()!.label}</p>378 <div class="peach-import-summary">379 Found <strong>{preview()!.entries.length}</strong>{" "}380 entr{preview()!.entries.length === 1 ? "y" : "ies"}381 {preview()!.skippedItems > 0382 ? `. ${preview()!.skippedItems} skipped because no password was present.`383 : "."}384 </div>385386 <div class="peach-import-preview">387 {preview()!.entries.slice(0, 8).map((entry) => (388 <div class="peach-import-preview-row">389 <span class="peach-import-preview-title">{entry.title}</span>390 <span class="peach-import-preview-meta">391 {entry.type === "passkey"392 ? `${entry.userName || "passkey"} • ${entry.rpId || "site"}`393 : entry.type === "note"394 ? "secure note"395 : entry.username || entry.url || "password item"}396 </span>397 </div>398 ))}399 <Show when={preview()!.entries.length > 8}>400 <div class="peach-import-preview-more">401 ...and {preview()!.entries.length - 8} more402 </div>403 </Show>404 </div>405 </div>406 </Show>407408 <Show when={mode() === "existing" && preview() && !needsBackupPassword() && preview()!.format !== "unknown" && preview()!.entries.length > 0}>409 <div class="peach-settings-group">410 <p class="peach-settings-label">merge or replace</p>411 <label class="peach-import-choice">412 <input413 type="radio"414 name="merge-mode"415 value="add"416 checked={mergeMode() === "add"}417 onChange={() => { setMergeMode("add"); setAwaitingReplaceConfirm(false); }}418 />419 <span class="peach-import-choice-main">420 <span class="peach-import-choice-label">Add to existing vault</span>421 <span class="peach-import-choice-sub">422 This will add {preview()!.entries.length} entries to your existing {existingCount()}.423 </span>424 </span>425 </label>426 <label class="peach-import-choice">427 <input428 type="radio"429 name="merge-mode"430 value="replace"431 checked={mergeMode() === "replace"}432 onChange={() => setMergeMode("replace")}433 />434 <span class="peach-import-choice-main">435 <span class="peach-import-choice-label">Replace vault</span>436 <span class="peach-import-choice-sub">437 This will replace your {existingCount()} entries with {preview()!.entries.length} imported ones.438 </span>439 </span>440 </label>441 <Show when={awaitingReplaceConfirm()}>442 <div class="peach-restore-warning" style="margin-top:6px">443 Replace forever? This cannot be undone.444 </div>445 </Show>446 </div>447 </Show>448449 <Show when={needsMasterPassword() && preview() && !needsBackupPassword() && preview()!.format !== "unknown"}>450 <div class="peach-settings-group">451 <p class="peach-settings-label">new master password</p>452 <p class="peach-restore-copy">453 This password unlocks the Peach vault created on this device after454 import. It is separate from any export password.455 </p>456 <div class="peach-auth-grid">457 <input458 class="peach-restore-input"459 type="password"460 placeholder="new master password"461 value={masterPassword()}462 onInput={(event) =>463 setMasterPassword((event.target as HTMLInputElement).value)464 }465 />466 <input467 class="peach-restore-input"468 type="password"469 placeholder="confirm master password"470 value={masterConfirm()}471 onInput={(event) =>472 setMasterConfirm((event.target as HTMLInputElement).value)473 }474 />475 </div>476 </div>477 </Show>478479 <Show when={error()}>480 <div class="peach-settings-group">481 <div class="peach-restore-error">{error()}</div>482 </div>483 </Show>484485 <Show when={importing() && importProgress()}>486 <div class="peach-settings-group">487 <div class="peach-import-summary">488 Importing... [{importProgress()!.current} / {importProgress()!.total}]489 </div>490 </div>491 </Show>492493 <Show when={duplicateWarning() > 0 && !importing()}>494 <div class="peach-settings-group">495 <div class="peach-restore-copy">496 {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.497 </div>498 </div>499 </Show>500501 <Show when={preview() && !needsBackupPassword() && preview()!.format !== "unknown"}>502 <div class="peach-settings-group">503 <div class="peach-settings-actions">504 <button505 class="peach-btn peach-btn--subtle peach-btn--sm"506 onClick={() => void handleImport()}507 disabled={508 importing() ||509 preview()!.entries.length === 0 ||510 (needsMasterPassword() && !masterPasswordReady())511 }512 >513 {importing()514 ? "importing..."515 : mode() === "onboarding"516 ? `Create vault + import ${preview()!.entries.length}`517 : mergeMode() === "replace"518 ? awaitingReplaceConfirm()519 ? "Yes, replace forever"520 : `Replace vault with ${preview()!.entries.length}`521 : `Import ${preview()!.entries.length}`}522 </button>523 <button524 class="peach-btn peach-btn--ghost peach-btn--sm"525 onClick={props.onBack}526 disabled={importing()}527 >528 Cancel529 </button>530 </div>531 </div>532 </Show>533 </div>534 </div>535 );536}537538function getImportErrorCopy(error: string): string {539 switch (error) {540 case "UNSUPPORTED_IMPORT_FORMAT":541 return "Unrecognised file format. Use CSV, Bitwarden JSON, Proton Pass JSON/CSV, or a Peach encrypted backup.";542 case "INVALID_EXPORT_PASSWORD":543 return "Incorrect backup password. This is the password you chose when you exported the Peach JSON backup.";544 case "INVALID_PEACH_EXPORT":545 return "This Peach backup file looks corrupted or incomplete.";546 case "CREATE_VAULT_FAILED":547 return "Peach couldn't create a new local vault before importing.";548 case "CLEAR_VAULT_FAILED":549 return "Peach couldn't clear the existing vault before replacing it.";550 case "NO_ENTRIES_IMPORTED":551 return "No importable password entries were found in that file.";552 default:553 return error;554 }555}