Import.tsx

34.6 KB · raw

1// Signal-conditioned views in this file must use <Show>, never a2// signal-conditioned early return — see Projects.md §4.1.3import { 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  /** Skip the menu and open straight into a specific subview. */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  // When FileImport detects a .peach file, it stashes the contents here and104  // routes the view to "peachscript" so the existing decode flow handles it.105  const [peachScriptPrefill, setPeachScriptPrefill] = createSignal<string>("");106  const mode = () => props.mode ?? "existing";107  const isOnboarding = () => mode() === "onboarding";108  // When the caller deep-links into a subview, the back arrow exits the109  // ImportView entirely. Otherwise it returns to the import menu.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}149150// ---------------------------------------------------------------------------151// Menu152// ---------------------------------------------------------------------------153154function 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}215216// ---------------------------------------------------------------------------217// PeachScript paste flow218// ---------------------------------------------------------------------------219220function PeachScriptImport(props: {221  mode: "existing" | "onboarding";222  replacesExistingVault?: boolean;223  /** Optional initial PeachScript content — set when the user loaded a224   *  .peach file from the CSV/file card and was routed here. */225  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}398399// ---------------------------------------------------------------------------400// File flow (Bitwarden / 1Password / Peach JSON / etc.)401// ---------------------------------------------------------------------------402403function FileImport(props: {404  mode: "existing" | "onboarding";405  replacesExistingVault?: boolean;406  onBack: () => void;407  onImport: () => void | Promise<void>;408  /** Called when the user picks a .peach file from this picker — the parent409   *  routes them to the PeachScript subview with the contents prefilled. */410  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    // Route .peach files to the PeachScript subview. Detection is two-pronged:476    // either the filename ends in .peach, or the body is entirely PUA chars477    // (the canonical PeachScript shape). The two-pronged check protects478    // against renamed-extension imports and against text editors that479    // accidentally save with a different extension.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}839840// ---------------------------------------------------------------------------841// Shared headers842// ---------------------------------------------------------------------------843844function 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  // Onboarding sub-flows reuse the lock-screen header; existing-vault flows868  // use the standard overlay back-arrow header.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  // UI-7 / MV-5: oversized-import errors carry a variable detail after the886  // code, so match the prefix and never surface the raw string.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}