Import.tsx

32.2 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}3031export function ImportView(props: ImportProps) {32  const [view, setView] = createSignal<ImportSubview>(props.initialView ?? "menu");33  // When FileImport detects a .peach file, it stashes the contents here and34  // routes the view to "peachscript" so the existing decode flow handles it.35  const [peachScriptPrefill, setPeachScriptPrefill] = createSignal<string>("");36  const mode = () => props.mode ?? "existing";37  const isOnboarding = () => mode() === "onboarding";38  // When the caller deep-links into a subview, the back arrow exits the39  // ImportView entirely. Otherwise it returns to the import menu.40  const subviewBack = () => (props.initialView ? props.onBack() : setView("menu"));4142  return (43    <div class={isOnboarding() ? "peach-lock-view" : "peach-overlay-view ov-enter-right"} style="display:flex">44      <Show when={view() === "menu"}>45        <ImportMenu46          mode={mode()}47          replacesExistingVault={props.replacesExistingVault}48          onBack={props.onBack}49          onPick={(v) => setView(v)}50        />51      </Show>52      <Show when={view() === "peachscript"}>53        <PeachScriptImport54          mode={mode()}55          replacesExistingVault={props.replacesExistingVault}56          initialScript={peachScriptPrefill()}57          onBack={() => {58            setPeachScriptPrefill("");59            subviewBack();60          }}61          onImport={props.onImport}62        />63      </Show>64      <Show when={view() === "file"}>65        <FileImport66          mode={mode()}67          replacesExistingVault={props.replacesExistingVault}68          onBack={subviewBack}69          onImport={props.onImport}70          onLoadPeachFile={(content) => {71            setPeachScriptPrefill(content);72            setView("peachscript");73          }}74        />75      </Show>76    </div>77  );78}7980// ---------------------------------------------------------------------------81// Menu82// ---------------------------------------------------------------------------8384function ImportMenu(props: {85  mode: "existing" | "onboarding";86  replacesExistingVault?: boolean;87  onBack: () => void;88  onPick: (view: ImportSubview) => void;89}) {90  const isOnboarding = props.mode === "onboarding";91  return (92    <>93      <Show94        when={isOnboarding}95        fallback={<OverlayHeader title="import" onBack={props.onBack} />}96      >97        <div class="peach-lock-view-header">98          <PeachWordmark class="peach-wordmark-wrap" />99          <button class="peach-lock-restore-link" onClick={props.onBack}>100            ‹ back101          </button>102        </div>103      </Show>104      <div class="peach-settings-body peach-export-menu">105        <Show when={isOnboarding && props.replacesExistingVault}>106          <div class="peach-settings-group">107            <div class="peach-restore-warning">108              Restoring now replaces the locked local vault on this device with109              a fresh one from your backup or export file.110            </div>111          </div>112        </Show>113114        <div class="peach-settings-group peach-export-card peach-export-card--primary">115          <p class="peach-settings-label">peachscript</p>116          <p class="peach-export-card-copy">117            Paste a PeachScript backup from another Peach device.118            {props.mode === "existing" ? " We'll merge it into your vault." : ""}119          </p>120          <button121            class="peach-btn peach-btn--subtle peach-btn--sm"122            onClick={() => props.onPick("peachscript")}123          >124            Paste PeachScript →125          </button>126        </div>127128        <div class="peach-settings-group peach-export-card">129          <p class="peach-settings-label">backup file</p>130          <p class="peach-export-card-copy">131            A Peach JSON export, or a file from Bitwarden, Proton Pass,132            1Password, or a browser CSV (Chrome, Safari, Edge).133          </p>134          <button135            class="peach-btn peach-btn--subtle peach-btn--sm"136            onClick={() => props.onPick("file")}137          >138            Choose file →139          </button>140        </div>141      </div>142    </>143  );144}145146// ---------------------------------------------------------------------------147// PeachScript paste flow148// ---------------------------------------------------------------------------149150function PeachScriptImport(props: {151  mode: "existing" | "onboarding";152  replacesExistingVault?: boolean;153  /** Optional initial PeachScript content — set when the user loaded a154   *  .peach file from the CSV/file card and was routed here. */155  initialScript?: string;156  onBack: () => void;157  onImport: () => void | Promise<void>;158}) {159  const [scriptInput, setScriptInput] = createSignal(props.initialScript ?? "");160  const [scriptPass, setScriptPass] = createSignal("");161  const [masterPassword, setMasterPassword] = createSignal("");162  const [masterConfirm, setMasterConfirm] = createSignal("");163  const [working, setWorking] = createSignal(false);164  const [error, setError] = createSignal<string | null>(null);165  const [result, setResult] = createSignal<{ entryCount: number } | null>(null);166167  const scriptLooksValid = createMemo(() => isPeachScript(scriptInput()));168  const isOnboarding = props.mode === "onboarding";169  const masterPasswordReady = createMemo(170    () => masterPassword().length >= 8 && masterPassword() === masterConfirm()171  );172173  async function handleImport() {174    setError(null);175    setResult(null);176    if (!scriptLooksValid()) {177      setError("Paste your PeachScript first.");178      return;179    }180    if (!scriptPass().trim()) {181      setError("Enter the backup password.");182      return;183    }184    setWorking(true);185    try {186      const bytes = decodePeachScript(normalisePeachScript(scriptInput()));187      const payload = await parsePayload(bytes, scriptPass());188189      if (isOnboarding) {190        if (!masterPasswordReady()) {191          throw new Error("MASTER_PASSWORD_REQUIRED");192        }193        const createRes = await sendMessage({194          type: "CREATE_VAULT",195          payload: { password: masterPassword() },196        });197        if (!(createRes as { ok: boolean }).ok) throw new Error("CREATE_VAULT_FAILED");198      }199200      const restoreRes = await sendMessage({201        type: "RESTORE_ENTRIES",202        payload: {203          entries: payload.entries,204          mode: "merge",205          settings: payload.settings,206        },207      });208      const restoreEnvelope = restoreRes as { ok: boolean; error?: string };209      if (!restoreEnvelope.ok) {210        throw new Error(restoreEnvelope.error ?? "RESTORE_FAILED");211      }212213      if (payload.license?.key) {214        await sendMessage({215          type: "ACTIVATE_LICENSE",216          payload: { key: payload.license.key },217        }).catch(() => {});218      }219220      setResult({ entryCount: payload.entries.length });221      await props.onImport();222    } catch (err) {223      const code = err instanceof Error ? err.message : String(err);224      setError(getImportErrorCopy(code));225    } finally {226      setWorking(false);227    }228  }229230  return (231    <>232      <SubviewHeader title="peachscript" onBack={props.onBack} mode={props.mode} />233      <div class="peach-settings-body">234        <Show when={isOnboarding && props.replacesExistingVault}>235          <div class="peach-settings-group">236            <div class="peach-restore-warning">237              Restoring now replaces the locked local vault on this device with238              a fresh one from your backup.239            </div>240          </div>241        </Show>242        <div class="peach-settings-group peach-export-card peach-export-card--primary peach-export-pass-group">243          <p class="peach-settings-label">paste peachscript</p>244          <p class="peach-export-card-copy">245            {isOnboarding246              ? "Paste a PeachScript backup from another Peach device to restore your vault — or load a .peach file from disk."247              : "We'll merge the entries into your existing vault. You can paste PeachScript or load a .peach file."}248          </p>249          <textarea250            class="peach-script-input"251            placeholder="paste peachscript…"252            value={scriptInput()}253            onInput={(e) => setScriptInput((e.target as HTMLTextAreaElement).value)}254          />255          <label256            class="peach-btn peach-btn--ghost peach-btn--sm"257            style="display:inline-flex;align-items:center;justify-content:center;cursor:pointer;"258          >259            Load .peach file260            <input261              type="file"262              accept=".peach,application/x-peach,text/plain"263              style="display:none;"264              onChange={async (event) => {265                const input = event.currentTarget;266                const file = input.files?.[0];267                if (!file) return;268                try {269                  const text = await file.text();270                  setScriptInput(text);271                } catch (err) {272                  setError(err instanceof Error ? err.message : "Couldn't read file.");273                } finally {274                  input.value = "";275                }276              }}277            />278          </label>279          <div class="peach-settings-stack peach-export-password-stack">280            <input281              class="peach-settings-input"282              type="password"283              placeholder="PeachScript password"284              value={scriptPass()}285              onInput={(e) => setScriptPass((e.target as HTMLInputElement).value)}286            />287          </div>288          <Show when={isOnboarding}>289            <p class="peach-settings-label" style="margin-top:4px">new master password</p>290            <p class="peach-export-card-copy">291              This password unlocks Peach on this device after the restore.292            </p>293            <div class="peach-settings-stack peach-export-password-stack">294              <input295                class="peach-settings-input"296                type="password"297                placeholder={`master password (min 8 chars)`}298                value={masterPassword()}299                onInput={(e) => setMasterPassword((e.target as HTMLInputElement).value)}300              />301              <input302                class="peach-settings-input"303                type="password"304                placeholder="confirm master password"305                value={masterConfirm()}306                onInput={(e) => setMasterConfirm((e.target as HTMLInputElement).value)}307              />308              <Show when={masterConfirm().length > 0 && masterPassword() !== masterConfirm()}>309                <p class="peach-export-note" style="color:var(--peach-t2)">310                  Passwords don't match311                </p>312              </Show>313            </div>314          </Show>315          <button316            class="peach-btn peach-btn--subtle peach-btn--sm"317            onClick={() => void handleImport()}318            disabled={319              working() ||320              !scriptLooksValid() ||321              !scriptPass().trim() ||322              (isOnboarding && !masterPasswordReady())323            }324          >325            {working() ? "decoding…" : "Restore from PeachScript"}326          </button>327          <Show when={error()}>328            <div class="peach-restore-error">{error()}</div>329          </Show>330          <Show when={result()}>331            <div class="peach-export-success">332              <span class="peach-export-success-check">✓</span>333              <span>334                Restored {result()!.entryCount} entr335                {result()!.entryCount === 1 ? "y" : "ies"}.336              </span>337            </div>338          </Show>339        </div>340      </div>341    </>342  );343}344345// ---------------------------------------------------------------------------346// File flow (Bitwarden / 1Password / Peach JSON / etc.)347// ---------------------------------------------------------------------------348349function FileImport(props: {350  mode: "existing" | "onboarding";351  replacesExistingVault?: boolean;352  onBack: () => void;353  onImport: () => void | Promise<void>;354  /** Called when the user picks a .peach file from this picker — the parent355   *  routes them to the PeachScript subview with the contents prefilled. */356  onLoadPeachFile?: (content: string) => void;357}) {358  const isOnboarding = props.mode === "onboarding";359  const needsMasterPassword = () => isOnboarding;360361  const [preview, setPreview] = createSignal<ImportParseResult | null>(null);362  const [rawImport, setRawImport] = createSignal<string | null>(null);363  const [backupPassword, setBackupPassword] = createSignal("");364  const [masterPassword, setMasterPassword] = createSignal("");365  const [masterConfirm, setMasterConfirm] = createSignal("");366  const [importing, setImporting] = createSignal(false);367  const [error, setError] = createSignal<string | null>(null);368  const [mergeMode, setMergeMode] = createSignal<ImportMergeMode>("add");369  const [awaitingReplaceConfirm, setAwaitingReplaceConfirm] = createSignal(false);370  const [importProgress, setImportProgress] = createSignal<{ current: number; total: number } | null>(null);371  const [duplicateWarning, setDuplicateWarning] = createSignal<number>(0);372  const [existingCount, setExistingCount] = createSignal<number>(0);373374  const needsBackupPassword = createMemo(() => preview()?.requiresPassword ?? false);375  const masterPasswordReady = createMemo(376    () => masterPassword().length >= 8 && masterPassword() === masterConfirm()377  );378379  async function refreshExistingCount() {380    if (isOnboarding) return;381    const res = await sendMessage({ type: "GET_ENTRIES" });382    const list = (res as { ok: boolean; data?: VaultEntry[] }).data ?? [];383    setExistingCount(list.length);384  }385  void refreshExistingCount();386387  async function parseSource(text: string, password?: string) {388    try {389      const result = await parseImportSource(text, password);390      setPreview(result);391      setError(null);392      if (result.format === "unknown") {393        setError(394          "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."395        );396      }397      return result;398    } catch (err) {399      const message = err instanceof Error ? err.message : String(err);400      setPreview(null);401      setError(getImportErrorCopy(message));402      return null;403    }404  }405406  async function handleFileSelect(event: Event) {407    const input = event.target as HTMLInputElement;408    const file = input.files?.[0];409    if (!file) return;410411    setPreview(null);412    setRawImport(null);413    setBackupPassword("");414    setError(null);415    setAwaitingReplaceConfirm(false);416417    const text = await file.text();418419    // Route .peach files to the PeachScript subview. Detection is two-pronged:420    // either the filename ends in .peach, or the body is entirely PUA chars421    // (the canonical PeachScript shape). The two-pronged check protects422    // against renamed-extension imports and against text editors that423    // accidentally save with a different extension.424    const looksLikePeach =425      file.name.toLowerCase().endsWith(".peach") || isPeachScript(text);426    if (looksLikePeach) {427      if (props.onLoadPeachFile) {428        props.onLoadPeachFile(text);429        input.value = "";430        return;431      }432      setError("This is a .peach file. Open the PeachScript card to import it.");433      return;434    }435436    setRawImport(text);437    await parseSource(text);438  }439440  async function handleUnlockBackup() {441    const raw = rawImport();442    if (!raw || !backupPassword().trim()) return;443    await parseSource(raw, backupPassword());444  }445446  async function countDuplicates(incoming: ImportParseResult["entries"]): Promise<number> {447    if (incoming.length === 0) return 0;448    const res = await sendMessage({ type: "GET_ENTRIES" });449    const existing = ((res as { ok: boolean; data?: VaultEntry[] }).data ?? []).filter((e) => !e.deletedAt);450    const keyOf = (title?: string, url?: string, username?: string) =>451      `${(title ?? "").toLowerCase()}|${(url ?? "").toLowerCase()}|${(username ?? "").toLowerCase()}`;452    const existingKeys = new Set(existing.map((e) => keyOf(e.title, e.url, e.username)));453    let dupes = 0;454    for (const entry of incoming) {455      const u = "username" in entry ? entry.username : undefined;456      const url = "url" in entry ? entry.url : undefined;457      if (existingKeys.has(keyOf(entry.title, url, u))) dupes += 1;458    }459    return dupes;460  }461462  async function handleImport() {463    const currentPreview = preview();464    if (!currentPreview) return;465466    if (needsBackupPassword() && !backupPassword().trim()) {467      setError("Enter the backup password used for this export.");468      return;469    }470471    if (needsMasterPassword()) {472      if (masterPassword().length < 8) {473        setError("Master password must be at least 8 characters.");474        return;475      }476      if (!masterPasswordReady()) {477        setError("Master passwords do not match.");478        return;479      }480    }481482    if (!isOnboarding && mergeMode() === "replace" && !awaitingReplaceConfirm()) {483      setAwaitingReplaceConfirm(true);484      return;485    }486487    setImporting(true);488    setError(null);489    setDuplicateWarning(0);490    setImportProgress(null);491492    try {493      if (currentPreview.entries.length === 0) {494        throw new Error("NO_ENTRIES_IMPORTED");495      }496497      if (needsMasterPassword()) {498        const createResponse = await sendMessage({499          type: "CREATE_VAULT",500          payload: { password: masterPassword() },501        });502        if (!(createResponse as { ok: boolean }).ok) {503          throw new Error("CREATE_VAULT_FAILED");504        }505      }506507      if (!isOnboarding && mergeMode() === "replace") {508        const clearRes = await sendMessage({ type: "CLEAR_VAULT_ENTRIES" });509        if (!(clearRes as { ok: boolean }).ok) {510          throw new Error("CLEAR_VAULT_FAILED");511        }512      }513514      let dupes = 0;515      if (!isOnboarding && mergeMode() === "add") {516        dupes = await countDuplicates(currentPreview.entries);517      }518519      let imported = 0;520      setImportProgress({ current: 0, total: currentPreview.entries.length });521522      const batchRes = await sendMessage({523        type: "BATCH_ADD_ENTRIES",524        payload: { entries: currentPreview.entries },525      });526527      if ((batchRes as { ok: boolean }).ok) {528        imported = ((batchRes as { ok: boolean; data?: VaultEntry[] }).data ?? []).length;529        setImportProgress({ current: imported, total: currentPreview.entries.length });530      } else {531        for (const entry of currentPreview.entries) {532          const response = await sendMessage({533            type: "ADD_ENTRY",534            payload: entry,535          });536          if ((response as { ok: boolean }).ok) imported += 1;537          setImportProgress({ current: imported, total: currentPreview.entries.length });538        }539      }540541      if (imported === 0) {542        throw new Error("NO_ENTRIES_IMPORTED");543      }544545      if (currentPreview.licenseKey) {546        await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key: currentPreview.licenseKey } }).catch(() => {});547      }548549      setDuplicateWarning(dupes);550      await props.onImport();551    } catch (err) {552      const message = err instanceof Error ? err.message : String(err);553      setError(getImportErrorCopy(message));554      setImporting(false);555      setImportProgress(null);556      return;557    }558559    setImporting(false);560    setImportProgress(null);561  }562563  return (564    <>565      <SubviewHeader title="backup file" onBack={props.onBack} mode={props.mode} />566      <div class="peach-settings-body">567        <Show when={isOnboarding && props.replacesExistingVault}>568          <div class="peach-settings-group">569            <div class="peach-restore-warning">570              Restoring now replaces the locked local vault on this device with571              a fresh one from your backup.572            </div>573          </div>574        </Show>575        <div class="peach-settings-group peach-export-card peach-export-card--primary">576          <p class="peach-settings-label">choose a file</p>577          <p class="peach-export-card-copy">578            Bitwarden, 1Password, Proton Pass, Chrome / Edge / Safari CSV, FIDO579            credential-exchange passkeys, or a Peach JSON backup.580          </p>581582          <div class="peach-provider-chips">583            <span class="peach-provider-chip">Peach JSON</span>584            <span class="peach-provider-chip">FIDO passkeys</span>585            <span class="peach-provider-chip">Proton Pass</span>586            <span class="peach-provider-chip">Bitwarden</span>587            <span class="peach-provider-chip">1Password</span>588            <span class="peach-provider-chip">Browser CSV</span>589          </div>590591          <input592            class="peach-restore-file"593            type="file"594            accept=".csv,.json,.peach,application/x-peach"595            onChange={(event) => void handleFileSelect(event)}596          />597        </div>598599        <Show when={needsBackupPassword()}>600          <div class="peach-settings-group">601            <p class="peach-settings-label">unlock backup</p>602            <p class="peach-export-card-copy">603              Enter the password used when you created this encrypted Peach JSON604              backup.605            </p>606            <input607              class="peach-restore-input"608              type="password"609              placeholder="backup password"610              value={backupPassword()}611              onInput={(event) =>612                setBackupPassword((event.target as HTMLInputElement).value)613              }614              onBlur={() => { if (backupPassword().trim()) void handleUnlockBackup(); }}615              onKeyDown={(event) => {616                if (event.key === "Enter") {617                  event.preventDefault();618                  void handleUnlockBackup();619                }620              }}621            />622            <div class="peach-settings-actions">623              <button624                class="peach-btn peach-btn--subtle peach-btn--sm"625                onClick={() => void handleUnlockBackup()}626                disabled={!backupPassword().trim()}627              >628                Decrypt →629              </button>630            </div>631          </div>632        </Show>633634        <Show when={preview() && !needsBackupPassword() && preview()!.format !== "unknown"}>635          <div class="peach-settings-group">636            <p class="peach-settings-label">{preview()!.label}</p>637            <div class="peach-import-summary">638              Found <strong>{preview()!.entries.length}</strong>{" "}639              entr{preview()!.entries.length === 1 ? "y" : "ies"}640              {preview()!.skippedItems > 0641                ? `. ${preview()!.skippedItems} skipped because no password was present.`642                : "."}643            </div>644645            <div class="peach-import-preview">646              {preview()!.entries.slice(0, 8).map((entry) => (647                <div class="peach-import-preview-row">648                  <span class="peach-import-preview-title">{entry.title}</span>649                  <span class="peach-import-preview-meta">650                    {entry.passkey651                      ? `${entry.passkey.userName} • ${entry.passkey.rpId} (passkey)`652                      : entry.type === "note"653                        ? "secure note"654                        : entry.username || entry.url || "password item"}655                  </span>656                </div>657              ))}658              <Show when={preview()!.entries.length > 8}>659                <div class="peach-import-preview-more">660                  ...and {preview()!.entries.length - 8} more661                </div>662              </Show>663            </div>664          </div>665        </Show>666667        <Show when={!isOnboarding && preview() && !needsBackupPassword() && preview()!.format !== "unknown" && preview()!.entries.length > 0}>668          <div class="peach-settings-group">669            <p class="peach-settings-label">merge or replace</p>670            <label class="peach-import-choice">671              <input672                type="radio"673                name="merge-mode"674                value="add"675                checked={mergeMode() === "add"}676                onChange={() => { setMergeMode("add"); setAwaitingReplaceConfirm(false); }}677              />678              <span class="peach-import-choice-main">679                <span class="peach-import-choice-label">Add to existing vault</span>680                <span class="peach-import-choice-sub">681                  This will add {preview()!.entries.length} entries to your existing {existingCount()}.682                </span>683              </span>684            </label>685            <label class="peach-import-choice">686              <input687                type="radio"688                name="merge-mode"689                value="replace"690                checked={mergeMode() === "replace"}691                onChange={() => setMergeMode("replace")}692              />693              <span class="peach-import-choice-main">694                <span class="peach-import-choice-label">Replace vault</span>695                <span class="peach-import-choice-sub">696                  This will replace your {existingCount()} entries with {preview()!.entries.length} imported ones.697                </span>698              </span>699            </label>700            <Show when={awaitingReplaceConfirm()}>701              <div class="peach-restore-warning" style="margin-top:6px">702                Replace forever? This cannot be undone.703              </div>704            </Show>705          </div>706        </Show>707708        <Show when={needsMasterPassword() && preview() && !needsBackupPassword() && preview()!.format !== "unknown"}>709          <div class="peach-settings-group">710            <p class="peach-settings-label">new master password</p>711            <p class="peach-export-card-copy">712              This password unlocks the Peach vault created on this device after713              import.714            </p>715            <div class="peach-auth-grid">716              <input717                class="peach-restore-input"718                type="password"719                placeholder="new master password"720                value={masterPassword()}721                onInput={(event) =>722                  setMasterPassword((event.target as HTMLInputElement).value)723                }724              />725              <input726                class="peach-restore-input"727                type="password"728                placeholder="confirm master password"729                value={masterConfirm()}730                onInput={(event) =>731                  setMasterConfirm((event.target as HTMLInputElement).value)732                }733              />734            </div>735          </div>736        </Show>737738        <Show when={error()}>739          <div class="peach-settings-group">740            <div class="peach-restore-error">{error()}</div>741          </div>742        </Show>743744        <Show when={importing() && importProgress()}>745          <div class="peach-settings-group">746            <div class="peach-import-summary">747              Importing... [{importProgress()!.current} / {importProgress()!.total}]748            </div>749          </div>750        </Show>751752        <Show when={duplicateWarning() > 0 && !importing()}>753          <div class="peach-settings-group">754            <div class="peach-export-card-copy">755              {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.756            </div>757          </div>758        </Show>759760        <Show when={preview() && !needsBackupPassword() && preview()!.format !== "unknown"}>761          <div class="peach-settings-group">762            <div class="peach-settings-actions">763              <button764                class="peach-btn peach-btn--subtle peach-btn--sm"765                onClick={() => void handleImport()}766                disabled={767                  importing() ||768                  preview()!.entries.length === 0 ||769                  (needsMasterPassword() && !masterPasswordReady())770                }771              >772                {importing()773                  ? "importing..."774                  : isOnboarding775                    ? `Create vault + import ${preview()!.entries.length}`776                    : mergeMode() === "replace"777                      ? awaitingReplaceConfirm()778                        ? "Yes, replace forever"779                        : `Replace vault with ${preview()!.entries.length}`780                      : `Import ${preview()!.entries.length}`}781              </button>782            </div>783          </div>784        </Show>785      </div>786    </>787  );788}789790// ---------------------------------------------------------------------------791// Shared headers792// ---------------------------------------------------------------------------793794function OverlayHeader(props: { title: string; onBack: () => void }) {795  return (796    <div class="peach-overlay-header">797      <button798        class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"799        onClick={props.onBack}800        aria-label="Back"801      >802        <svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">803          <path d="M6 1L1 6L6 11" />804        </svg>805      </button>806      <span class="peach-overlay-title">{props.title}</span>807      <div class="peach-overlay-spacer"></div>808    </div>809  );810}811812function SubviewHeader(props: {813  title: string;814  onBack: () => void;815  mode: "existing" | "onboarding";816}) {817  // Onboarding sub-flows reuse the lock-screen header; existing-vault flows818  // use the standard overlay back-arrow header.819  return (820    <Show821      when={props.mode === "onboarding"}822      fallback={<OverlayHeader title={props.title} onBack={props.onBack} />}823    >824      <div class="peach-lock-view-header">825        <PeachWordmark class="peach-wordmark-wrap" />826        <button class="peach-lock-restore-link" onClick={props.onBack}>827          ‹ back828        </button>829      </div>830    </Show>831  );832}833834function getImportErrorCopy(error: string): string {835  switch (error) {836    case "UNSUPPORTED_IMPORT_FORMAT":837      return "Unrecognised file format. Use CSV, Bitwarden JSON, Proton Pass JSON/CSV, or a Peach encrypted backup.";838    case "INVALID_EXPORT_PASSWORD":839    case "INVALID_PASSPHRASE":840      return "Wrong backup password. This is the password you chose when you created the backup.";841    case "INVALID_PEACH_EXPORT":842      return "This Peach backup file looks corrupted or incomplete.";843    case "INVALID_PEACHSCRIPT_CHARACTER":844      return "This doesn't look like PeachScript. Make sure you copied the entire backup string.";845    case "PAYLOAD_TOO_SHORT":846      return "PeachScript looks truncated. Copy the full backup string and try again.";847    case "BAD_MAGIC":848    case "UNSUPPORTED_VERSION":849      return "This PeachScript was created by a different version of Peach.";850    case "CHECKSUM_FAILED":851      return "PeachScript appears corrupted. Re-copy from the source device.";852    case "CREATE_VAULT_FAILED":853      return "Peach couldn't create a new local vault before importing.";854    case "CLEAR_VAULT_FAILED":855      return "Peach couldn't clear the existing vault before replacing it.";856    case "MASTER_PASSWORD_REQUIRED":857      return "Choose a master password for your new vault first.";858    case "RESTORE_FAILED":859      return "Restore failed. Your existing vault is unchanged.";860    case "vault_locked":861      return "Peach is locked. Open Peach with your master password and try again.";862    case "refuse to replace with empty entry set":863      return "This PeachScript decoded to zero entries — refusing to wipe the vault.";864    case "NO_ENTRIES_IMPORTED":865      return "No importable password entries were found in that file.";866    default:867      return error;868  }869}