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} from "../../crypto/peachScript";15import { parsePayload } from "../../crypto/peachPayload";16import { IMPORT_LIMITS } from "../../shared/import-limits";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 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      // PB-26-208: decodePeachScript skips whitespace internally — no257      // separate normalise pass.258      const bytes = decodePeachScript(scriptInput());259      const payload = await parsePayload(bytes, scriptPass());260261      if (isOnboarding) {262        if (!masterPasswordReady()) {263          throw new Error("MASTER_PASSWORD_REQUIRED");264        }265        const createRes = await sendMessage({266          type: "CREATE_VAULT",267          payload: { password: masterPassword() },268        });269        if (!(createRes as { ok: boolean }).ok) throw new Error("CREATE_VAULT_FAILED");270      }271272      const restoreRes = await sendMessage({273        type: "RESTORE_ENTRIES",274        payload: {275          entries: payload.entries,276          mode: "merge",277          settings: payload.settings,278        },279      });280      const restoreEnvelope = restoreRes as { ok: boolean; error?: string };281      if (!restoreEnvelope.ok) {282        throw new Error(restoreEnvelope.error ?? "RESTORE_FAILED");283      }284285      if (payload.license?.key) {286        await sendMessage({287          type: "ACTIVATE_LICENSE",288          payload: { key: payload.license.key },289        }).catch(() => {});290      }291292      setResult({ entryCount: payload.entries.length });293      await props.onImport();294    } catch (err) {295      const code = err instanceof Error ? err.message : String(err);296      setError(getImportErrorCopy(code));297    } finally {298      setWorking(false);299    }300  }301302  return (303    <>304      <SubviewHeader title="peachscript" onBack={props.onBack} mode={props.mode} />305      <div class="peach-settings-body">306        <Show when={isOnboarding && props.replacesExistingVault}>307          <div class="peach-settings-group">308            <div class="peach-restore-warning">309              Restoring now replaces the locked local vault on this device with310              a fresh one from your backup.311            </div>312          </div>313        </Show>314        <div class="peach-settings-group peach-export-card peach-export-card--primary peach-export-pass-group">315          <p class="peach-settings-label">paste peachscript</p>316          <p class="peach-export-card-copy">317            {isOnboarding318              ? "Paste a PeachScript backup from another Peach device to restore your vault — or load a .peach file from disk."319              : "We'll merge the entries into your existing vault. You can paste PeachScript or load a .peach file."}320          </p>321          <textarea322            class="peach-script-input"323            placeholder="paste peachscript…"324            value={scriptInput()}325            onInput={(e) => setScriptInput((e.target as HTMLTextAreaElement).value)}326          />327          <label328            class="peach-btn peach-btn--ghost peach-btn--sm"329            style="display:inline-flex;align-items:center;justify-content:center;cursor:pointer;"330          >331            Load .peach file332            <input333              type="file"334              accept=".peach,application/x-peach,text/plain"335              style="display:none;"336              onChange={async (event) => {337                const input = event.currentTarget;338                const file = input.files?.[0];339                if (!file) return;340                try {341                  // PB-26-010: bound before reading the file into a string.342                  if (file.size > IMPORT_LIMITS.MAX_JSON_BYTES) {343                    setError("That file is too large.");344                    return;345                  }346                  const text = await file.text();347                  setScriptInput(text);348                } catch (err) {349                  setError(err instanceof Error ? err.message : "Couldn't read file.");350                } finally {351                  input.value = "";352                }353              }}354            />355          </label>356          <div class="peach-settings-stack peach-export-password-stack">357            <input358              class="peach-settings-input"359              type="password"360              placeholder="PeachScript password"361              value={scriptPass()}362              onInput={(e) => setScriptPass((e.target as HTMLInputElement).value)}363            />364          </div>365          <Show when={isOnboarding}>366            <OnboardingMasterPasswordFields367              idPrefix="peachscript"368              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."369              masterPlaceholder="new master password"370              password={masterPassword()}371              confirm={masterConfirm()}372              onPasswordInput={setMasterPassword}373              onConfirmInput={setMasterConfirm}374            />375          </Show>376          <button377            class="peach-btn peach-btn--subtle peach-btn--sm"378            onClick={() => void handleImport()}379            disabled={380              working() ||381              !scriptLooksValid() ||382              !scriptPass().trim() ||383              (isOnboarding && !masterPasswordReady())384            }385          >386            {working() ? "decoding…" : "Restore from PeachScript"}387          </button>388          <Show when={error()}>389            <div class="peach-restore-error">{error()}</div>390          </Show>391          <Show when={result()}>392            <div class="peach-export-success">393              <span class="peach-export-success-check">✓</span>394              <span>395                Restored {result()!.entryCount} entr396                {result()!.entryCount === 1 ? "y" : "ies"}.397              </span>398            </div>399          </Show>400        </div>401      </div>402    </>403  );404}405406// ---------------------------------------------------------------------------407// File flow (Bitwarden / 1Password / etc.)408// ---------------------------------------------------------------------------409410function FileImport(props: {411  mode: "existing" | "onboarding";412  replacesExistingVault?: boolean;413  onBack: () => void;414  onImport: () => void | Promise<void>;415  /** Called when the user picks a .peach file from this picker — the parent416   *  routes them to the PeachScript subview with the contents prefilled. */417  onLoadPeachFile?: (content: string) => void;418}) {419  const isOnboarding = props.mode === "onboarding";420  const needsMasterPassword = () => isOnboarding;421422  const [preview, setPreview] = createSignal<ImportParseResult | null>(null);423  const [masterPassword, setMasterPassword] = createSignal("");424  const [masterConfirm, setMasterConfirm] = createSignal("");425  const [importing, setImporting] = createSignal(false);426  const [error, setError] = createSignal<string | null>(null);427  const [mergeMode, setMergeMode] = createSignal<ImportMergeMode>("add");428  const [awaitingReplaceConfirm, setAwaitingReplaceConfirm] = createSignal(false);429  const [importProgress, setImportProgress] = createSignal<{ current: number; total: number } | null>(null);430  const [duplicateWarning, setDuplicateWarning] = createSignal<number>(0);431  const [existingCount, setExistingCount] = createSignal<number>(0);432433  const masterPasswordReady = createMemo(434    () => masterPassword().length >= 8 && masterPassword() === masterConfirm()435  );436437  async function refreshExistingCount() {438    if (isOnboarding) return;439    const res = await sendMessage({ type: "GET_ENTRIES" });440    const list = (res as { ok: boolean; data?: VaultEntry[] }).data ?? [];441    setExistingCount(list.length);442  }443  void refreshExistingCount();444445  async function parseSource(text: string) {446    try {447      const result = await parseImportSource(text);448      setPreview(result);449      setError(null);450      if (result.format === "unknown") {451        setError(452          "We couldn't recognise this file format. Peach supports: Bitwarden JSON/CSV, Proton Pass JSON/CSV, 1Password JSON, and generic browser CSV exports."453        );454      }455      return result;456    } catch (err) {457      const message = err instanceof Error ? err.message : String(err);458      setPreview(null);459      setError(getImportErrorCopy(message));460      return null;461    }462  }463464  async function handleFileSelect(event: Event) {465    const input = event.target as HTMLInputElement;466    const file = input.files?.[0];467    if (!file) return;468469    setPreview(null);470    setError(null);471    setAwaitingReplaceConfirm(false);472473    if (file.size > IMPORT_LIMITS.MAX_JSON_BYTES) {474      setError("That file is too large.");475      input.value = "";476      return;477    }478    const text = await file.text();479480    // Route .peach files to the PeachScript subview. Detection is two-pronged:481    // either the filename ends in .peach, or the body is entirely PUA chars482    // (the canonical PeachScript shape). The two-pronged check protects483    // against renamed-extension imports and against text editors that484    // accidentally save with a different extension.485    const looksLikePeach =486      file.name.toLowerCase().endsWith(".peach") || isPeachScript(text);487    if (looksLikePeach) {488      if (props.onLoadPeachFile) {489        props.onLoadPeachFile(text);490        input.value = "";491        return;492      }493      setError("This is a .peach file. Open the PeachScript card to import it.");494      return;495    }496497    await parseSource(text);498  }499500  async function countDuplicates(incoming: ImportParseResult["entries"]): Promise<number> {501    if (incoming.length === 0) return 0;502    const res = await sendMessage({ type: "GET_ENTRIES" });503    const existing = ((res as { ok: boolean; data?: VaultEntry[] }).data ?? []).filter((e) => !e.deletedAt);504    const keyOf = (title?: string, url?: string, username?: string) =>505      `${(title ?? "").toLowerCase()}|${(url ?? "").toLowerCase()}|${(username ?? "").toLowerCase()}`;506    const existingKeys = new Set(existing.map((e) => keyOf(e.title, e.url, e.username)));507    let dupes = 0;508    for (const entry of incoming) {509      const u = "username" in entry ? entry.username : undefined;510      const url = "url" in entry ? entry.url : undefined;511      if (existingKeys.has(keyOf(entry.title, url, u))) dupes += 1;512    }513    return dupes;514  }515516  async function handleImport() {517    const currentPreview = preview();518    if (!currentPreview) return;519520    if (needsMasterPassword()) {521      if (masterPassword().length < 8) {522        setError("Master password must be at least 8 characters.");523        return;524      }525      if (!masterPasswordReady()) {526        setError("Master passwords do not match.");527        return;528      }529    }530531    if (!isOnboarding && mergeMode() === "replace" && !awaitingReplaceConfirm()) {532      setAwaitingReplaceConfirm(true);533      return;534    }535536    setImporting(true);537    setError(null);538    setDuplicateWarning(0);539    setImportProgress(null);540541    try {542      if (currentPreview.entries.length === 0) {543        throw new Error("NO_ENTRIES_IMPORTED");544      }545546      if (needsMasterPassword()) {547        const createResponse = await sendMessage({548          type: "CREATE_VAULT",549          payload: { password: masterPassword() },550        });551        if (!(createResponse as { ok: boolean }).ok) {552          throw new Error("CREATE_VAULT_FAILED");553        }554      }555556      if (!isOnboarding && mergeMode() === "replace") {557        const clearRes = await sendMessage({ type: "CLEAR_VAULT_ENTRIES" });558        if (!(clearRes as { ok: boolean }).ok) {559          throw new Error("CLEAR_VAULT_FAILED");560        }561      }562563      let dupes = 0;564      if (!isOnboarding && mergeMode() === "add") {565        dupes = await countDuplicates(currentPreview.entries);566      }567568      let imported = 0;569      setImportProgress({ current: 0, total: currentPreview.entries.length });570571      const batchRes = await sendMessage({572        type: "BATCH_ADD_ENTRIES",573        payload: { entries: currentPreview.entries },574      });575576      if ((batchRes as { ok: boolean }).ok) {577        imported = ((batchRes as { ok: boolean; data?: VaultEntry[] }).data ?? []).length;578        setImportProgress({ current: imported, total: currentPreview.entries.length });579      } else {580        for (const entry of currentPreview.entries) {581          const response = await sendMessage({582            type: "ADD_ENTRY",583            payload: entry,584          });585          if ((response as { ok: boolean }).ok) imported += 1;586          setImportProgress({ current: imported, total: currentPreview.entries.length });587        }588      }589590      if (imported === 0) {591        throw new Error("NO_ENTRIES_IMPORTED");592      }593594      setDuplicateWarning(dupes);595      await props.onImport();596    } catch (err) {597      const message = err instanceof Error ? err.message : String(err);598      setError(getImportErrorCopy(message));599      setImporting(false);600      setImportProgress(null);601      return;602    }603604    setImporting(false);605    setImportProgress(null);606  }607608  return (609    <>610      <SubviewHeader title="backup file" onBack={props.onBack} mode={props.mode} />611      <div class="peach-settings-body">612        <Show when={isOnboarding && props.replacesExistingVault}>613          <div class="peach-settings-group">614            <div class="peach-restore-warning">615              Restoring now replaces the locked local vault on this device with616              a fresh one from your backup.617            </div>618          </div>619        </Show>620        <div class="peach-settings-group peach-export-card peach-export-card--primary">621          <p class="peach-settings-label">choose a file</p>622          <p class="peach-export-card-copy">623            Bitwarden, 1Password, Proton Pass, Chrome / Edge / Safari CSV, FIDO624              credential-exchange passkeys.625          </p>626627          <div class="peach-provider-chips">628            <span class="peach-provider-chip">FIDO passkeys</span>629            <span class="peach-provider-chip">Proton Pass</span>630            <span class="peach-provider-chip">Bitwarden</span>631            <span class="peach-provider-chip">1Password</span>632            <span class="peach-provider-chip">Browser CSV</span>633          </div>634635          <input636            class="peach-restore-file"637            type="file"638            accept=".csv,.json,.peach,application/x-peach"639            onChange={(event) => void handleFileSelect(event)}640          />641        </div>642643        <Show when={preview() && preview()!.format !== "unknown"}>644          <div class="peach-settings-group">645            <p class="peach-settings-label">{preview()!.label}</p>646            <div class="peach-import-summary">647              Found <strong>{preview()!.entries.length}</strong>{" "}648              entr{preview()!.entries.length === 1 ? "y" : "ies"}649              {preview()!.skippedItems > 0650                ? `. ${preview()!.skippedItems} skipped because no password was present.`651                : "."}652            </div>653654            <div class="peach-import-preview">655              {preview()!.entries.slice(0, 8).map((entry) => (656                <div class="peach-import-preview-row">657                  <span class="peach-import-preview-title">{entry.title}</span>658                  <span class="peach-import-preview-meta">659                    {entry.passkey660                      ? `${entry.passkey.userName} • ${entry.passkey.rpId} (passkey)`661                      : entry.type === "note"662                        ? "secure note"663                        : entry.username || entry.url || "password item"}664                  </span>665                </div>666              ))}667              <Show when={preview()!.entries.length > 8}>668                <div class="peach-import-preview-more">669                  ...and {preview()!.entries.length - 8} more670                </div>671              </Show>672            </div>673          </div>674        </Show>675676        <Show when={!isOnboarding && preview() && preview()!.format !== "unknown" && preview()!.entries.length > 0}>677          <div class="peach-settings-group">678            <p class="peach-settings-label">merge or replace</p>679            <label class="peach-import-choice">680              <input681                type="radio"682                name="merge-mode"683                value="add"684                checked={mergeMode() === "add"}685                onChange={() => { setMergeMode("add"); setAwaitingReplaceConfirm(false); }}686              />687              <span class="peach-import-choice-main">688                <span class="peach-import-choice-label">Add to existing vault</span>689                <span class="peach-import-choice-sub">690                  This will add {preview()!.entries.length} entries to your existing {existingCount()}.691                </span>692              </span>693            </label>694            <label class="peach-import-choice">695              <input696                type="radio"697                name="merge-mode"698                value="replace"699                checked={mergeMode() === "replace"}700                onChange={() => setMergeMode("replace")}701              />702              <span class="peach-import-choice-main">703                <span class="peach-import-choice-label">Replace vault</span>704                <span class="peach-import-choice-sub">705                  This will replace your {existingCount()} entries with {preview()!.entries.length} imported ones.706                </span>707              </span>708            </label>709            <Show when={awaitingReplaceConfirm()}>710              <div class="peach-restore-warning" style="margin-top:6px">711                Replace forever? This cannot be undone.712              </div>713            </Show>714          </div>715        </Show>716717        <Show when={needsMasterPassword() && preview() && preview()!.format !== "unknown"}>718          <div class="peach-settings-group">719            <OnboardingMasterPasswordFields720              idPrefix="backup"721              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."722              masterPlaceholder="new master password"723              password={masterPassword()}724              confirm={masterConfirm()}725              onPasswordInput={setMasterPassword}726              onConfirmInput={setMasterConfirm}727            />728          </div>729        </Show>730731        <Show when={error()}>732          <div class="peach-settings-group">733            <div class="peach-restore-error">{error()}</div>734          </div>735        </Show>736737        <Show when={importing() && importProgress()}>738          <div class="peach-settings-group">739            <div class="peach-import-summary">740              Importing... [{importProgress()!.current} / {importProgress()!.total}]741            </div>742          </div>743        </Show>744745        <Show when={duplicateWarning() > 0 && !importing()}>746          <div class="peach-settings-group">747            <div class="peach-export-card-copy">748              {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.749            </div>750          </div>751        </Show>752753        <Show when={preview() && preview()!.format !== "unknown"}>754          <div class="peach-settings-group">755            <div class="peach-settings-actions">756              <button757                class="peach-btn peach-btn--subtle peach-btn--sm"758                onClick={() => void handleImport()}759                disabled={760                  importing() ||761                  preview()!.entries.length === 0 ||762                  (needsMasterPassword() && !masterPasswordReady())763                }764              >765                {importing()766                  ? "importing..."767                  : isOnboarding768                    ? `Create vault + import ${preview()!.entries.length}`769                    : mergeMode() === "replace"770                      ? awaitingReplaceConfirm()771                        ? "Yes, replace forever"772                        : `Replace vault with ${preview()!.entries.length}`773                      : `Import ${preview()!.entries.length}`}774              </button>775            </div>776          </div>777        </Show>778      </div>779    </>780  );781}782783// ---------------------------------------------------------------------------784// Shared headers785// ---------------------------------------------------------------------------786787function OverlayHeader(props: { title: string; onBack: () => void }) {788  return (789    <div class="peach-overlay-header">790      <button791        class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"792        onClick={props.onBack}793        aria-label="Back"794      >795        <svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">796          <path d="M6 1L1 6L6 11" />797        </svg>798      </button>799      <span class="peach-overlay-title">{props.title}</span>800      <div class="peach-overlay-spacer"></div>801    </div>802  );803}804805function SubviewHeader(props: {806  title: string;807  onBack: () => void;808  mode: "existing" | "onboarding";809}) {810  // Onboarding sub-flows reuse the lock-screen header; existing-vault flows811  // use the standard overlay back-arrow header.812  return (813    <Show814      when={props.mode === "onboarding"}815      fallback={<OverlayHeader title={props.title} onBack={props.onBack} />}816    >817      <div class="peach-lock-view-header">818        <PeachWordmark class="peach-wordmark-wrap" />819        <button class="peach-lock-restore-link" onClick={props.onBack}>820          ‹ back821        </button>822      </div>823    </Show>824  );825}826827function getImportErrorCopy(error: string): string {828  // UI-7 / MV-5: oversized-import errors carry a variable detail after the829  // code, so match the prefix and never surface the raw string.830  if (error.startsWith("IMPORT_TOO_LARGE")) {831    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.";832  }833  switch (error) {834    case "UNSUPPORTED_IMPORT_FORMAT":835      return "Unrecognised file format. Use CSV, Bitwarden JSON, Proton Pass JSON/CSV, or a PeachScript backup.";836    case "INVALID_PASSPHRASE":837      return "Wrong backup password. This is the password you chose when you created the backup.";838    case "INVALID_PEACHSCRIPT_CHARACTER":839      return "This doesn't look like PeachScript. Make sure you copied the entire backup string.";840    case "PAYLOAD_TOO_SHORT":841      return "PeachScript looks truncated. Copy the full backup string and try again.";842    case "BAD_MAGIC":843    case "UNSUPPORTED_VERSION":844      return "This PeachScript was created by a different version of Peach.";845    case "CHECKSUM_FAILED":846      return "PeachScript appears corrupted. Re-copy from the source device.";847    case "CREATE_VAULT_FAILED":848      return "Peach couldn't create a new local vault before importing.";849    case "CLEAR_VAULT_FAILED":850      return "Peach couldn't clear the existing vault before replacing it.";851    case "MASTER_PASSWORD_REQUIRED":852      return "Choose a master password for your new vault first.";853    case "RESTORE_FAILED":854      return "Restore failed. Your existing vault is unchanged.";855    case "vault_locked":856      return "Peach is locked. Open Peach with your master password and try again.";857    case "refuse to replace with empty entry set":858      return "This PeachScript decoded to zero entries — refusing to wipe the vault.";859    case "NO_ENTRIES_IMPORTED":860      return "No importable password entries were found in that file.";861    case "PEACHSCRIPT_TOO_LARGE":862      return "That backup is too large.";863    default:864      // Never echo raw error strings — internal messages must not reach the UI.865      return "Import failed. Please try again.";866  }867}