123import { createMemo, createSignal, Show } from "solid-js";45import { sendMessage } from "../App";6import { PeachWordmark } from "../components/PeachMark";7import {8 parseImportSource,9 type ImportParseResult,10} from "../../shared/import";11import {12 decodePeachScript,13 isPeachScript,14 normalisePeachScript,15} from "../../crypto/peachScript";16import { parsePayload } from "../../crypto/peachPayload";17import type { VaultEntry } from "../../shared/types";18import { CJKImportView } from "./CJKImport";1920interface ImportProps {21 mode?: "existing" | "onboarding";22 replacesExistingVault?: boolean;23 onBack: () => void;24 onImport: () => void | Promise<void>;25}2627type ImportMergeMode = "add" | "replace";2829export function ImportView(props: ImportProps) {30 const [showCjkImport, setShowCjkImport] = createSignal(false);31 return (32 <Show33 when={showCjkImport()}34 fallback={35 <MainImportView36 mode={props.mode}37 replacesExistingVault={props.replacesExistingVault}38 onBack={props.onBack}39 onImport={props.onImport}40 onOpenCjk={() => setShowCjkImport(true)}41 />42 }43 >44 <CJKImportView45 mode="existing"46 onBack={() => setShowCjkImport(false)}47 onDone={async () => {48 setShowCjkImport(false);49 await props.onImport();50 }}51 />52 </Show>53 );54}5556function MainImportView(props: ImportProps & { onOpenCjk: () => void }) {57 const mode = () => props.mode ?? "existing";5859 60 const [scriptInput, setScriptInput] = createSignal("");61 const [scriptPass, setScriptPass] = createSignal("");62 const [scriptWorking, setScriptWorking] = createSignal(false);63 const [scriptError, setScriptError] = createSignal<string | null>(null);64 const [scriptResult, setScriptResult] = createSignal<{ entryCount: number } | null>(null);6566 67 const [preview, setPreview] = createSignal<ImportParseResult | null>(null);68 const [rawImport, setRawImport] = createSignal<string | null>(null);69 const [backupPassword, setBackupPassword] = createSignal("");70 const [masterPassword, setMasterPassword] = createSignal("");71 const [masterConfirm, setMasterConfirm] = createSignal("");72 const [importing, setImporting] = createSignal(false);73 const [error, setError] = createSignal<string | null>(null);74 const [mergeMode, setMergeMode] = createSignal<ImportMergeMode>("add");75 const [awaitingReplaceConfirm, setAwaitingReplaceConfirm] = createSignal(false);76 const [importProgress, setImportProgress] = createSignal<{ current: number; total: number } | null>(null);77 const [duplicateWarning, setDuplicateWarning] = createSignal<number>(0);78 const [existingCount, setExistingCount] = createSignal<number>(0);7980 const needsBackupPassword = createMemo(() => preview()?.requiresPassword ?? false);81 const needsMasterPassword = createMemo(() => mode() === "onboarding");82 const masterPasswordReady = createMemo(83 () =>84 masterPassword().length >= 8 && masterPassword() === masterConfirm()85 );8687 const scriptLooksValid = createMemo(() => isPeachScript(scriptInput()));8889 async function refreshExistingCount() {90 if (mode() !== "existing") return;91 const res = await sendMessage({ type: "GET_ENTRIES" });92 const list = (res as { ok: boolean; data?: VaultEntry[] }).data ?? [];93 setExistingCount(list.length);94 }9596 void refreshExistingCount();9798 99 100 101102 async function handlePeachScriptImport() {103 setScriptError(null);104 setScriptResult(null);105 if (!scriptLooksValid()) {106 setScriptError("Paste your PeachScript first.");107 return;108 }109 if (!scriptPass().trim()) {110 setScriptError("Enter the backup password.");111 return;112 }113 setScriptWorking(true);114 try {115 const bytes = decodePeachScript(normalisePeachScript(scriptInput()));116 const payload = await parsePayload(bytes, scriptPass());117118 119 if (mode() === "onboarding") {120 if (!masterPasswordReady()) {121 throw new Error("MASTER_PASSWORD_REQUIRED");122 }123 const createRes = await sendMessage({124 type: "CREATE_VAULT",125 payload: { password: masterPassword() },126 });127 if (!(createRes as { ok: boolean }).ok) throw new Error("CREATE_VAULT_FAILED");128 }129130 const restoreRes = await sendMessage({131 type: "RESTORE_ENTRIES",132 payload: {133 entries: payload.entries,134 mode: "merge",135 settings: payload.settings,136 },137 });138 if (!(restoreRes as { ok: boolean }).ok) throw new Error("RESTORE_FAILED");139140 if (payload.license?.key) {141 await sendMessage({142 type: "ACTIVATE_LICENSE",143 payload: { key: payload.license.key },144 }).catch(() => {});145 }146147 setScriptResult({ entryCount: payload.entries.length });148 await props.onImport();149 } catch (err) {150 const code = err instanceof Error ? err.message : String(err);151 setScriptError(getImportErrorCopy(code));152 } finally {153 setScriptWorking(false);154 }155 }156157 158 159 160161 async function parseSource(text: string, password?: string) {162 try {163 const result = await parseImportSource(text, password);164 setPreview(result);165 setError(null);166 if (result.format === "unknown") {167 setError(168 "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."169 );170 }171 return result;172 } catch (err) {173 const message = err instanceof Error ? err.message : String(err);174 setPreview(null);175 setError(getImportErrorCopy(message));176 return null;177 }178 }179180 async function handleFileSelect(event: Event) {181 const file = (event.target as HTMLInputElement).files?.[0];182 if (!file) return;183184 setPreview(null);185 setRawImport(null);186 setBackupPassword("");187 setError(null);188 setAwaitingReplaceConfirm(false);189190 const text = await file.text();191 setRawImport(text);192 await parseSource(text);193 }194195 async function handleUnlockBackup() {196 const raw = rawImport();197 if (!raw || !backupPassword().trim()) return;198 await parseSource(raw, backupPassword());199 }200201 async function countDuplicates(incoming: ImportParseResult["entries"]): Promise<number> {202 if (incoming.length === 0) return 0;203 const res = await sendMessage({ type: "GET_ENTRIES" });204 const existing = ((res as { ok: boolean; data?: VaultEntry[] }).data ?? []).filter((e) => !e.deletedAt);205 const keyOf = (title?: string, url?: string, username?: string) =>206 `${(title ?? "").toLowerCase()}|${(url ?? "").toLowerCase()}|${(username ?? "").toLowerCase()}`;207 const existingKeys = new Set(existing.map((e) => keyOf(e.title, e.url, e.username)));208 let dupes = 0;209 for (const entry of incoming) {210 const u = "username" in entry ? entry.username : undefined;211 const url = "url" in entry ? entry.url : undefined;212 if (existingKeys.has(keyOf(entry.title, url, u))) dupes += 1;213 }214 return dupes;215 }216217 async function handleImport() {218 const currentPreview = preview();219 if (!currentPreview) return;220221 if (needsBackupPassword() && !backupPassword().trim()) {222 setError("Enter the backup password used for this export.");223 return;224 }225226 if (needsMasterPassword()) {227 if (masterPassword().length < 8) {228 setError("Master password must be at least 8 characters.");229 return;230 }231 if (!masterPasswordReady()) {232 setError("Master passwords do not match.");233 return;234 }235 }236237 if (mode() === "existing" && mergeMode() === "replace" && !awaitingReplaceConfirm()) {238 setAwaitingReplaceConfirm(true);239 return;240 }241242 setImporting(true);243 setError(null);244 setDuplicateWarning(0);245 setImportProgress(null);246247 try {248 if (currentPreview.entries.length === 0) {249 throw new Error("NO_ENTRIES_IMPORTED");250 }251252 if (needsMasterPassword()) {253 const createResponse = await sendMessage({254 type: "CREATE_VAULT",255 payload: { password: masterPassword() },256 });257 if (!(createResponse as { ok: boolean }).ok) {258 throw new Error("CREATE_VAULT_FAILED");259 }260 }261262 if (mode() === "existing" && mergeMode() === "replace") {263 const clearRes = await sendMessage({ type: "CLEAR_VAULT_ENTRIES" });264 if (!(clearRes as { ok: boolean }).ok) {265 throw new Error("CLEAR_VAULT_FAILED");266 }267 }268269 let dupes = 0;270 if (mode() === "existing" && mergeMode() === "add") {271 dupes = await countDuplicates(currentPreview.entries);272 }273274 let imported = 0;275 setImportProgress({ current: 0, total: currentPreview.entries.length });276277 const batchRes = await sendMessage({278 type: "BATCH_ADD_ENTRIES",279 payload: { entries: currentPreview.entries },280 });281282 if ((batchRes as { ok: boolean }).ok) {283 imported = ((batchRes as { ok: boolean; data?: VaultEntry[] }).data ?? []).length;284 setImportProgress({ current: imported, total: currentPreview.entries.length });285 } else {286 for (const entry of currentPreview.entries) {287 const response = await sendMessage({288 type: "ADD_ENTRY",289 payload: entry,290 });291 if ((response as { ok: boolean }).ok) imported += 1;292 setImportProgress({ current: imported, total: currentPreview.entries.length });293 }294 }295296 if (imported === 0) {297 throw new Error("NO_ENTRIES_IMPORTED");298 }299300 if (currentPreview.licenseKey) {301 await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key: currentPreview.licenseKey } }).catch(() => {});302 }303304 setDuplicateWarning(dupes);305 await props.onImport();306 } catch (err) {307 const message = err instanceof Error ? err.message : String(err);308 setError(getImportErrorCopy(message));309 setImporting(false);310 setImportProgress(null);311 return;312 }313314 setImporting(false);315 setImportProgress(null);316 }317318 return (319 <div class={mode() === "onboarding" ? "peach-lock-view" : "peach-overlay-view ov-enter-right"}>320 <Show321 when={mode() === "onboarding"}322 fallback={323 <div class="peach-overlay-header">324 <button325 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"326 onClick={props.onBack}327 aria-label="Back"328 >329 <svg330 width="7" height="12" viewBox="0 0 7 12"331 fill="none" stroke="currentColor"332 stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round"333 >334 <path d="M6 1L1 6L6 11" />335 </svg>336 </button>337 <span class="peach-overlay-title">import</span>338 <div class="peach-overlay-spacer"></div>339 </div>340 }341 >342 <div class="peach-lock-view-header">343 <PeachWordmark class="peach-wordmark-wrap" />344 <button345 class="peach-lock-restore-link"346 onClick={props.onBack}347 disabled={importing() || scriptWorking()}348 >349 ‹ back350 </button>351 </div>352 </Show>353354 <div class="peach-settings-body">355 <Show when={mode() === "onboarding" && props.replacesExistingVault}>356 <div class="peach-settings-group">357 <div class="peach-restore-warning">358 Restoring now replaces the locked local vault on this device with359 a fresh one from your backup or export file.360 </div>361 </div>362 </Show>363364 {}365 <div class="peach-settings-group peach-restore-group">366 <p class="peach-settings-label">paste peachscript</p>367 <p class="peach-restore-copy">368 {mode() === "onboarding"369 ? "Paste a PeachScript backup from another Peach device to restore your vault."370 : "Paste a PeachScript backup from another Peach device. We'll merge it into your vault."}371 </p>372 <textarea373 class="peach-script-input"374 placeholder="paste PeachScript here…"375 value={scriptInput()}376 onInput={(e) => setScriptInput((e.target as HTMLTextAreaElement).value)}377 />378 <input379 class="peach-restore-input"380 type="password"381 placeholder="PeachScript password"382 value={scriptPass()}383 onInput={(e) => setScriptPass((e.target as HTMLInputElement).value)}384 style="margin-top:8px"385 />386 <Show when={mode() === "onboarding"}>387 <div class="peach-auth-grid" style="margin-top:8px">388 <input389 class="peach-restore-input"390 type="password"391 placeholder="new master password"392 value={masterPassword()}393 onInput={(e) => setMasterPassword((e.target as HTMLInputElement).value)}394 />395 <input396 class="peach-restore-input"397 type="password"398 placeholder="confirm master password"399 value={masterConfirm()}400 onInput={(e) => setMasterConfirm((e.target as HTMLInputElement).value)}401 />402 </div>403 </Show>404 <div class="peach-settings-actions" style="margin-top:8px">405 <button406 class="peach-btn peach-btn--subtle peach-btn--sm"407 onClick={() => void handlePeachScriptImport()}408 disabled={409 scriptWorking() ||410 !scriptLooksValid() ||411 !scriptPass().trim() ||412 (mode() === "onboarding" && !masterPasswordReady())413 }414 >415 {scriptWorking() ? "decoding…" : "Restore from PeachScript"}416 </button>417 </div>418 <Show when={scriptError()}>419 <div class="peach-restore-error" style="margin-top:8px">{scriptError()}</div>420 </Show>421 <Show when={scriptResult()}>422 <div class="peach-export-success" style="margin-top:8px">423 <span class="peach-export-success-check">✓</span>424 <span>Restored {scriptResult()!.entryCount} entr{scriptResult()!.entryCount === 1 ? "y" : "ies"}.</span>425 </div>426 </Show>427 </div>428429 {}430 <div class="peach-settings-group peach-restore-group">431 <p class="peach-settings-label">import a file</p>432 <p class="peach-restore-copy">433 Bitwarden, 1Password, Proton Pass, Chrome / Edge / Safari CSV, FIDO434 credential-exchange passkeys, or a Peach JSON backup.435 </p>436437 <div class="peach-provider-chips">438 <span class="peach-provider-chip">Peach JSON</span>439 <span class="peach-provider-chip">FIDO passkeys</span>440 <span class="peach-provider-chip">Proton Pass CSV/JSON</span>441 <span class="peach-provider-chip">Bitwarden CSV/JSON</span>442 <span class="peach-provider-chip">1Password JSON</span>443 <span class="peach-provider-chip">Chrome / Edge / Safari CSV</span>444 </div>445446 <input447 class="peach-restore-file"448 type="file"449 accept=".csv,.json"450 onChange={(event) => void handleFileSelect(event)}451 style="margin-top:8px"452 />453 </div>454455 <Show when={needsBackupPassword()}>456 <div class="peach-settings-group">457 <p class="peach-settings-label">unlock backup</p>458 <p class="peach-restore-copy">459 Enter the password used when you created this encrypted Peach JSON460 backup.461 </p>462 <input463 class="peach-restore-input"464 type="password"465 placeholder="backup password"466 value={backupPassword()}467 onInput={(event) =>468 setBackupPassword((event.target as HTMLInputElement).value)469 }470 onBlur={() => { if (backupPassword().trim()) void handleUnlockBackup(); }}471 onKeyDown={(event) => {472 if (event.key === "Enter") {473 event.preventDefault();474 void handleUnlockBackup();475 }476 }}477 />478 <div class="peach-settings-actions">479 <button480 class="peach-btn peach-btn--subtle peach-btn--sm"481 onClick={() => void handleUnlockBackup()}482 disabled={!backupPassword().trim()}483 >484 Decrypt →485 </button>486 </div>487 </div>488 </Show>489490 <Show when={preview() && !needsBackupPassword() && preview()!.format !== "unknown"}>491 <div class="peach-settings-group">492 <p class="peach-settings-label">{preview()!.label}</p>493 <div class="peach-import-summary">494 Found <strong>{preview()!.entries.length}</strong>{" "}495 entr{preview()!.entries.length === 1 ? "y" : "ies"}496 {preview()!.skippedItems > 0497 ? `. ${preview()!.skippedItems} skipped because no password was present.`498 : "."}499 </div>500501 <div class="peach-import-preview">502 {preview()!.entries.slice(0, 8).map((entry) => (503 <div class="peach-import-preview-row">504 <span class="peach-import-preview-title">{entry.title}</span>505 <span class="peach-import-preview-meta">506 {entry.type === "passkey"507 ? `${entry.userName || "passkey"} • ${entry.rpId || "site"}`508 : entry.type === "note"509 ? "secure note"510 : entry.username || entry.url || "password item"}511 </span>512 </div>513 ))}514 <Show when={preview()!.entries.length > 8}>515 <div class="peach-import-preview-more">516 ...and {preview()!.entries.length - 8} more517 </div>518 </Show>519 </div>520 </div>521 </Show>522523 <Show when={mode() === "existing" && preview() && !needsBackupPassword() && preview()!.format !== "unknown" && preview()!.entries.length > 0}>524 <div class="peach-settings-group">525 <p class="peach-settings-label">merge or replace</p>526 <label class="peach-import-choice">527 <input528 type="radio"529 name="merge-mode"530 value="add"531 checked={mergeMode() === "add"}532 onChange={() => { setMergeMode("add"); setAwaitingReplaceConfirm(false); }}533 />534 <span class="peach-import-choice-main">535 <span class="peach-import-choice-label">Add to existing vault</span>536 <span class="peach-import-choice-sub">537 This will add {preview()!.entries.length} entries to your existing {existingCount()}.538 </span>539 </span>540 </label>541 <label class="peach-import-choice">542 <input543 type="radio"544 name="merge-mode"545 value="replace"546 checked={mergeMode() === "replace"}547 onChange={() => setMergeMode("replace")}548 />549 <span class="peach-import-choice-main">550 <span class="peach-import-choice-label">Replace vault</span>551 <span class="peach-import-choice-sub">552 This will replace your {existingCount()} entries with {preview()!.entries.length} imported ones.553 </span>554 </span>555 </label>556 <Show when={awaitingReplaceConfirm()}>557 <div class="peach-restore-warning" style="margin-top:6px">558 Replace forever? This cannot be undone.559 </div>560 </Show>561 </div>562 </Show>563564 <Show when={needsMasterPassword() && preview() && !needsBackupPassword() && preview()!.format !== "unknown"}>565 <div class="peach-settings-group">566 <p class="peach-settings-label">new master password</p>567 <p class="peach-restore-copy">568 This password unlocks the Peach vault created on this device after569 import.570 </p>571 <div class="peach-auth-grid">572 <input573 class="peach-restore-input"574 type="password"575 placeholder="new master password"576 value={masterPassword()}577 onInput={(event) =>578 setMasterPassword((event.target as HTMLInputElement).value)579 }580 />581 <input582 class="peach-restore-input"583 type="password"584 placeholder="confirm master password"585 value={masterConfirm()}586 onInput={(event) =>587 setMasterConfirm((event.target as HTMLInputElement).value)588 }589 />590 </div>591 </div>592 </Show>593594 <Show when={error()}>595 <div class="peach-settings-group">596 <div class="peach-restore-error">{error()}</div>597 </div>598 </Show>599600 <Show when={importing() && importProgress()}>601 <div class="peach-settings-group">602 <div class="peach-import-summary">603 Importing... [{importProgress()!.current} / {importProgress()!.total}]604 </div>605 </div>606 </Show>607608 <Show when={duplicateWarning() > 0 && !importing()}>609 <div class="peach-settings-group">610 <div class="peach-restore-copy">611 {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.612 </div>613 </div>614 </Show>615616 <Show when={preview() && !needsBackupPassword() && preview()!.format !== "unknown"}>617 <div class="peach-settings-group">618 <div class="peach-settings-actions">619 <button620 class="peach-btn peach-btn--subtle peach-btn--sm"621 onClick={() => void handleImport()}622 disabled={623 importing() ||624 preview()!.entries.length === 0 ||625 (needsMasterPassword() && !masterPasswordReady())626 }627 >628 {importing()629 ? "importing..."630 : mode() === "onboarding"631 ? `Create vault + import ${preview()!.entries.length}`632 : mergeMode() === "replace"633 ? awaitingReplaceConfirm()634 ? "Yes, replace forever"635 : `Replace vault with ${preview()!.entries.length}`636 : `Import ${preview()!.entries.length}`}637 </button>638 <button639 class="peach-btn peach-btn--ghost peach-btn--sm"640 onClick={props.onBack}641 disabled={importing()}642 >643 Cancel644 </button>645 </div>646 </div>647 </Show>648649 {}650 <Show when={mode() === "existing"}>651 <div class="peach-settings-group">652 <details>653 <summary class="peach-legacy-cjk-summary">654 Have an old CJK phrase backup?655 </summary>656 <p class="peach-legacy-cjk-note">657 CJK phrase backups from earlier versions of Peach are still658 supported. After restoring, we recommend creating a fresh659 PeachScript or QR backup.660 </p>661 <div class="peach-settings-actions" style="margin-top:6px">662 <button663 class="peach-btn peach-btn--ghost peach-btn--sm"664 onClick={props.onOpenCjk}665 >666 Restore from CJK phrase667 </button>668 </div>669 </details>670 </div>671 </Show>672 </div>673 </div>674 );675}676677function getImportErrorCopy(error: string): string {678 switch (error) {679 case "UNSUPPORTED_IMPORT_FORMAT":680 return "Unrecognised file format. Use CSV, Bitwarden JSON, Proton Pass JSON/CSV, or a Peach encrypted backup.";681 case "INVALID_EXPORT_PASSWORD":682 case "INVALID_PASSPHRASE":683 return "Wrong backup password. This is the password you chose when you created the backup.";684 case "INVALID_PEACH_EXPORT":685 return "This Peach backup file looks corrupted or incomplete.";686 case "INVALID_PEACHSCRIPT_CHARACTER":687 return "This doesn't look like PeachScript. Make sure you copied the entire backup string.";688 case "PAYLOAD_TOO_SHORT":689 return "PeachScript looks truncated. Copy the full backup string and try again.";690 case "BAD_MAGIC":691 case "UNSUPPORTED_VERSION":692 return "This PeachScript was created by a different version of Peach.";693 case "CHECKSUM_FAILED":694 return "PeachScript appears corrupted. Re-copy from the source device.";695 case "CREATE_VAULT_FAILED":696 return "Peach couldn't create a new local vault before importing.";697 case "CLEAR_VAULT_FAILED":698 return "Peach couldn't clear the existing vault before replacing it.";699 case "MASTER_PASSWORD_REQUIRED":700 return "Choose a master password for your new vault first.";701 case "RESTORE_FAILED":702 return "Restore failed. Your existing vault is unchanged.";703 case "NO_ENTRIES_IMPORTED":704 return "No importable password entries were found in that file.";705 default:706 return error;707 }708}