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";1819interface ImportProps {20 mode?: "existing" | "onboarding";21 replacesExistingVault?: boolean;22 onBack: () => void;23 onImport: () => void | Promise<void>;24}2526type ImportMergeMode = "add" | "replace";2728export function ImportView(props: ImportProps) {29 return (30 <MainImportView31 mode={props.mode}32 replacesExistingVault={props.replacesExistingVault}33 onBack={props.onBack}34 onImport={props.onImport}35 />36 );37}3839function MainImportView(props: ImportProps) {40 const mode = () => props.mode ?? "existing";4142 43 const [scriptInput, setScriptInput] = createSignal("");44 const [scriptPass, setScriptPass] = createSignal("");45 const [scriptWorking, setScriptWorking] = createSignal(false);46 const [scriptError, setScriptError] = createSignal<string | null>(null);47 const [scriptResult, setScriptResult] = createSignal<{ entryCount: number } | null>(null);4849 50 const [preview, setPreview] = createSignal<ImportParseResult | null>(null);51 const [rawImport, setRawImport] = createSignal<string | null>(null);52 const [backupPassword, setBackupPassword] = createSignal("");53 const [masterPassword, setMasterPassword] = createSignal("");54 const [masterConfirm, setMasterConfirm] = createSignal("");55 const [importing, setImporting] = createSignal(false);56 const [error, setError] = createSignal<string | null>(null);57 const [mergeMode, setMergeMode] = createSignal<ImportMergeMode>("add");58 const [awaitingReplaceConfirm, setAwaitingReplaceConfirm] = createSignal(false);59 const [importProgress, setImportProgress] = createSignal<{ current: number; total: number } | null>(null);60 const [duplicateWarning, setDuplicateWarning] = createSignal<number>(0);61 const [existingCount, setExistingCount] = createSignal<number>(0);6263 const needsBackupPassword = createMemo(() => preview()?.requiresPassword ?? false);64 const needsMasterPassword = createMemo(() => mode() === "onboarding");65 const masterPasswordReady = createMemo(66 () =>67 masterPassword().length >= 8 && masterPassword() === masterConfirm()68 );6970 const scriptLooksValid = createMemo(() => isPeachScript(scriptInput()));7172 async function refreshExistingCount() {73 if (mode() !== "existing") return;74 const res = await sendMessage({ type: "GET_ENTRIES" });75 const list = (res as { ok: boolean; data?: VaultEntry[] }).data ?? [];76 setExistingCount(list.length);77 }7879 void refreshExistingCount();8081 82 83 8485 async function handlePeachScriptImport() {86 setScriptError(null);87 setScriptResult(null);88 if (!scriptLooksValid()) {89 setScriptError("Paste your PeachScript first.");90 return;91 }92 if (!scriptPass().trim()) {93 setScriptError("Enter the backup password.");94 return;95 }96 setScriptWorking(true);97 try {98 const bytes = decodePeachScript(normalisePeachScript(scriptInput()));99 const payload = await parsePayload(bytes, scriptPass());100101 102 if (mode() === "onboarding") {103 if (!masterPasswordReady()) {104 throw new Error("MASTER_PASSWORD_REQUIRED");105 }106 const createRes = await sendMessage({107 type: "CREATE_VAULT",108 payload: { password: masterPassword() },109 });110 if (!(createRes as { ok: boolean }).ok) throw new Error("CREATE_VAULT_FAILED");111 }112113 const restoreRes = await sendMessage({114 type: "RESTORE_ENTRIES",115 payload: {116 entries: payload.entries,117 mode: "merge",118 settings: payload.settings,119 },120 });121 if (!(restoreRes as { ok: boolean }).ok) throw new Error("RESTORE_FAILED");122123 if (payload.license?.key) {124 await sendMessage({125 type: "ACTIVATE_LICENSE",126 payload: { key: payload.license.key },127 }).catch(() => {});128 }129130 setScriptResult({ entryCount: payload.entries.length });131 await props.onImport();132 } catch (err) {133 const code = err instanceof Error ? err.message : String(err);134 setScriptError(getImportErrorCopy(code));135 } finally {136 setScriptWorking(false);137 }138 }139140 141 142 143144 async function parseSource(text: string, password?: string) {145 try {146 const result = await parseImportSource(text, password);147 setPreview(result);148 setError(null);149 if (result.format === "unknown") {150 setError(151 "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."152 );153 }154 return result;155 } catch (err) {156 const message = err instanceof Error ? err.message : String(err);157 setPreview(null);158 setError(getImportErrorCopy(message));159 return null;160 }161 }162163 async function handleFileSelect(event: Event) {164 const file = (event.target as HTMLInputElement).files?.[0];165 if (!file) return;166167 setPreview(null);168 setRawImport(null);169 setBackupPassword("");170 setError(null);171 setAwaitingReplaceConfirm(false);172173 const text = await file.text();174 setRawImport(text);175 await parseSource(text);176 }177178 async function handleUnlockBackup() {179 const raw = rawImport();180 if (!raw || !backupPassword().trim()) return;181 await parseSource(raw, backupPassword());182 }183184 async function countDuplicates(incoming: ImportParseResult["entries"]): Promise<number> {185 if (incoming.length === 0) return 0;186 const res = await sendMessage({ type: "GET_ENTRIES" });187 const existing = ((res as { ok: boolean; data?: VaultEntry[] }).data ?? []).filter((e) => !e.deletedAt);188 const keyOf = (title?: string, url?: string, username?: string) =>189 `${(title ?? "").toLowerCase()}|${(url ?? "").toLowerCase()}|${(username ?? "").toLowerCase()}`;190 const existingKeys = new Set(existing.map((e) => keyOf(e.title, e.url, e.username)));191 let dupes = 0;192 for (const entry of incoming) {193 const u = "username" in entry ? entry.username : undefined;194 const url = "url" in entry ? entry.url : undefined;195 if (existingKeys.has(keyOf(entry.title, url, u))) dupes += 1;196 }197 return dupes;198 }199200 async function handleImport() {201 const currentPreview = preview();202 if (!currentPreview) return;203204 if (needsBackupPassword() && !backupPassword().trim()) {205 setError("Enter the backup password used for this export.");206 return;207 }208209 if (needsMasterPassword()) {210 if (masterPassword().length < 8) {211 setError("Master password must be at least 8 characters.");212 return;213 }214 if (!masterPasswordReady()) {215 setError("Master passwords do not match.");216 return;217 }218 }219220 if (mode() === "existing" && mergeMode() === "replace" && !awaitingReplaceConfirm()) {221 setAwaitingReplaceConfirm(true);222 return;223 }224225 setImporting(true);226 setError(null);227 setDuplicateWarning(0);228 setImportProgress(null);229230 try {231 if (currentPreview.entries.length === 0) {232 throw new Error("NO_ENTRIES_IMPORTED");233 }234235 if (needsMasterPassword()) {236 const createResponse = await sendMessage({237 type: "CREATE_VAULT",238 payload: { password: masterPassword() },239 });240 if (!(createResponse as { ok: boolean }).ok) {241 throw new Error("CREATE_VAULT_FAILED");242 }243 }244245 if (mode() === "existing" && mergeMode() === "replace") {246 const clearRes = await sendMessage({ type: "CLEAR_VAULT_ENTRIES" });247 if (!(clearRes as { ok: boolean }).ok) {248 throw new Error("CLEAR_VAULT_FAILED");249 }250 }251252 let dupes = 0;253 if (mode() === "existing" && mergeMode() === "add") {254 dupes = await countDuplicates(currentPreview.entries);255 }256257 let imported = 0;258 setImportProgress({ current: 0, total: currentPreview.entries.length });259260 const batchRes = await sendMessage({261 type: "BATCH_ADD_ENTRIES",262 payload: { entries: currentPreview.entries },263 });264265 if ((batchRes as { ok: boolean }).ok) {266 imported = ((batchRes as { ok: boolean; data?: VaultEntry[] }).data ?? []).length;267 setImportProgress({ current: imported, total: currentPreview.entries.length });268 } else {269 for (const entry of currentPreview.entries) {270 const response = await sendMessage({271 type: "ADD_ENTRY",272 payload: entry,273 });274 if ((response as { ok: boolean }).ok) imported += 1;275 setImportProgress({ current: imported, total: currentPreview.entries.length });276 }277 }278279 if (imported === 0) {280 throw new Error("NO_ENTRIES_IMPORTED");281 }282283 if (currentPreview.licenseKey) {284 await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key: currentPreview.licenseKey } }).catch(() => {});285 }286287 setDuplicateWarning(dupes);288 await props.onImport();289 } catch (err) {290 const message = err instanceof Error ? err.message : String(err);291 setError(getImportErrorCopy(message));292 setImporting(false);293 setImportProgress(null);294 return;295 }296297 setImporting(false);298 setImportProgress(null);299 }300301 return (302 <div class={mode() === "onboarding" ? "peach-lock-view" : "peach-overlay-view ov-enter-right"}>303 <Show304 when={mode() === "onboarding"}305 fallback={306 <div class="peach-overlay-header">307 <button308 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"309 onClick={props.onBack}310 aria-label="Back"311 >312 <svg313 width="7" height="12" viewBox="0 0 7 12"314 fill="none" stroke="currentColor"315 stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round"316 >317 <path d="M6 1L1 6L6 11" />318 </svg>319 </button>320 <span class="peach-overlay-title">import</span>321 <div class="peach-overlay-spacer"></div>322 </div>323 }324 >325 <div class="peach-lock-view-header">326 <PeachWordmark class="peach-wordmark-wrap" />327 <button328 class="peach-lock-restore-link"329 onClick={props.onBack}330 disabled={importing() || scriptWorking()}331 >332 ‹ back333 </button>334 </div>335 </Show>336337 <div class="peach-settings-body">338 <Show when={mode() === "onboarding" && props.replacesExistingVault}>339 <div class="peach-settings-group">340 <div class="peach-restore-warning">341 Restoring now replaces the locked local vault on this device with342 a fresh one from your backup or export file.343 </div>344 </div>345 </Show>346347 {}348 <div class="peach-settings-group peach-restore-group">349 <p class="peach-settings-label">paste peachscript</p>350 <p class="peach-restore-copy">351 {mode() === "onboarding"352 ? "Paste a PeachScript backup from another Peach device to restore your vault."353 : "Paste a PeachScript backup from another Peach device. We'll merge it into your vault."}354 </p>355 <textarea356 class="peach-script-input"357 placeholder="paste PeachScript here…"358 value={scriptInput()}359 onInput={(e) => setScriptInput((e.target as HTMLTextAreaElement).value)}360 />361 <input362 class="peach-restore-input"363 type="password"364 placeholder="PeachScript password"365 value={scriptPass()}366 onInput={(e) => setScriptPass((e.target as HTMLInputElement).value)}367 style="margin-top:8px"368 />369 <Show when={mode() === "onboarding"}>370 <div class="peach-auth-grid" style="margin-top:8px">371 <input372 class="peach-restore-input"373 type="password"374 placeholder="new master password"375 value={masterPassword()}376 onInput={(e) => setMasterPassword((e.target as HTMLInputElement).value)}377 />378 <input379 class="peach-restore-input"380 type="password"381 placeholder="confirm master password"382 value={masterConfirm()}383 onInput={(e) => setMasterConfirm((e.target as HTMLInputElement).value)}384 />385 </div>386 </Show>387 <div class="peach-settings-actions" style="margin-top:8px">388 <button389 class="peach-btn peach-btn--subtle peach-btn--sm"390 onClick={() => void handlePeachScriptImport()}391 disabled={392 scriptWorking() ||393 !scriptLooksValid() ||394 !scriptPass().trim() ||395 (mode() === "onboarding" && !masterPasswordReady())396 }397 >398 {scriptWorking() ? "decoding…" : "Restore from PeachScript"}399 </button>400 </div>401 <Show when={scriptError()}>402 <div class="peach-restore-error" style="margin-top:8px">{scriptError()}</div>403 </Show>404 <Show when={scriptResult()}>405 <div class="peach-export-success" style="margin-top:8px">406 <span class="peach-export-success-check">✓</span>407 <span>Restored {scriptResult()!.entryCount} entr{scriptResult()!.entryCount === 1 ? "y" : "ies"}.</span>408 </div>409 </Show>410 </div>411412 {}413 <div class="peach-settings-group peach-restore-group">414 <p class="peach-settings-label">import a file</p>415 <p class="peach-restore-copy">416 Bitwarden, 1Password, Proton Pass, Chrome / Edge / Safari CSV, FIDO417 credential-exchange passkeys, or a Peach JSON backup.418 </p>419420 <div class="peach-provider-chips">421 <span class="peach-provider-chip">Peach JSON</span>422 <span class="peach-provider-chip">FIDO passkeys</span>423 <span class="peach-provider-chip">Proton Pass CSV/JSON</span>424 <span class="peach-provider-chip">Bitwarden CSV/JSON</span>425 <span class="peach-provider-chip">1Password JSON</span>426 <span class="peach-provider-chip">Chrome / Edge / Safari CSV</span>427 </div>428429 <input430 class="peach-restore-file"431 type="file"432 accept=".csv,.json"433 onChange={(event) => void handleFileSelect(event)}434 style="margin-top:8px"435 />436 </div>437438 <Show when={needsBackupPassword()}>439 <div class="peach-settings-group">440 <p class="peach-settings-label">unlock backup</p>441 <p class="peach-restore-copy">442 Enter the password used when you created this encrypted Peach JSON443 backup.444 </p>445 <input446 class="peach-restore-input"447 type="password"448 placeholder="backup password"449 value={backupPassword()}450 onInput={(event) =>451 setBackupPassword((event.target as HTMLInputElement).value)452 }453 onBlur={() => { if (backupPassword().trim()) void handleUnlockBackup(); }}454 onKeyDown={(event) => {455 if (event.key === "Enter") {456 event.preventDefault();457 void handleUnlockBackup();458 }459 }}460 />461 <div class="peach-settings-actions">462 <button463 class="peach-btn peach-btn--subtle peach-btn--sm"464 onClick={() => void handleUnlockBackup()}465 disabled={!backupPassword().trim()}466 >467 Decrypt →468 </button>469 </div>470 </div>471 </Show>472473 <Show when={preview() && !needsBackupPassword() && preview()!.format !== "unknown"}>474 <div class="peach-settings-group">475 <p class="peach-settings-label">{preview()!.label}</p>476 <div class="peach-import-summary">477 Found <strong>{preview()!.entries.length}</strong>{" "}478 entr{preview()!.entries.length === 1 ? "y" : "ies"}479 {preview()!.skippedItems > 0480 ? `. ${preview()!.skippedItems} skipped because no password was present.`481 : "."}482 </div>483484 <div class="peach-import-preview">485 {preview()!.entries.slice(0, 8).map((entry) => (486 <div class="peach-import-preview-row">487 <span class="peach-import-preview-title">{entry.title}</span>488 <span class="peach-import-preview-meta">489 {entry.type === "passkey"490 ? `${entry.userName || "passkey"} • ${entry.rpId || "site"}`491 : entry.type === "note"492 ? "secure note"493 : entry.username || entry.url || "password item"}494 </span>495 </div>496 ))}497 <Show when={preview()!.entries.length > 8}>498 <div class="peach-import-preview-more">499 ...and {preview()!.entries.length - 8} more500 </div>501 </Show>502 </div>503 </div>504 </Show>505506 <Show when={mode() === "existing" && preview() && !needsBackupPassword() && preview()!.format !== "unknown" && preview()!.entries.length > 0}>507 <div class="peach-settings-group">508 <p class="peach-settings-label">merge or replace</p>509 <label class="peach-import-choice">510 <input511 type="radio"512 name="merge-mode"513 value="add"514 checked={mergeMode() === "add"}515 onChange={() => { setMergeMode("add"); setAwaitingReplaceConfirm(false); }}516 />517 <span class="peach-import-choice-main">518 <span class="peach-import-choice-label">Add to existing vault</span>519 <span class="peach-import-choice-sub">520 This will add {preview()!.entries.length} entries to your existing {existingCount()}.521 </span>522 </span>523 </label>524 <label class="peach-import-choice">525 <input526 type="radio"527 name="merge-mode"528 value="replace"529 checked={mergeMode() === "replace"}530 onChange={() => setMergeMode("replace")}531 />532 <span class="peach-import-choice-main">533 <span class="peach-import-choice-label">Replace vault</span>534 <span class="peach-import-choice-sub">535 This will replace your {existingCount()} entries with {preview()!.entries.length} imported ones.536 </span>537 </span>538 </label>539 <Show when={awaitingReplaceConfirm()}>540 <div class="peach-restore-warning" style="margin-top:6px">541 Replace forever? This cannot be undone.542 </div>543 </Show>544 </div>545 </Show>546547 <Show when={needsMasterPassword() && preview() && !needsBackupPassword() && preview()!.format !== "unknown"}>548 <div class="peach-settings-group">549 <p class="peach-settings-label">new master password</p>550 <p class="peach-restore-copy">551 This password unlocks the Peach vault created on this device after552 import.553 </p>554 <div class="peach-auth-grid">555 <input556 class="peach-restore-input"557 type="password"558 placeholder="new master password"559 value={masterPassword()}560 onInput={(event) =>561 setMasterPassword((event.target as HTMLInputElement).value)562 }563 />564 <input565 class="peach-restore-input"566 type="password"567 placeholder="confirm master password"568 value={masterConfirm()}569 onInput={(event) =>570 setMasterConfirm((event.target as HTMLInputElement).value)571 }572 />573 </div>574 </div>575 </Show>576577 <Show when={error()}>578 <div class="peach-settings-group">579 <div class="peach-restore-error">{error()}</div>580 </div>581 </Show>582583 <Show when={importing() && importProgress()}>584 <div class="peach-settings-group">585 <div class="peach-import-summary">586 Importing... [{importProgress()!.current} / {importProgress()!.total}]587 </div>588 </div>589 </Show>590591 <Show when={duplicateWarning() > 0 && !importing()}>592 <div class="peach-settings-group">593 <div class="peach-restore-copy">594 {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.595 </div>596 </div>597 </Show>598599 <Show when={preview() && !needsBackupPassword() && preview()!.format !== "unknown"}>600 <div class="peach-settings-group">601 <div class="peach-settings-actions">602 <button603 class="peach-btn peach-btn--subtle peach-btn--sm"604 onClick={() => void handleImport()}605 disabled={606 importing() ||607 preview()!.entries.length === 0 ||608 (needsMasterPassword() && !masterPasswordReady())609 }610 >611 {importing()612 ? "importing..."613 : mode() === "onboarding"614 ? `Create vault + import ${preview()!.entries.length}`615 : mergeMode() === "replace"616 ? awaitingReplaceConfirm()617 ? "Yes, replace forever"618 : `Replace vault with ${preview()!.entries.length}`619 : `Import ${preview()!.entries.length}`}620 </button>621 <button622 class="peach-btn peach-btn--ghost peach-btn--sm"623 onClick={props.onBack}624 disabled={importing()}625 >626 Cancel627 </button>628 </div>629 </div>630 </Show>631632 </div>633 </div>634 );635}636637function getImportErrorCopy(error: string): string {638 switch (error) {639 case "UNSUPPORTED_IMPORT_FORMAT":640 return "Unrecognised file format. Use CSV, Bitwarden JSON, Proton Pass JSON/CSV, or a Peach encrypted backup.";641 case "INVALID_EXPORT_PASSWORD":642 case "INVALID_PASSPHRASE":643 return "Wrong backup password. This is the password you chose when you created the backup.";644 case "INVALID_PEACH_EXPORT":645 return "This Peach backup file looks corrupted or incomplete.";646 case "INVALID_PEACHSCRIPT_CHARACTER":647 return "This doesn't look like PeachScript. Make sure you copied the entire backup string.";648 case "PAYLOAD_TOO_SHORT":649 return "PeachScript looks truncated. Copy the full backup string and try again.";650 case "BAD_MAGIC":651 case "UNSUPPORTED_VERSION":652 return "This PeachScript was created by a different version of Peach.";653 case "CHECKSUM_FAILED":654 return "PeachScript appears corrupted. Re-copy from the source device.";655 case "CREATE_VAULT_FAILED":656 return "Peach couldn't create a new local vault before importing.";657 case "CLEAR_VAULT_FAILED":658 return "Peach couldn't clear the existing vault before replacing it.";659 case "MASTER_PASSWORD_REQUIRED":660 return "Choose a master password for your new vault first.";661 case "RESTORE_FAILED":662 return "Restore failed. Your existing vault is unchanged.";663 case "NO_ENTRIES_IMPORTED":664 return "No importable password entries were found in that file.";665 default:666 return error;667 }668}