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";1819type ImportSubview = "menu" | "peachscript" | "file";20type ImportMergeMode = "add" | "replace";2122interface ImportProps {23 mode?: "existing" | "onboarding";24 replacesExistingVault?: boolean;25 26 initialView?: ImportSubview;27 onBack: () => void;28 onImport: () => void | Promise<void>;29}3031function OnboardingMasterPasswordFields(props: {32 idPrefix: string;33 copy: string;34 masterPlaceholder: string;35 password: string;36 confirm: string;37 onPasswordInput: (value: string) => void;38 onConfirmInput: (value: string) => void;39}) {40 const [showPassword, setShowPassword] = createSignal(false);4142 return (43 <>44 <div class="peach-newvault-label-row">45 <label class="peach-label" for={`${props.idPrefix}-master-password`}>46 new master password47 </label>48 <button49 class="peach-lock-reveal-btn"50 type="button"51 onClick={() => setShowPassword((value) => !value)}52 aria-label={showPassword() ? "Hide passwords" : "Show passwords"}53 >54 <Show55 when={showPassword()}56 fallback={57 <svg width="15" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">58 <path d="M1 5C1 5 2.8 1 7 1C11.2 1 13 5 13 5C13 5 11.2 9 7 9C2.8 9 1 5 1 5Z" />59 <circle cx="7" cy="5" r="1.75" />60 </svg>61 }62 >63 <svg width="15" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">64 <path d="M1 2C3 4.5 5 6 7 6C9 6 11 4.5 13 2" />65 <path d="M3.5 5.5L2.5 7.5" />66 <path d="M7 6.5V8.5" />67 <path d="M10.5 5.5L11.5 7.5" />68 </svg>69 </Show>70 {showPassword() ? "hide" : "show"}71 </button>72 </div>73 <p class="peach-export-card-copy">{props.copy}</p>74 <div class="peach-settings-stack peach-export-password-stack">75 <input76 id={`${props.idPrefix}-master-password`}77 class="peach-settings-input"78 type={showPassword() ? "text" : "password"}79 placeholder={props.masterPlaceholder}80 value={props.password}81 onInput={(event) => props.onPasswordInput((event.target as HTMLInputElement).value)}82 />83 <input84 id={`${props.idPrefix}-confirm-password`}85 class="peach-settings-input"86 type={showPassword() ? "text" : "password"}87 placeholder="confirm master password"88 value={props.confirm}89 onInput={(event) => props.onConfirmInput((event.target as HTMLInputElement).value)}90 />91 <Show when={props.confirm.length > 0 && props.password !== props.confirm}>92 <p class="peach-export-note" style="color:var(--peach-t2)">93 Passwords don't match94 </p>95 </Show>96 </div>97 </>98 );99}100101export function ImportView(props: ImportProps) {102 const [view, setView] = createSignal<ImportSubview>(props.initialView ?? "menu");103 104 105 const [peachScriptPrefill, setPeachScriptPrefill] = createSignal<string>("");106 const mode = () => props.mode ?? "existing";107 const isOnboarding = () => mode() === "onboarding";108 109 110 const subviewBack = () => (props.initialView ? props.onBack() : setView("menu"));111112 return (113 <div class={isOnboarding() ? "peach-lock-view" : "peach-overlay-view ov-enter-right"} style="display:flex">114 <Show when={view() === "menu"}>115 <ImportMenu116 mode={mode()}117 replacesExistingVault={props.replacesExistingVault}118 onBack={props.onBack}119 onPick={(v) => setView(v)}120 />121 </Show>122 <Show when={view() === "peachscript"}>123 <PeachScriptImport124 mode={mode()}125 replacesExistingVault={props.replacesExistingVault}126 initialScript={peachScriptPrefill()}127 onBack={() => {128 setPeachScriptPrefill("");129 subviewBack();130 }}131 onImport={props.onImport}132 />133 </Show>134 <Show when={view() === "file"}>135 <FileImport136 mode={mode()}137 replacesExistingVault={props.replacesExistingVault}138 onBack={subviewBack}139 onImport={props.onImport}140 onLoadPeachFile={(content) => {141 setPeachScriptPrefill(content);142 setView("peachscript");143 }}144 />145 </Show>146 </div>147 );148}149150151152153154function ImportMenu(props: {155 mode: "existing" | "onboarding";156 replacesExistingVault?: boolean;157 onBack: () => void;158 onPick: (view: ImportSubview) => void;159}) {160 const isOnboarding = props.mode === "onboarding";161 return (162 <>163 <Show164 when={isOnboarding}165 fallback={<OverlayHeader title="import" onBack={props.onBack} />}166 >167 <div class="peach-lock-view-header">168 <PeachWordmark class="peach-wordmark-wrap" />169 <button class="peach-lock-restore-link" onClick={props.onBack}>170 ‹ back171 </button>172 </div>173 </Show>174 <div class="peach-settings-body peach-export-menu">175 <Show when={isOnboarding && props.replacesExistingVault}>176 <div class="peach-settings-group">177 <div class="peach-restore-warning">178 Restoring now replaces the locked local vault on this device with179 a fresh one from your backup or export file.180 </div>181 </div>182 </Show>183184 <div class="peach-settings-group peach-export-card peach-export-card--primary">185 <p class="peach-settings-label">peachscript</p>186 <p class="peach-export-card-copy">187 Paste a PeachScript backup from another Peach device.188 {props.mode === "existing" ? " We'll merge it into your vault." : ""}189 </p>190 <button191 class="peach-btn peach-btn--subtle peach-btn--sm"192 onClick={() => props.onPick("peachscript")}193 >194 Paste PeachScript →195 </button>196 </div>197198 <div class="peach-settings-group peach-export-card">199 <p class="peach-settings-label">backup file</p>200 <p class="peach-export-card-copy">201 A Peach JSON export, or a file from Bitwarden, Proton Pass,202 1Password, or a browser CSV (Chrome, Safari, Edge).203 </p>204 <button205 class="peach-btn peach-btn--subtle peach-btn--sm"206 onClick={() => props.onPick("file")}207 >208 Choose file →209 </button>210 </div>211 </div>212 </>213 );214}215216217218219220function PeachScriptImport(props: {221 mode: "existing" | "onboarding";222 replacesExistingVault?: boolean;223 224225 initialScript?: string;226 onBack: () => void;227 onImport: () => void | Promise<void>;228}) {229 const [scriptInput, setScriptInput] = createSignal(props.initialScript ?? "");230 const [scriptPass, setScriptPass] = createSignal("");231 const [masterPassword, setMasterPassword] = createSignal("");232 const [masterConfirm, setMasterConfirm] = createSignal("");233 const [working, setWorking] = createSignal(false);234 const [error, setError] = createSignal<string | null>(null);235 const [result, setResult] = createSignal<{ entryCount: number } | null>(null);236237 const scriptLooksValid = createMemo(() => isPeachScript(scriptInput()));238 const isOnboarding = props.mode === "onboarding";239 const masterPasswordReady = createMemo(240 () => masterPassword().length >= 8 && masterPassword() === masterConfirm()241 );242243 async function handleImport() {244 setError(null);245 setResult(null);246 if (!scriptLooksValid()) {247 setError("Paste your PeachScript first.");248 return;249 }250 if (!scriptPass().trim()) {251 setError("Enter the backup password.");252 return;253 }254 setWorking(true);255 try {256 const bytes = decodePeachScript(normalisePeachScript(scriptInput()));257 const payload = await parsePayload(bytes, scriptPass());258259 if (isOnboarding) {260 if (!masterPasswordReady()) {261 throw new Error("MASTER_PASSWORD_REQUIRED");262 }263 const createRes = await sendMessage({264 type: "CREATE_VAULT",265 payload: { password: masterPassword() },266 });267 if (!(createRes as { ok: boolean }).ok) throw new Error("CREATE_VAULT_FAILED");268 }269270 const restoreRes = await sendMessage({271 type: "RESTORE_ENTRIES",272 payload: {273 entries: payload.entries,274 mode: "merge",275 settings: payload.settings,276 },277 });278 const restoreEnvelope = restoreRes as { ok: boolean; error?: string };279 if (!restoreEnvelope.ok) {280 throw new Error(restoreEnvelope.error ?? "RESTORE_FAILED");281 }282283 if (payload.license?.key) {284 await sendMessage({285 type: "ACTIVATE_LICENSE",286 payload: { key: payload.license.key },287 }).catch(() => {});288 }289290 setResult({ entryCount: payload.entries.length });291 await props.onImport();292 } catch (err) {293 const code = err instanceof Error ? err.message : String(err);294 setError(getImportErrorCopy(code));295 } finally {296 setWorking(false);297 }298 }299300 return (301 <>302 <SubviewHeader title="peachscript" onBack={props.onBack} mode={props.mode} />303 <div class="peach-settings-body">304 <Show when={isOnboarding && props.replacesExistingVault}>305 <div class="peach-settings-group">306 <div class="peach-restore-warning">307 Restoring now replaces the locked local vault on this device with308 a fresh one from your backup.309 </div>310 </div>311 </Show>312 <div class="peach-settings-group peach-export-card peach-export-card--primary peach-export-pass-group">313 <p class="peach-settings-label">paste peachscript</p>314 <p class="peach-export-card-copy">315 {isOnboarding316 ? "Paste a PeachScript backup from another Peach device to restore your vault — or load a .peach file from disk."317 : "We'll merge the entries into your existing vault. You can paste PeachScript or load a .peach file."}318 </p>319 <textarea320 class="peach-script-input"321 placeholder="paste peachscript…"322 value={scriptInput()}323 onInput={(e) => setScriptInput((e.target as HTMLTextAreaElement).value)}324 />325 <label326 class="peach-btn peach-btn--ghost peach-btn--sm"327 style="display:inline-flex;align-items:center;justify-content:center;cursor:pointer;"328 >329 Load .peach file330 <input331 type="file"332 accept=".peach,application/x-peach,text/plain"333 style="display:none;"334 onChange={async (event) => {335 const input = event.currentTarget;336 const file = input.files?.[0];337 if (!file) return;338 try {339 const text = await file.text();340 setScriptInput(text);341 } catch (err) {342 setError(err instanceof Error ? err.message : "Couldn't read file.");343 } finally {344 input.value = "";345 }346 }}347 />348 </label>349 <div class="peach-settings-stack peach-export-password-stack">350 <input351 class="peach-settings-input"352 type="password"353 placeholder="PeachScript password"354 value={scriptPass()}355 onInput={(e) => setScriptPass((e.target as HTMLInputElement).value)}356 />357 </div>358 <Show when={isOnboarding}>359 <OnboardingMasterPasswordFields360 idPrefix="peachscript"361 copy="Use at least 8 characters. This password unlocks Peach on this device after the restore, and both fields must match before Peach can create it."362 masterPlaceholder="new master password"363 password={masterPassword()}364 confirm={masterConfirm()}365 onPasswordInput={setMasterPassword}366 onConfirmInput={setMasterConfirm}367 />368 </Show>369 <button370 class="peach-btn peach-btn--subtle peach-btn--sm"371 onClick={() => void handleImport()}372 disabled={373 working() ||374 !scriptLooksValid() ||375 !scriptPass().trim() ||376 (isOnboarding && !masterPasswordReady())377 }378 >379 {working() ? "decoding…" : "Restore from PeachScript"}380 </button>381 <Show when={error()}>382 <div class="peach-restore-error">{error()}</div>383 </Show>384 <Show when={result()}>385 <div class="peach-export-success">386 <span class="peach-export-success-check">✓</span>387 <span>388 Restored {result()!.entryCount} entr389 {result()!.entryCount === 1 ? "y" : "ies"}.390 </span>391 </div>392 </Show>393 </div>394 </div>395 </>396 );397}398399400401402403function FileImport(props: {404 mode: "existing" | "onboarding";405 replacesExistingVault?: boolean;406 onBack: () => void;407 onImport: () => void | Promise<void>;408 409410 onLoadPeachFile?: (content: string) => void;411}) {412 const isOnboarding = props.mode === "onboarding";413 const needsMasterPassword = () => isOnboarding;414415 const [preview, setPreview] = createSignal<ImportParseResult | null>(null);416 const [rawImport, setRawImport] = createSignal<string | null>(null);417 const [backupPassword, setBackupPassword] = createSignal("");418 const [masterPassword, setMasterPassword] = createSignal("");419 const [masterConfirm, setMasterConfirm] = createSignal("");420 const [importing, setImporting] = createSignal(false);421 const [error, setError] = createSignal<string | null>(null);422 const [mergeMode, setMergeMode] = createSignal<ImportMergeMode>("add");423 const [awaitingReplaceConfirm, setAwaitingReplaceConfirm] = createSignal(false);424 const [importProgress, setImportProgress] = createSignal<{ current: number; total: number } | null>(null);425 const [duplicateWarning, setDuplicateWarning] = createSignal<number>(0);426 const [legacyKdfNotice, setLegacyKdfNotice] = createSignal(false);427 const [existingCount, setExistingCount] = createSignal<number>(0);428429 const needsBackupPassword = createMemo(() => preview()?.requiresPassword ?? false);430 const masterPasswordReady = createMemo(431 () => masterPassword().length >= 8 && masterPassword() === masterConfirm()432 );433434 async function refreshExistingCount() {435 if (isOnboarding) return;436 const res = await sendMessage({ type: "GET_ENTRIES" });437 const list = (res as { ok: boolean; data?: VaultEntry[] }).data ?? [];438 setExistingCount(list.length);439 }440 void refreshExistingCount();441442 async function parseSource(text: string, password?: string) {443 try {444 const result = await parseImportSource(text, password);445 setPreview(result);446 setError(null);447 if (result.format === "unknown") {448 setError(449 "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."450 );451 }452 return result;453 } catch (err) {454 const message = err instanceof Error ? err.message : String(err);455 setPreview(null);456 setError(getImportErrorCopy(message));457 return null;458 }459 }460461 async function handleFileSelect(event: Event) {462 const input = event.target as HTMLInputElement;463 const file = input.files?.[0];464 if (!file) return;465466 setPreview(null);467 setRawImport(null);468 setBackupPassword("");469 setError(null);470 setAwaitingReplaceConfirm(false);471 setLegacyKdfNotice(false);472473 const text = await file.text();474475 476 477 478 479 480 const looksLikePeach =481 file.name.toLowerCase().endsWith(".peach") || isPeachScript(text);482 if (looksLikePeach) {483 if (props.onLoadPeachFile) {484 props.onLoadPeachFile(text);485 input.value = "";486 return;487 }488 setError("This is a .peach file. Open the PeachScript card to import it.");489 return;490 }491492 setRawImport(text);493 await parseSource(text);494 }495496 async function handleUnlockBackup() {497 const raw = rawImport();498 if (!raw || !backupPassword().trim()) return;499 await parseSource(raw, backupPassword());500 }501502 async function countDuplicates(incoming: ImportParseResult["entries"]): Promise<number> {503 if (incoming.length === 0) return 0;504 const res = await sendMessage({ type: "GET_ENTRIES" });505 const existing = ((res as { ok: boolean; data?: VaultEntry[] }).data ?? []).filter((e) => !e.deletedAt);506 const keyOf = (title?: string, url?: string, username?: string) =>507 `${(title ?? "").toLowerCase()}|${(url ?? "").toLowerCase()}|${(username ?? "").toLowerCase()}`;508 const existingKeys = new Set(existing.map((e) => keyOf(e.title, e.url, e.username)));509 let dupes = 0;510 for (const entry of incoming) {511 const u = "username" in entry ? entry.username : undefined;512 const url = "url" in entry ? entry.url : undefined;513 if (existingKeys.has(keyOf(entry.title, url, u))) dupes += 1;514 }515 return dupes;516 }517518 async function handleImport() {519 const currentPreview = preview();520 if (!currentPreview) return;521522 if (needsBackupPassword() && !backupPassword().trim()) {523 setError("Enter the backup password used for this export.");524 return;525 }526527 if (needsMasterPassword()) {528 if (masterPassword().length < 8) {529 setError("Master password must be at least 8 characters.");530 return;531 }532 if (!masterPasswordReady()) {533 setError("Master passwords do not match.");534 return;535 }536 }537538 if (!isOnboarding && mergeMode() === "replace" && !awaitingReplaceConfirm()) {539 setAwaitingReplaceConfirm(true);540 return;541 }542543 setImporting(true);544 setError(null);545 setDuplicateWarning(0);546 setLegacyKdfNotice(false);547 setImportProgress(null);548549 try {550 if (currentPreview.entries.length === 0) {551 throw new Error("NO_ENTRIES_IMPORTED");552 }553554 if (needsMasterPassword()) {555 const createResponse = await sendMessage({556 type: "CREATE_VAULT",557 payload: { password: masterPassword() },558 });559 if (!(createResponse as { ok: boolean }).ok) {560 throw new Error("CREATE_VAULT_FAILED");561 }562 }563564 if (!isOnboarding && mergeMode() === "replace") {565 const clearRes = await sendMessage({ type: "CLEAR_VAULT_ENTRIES" });566 if (!(clearRes as { ok: boolean }).ok) {567 throw new Error("CLEAR_VAULT_FAILED");568 }569 }570571 let dupes = 0;572 if (!isOnboarding && mergeMode() === "add") {573 dupes = await countDuplicates(currentPreview.entries);574 }575576 let imported = 0;577 setImportProgress({ current: 0, total: currentPreview.entries.length });578579 const batchRes = await sendMessage({580 type: "BATCH_ADD_ENTRIES",581 payload: { entries: currentPreview.entries },582 });583584 if ((batchRes as { ok: boolean }).ok) {585 imported = ((batchRes as { ok: boolean; data?: VaultEntry[] }).data ?? []).length;586 setImportProgress({ current: imported, total: currentPreview.entries.length });587 } else {588 for (const entry of currentPreview.entries) {589 const response = await sendMessage({590 type: "ADD_ENTRY",591 payload: entry,592 });593 if ((response as { ok: boolean }).ok) imported += 1;594 setImportProgress({ current: imported, total: currentPreview.entries.length });595 }596 }597598 if (imported === 0) {599 throw new Error("NO_ENTRIES_IMPORTED");600 }601602 if (currentPreview.licenseKey) {603 await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key: currentPreview.licenseKey } }).catch(() => {});604 }605606 setDuplicateWarning(dupes);607 setLegacyKdfNotice(currentPreview.format === "peach-json");608 await props.onImport();609 } catch (err) {610 const message = err instanceof Error ? err.message : String(err);611 setError(getImportErrorCopy(message));612 setImporting(false);613 setImportProgress(null);614 return;615 }616617 setImporting(false);618 setImportProgress(null);619 }620621 return (622 <>623 <SubviewHeader title="backup file" onBack={props.onBack} mode={props.mode} />624 <div class="peach-settings-body">625 <Show when={isOnboarding && props.replacesExistingVault}>626 <div class="peach-settings-group">627 <div class="peach-restore-warning">628 Restoring now replaces the locked local vault on this device with629 a fresh one from your backup.630 </div>631 </div>632 </Show>633 <div class="peach-settings-group peach-export-card peach-export-card--primary">634 <p class="peach-settings-label">choose a file</p>635 <p class="peach-export-card-copy">636 Bitwarden, 1Password, Proton Pass, Chrome / Edge / Safari CSV, FIDO637 credential-exchange passkeys, or a Peach JSON backup.638 </p>639640 <div class="peach-provider-chips">641 <span class="peach-provider-chip">Peach JSON</span>642 <span class="peach-provider-chip">FIDO passkeys</span>643 <span class="peach-provider-chip">Proton Pass</span>644 <span class="peach-provider-chip">Bitwarden</span>645 <span class="peach-provider-chip">1Password</span>646 <span class="peach-provider-chip">Browser CSV</span>647 </div>648649 <input650 class="peach-restore-file"651 type="file"652 accept=".csv,.json,.peach,application/x-peach"653 onChange={(event) => void handleFileSelect(event)}654 />655 </div>656657 <Show when={needsBackupPassword()}>658 <div class="peach-settings-group">659 <p class="peach-settings-label">unlock backup</p>660 <p class="peach-export-card-copy">661 Enter the password used when you created this encrypted Peach JSON662 backup.663 </p>664 <input665 class="peach-restore-input"666 type="password"667 placeholder="backup password"668 value={backupPassword()}669 onInput={(event) =>670 setBackupPassword((event.target as HTMLInputElement).value)671 }672 onBlur={() => { if (backupPassword().trim()) void handleUnlockBackup(); }}673 onKeyDown={(event) => {674 if (event.key === "Enter") {675 event.preventDefault();676 void handleUnlockBackup();677 }678 }}679 />680 <div class="peach-settings-actions">681 <button682 class="peach-btn peach-btn--subtle peach-btn--sm"683 onClick={() => void handleUnlockBackup()}684 disabled={!backupPassword().trim()}685 >686 Decrypt →687 </button>688 </div>689 </div>690 </Show>691692 <Show when={preview() && !needsBackupPassword() && preview()!.format !== "unknown"}>693 <div class="peach-settings-group">694 <p class="peach-settings-label">{preview()!.label}</p>695 <div class="peach-import-summary">696 Found <strong>{preview()!.entries.length}</strong>{" "}697 entr{preview()!.entries.length === 1 ? "y" : "ies"}698 {preview()!.skippedItems > 0699 ? `. ${preview()!.skippedItems} skipped because no password was present.`700 : "."}701 </div>702703 <div class="peach-import-preview">704 {preview()!.entries.slice(0, 8).map((entry) => (705 <div class="peach-import-preview-row">706 <span class="peach-import-preview-title">{entry.title}</span>707 <span class="peach-import-preview-meta">708 {entry.passkey709 ? `${entry.passkey.userName} • ${entry.passkey.rpId} (passkey)`710 : entry.type === "note"711 ? "secure note"712 : entry.username || entry.url || "password item"}713 </span>714 </div>715 ))}716 <Show when={preview()!.entries.length > 8}>717 <div class="peach-import-preview-more">718 ...and {preview()!.entries.length - 8} more719 </div>720 </Show>721 </div>722 </div>723 </Show>724725 <Show when={!isOnboarding && preview() && !needsBackupPassword() && preview()!.format !== "unknown" && preview()!.entries.length > 0}>726 <div class="peach-settings-group">727 <p class="peach-settings-label">merge or replace</p>728 <label class="peach-import-choice">729 <input730 type="radio"731 name="merge-mode"732 value="add"733 checked={mergeMode() === "add"}734 onChange={() => { setMergeMode("add"); setAwaitingReplaceConfirm(false); }}735 />736 <span class="peach-import-choice-main">737 <span class="peach-import-choice-label">Add to existing vault</span>738 <span class="peach-import-choice-sub">739 This will add {preview()!.entries.length} entries to your existing {existingCount()}.740 </span>741 </span>742 </label>743 <label class="peach-import-choice">744 <input745 type="radio"746 name="merge-mode"747 value="replace"748 checked={mergeMode() === "replace"}749 onChange={() => setMergeMode("replace")}750 />751 <span class="peach-import-choice-main">752 <span class="peach-import-choice-label">Replace vault</span>753 <span class="peach-import-choice-sub">754 This will replace your {existingCount()} entries with {preview()!.entries.length} imported ones.755 </span>756 </span>757 </label>758 <Show when={awaitingReplaceConfirm()}>759 <div class="peach-restore-warning" style="margin-top:6px">760 Replace forever? This cannot be undone.761 </div>762 </Show>763 </div>764 </Show>765766 <Show when={needsMasterPassword() && preview() && !needsBackupPassword() && preview()!.format !== "unknown"}>767 <div class="peach-settings-group">768 <OnboardingMasterPasswordFields769 idPrefix="backup"770 copy="Use at least 8 characters. This password unlocks the Peach vault created on this device after import, and both fields must match before Peach can create it."771 masterPlaceholder="new master password"772 password={masterPassword()}773 confirm={masterConfirm()}774 onPasswordInput={setMasterPassword}775 onConfirmInput={setMasterConfirm}776 />777 </div>778 </Show>779780 <Show when={error()}>781 <div class="peach-settings-group">782 <div class="peach-restore-error">{error()}</div>783 </div>784 </Show>785786 <Show when={importing() && importProgress()}>787 <div class="peach-settings-group">788 <div class="peach-import-summary">789 Importing... [{importProgress()!.current} / {importProgress()!.total}]790 </div>791 </div>792 </Show>793794 <Show when={duplicateWarning() > 0 && !importing()}>795 <div class="peach-settings-group">796 <div class="peach-export-card-copy">797 {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.798 </div>799 </div>800 </Show>801802 <Show when={legacyKdfNotice() && !importing()}>803 <div class="peach-settings-group">804 <div class="peach-restore-warning">805 This backup was created with Peach's older encryption format (PBKDF2). It imported fine — but we recommend exporting a fresh backup now so your paper/file backups use the current, stronger format.806 </div>807 </div>808 </Show>809810 <Show when={preview() && !needsBackupPassword() && preview()!.format !== "unknown"}>811 <div class="peach-settings-group">812 <div class="peach-settings-actions">813 <button814 class="peach-btn peach-btn--subtle peach-btn--sm"815 onClick={() => void handleImport()}816 disabled={817 importing() ||818 preview()!.entries.length === 0 ||819 (needsMasterPassword() && !masterPasswordReady())820 }821 >822 {importing()823 ? "importing..."824 : isOnboarding825 ? `Create vault + import ${preview()!.entries.length}`826 : mergeMode() === "replace"827 ? awaitingReplaceConfirm()828 ? "Yes, replace forever"829 : `Replace vault with ${preview()!.entries.length}`830 : `Import ${preview()!.entries.length}`}831 </button>832 </div>833 </div>834 </Show>835 </div>836 </>837 );838}839840841842843844function OverlayHeader(props: { title: string; onBack: () => void }) {845 return (846 <div class="peach-overlay-header">847 <button848 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"849 onClick={props.onBack}850 aria-label="Back"851 >852 <svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">853 <path d="M6 1L1 6L6 11" />854 </svg>855 </button>856 <span class="peach-overlay-title">{props.title}</span>857 <div class="peach-overlay-spacer"></div>858 </div>859 );860}861862function SubviewHeader(props: {863 title: string;864 onBack: () => void;865 mode: "existing" | "onboarding";866}) {867 868 869 return (870 <Show871 when={props.mode === "onboarding"}872 fallback={<OverlayHeader title={props.title} onBack={props.onBack} />}873 >874 <div class="peach-lock-view-header">875 <PeachWordmark class="peach-wordmark-wrap" />876 <button class="peach-lock-restore-link" onClick={props.onBack}>877 ‹ back878 </button>879 </div>880 </Show>881 );882}883884function getImportErrorCopy(error: string): string {885 886 887 if (error.startsWith("IMPORT_TOO_LARGE")) {888 return "This file is too large to import (limit: 10 MiB for backups, 25 MiB for Codex PDFs). If this is a real Peach backup, please report it — backups never approach this size.";889 }890 switch (error) {891 case "UNSUPPORTED_IMPORT_FORMAT":892 return "Unrecognised file format. Use CSV, Bitwarden JSON, Proton Pass JSON/CSV, or a Peach encrypted backup.";893 case "INVALID_EXPORT_PASSWORD":894 case "INVALID_PASSPHRASE":895 return "Wrong backup password. This is the password you chose when you created the backup.";896 case "INVALID_PEACH_EXPORT":897 return "This Peach backup file looks corrupted or incomplete.";898 case "INVALID_PEACHSCRIPT_CHARACTER":899 return "This doesn't look like PeachScript. Make sure you copied the entire backup string.";900 case "PAYLOAD_TOO_SHORT":901 return "PeachScript looks truncated. Copy the full backup string and try again.";902 case "BAD_MAGIC":903 case "UNSUPPORTED_VERSION":904 return "This PeachScript was created by a different version of Peach.";905 case "CHECKSUM_FAILED":906 return "PeachScript appears corrupted. Re-copy from the source device.";907 case "CREATE_VAULT_FAILED":908 return "Peach couldn't create a new local vault before importing.";909 case "CLEAR_VAULT_FAILED":910 return "Peach couldn't clear the existing vault before replacing it.";911 case "MASTER_PASSWORD_REQUIRED":912 return "Choose a master password for your new vault first.";913 case "RESTORE_FAILED":914 return "Restore failed. Your existing vault is unchanged.";915 case "vault_locked":916 return "Peach is locked. Open Peach with your master password and try again.";917 case "refuse to replace with empty entry set":918 return "This PeachScript decoded to zero entries — refusing to wipe the vault.";919 case "NO_ENTRIES_IMPORTED":920 return "No importable password entries were found in that file.";921 default:922 return error;923 }924}