Import.tsx

30.6 KB · raw

1// Signal-conditioned views in this file must use <Show>, never a2// signal-conditioned early return — see Projects.md §4.1.3import { createMemo, createSignal, Show } from "solid-js";45import { sendMessage } from "../App";6import { PeachWordmark } from "../components/PeachMark";7import {8  parseImportSource,9  type ImportParseResult,10} from "../../shared/import";11import {12  decodePeachScript,13  isPeachScript,14  normalisePeachScript,15} from "../../crypto/peachScript";16import { parsePayload } from "../../crypto/peachPayload";17import type { VaultEntry } from "../../shared/types";1819type ImportSubview = "menu" | "peachscript" | "file";20type ImportMergeMode = "add" | "replace";2122interface ImportProps {23  mode?: "existing" | "onboarding";24  replacesExistingVault?: boolean;25  /** Skip the menu and open straight into a specific subview. */26  initialView?: ImportSubview;27  onBack: () => void;28  onImport: () => void | Promise<void>;29}3031export function ImportView(props: ImportProps) {32  const [view, setView] = createSignal<ImportSubview>(props.initialView ?? "menu");33  const mode = () => props.mode ?? "existing";34  const isOnboarding = () => mode() === "onboarding";35  // When the caller deep-links into a subview, the back arrow exits the36  // ImportView entirely. Otherwise it returns to the import menu.37  const subviewBack = () => (props.initialView ? props.onBack() : setView("menu"));3839  return (40    <div class={isOnboarding() ? "peach-lock-view" : "peach-overlay-view ov-enter-right"} style="display:flex">41      <Show when={view() === "menu"}>42        <ImportMenu43          mode={mode()}44          replacesExistingVault={props.replacesExistingVault}45          onBack={props.onBack}46          onPick={(v) => setView(v)}47        />48      </Show>49      <Show when={view() === "peachscript"}>50        <PeachScriptImport51          mode={mode()}52          replacesExistingVault={props.replacesExistingVault}53          onBack={subviewBack}54          onImport={props.onImport}55        />56      </Show>57      <Show when={view() === "file"}>58        <FileImport59          mode={mode()}60          replacesExistingVault={props.replacesExistingVault}61          onBack={subviewBack}62          onImport={props.onImport}63        />64      </Show>65    </div>66  );67}6869// ---------------------------------------------------------------------------70// Menu71// ---------------------------------------------------------------------------7273function ImportMenu(props: {74  mode: "existing" | "onboarding";75  replacesExistingVault?: boolean;76  onBack: () => void;77  onPick: (view: ImportSubview) => void;78}) {79  const isOnboarding = props.mode === "onboarding";80  return (81    <>82      <Show83        when={isOnboarding}84        fallback={<OverlayHeader title="import" onBack={props.onBack} />}85      >86        <div class="peach-lock-view-header">87          <PeachWordmark class="peach-wordmark-wrap" />88          <button class="peach-lock-restore-link" onClick={props.onBack}>89            ‹ back90          </button>91        </div>92      </Show>93      <div class="peach-settings-body peach-export-menu">94        <Show when={isOnboarding && props.replacesExistingVault}>95          <div class="peach-settings-group">96            <div class="peach-restore-warning">97              Restoring now replaces the locked local vault on this device with98              a fresh one from your backup or export file.99            </div>100          </div>101        </Show>102103        <div class="peach-settings-group peach-export-card peach-export-card--primary">104          <p class="peach-settings-label">peachscript</p>105          <p class="peach-export-card-copy">106            Paste a PeachScript backup from another Peach device.107            {props.mode === "existing" ? " We'll merge it into your vault." : ""}108          </p>109          <button110            class="peach-btn peach-btn--subtle peach-btn--sm"111            onClick={() => props.onPick("peachscript")}112          >113            Paste PeachScript →114          </button>115        </div>116117        <div class="peach-settings-group peach-export-card">118          <p class="peach-settings-label">backup file</p>119          <p class="peach-export-card-copy">120            A Peach JSON export, or a file from Bitwarden, Proton Pass,121            1Password, or a browser CSV (Chrome, Safari, Edge).122          </p>123          <button124            class="peach-btn peach-btn--subtle peach-btn--sm"125            onClick={() => props.onPick("file")}126          >127            Choose file →128          </button>129        </div>130      </div>131    </>132  );133}134135// ---------------------------------------------------------------------------136// PeachScript paste flow137// ---------------------------------------------------------------------------138139function PeachScriptImport(props: {140  mode: "existing" | "onboarding";141  replacesExistingVault?: boolean;142  onBack: () => void;143  onImport: () => void | Promise<void>;144}) {145  const [scriptInput, setScriptInput] = createSignal("");146  const [scriptPass, setScriptPass] = createSignal("");147  const [masterPassword, setMasterPassword] = createSignal("");148  const [masterConfirm, setMasterConfirm] = createSignal("");149  const [working, setWorking] = createSignal(false);150  const [error, setError] = createSignal<string | null>(null);151  const [result, setResult] = createSignal<{ entryCount: number } | null>(null);152153  const scriptLooksValid = createMemo(() => isPeachScript(scriptInput()));154  const isOnboarding = props.mode === "onboarding";155  const masterPasswordReady = createMemo(156    () => masterPassword().length >= 8 && masterPassword() === masterConfirm()157  );158159  async function handleImport() {160    setError(null);161    setResult(null);162    if (!scriptLooksValid()) {163      setError("Paste your PeachScript first.");164      return;165    }166    if (!scriptPass().trim()) {167      setError("Enter the backup password.");168      return;169    }170    setWorking(true);171    try {172      const bytes = decodePeachScript(normalisePeachScript(scriptInput()));173      const payload = await parsePayload(bytes, scriptPass());174175      if (isOnboarding) {176        if (!masterPasswordReady()) {177          throw new Error("MASTER_PASSWORD_REQUIRED");178        }179        const createRes = await sendMessage({180          type: "CREATE_VAULT",181          payload: { password: masterPassword() },182        });183        if (!(createRes as { ok: boolean }).ok) throw new Error("CREATE_VAULT_FAILED");184      }185186      const restoreRes = await sendMessage({187        type: "RESTORE_ENTRIES",188        payload: {189          entries: payload.entries,190          mode: "merge",191          settings: payload.settings,192        },193      });194      const restoreEnvelope = restoreRes as { ok: boolean; error?: string };195      if (!restoreEnvelope.ok) {196        throw new Error(restoreEnvelope.error ?? "RESTORE_FAILED");197      }198199      if (payload.license?.key) {200        await sendMessage({201          type: "ACTIVATE_LICENSE",202          payload: { key: payload.license.key },203        }).catch(() => {});204      }205206      setResult({ entryCount: payload.entries.length });207      await props.onImport();208    } catch (err) {209      const code = err instanceof Error ? err.message : String(err);210      setError(getImportErrorCopy(code));211    } finally {212      setWorking(false);213    }214  }215216  return (217    <>218      <SubviewHeader title="peachscript" onBack={props.onBack} mode={props.mode} />219      <div class="peach-settings-body">220        <Show when={isOnboarding && props.replacesExistingVault}>221          <div class="peach-settings-group">222            <div class="peach-restore-warning">223              Restoring now replaces the locked local vault on this device with224              a fresh one from your backup.225            </div>226          </div>227        </Show>228        <div class="peach-settings-group peach-export-card peach-export-card--primary peach-export-pass-group">229          <p class="peach-settings-label">paste peachscript</p>230          <p class="peach-export-card-copy">231            {isOnboarding232              ? "Paste a PeachScript backup from another Peach device to restore your vault — or load a .peach file from disk."233              : "We'll merge the entries into your existing vault. You can paste PeachScript or load a .peach file."}234          </p>235          <textarea236            class="peach-script-input"237            placeholder="paste PeachScript here…"238            value={scriptInput()}239            onInput={(e) => setScriptInput((e.target as HTMLTextAreaElement).value)}240          />241          <label242            class="peach-btn peach-btn--ghost peach-btn--sm"243            style="display:inline-flex;align-items:center;justify-content:center;cursor:pointer;"244          >245            Load .peach file246            <input247              type="file"248              accept=".peach,application/x-peach,text/plain"249              style="display:none;"250              onChange={async (event) => {251                const input = event.currentTarget;252                const file = input.files?.[0];253                if (!file) return;254                try {255                  const text = await file.text();256                  setScriptInput(text);257                } catch (err) {258                  setError(err instanceof Error ? err.message : "Couldn't read file.");259                } finally {260                  input.value = "";261                }262              }}263            />264          </label>265          <div class="peach-settings-stack peach-export-password-stack">266            <input267              class="peach-settings-input"268              type="password"269              placeholder="PeachScript password"270              value={scriptPass()}271              onInput={(e) => setScriptPass((e.target as HTMLInputElement).value)}272            />273          </div>274          <Show when={isOnboarding}>275            <p class="peach-settings-label" style="margin-top:4px">new master password</p>276            <p class="peach-export-card-copy">277              This password unlocks Peach on this device after the restore.278            </p>279            <div class="peach-settings-stack peach-export-password-stack">280              <input281                class="peach-settings-input"282                type="password"283                placeholder={`master password (min 8 chars)`}284                value={masterPassword()}285                onInput={(e) => setMasterPassword((e.target as HTMLInputElement).value)}286              />287              <input288                class="peach-settings-input"289                type="password"290                placeholder="confirm master password"291                value={masterConfirm()}292                onInput={(e) => setMasterConfirm((e.target as HTMLInputElement).value)}293              />294              <Show when={masterConfirm().length > 0 && masterPassword() !== masterConfirm()}>295                <p class="peach-export-note" style="color:var(--peach-t2)">296                  Passwords don't match297                </p>298              </Show>299            </div>300          </Show>301          <button302            class="peach-btn peach-btn--subtle peach-btn--sm"303            onClick={() => void handleImport()}304            disabled={305              working() ||306              !scriptLooksValid() ||307              !scriptPass().trim() ||308              (isOnboarding && !masterPasswordReady())309            }310          >311            {working() ? "decoding…" : "Restore from PeachScript"}312          </button>313          <Show when={error()}>314            <div class="peach-restore-error">{error()}</div>315          </Show>316          <Show when={result()}>317            <div class="peach-export-success">318              <span class="peach-export-success-check">✓</span>319              <span>320                Restored {result()!.entryCount} entr321                {result()!.entryCount === 1 ? "y" : "ies"}.322              </span>323            </div>324          </Show>325        </div>326      </div>327    </>328  );329}330331// ---------------------------------------------------------------------------332// File flow (Bitwarden / 1Password / Peach JSON / etc.)333// ---------------------------------------------------------------------------334335function FileImport(props: {336  mode: "existing" | "onboarding";337  replacesExistingVault?: boolean;338  onBack: () => void;339  onImport: () => void | Promise<void>;340}) {341  const isOnboarding = props.mode === "onboarding";342  const needsMasterPassword = () => isOnboarding;343344  const [preview, setPreview] = createSignal<ImportParseResult | null>(null);345  const [rawImport, setRawImport] = createSignal<string | null>(null);346  const [backupPassword, setBackupPassword] = createSignal("");347  const [masterPassword, setMasterPassword] = createSignal("");348  const [masterConfirm, setMasterConfirm] = createSignal("");349  const [importing, setImporting] = createSignal(false);350  const [error, setError] = createSignal<string | null>(null);351  const [mergeMode, setMergeMode] = createSignal<ImportMergeMode>("add");352  const [awaitingReplaceConfirm, setAwaitingReplaceConfirm] = createSignal(false);353  const [importProgress, setImportProgress] = createSignal<{ current: number; total: number } | null>(null);354  const [duplicateWarning, setDuplicateWarning] = createSignal<number>(0);355  const [existingCount, setExistingCount] = createSignal<number>(0);356357  const needsBackupPassword = createMemo(() => preview()?.requiresPassword ?? false);358  const masterPasswordReady = createMemo(359    () => masterPassword().length >= 8 && masterPassword() === masterConfirm()360  );361362  async function refreshExistingCount() {363    if (isOnboarding) return;364    const res = await sendMessage({ type: "GET_ENTRIES" });365    const list = (res as { ok: boolean; data?: VaultEntry[] }).data ?? [];366    setExistingCount(list.length);367  }368  void refreshExistingCount();369370  async function parseSource(text: string, password?: string) {371    try {372      const result = await parseImportSource(text, password);373      setPreview(result);374      setError(null);375      if (result.format === "unknown") {376        setError(377          "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."378        );379      }380      return result;381    } catch (err) {382      const message = err instanceof Error ? err.message : String(err);383      setPreview(null);384      setError(getImportErrorCopy(message));385      return null;386    }387  }388389  async function handleFileSelect(event: Event) {390    const file = (event.target as HTMLInputElement).files?.[0];391    if (!file) return;392393    setPreview(null);394    setRawImport(null);395    setBackupPassword("");396    setError(null);397    setAwaitingReplaceConfirm(false);398399    const text = await file.text();400    setRawImport(text);401    await parseSource(text);402  }403404  async function handleUnlockBackup() {405    const raw = rawImport();406    if (!raw || !backupPassword().trim()) return;407    await parseSource(raw, backupPassword());408  }409410  async function countDuplicates(incoming: ImportParseResult["entries"]): Promise<number> {411    if (incoming.length === 0) return 0;412    const res = await sendMessage({ type: "GET_ENTRIES" });413    const existing = ((res as { ok: boolean; data?: VaultEntry[] }).data ?? []).filter((e) => !e.deletedAt);414    const keyOf = (title?: string, url?: string, username?: string) =>415      `${(title ?? "").toLowerCase()}|${(url ?? "").toLowerCase()}|${(username ?? "").toLowerCase()}`;416    const existingKeys = new Set(existing.map((e) => keyOf(e.title, e.url, e.username)));417    let dupes = 0;418    for (const entry of incoming) {419      const u = "username" in entry ? entry.username : undefined;420      const url = "url" in entry ? entry.url : undefined;421      if (existingKeys.has(keyOf(entry.title, url, u))) dupes += 1;422    }423    return dupes;424  }425426  async function handleImport() {427    const currentPreview = preview();428    if (!currentPreview) return;429430    if (needsBackupPassword() && !backupPassword().trim()) {431      setError("Enter the backup password used for this export.");432      return;433    }434435    if (needsMasterPassword()) {436      if (masterPassword().length < 8) {437        setError("Master password must be at least 8 characters.");438        return;439      }440      if (!masterPasswordReady()) {441        setError("Master passwords do not match.");442        return;443      }444    }445446    if (!isOnboarding && mergeMode() === "replace" && !awaitingReplaceConfirm()) {447      setAwaitingReplaceConfirm(true);448      return;449    }450451    setImporting(true);452    setError(null);453    setDuplicateWarning(0);454    setImportProgress(null);455456    try {457      if (currentPreview.entries.length === 0) {458        throw new Error("NO_ENTRIES_IMPORTED");459      }460461      if (needsMasterPassword()) {462        const createResponse = await sendMessage({463          type: "CREATE_VAULT",464          payload: { password: masterPassword() },465        });466        if (!(createResponse as { ok: boolean }).ok) {467          throw new Error("CREATE_VAULT_FAILED");468        }469      }470471      if (!isOnboarding && mergeMode() === "replace") {472        const clearRes = await sendMessage({ type: "CLEAR_VAULT_ENTRIES" });473        if (!(clearRes as { ok: boolean }).ok) {474          throw new Error("CLEAR_VAULT_FAILED");475        }476      }477478      let dupes = 0;479      if (!isOnboarding && mergeMode() === "add") {480        dupes = await countDuplicates(currentPreview.entries);481      }482483      let imported = 0;484      setImportProgress({ current: 0, total: currentPreview.entries.length });485486      const batchRes = await sendMessage({487        type: "BATCH_ADD_ENTRIES",488        payload: { entries: currentPreview.entries },489      });490491      if ((batchRes as { ok: boolean }).ok) {492        imported = ((batchRes as { ok: boolean; data?: VaultEntry[] }).data ?? []).length;493        setImportProgress({ current: imported, total: currentPreview.entries.length });494      } else {495        for (const entry of currentPreview.entries) {496          const response = await sendMessage({497            type: "ADD_ENTRY",498            payload: entry,499          });500          if ((response as { ok: boolean }).ok) imported += 1;501          setImportProgress({ current: imported, total: currentPreview.entries.length });502        }503      }504505      if (imported === 0) {506        throw new Error("NO_ENTRIES_IMPORTED");507      }508509      if (currentPreview.licenseKey) {510        await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key: currentPreview.licenseKey } }).catch(() => {});511      }512513      setDuplicateWarning(dupes);514      await props.onImport();515    } catch (err) {516      const message = err instanceof Error ? err.message : String(err);517      setError(getImportErrorCopy(message));518      setImporting(false);519      setImportProgress(null);520      return;521    }522523    setImporting(false);524    setImportProgress(null);525  }526527  return (528    <>529      <SubviewHeader title="backup file" onBack={props.onBack} mode={props.mode} />530      <div class="peach-settings-body">531        <Show when={isOnboarding && props.replacesExistingVault}>532          <div class="peach-settings-group">533            <div class="peach-restore-warning">534              Restoring now replaces the locked local vault on this device with535              a fresh one from your backup.536            </div>537          </div>538        </Show>539        <div class="peach-settings-group peach-export-card peach-export-card--primary">540          <p class="peach-settings-label">choose a file</p>541          <p class="peach-export-card-copy">542            Bitwarden, 1Password, Proton Pass, Chrome / Edge / Safari CSV, FIDO543            credential-exchange passkeys, or a Peach JSON backup.544          </p>545546          <div class="peach-provider-chips">547            <span class="peach-provider-chip">Peach JSON</span>548            <span class="peach-provider-chip">FIDO passkeys</span>549            <span class="peach-provider-chip">Proton Pass</span>550            <span class="peach-provider-chip">Bitwarden</span>551            <span class="peach-provider-chip">1Password</span>552            <span class="peach-provider-chip">Browser CSV</span>553          </div>554555          <input556            class="peach-restore-file"557            type="file"558            accept=".csv,.json"559            onChange={(event) => void handleFileSelect(event)}560          />561        </div>562563        <Show when={needsBackupPassword()}>564          <div class="peach-settings-group">565            <p class="peach-settings-label">unlock backup</p>566            <p class="peach-export-card-copy">567              Enter the password used when you created this encrypted Peach JSON568              backup.569            </p>570            <input571              class="peach-restore-input"572              type="password"573              placeholder="backup password"574              value={backupPassword()}575              onInput={(event) =>576                setBackupPassword((event.target as HTMLInputElement).value)577              }578              onBlur={() => { if (backupPassword().trim()) void handleUnlockBackup(); }}579              onKeyDown={(event) => {580                if (event.key === "Enter") {581                  event.preventDefault();582                  void handleUnlockBackup();583                }584              }}585            />586            <div class="peach-settings-actions">587              <button588                class="peach-btn peach-btn--subtle peach-btn--sm"589                onClick={() => void handleUnlockBackup()}590                disabled={!backupPassword().trim()}591              >592                Decrypt →593              </button>594            </div>595          </div>596        </Show>597598        <Show when={preview() && !needsBackupPassword() && preview()!.format !== "unknown"}>599          <div class="peach-settings-group">600            <p class="peach-settings-label">{preview()!.label}</p>601            <div class="peach-import-summary">602              Found <strong>{preview()!.entries.length}</strong>{" "}603              entr{preview()!.entries.length === 1 ? "y" : "ies"}604              {preview()!.skippedItems > 0605                ? `. ${preview()!.skippedItems} skipped because no password was present.`606                : "."}607            </div>608609            <div class="peach-import-preview">610              {preview()!.entries.slice(0, 8).map((entry) => (611                <div class="peach-import-preview-row">612                  <span class="peach-import-preview-title">{entry.title}</span>613                  <span class="peach-import-preview-meta">614                    {entry.passkey615                      ? `${entry.passkey.userName} • ${entry.passkey.rpId} (passkey)`616                      : entry.type === "note"617                        ? "secure note"618                        : entry.username || entry.url || "password item"}619                  </span>620                </div>621              ))}622              <Show when={preview()!.entries.length > 8}>623                <div class="peach-import-preview-more">624                  ...and {preview()!.entries.length - 8} more625                </div>626              </Show>627            </div>628          </div>629        </Show>630631        <Show when={!isOnboarding && preview() && !needsBackupPassword() && preview()!.format !== "unknown" && preview()!.entries.length > 0}>632          <div class="peach-settings-group">633            <p class="peach-settings-label">merge or replace</p>634            <label class="peach-import-choice">635              <input636                type="radio"637                name="merge-mode"638                value="add"639                checked={mergeMode() === "add"}640                onChange={() => { setMergeMode("add"); setAwaitingReplaceConfirm(false); }}641              />642              <span class="peach-import-choice-main">643                <span class="peach-import-choice-label">Add to existing vault</span>644                <span class="peach-import-choice-sub">645                  This will add {preview()!.entries.length} entries to your existing {existingCount()}.646                </span>647              </span>648            </label>649            <label class="peach-import-choice">650              <input651                type="radio"652                name="merge-mode"653                value="replace"654                checked={mergeMode() === "replace"}655                onChange={() => setMergeMode("replace")}656              />657              <span class="peach-import-choice-main">658                <span class="peach-import-choice-label">Replace vault</span>659                <span class="peach-import-choice-sub">660                  This will replace your {existingCount()} entries with {preview()!.entries.length} imported ones.661                </span>662              </span>663            </label>664            <Show when={awaitingReplaceConfirm()}>665              <div class="peach-restore-warning" style="margin-top:6px">666                Replace forever? This cannot be undone.667              </div>668            </Show>669          </div>670        </Show>671672        <Show when={needsMasterPassword() && preview() && !needsBackupPassword() && preview()!.format !== "unknown"}>673          <div class="peach-settings-group">674            <p class="peach-settings-label">new master password</p>675            <p class="peach-export-card-copy">676              This password unlocks the Peach vault created on this device after677              import.678            </p>679            <div class="peach-auth-grid">680              <input681                class="peach-restore-input"682                type="password"683                placeholder="new master password"684                value={masterPassword()}685                onInput={(event) =>686                  setMasterPassword((event.target as HTMLInputElement).value)687                }688              />689              <input690                class="peach-restore-input"691                type="password"692                placeholder="confirm master password"693                value={masterConfirm()}694                onInput={(event) =>695                  setMasterConfirm((event.target as HTMLInputElement).value)696                }697              />698            </div>699          </div>700        </Show>701702        <Show when={error()}>703          <div class="peach-settings-group">704            <div class="peach-restore-error">{error()}</div>705          </div>706        </Show>707708        <Show when={importing() && importProgress()}>709          <div class="peach-settings-group">710            <div class="peach-import-summary">711              Importing... [{importProgress()!.current} / {importProgress()!.total}]712            </div>713          </div>714        </Show>715716        <Show when={duplicateWarning() > 0 && !importing()}>717          <div class="peach-settings-group">718            <div class="peach-export-card-copy">719              {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.720            </div>721          </div>722        </Show>723724        <Show when={preview() && !needsBackupPassword() && preview()!.format !== "unknown"}>725          <div class="peach-settings-group">726            <div class="peach-settings-actions">727              <button728                class="peach-btn peach-btn--subtle peach-btn--sm"729                onClick={() => void handleImport()}730                disabled={731                  importing() ||732                  preview()!.entries.length === 0 ||733                  (needsMasterPassword() && !masterPasswordReady())734                }735              >736                {importing()737                  ? "importing..."738                  : isOnboarding739                    ? `Create vault + import ${preview()!.entries.length}`740                    : mergeMode() === "replace"741                      ? awaitingReplaceConfirm()742                        ? "Yes, replace forever"743                        : `Replace vault with ${preview()!.entries.length}`744                      : `Import ${preview()!.entries.length}`}745              </button>746            </div>747          </div>748        </Show>749      </div>750    </>751  );752}753754// ---------------------------------------------------------------------------755// Shared headers756// ---------------------------------------------------------------------------757758function OverlayHeader(props: { title: string; onBack: () => void }) {759  return (760    <div class="peach-overlay-header">761      <button762        class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"763        onClick={props.onBack}764        aria-label="Back"765      >766        <svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">767          <path d="M6 1L1 6L6 11" />768        </svg>769      </button>770      <span class="peach-overlay-title">{props.title}</span>771      <div class="peach-overlay-spacer"></div>772    </div>773  );774}775776function SubviewHeader(props: {777  title: string;778  onBack: () => void;779  mode: "existing" | "onboarding";780}) {781  // Onboarding sub-flows reuse the lock-screen header; existing-vault flows782  // use the standard overlay back-arrow header.783  return (784    <Show785      when={props.mode === "onboarding"}786      fallback={<OverlayHeader title={props.title} onBack={props.onBack} />}787    >788      <div class="peach-lock-view-header">789        <PeachWordmark class="peach-wordmark-wrap" />790        <button class="peach-lock-restore-link" onClick={props.onBack}>791          ‹ back792        </button>793      </div>794    </Show>795  );796}797798function getImportErrorCopy(error: string): string {799  switch (error) {800    case "UNSUPPORTED_IMPORT_FORMAT":801      return "Unrecognised file format. Use CSV, Bitwarden JSON, Proton Pass JSON/CSV, or a Peach encrypted backup.";802    case "INVALID_EXPORT_PASSWORD":803    case "INVALID_PASSPHRASE":804      return "Wrong backup password. This is the password you chose when you created the backup.";805    case "INVALID_PEACH_EXPORT":806      return "This Peach backup file looks corrupted or incomplete.";807    case "INVALID_PEACHSCRIPT_CHARACTER":808      return "This doesn't look like PeachScript. Make sure you copied the entire backup string.";809    case "PAYLOAD_TOO_SHORT":810      return "PeachScript looks truncated. Copy the full backup string and try again.";811    case "BAD_MAGIC":812    case "UNSUPPORTED_VERSION":813      return "This PeachScript was created by a different version of Peach.";814    case "CHECKSUM_FAILED":815      return "PeachScript appears corrupted. Re-copy from the source device.";816    case "CREATE_VAULT_FAILED":817      return "Peach couldn't create a new local vault before importing.";818    case "CLEAR_VAULT_FAILED":819      return "Peach couldn't clear the existing vault before replacing it.";820    case "MASTER_PASSWORD_REQUIRED":821      return "Choose a master password for your new vault first.";822    case "RESTORE_FAILED":823      return "Restore failed. Your existing vault is unchanged.";824    case "vault locked":825      return "Peach is locked. Open Peach with your master password and try again.";826    case "refuse to replace with empty entry set":827      return "This PeachScript decoded to zero entries — refusing to wipe the vault.";828    case "NO_ENTRIES_IMPORTED":829      return "No importable password entries were found in that file.";830    default:831      return error;832  }833}