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 { IMPORT_LIMITS } from "../../shared/import-limits";18import type { VaultEntry } from "../../shared/types";1920type ImportSubview = "menu" | "peachscript" | "file";21type ImportMergeMode = "add" | "replace";2223interface ImportProps {24  mode?: "existing" | "onboarding";25  replacesExistingVault?: boolean;26  /** Skip the menu and open straight into a specific subview. */27  initialView?: ImportSubview;28  onBack: () => void;29  onImport: () => void | Promise<void>;30}3132function OnboardingMasterPasswordFields(props: {33  idPrefix: string;34  copy: string;35  masterPlaceholder: string;36  password: string;37  confirm: string;38  onPasswordInput: (value: string) => void;39  onConfirmInput: (value: string) => void;40}) {41  const [showPassword, setShowPassword] = createSignal(false);4243  return (44    <>45      <div class="peach-newvault-label-row">46        <label class="peach-label" for={`${props.idPrefix}-master-password`}>47          new master password48        </label>49        <button50          class="peach-lock-reveal-btn"51          type="button"52          onClick={() => setShowPassword((value) => !value)}53          aria-label={showPassword() ? "Hide passwords" : "Show passwords"}54        >55          <Show56            when={showPassword()}57            fallback={58              <svg width="15" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">59                <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" />60                <circle cx="7" cy="5" r="1.75" />61              </svg>62            }63          >64            <svg width="15" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">65              <path d="M1 2C3 4.5 5 6 7 6C9 6 11 4.5 13 2" />66              <path d="M3.5 5.5L2.5 7.5" />67              <path d="M7 6.5V8.5" />68              <path d="M10.5 5.5L11.5 7.5" />69            </svg>70          </Show>71          {showPassword() ? "hide" : "show"}72        </button>73      </div>74      <p class="peach-export-card-copy">{props.copy}</p>75      <div class="peach-settings-stack peach-export-password-stack">76        <input77          id={`${props.idPrefix}-master-password`}78          class="peach-settings-input"79          type={showPassword() ? "text" : "password"}80          placeholder={props.masterPlaceholder}81          value={props.password}82          onInput={(event) => props.onPasswordInput((event.target as HTMLInputElement).value)}83        />84        <input85          id={`${props.idPrefix}-confirm-password`}86          class="peach-settings-input"87          type={showPassword() ? "text" : "password"}88          placeholder="confirm master password"89          value={props.confirm}90          onInput={(event) => props.onConfirmInput((event.target as HTMLInputElement).value)}91        />92        <Show when={props.confirm.length > 0 && props.password !== props.confirm}>93          <p class="peach-export-note" style="color:var(--peach-t2)">94            Passwords don't match95          </p>96        </Show>97      </div>98    </>99  );100}101102export function ImportView(props: ImportProps) {103  const [view, setView] = createSignal<ImportSubview>(props.initialView ?? "menu");104  // When FileImport detects a .peach file, it stashes the contents here and105  // routes the view to "peachscript" so the existing decode flow handles it.106  const [peachScriptPrefill, setPeachScriptPrefill] = createSignal<string>("");107  const mode = () => props.mode ?? "existing";108  const isOnboarding = () => mode() === "onboarding";109  // When the caller deep-links into a subview, the back arrow exits the110  // ImportView entirely. Otherwise it returns to the import menu.111  const subviewBack = () => (props.initialView ? props.onBack() : setView("menu"));112113  return (114    <div class={isOnboarding() ? "peach-lock-view" : "peach-overlay-view ov-enter-right"} style="display:flex">115      <Show when={view() === "menu"}>116        <ImportMenu117          mode={mode()}118          replacesExistingVault={props.replacesExistingVault}119          onBack={props.onBack}120          onPick={(v) => setView(v)}121        />122      </Show>123      <Show when={view() === "peachscript"}>124        <PeachScriptImport125          mode={mode()}126          replacesExistingVault={props.replacesExistingVault}127          initialScript={peachScriptPrefill()}128          onBack={() => {129            setPeachScriptPrefill("");130            subviewBack();131          }}132          onImport={props.onImport}133        />134      </Show>135      <Show when={view() === "file"}>136        <FileImport137          mode={mode()}138          replacesExistingVault={props.replacesExistingVault}139          onBack={subviewBack}140          onImport={props.onImport}141          onLoadPeachFile={(content) => {142            setPeachScriptPrefill(content);143            setView("peachscript");144          }}145        />146      </Show>147    </div>148  );149}150151// ---------------------------------------------------------------------------152// Menu153// ---------------------------------------------------------------------------154155function ImportMenu(props: {156  mode: "existing" | "onboarding";157  replacesExistingVault?: boolean;158  onBack: () => void;159  onPick: (view: ImportSubview) => void;160}) {161  const isOnboarding = props.mode === "onboarding";162  return (163    <>164      <Show165        when={isOnboarding}166        fallback={<OverlayHeader title="import" onBack={props.onBack} />}167      >168        <div class="peach-lock-view-header">169          <PeachWordmark class="peach-wordmark-wrap" />170          <button class="peach-lock-restore-link" onClick={props.onBack}>171            ‹ back172          </button>173        </div>174      </Show>175      <div class="peach-settings-body peach-export-menu">176        <Show when={isOnboarding && props.replacesExistingVault}>177          <div class="peach-settings-group">178            <div class="peach-restore-warning">179              Restoring now replaces the locked local vault on this device with180              a fresh one from your backup or export file.181            </div>182          </div>183        </Show>184185        <div class="peach-settings-group peach-export-card peach-export-card--primary">186          <p class="peach-settings-label">peachscript</p>187          <p class="peach-export-card-copy">188            Paste a PeachScript backup from another Peach device.189            {props.mode === "existing" ? " We'll merge it into your vault." : ""}190          </p>191          <button192            class="peach-btn peach-btn--subtle peach-btn--sm"193            onClick={() => props.onPick("peachscript")}194          >195            Paste PeachScript →196          </button>197        </div>198199        <div class="peach-settings-group peach-export-card">200          <p class="peach-settings-label">backup file</p>201          <p class="peach-export-card-copy">202            A file from Bitwarden, Proton Pass,203            1Password, or a browser CSV (Chrome, Safari, Edge).204          </p>205          <button206            class="peach-btn peach-btn--subtle peach-btn--sm"207            onClick={() => props.onPick("file")}208          >209            Choose file →210          </button>211        </div>212      </div>213    </>214  );215}216217// ---------------------------------------------------------------------------218// PeachScript paste flow219// ---------------------------------------------------------------------------220221function PeachScriptImport(props: {222  mode: "existing" | "onboarding";223  replacesExistingVault?: boolean;224  /** Optional initial PeachScript content — set when the user loaded a225   *  .peach file from the CSV/file card and was routed here. */226  initialScript?: string;227  onBack: () => void;228  onImport: () => void | Promise<void>;229}) {230  const [scriptInput, setScriptInput] = createSignal(props.initialScript ?? "");231  const [scriptPass, setScriptPass] = createSignal("");232  const [masterPassword, setMasterPassword] = createSignal("");233  const [masterConfirm, setMasterConfirm] = createSignal("");234  const [working, setWorking] = createSignal(false);235  const [error, setError] = createSignal<string | null>(null);236  const [result, setResult] = createSignal<{ entryCount: number } | null>(null);237238  const scriptLooksValid = createMemo(() => isPeachScript(scriptInput()));239  const isOnboarding = props.mode === "onboarding";240  const masterPasswordReady = createMemo(241    () => masterPassword().length >= 8 && masterPassword() === masterConfirm()242  );243244  async function handleImport() {245    setError(null);246    setResult(null);247    if (!scriptLooksValid()) {248      setError("Paste your PeachScript first.");249      return;250    }251    if (!scriptPass().trim()) {252      setError("Enter the backup password.");253      return;254    }255    setWorking(true);256    try {257      const bytes = decodePeachScript(normalisePeachScript(scriptInput()));258      const payload = await parsePayload(bytes, scriptPass());259260      if (isOnboarding) {261        if (!masterPasswordReady()) {262          throw new Error("MASTER_PASSWORD_REQUIRED");263        }264        const createRes = await sendMessage({265          type: "CREATE_VAULT",266          payload: { password: masterPassword() },267        });268        if (!(createRes as { ok: boolean }).ok) throw new Error("CREATE_VAULT_FAILED");269      }270271      const restoreRes = await sendMessage({272        type: "RESTORE_ENTRIES",273        payload: {274          entries: payload.entries,275          mode: "merge",276          settings: payload.settings,277        },278      });279      const restoreEnvelope = restoreRes as { ok: boolean; error?: string };280      if (!restoreEnvelope.ok) {281        throw new Error(restoreEnvelope.error ?? "RESTORE_FAILED");282      }283284      if (payload.license?.key) {285        await sendMessage({286          type: "ACTIVATE_LICENSE",287          payload: { key: payload.license.key },288        }).catch(() => {});289      }290291      setResult({ entryCount: payload.entries.length });292      await props.onImport();293    } catch (err) {294      const code = err instanceof Error ? err.message : String(err);295      setError(getImportErrorCopy(code));296    } finally {297      setWorking(false);298    }299  }300301  return (302    <>303      <SubviewHeader title="peachscript" onBack={props.onBack} mode={props.mode} />304      <div class="peach-settings-body">305        <Show when={isOnboarding && props.replacesExistingVault}>306          <div class="peach-settings-group">307            <div class="peach-restore-warning">308              Restoring now replaces the locked local vault on this device with309              a fresh one from your backup.310            </div>311          </div>312        </Show>313        <div class="peach-settings-group peach-export-card peach-export-card--primary peach-export-pass-group">314          <p class="peach-settings-label">paste peachscript</p>315          <p class="peach-export-card-copy">316            {isOnboarding317              ? "Paste a PeachScript backup from another Peach device to restore your vault — or load a .peach file from disk."318              : "We'll merge the entries into your existing vault. You can paste PeachScript or load a .peach file."}319          </p>320          <textarea321            class="peach-script-input"322            placeholder="paste peachscript…"323            value={scriptInput()}324            onInput={(e) => setScriptInput((e.target as HTMLTextAreaElement).value)}325          />326          <label327            class="peach-btn peach-btn--ghost peach-btn--sm"328            style="display:inline-flex;align-items:center;justify-content:center;cursor:pointer;"329          >330            Load .peach file331            <input332              type="file"333              accept=".peach,application/x-peach,text/plain"334              style="display:none;"335              onChange={async (event) => {336                const input = event.currentTarget;337                const file = input.files?.[0];338                if (!file) return;339                try {340                  // PB-26-010: bound before reading the file into a string.341                  if (file.size > IMPORT_LIMITS.MAX_JSON_BYTES) {342                    setError("That file is too large.");343                    return;344                  }345                  const text = await file.text();346                  setScriptInput(text);347                } catch (err) {348                  setError(err instanceof Error ? err.message : "Couldn't read file.");349                } finally {350                  input.value = "";351                }352              }}353            />354          </label>355          <div class="peach-settings-stack peach-export-password-stack">356            <input357              class="peach-settings-input"358              type="password"359              placeholder="PeachScript password"360              value={scriptPass()}361              onInput={(e) => setScriptPass((e.target as HTMLInputElement).value)}362            />363          </div>364          <Show when={isOnboarding}>365            <OnboardingMasterPasswordFields366              idPrefix="peachscript"367              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."368              masterPlaceholder="new master password"369              password={masterPassword()}370              confirm={masterConfirm()}371              onPasswordInput={setMasterPassword}372              onConfirmInput={setMasterConfirm}373            />374          </Show>375          <button376            class="peach-btn peach-btn--subtle peach-btn--sm"377            onClick={() => void handleImport()}378            disabled={379              working() ||380              !scriptLooksValid() ||381              !scriptPass().trim() ||382              (isOnboarding && !masterPasswordReady())383            }384          >385            {working() ? "decoding…" : "Restore from PeachScript"}386          </button>387          <Show when={error()}>388            <div class="peach-restore-error">{error()}</div>389          </Show>390          <Show when={result()}>391            <div class="peach-export-success">392              <span class="peach-export-success-check">✓</span>393              <span>394                Restored {result()!.entryCount} entr395                {result()!.entryCount === 1 ? "y" : "ies"}.396              </span>397            </div>398          </Show>399        </div>400      </div>401    </>402  );403}404405// ---------------------------------------------------------------------------406// File flow (Bitwarden / 1Password / etc.)407// ---------------------------------------------------------------------------408409function FileImport(props: {410  mode: "existing" | "onboarding";411  replacesExistingVault?: boolean;412  onBack: () => void;413  onImport: () => void | Promise<void>;414  /** Called when the user picks a .peach file from this picker — the parent415   *  routes them to the PeachScript subview with the contents prefilled. */416  onLoadPeachFile?: (content: string) => void;417}) {418  const isOnboarding = props.mode === "onboarding";419  const needsMasterPassword = () => isOnboarding;420421  const [preview, setPreview] = createSignal<ImportParseResult | null>(null);422  const [masterPassword, setMasterPassword] = createSignal("");423  const [masterConfirm, setMasterConfirm] = createSignal("");424  const [importing, setImporting] = createSignal(false);425  const [error, setError] = createSignal<string | null>(null);426  const [mergeMode, setMergeMode] = createSignal<ImportMergeMode>("add");427  const [awaitingReplaceConfirm, setAwaitingReplaceConfirm] = createSignal(false);428  const [importProgress, setImportProgress] = createSignal<{ current: number; total: number } | null>(null);429  const [duplicateWarning, setDuplicateWarning] = createSignal<number>(0);430  const [existingCount, setExistingCount] = createSignal<number>(0);431432  const masterPasswordReady = createMemo(433    () => masterPassword().length >= 8 && masterPassword() === masterConfirm()434  );435436  async function refreshExistingCount() {437    if (isOnboarding) return;438    const res = await sendMessage({ type: "GET_ENTRIES" });439    const list = (res as { ok: boolean; data?: VaultEntry[] }).data ?? [];440    setExistingCount(list.length);441  }442  void refreshExistingCount();443444  async function parseSource(text: string) {445    try {446      const result = await parseImportSource(text);447      setPreview(result);448      setError(null);449      if (result.format === "unknown") {450        setError(451          "We couldn't recognise this file format. Peach supports: Bitwarden JSON/CSV, Proton Pass JSON/CSV, 1Password JSON, and generic browser CSV exports."452        );453      }454      return result;455    } catch (err) {456      const message = err instanceof Error ? err.message : String(err);457      setPreview(null);458      setError(getImportErrorCopy(message));459      return null;460    }461  }462463  async function handleFileSelect(event: Event) {464    const input = event.target as HTMLInputElement;465    const file = input.files?.[0];466    if (!file) return;467468    setPreview(null);469    setError(null);470    setAwaitingReplaceConfirm(false);471472    if (file.size > IMPORT_LIMITS.MAX_JSON_BYTES) {473      setError("That file is too large.");474      input.value = "";475      return;476    }477    const text = await file.text();478479    // Route .peach files to the PeachScript subview. Detection is two-pronged:480    // either the filename ends in .peach, or the body is entirely PUA chars481    // (the canonical PeachScript shape). The two-pronged check protects482    // against renamed-extension imports and against text editors that483    // accidentally save with a different extension.484    const looksLikePeach =485      file.name.toLowerCase().endsWith(".peach") || isPeachScript(text);486    if (looksLikePeach) {487      if (props.onLoadPeachFile) {488        props.onLoadPeachFile(text);489        input.value = "";490        return;491      }492      setError("This is a .peach file. Open the PeachScript card to import it.");493      return;494    }495496    await parseSource(text);497  }498499  async function countDuplicates(incoming: ImportParseResult["entries"]): Promise<number> {500    if (incoming.length === 0) return 0;501    const res = await sendMessage({ type: "GET_ENTRIES" });502    const existing = ((res as { ok: boolean; data?: VaultEntry[] }).data ?? []).filter((e) => !e.deletedAt);503    const keyOf = (title?: string, url?: string, username?: string) =>504      `${(title ?? "").toLowerCase()}|${(url ?? "").toLowerCase()}|${(username ?? "").toLowerCase()}`;505    const existingKeys = new Set(existing.map((e) => keyOf(e.title, e.url, e.username)));506    let dupes = 0;507    for (const entry of incoming) {508      const u = "username" in entry ? entry.username : undefined;509      const url = "url" in entry ? entry.url : undefined;510      if (existingKeys.has(keyOf(entry.title, url, u))) dupes += 1;511    }512    return dupes;513  }514515  async function handleImport() {516    const currentPreview = preview();517    if (!currentPreview) return;518519    if (needsMasterPassword()) {520      if (masterPassword().length < 8) {521        setError("Master password must be at least 8 characters.");522        return;523      }524      if (!masterPasswordReady()) {525        setError("Master passwords do not match.");526        return;527      }528    }529530    if (!isOnboarding && mergeMode() === "replace" && !awaitingReplaceConfirm()) {531      setAwaitingReplaceConfirm(true);532      return;533    }534535    setImporting(true);536    setError(null);537    setDuplicateWarning(0);538    setImportProgress(null);539540    try {541      if (currentPreview.entries.length === 0) {542        throw new Error("NO_ENTRIES_IMPORTED");543      }544545      if (needsMasterPassword()) {546        const createResponse = await sendMessage({547          type: "CREATE_VAULT",548          payload: { password: masterPassword() },549        });550        if (!(createResponse as { ok: boolean }).ok) {551          throw new Error("CREATE_VAULT_FAILED");552        }553      }554555      if (!isOnboarding && mergeMode() === "replace") {556        const clearRes = await sendMessage({ type: "CLEAR_VAULT_ENTRIES" });557        if (!(clearRes as { ok: boolean }).ok) {558          throw new Error("CLEAR_VAULT_FAILED");559        }560      }561562      let dupes = 0;563      if (!isOnboarding && mergeMode() === "add") {564        dupes = await countDuplicates(currentPreview.entries);565      }566567      let imported = 0;568      setImportProgress({ current: 0, total: currentPreview.entries.length });569570      const batchRes = await sendMessage({571        type: "BATCH_ADD_ENTRIES",572        payload: { entries: currentPreview.entries },573      });574575      if ((batchRes as { ok: boolean }).ok) {576        imported = ((batchRes as { ok: boolean; data?: VaultEntry[] }).data ?? []).length;577        setImportProgress({ current: imported, total: currentPreview.entries.length });578      } else {579        for (const entry of currentPreview.entries) {580          const response = await sendMessage({581            type: "ADD_ENTRY",582            payload: entry,583          });584          if ((response as { ok: boolean }).ok) imported += 1;585          setImportProgress({ current: imported, total: currentPreview.entries.length });586        }587      }588589      if (imported === 0) {590        throw new Error("NO_ENTRIES_IMPORTED");591      }592593      setDuplicateWarning(dupes);594      await props.onImport();595    } catch (err) {596      const message = err instanceof Error ? err.message : String(err);597      setError(getImportErrorCopy(message));598      setImporting(false);599      setImportProgress(null);600      return;601    }602603    setImporting(false);604    setImportProgress(null);605  }606607  return (608    <>609      <SubviewHeader title="backup file" onBack={props.onBack} mode={props.mode} />610      <div class="peach-settings-body">611        <Show when={isOnboarding && props.replacesExistingVault}>612          <div class="peach-settings-group">613            <div class="peach-restore-warning">614              Restoring now replaces the locked local vault on this device with615              a fresh one from your backup.616            </div>617          </div>618        </Show>619        <div class="peach-settings-group peach-export-card peach-export-card--primary">620          <p class="peach-settings-label">choose a file</p>621          <p class="peach-export-card-copy">622            Bitwarden, 1Password, Proton Pass, Chrome / Edge / Safari CSV, FIDO623              credential-exchange passkeys.624          </p>625626          <div class="peach-provider-chips">627            <span class="peach-provider-chip">FIDO passkeys</span>628            <span class="peach-provider-chip">Proton Pass</span>629            <span class="peach-provider-chip">Bitwarden</span>630            <span class="peach-provider-chip">1Password</span>631            <span class="peach-provider-chip">Browser CSV</span>632          </div>633634          <input635            class="peach-restore-file"636            type="file"637            accept=".csv,.json,.peach,application/x-peach"638            onChange={(event) => void handleFileSelect(event)}639          />640        </div>641642        <Show when={preview() && preview()!.format !== "unknown"}>643          <div class="peach-settings-group">644            <p class="peach-settings-label">{preview()!.label}</p>645            <div class="peach-import-summary">646              Found <strong>{preview()!.entries.length}</strong>{" "}647              entr{preview()!.entries.length === 1 ? "y" : "ies"}648              {preview()!.skippedItems > 0649                ? `. ${preview()!.skippedItems} skipped because no password was present.`650                : "."}651            </div>652653            <div class="peach-import-preview">654              {preview()!.entries.slice(0, 8).map((entry) => (655                <div class="peach-import-preview-row">656                  <span class="peach-import-preview-title">{entry.title}</span>657                  <span class="peach-import-preview-meta">658                    {entry.passkey659                      ? `${entry.passkey.userName} • ${entry.passkey.rpId} (passkey)`660                      : entry.type === "note"661                        ? "secure note"662                        : entry.username || entry.url || "password item"}663                  </span>664                </div>665              ))}666              <Show when={preview()!.entries.length > 8}>667                <div class="peach-import-preview-more">668                  ...and {preview()!.entries.length - 8} more669                </div>670              </Show>671            </div>672          </div>673        </Show>674675        <Show when={!isOnboarding && preview() && preview()!.format !== "unknown" && preview()!.entries.length > 0}>676          <div class="peach-settings-group">677            <p class="peach-settings-label">merge or replace</p>678            <label class="peach-import-choice">679              <input680                type="radio"681                name="merge-mode"682                value="add"683                checked={mergeMode() === "add"}684                onChange={() => { setMergeMode("add"); setAwaitingReplaceConfirm(false); }}685              />686              <span class="peach-import-choice-main">687                <span class="peach-import-choice-label">Add to existing vault</span>688                <span class="peach-import-choice-sub">689                  This will add {preview()!.entries.length} entries to your existing {existingCount()}.690                </span>691              </span>692            </label>693            <label class="peach-import-choice">694              <input695                type="radio"696                name="merge-mode"697                value="replace"698                checked={mergeMode() === "replace"}699                onChange={() => setMergeMode("replace")}700              />701              <span class="peach-import-choice-main">702                <span class="peach-import-choice-label">Replace vault</span>703                <span class="peach-import-choice-sub">704                  This will replace your {existingCount()} entries with {preview()!.entries.length} imported ones.705                </span>706              </span>707            </label>708            <Show when={awaitingReplaceConfirm()}>709              <div class="peach-restore-warning" style="margin-top:6px">710                Replace forever? This cannot be undone.711              </div>712            </Show>713          </div>714        </Show>715716        <Show when={needsMasterPassword() && preview() && preview()!.format !== "unknown"}>717          <div class="peach-settings-group">718            <OnboardingMasterPasswordFields719              idPrefix="backup"720              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."721              masterPlaceholder="new master password"722              password={masterPassword()}723              confirm={masterConfirm()}724              onPasswordInput={setMasterPassword}725              onConfirmInput={setMasterConfirm}726            />727          </div>728        </Show>729730        <Show when={error()}>731          <div class="peach-settings-group">732            <div class="peach-restore-error">{error()}</div>733          </div>734        </Show>735736        <Show when={importing() && importProgress()}>737          <div class="peach-settings-group">738            <div class="peach-import-summary">739              Importing... [{importProgress()!.current} / {importProgress()!.total}]740            </div>741          </div>742        </Show>743744        <Show when={duplicateWarning() > 0 && !importing()}>745          <div class="peach-settings-group">746            <div class="peach-export-card-copy">747              {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.748            </div>749          </div>750        </Show>751752        <Show when={preview() && preview()!.format !== "unknown"}>753          <div class="peach-settings-group">754            <div class="peach-settings-actions">755              <button756                class="peach-btn peach-btn--subtle peach-btn--sm"757                onClick={() => void handleImport()}758                disabled={759                  importing() ||760                  preview()!.entries.length === 0 ||761                  (needsMasterPassword() && !masterPasswordReady())762                }763              >764                {importing()765                  ? "importing..."766                  : isOnboarding767                    ? `Create vault + import ${preview()!.entries.length}`768                    : mergeMode() === "replace"769                      ? awaitingReplaceConfirm()770                        ? "Yes, replace forever"771                        : `Replace vault with ${preview()!.entries.length}`772                      : `Import ${preview()!.entries.length}`}773              </button>774            </div>775          </div>776        </Show>777      </div>778    </>779  );780}781782// ---------------------------------------------------------------------------783// Shared headers784// ---------------------------------------------------------------------------785786function OverlayHeader(props: { title: string; onBack: () => void }) {787  return (788    <div class="peach-overlay-header">789      <button790        class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"791        onClick={props.onBack}792        aria-label="Back"793      >794        <svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">795          <path d="M6 1L1 6L6 11" />796        </svg>797      </button>798      <span class="peach-overlay-title">{props.title}</span>799      <div class="peach-overlay-spacer"></div>800    </div>801  );802}803804function SubviewHeader(props: {805  title: string;806  onBack: () => void;807  mode: "existing" | "onboarding";808}) {809  // Onboarding sub-flows reuse the lock-screen header; existing-vault flows810  // use the standard overlay back-arrow header.811  return (812    <Show813      when={props.mode === "onboarding"}814      fallback={<OverlayHeader title={props.title} onBack={props.onBack} />}815    >816      <div class="peach-lock-view-header">817        <PeachWordmark class="peach-wordmark-wrap" />818        <button class="peach-lock-restore-link" onClick={props.onBack}>819          ‹ back820        </button>821      </div>822    </Show>823  );824}825826function getImportErrorCopy(error: string): string {827  // UI-7 / MV-5: oversized-import errors carry a variable detail after the828  // code, so match the prefix and never surface the raw string.829  if (error.startsWith("IMPORT_TOO_LARGE")) {830    return "This file is too large to import (limit: 10 MiB for backups, 25 MiB for Codex PDFs). If this is a real PeachScript backup, please report it — backups never approach this size.";831  }832  switch (error) {833    case "UNSUPPORTED_IMPORT_FORMAT":834      return "Unrecognised file format. Use CSV, Bitwarden JSON, Proton Pass JSON/CSV, or a PeachScript backup.";835    case "INVALID_PASSPHRASE":836      return "Wrong backup password. This is the password you chose when you created the backup.";837    case "INVALID_PEACHSCRIPT_CHARACTER":838      return "This doesn't look like PeachScript. Make sure you copied the entire backup string.";839    case "PAYLOAD_TOO_SHORT":840      return "PeachScript looks truncated. Copy the full backup string and try again.";841    case "BAD_MAGIC":842    case "UNSUPPORTED_VERSION":843      return "This PeachScript was created by a different version of Peach.";844    case "CHECKSUM_FAILED":845      return "PeachScript appears corrupted. Re-copy from the source device.";846    case "CREATE_VAULT_FAILED":847      return "Peach couldn't create a new local vault before importing.";848    case "CLEAR_VAULT_FAILED":849      return "Peach couldn't clear the existing vault before replacing it.";850    case "MASTER_PASSWORD_REQUIRED":851      return "Choose a master password for your new vault first.";852    case "RESTORE_FAILED":853      return "Restore failed. Your existing vault is unchanged.";854    case "vault_locked":855      return "Peach is locked. Open Peach with your master password and try again.";856    case "refuse to replace with empty entry set":857      return "This PeachScript decoded to zero entries — refusing to wipe the vault.";858    case "NO_ENTRIES_IMPORTED":859      return "No importable password entries were found in that file.";860    case "PEACHSCRIPT_TOO_LARGE":861      return "That backup is too large.";862    default:863      // Never echo raw error strings — internal messages must not reach the UI.864      return "Import failed. Please try again.";865  }866}