Import.tsx

26.1 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";18import { CJKImportView } from "./CJKImport";1920interface ImportProps {21  mode?: "existing" | "onboarding";22  replacesExistingVault?: boolean;23  onBack: () => void;24  onImport: () => void | Promise<void>;25}2627type ImportMergeMode = "add" | "replace";2829export function ImportView(props: ImportProps) {30  const [showCjkImport, setShowCjkImport] = createSignal(false);31  return (32    <Show33      when={showCjkImport()}34      fallback={35        <MainImportView36          mode={props.mode}37          replacesExistingVault={props.replacesExistingVault}38          onBack={props.onBack}39          onImport={props.onImport}40          onOpenCjk={() => setShowCjkImport(true)}41        />42      }43    >44      <CJKImportView45        mode="existing"46        onBack={() => setShowCjkImport(false)}47        onDone={async () => {48          setShowCjkImport(false);49          await props.onImport();50        }}51      />52    </Show>53  );54}5556function MainImportView(props: ImportProps & { onOpenCjk: () => void }) {57  const mode = () => props.mode ?? "existing";5859  // PeachScript paste flow60  const [scriptInput, setScriptInput] = createSignal("");61  const [scriptPass, setScriptPass] = createSignal("");62  const [scriptWorking, setScriptWorking] = createSignal(false);63  const [scriptError, setScriptError] = createSignal<string | null>(null);64  const [scriptResult, setScriptResult] = createSignal<{ entryCount: number } | null>(null);6566  // File flow (existing format detection)67  const [preview, setPreview] = createSignal<ImportParseResult | null>(null);68  const [rawImport, setRawImport] = createSignal<string | null>(null);69  const [backupPassword, setBackupPassword] = createSignal("");70  const [masterPassword, setMasterPassword] = createSignal("");71  const [masterConfirm, setMasterConfirm] = createSignal("");72  const [importing, setImporting] = createSignal(false);73  const [error, setError] = createSignal<string | null>(null);74  const [mergeMode, setMergeMode] = createSignal<ImportMergeMode>("add");75  const [awaitingReplaceConfirm, setAwaitingReplaceConfirm] = createSignal(false);76  const [importProgress, setImportProgress] = createSignal<{ current: number; total: number } | null>(null);77  const [duplicateWarning, setDuplicateWarning] = createSignal<number>(0);78  const [existingCount, setExistingCount] = createSignal<number>(0);7980  const needsBackupPassword = createMemo(() => preview()?.requiresPassword ?? false);81  const needsMasterPassword = createMemo(() => mode() === "onboarding");82  const masterPasswordReady = createMemo(83    () =>84      masterPassword().length >= 8 && masterPassword() === masterConfirm()85  );8687  const scriptLooksValid = createMemo(() => isPeachScript(scriptInput()));8889  async function refreshExistingCount() {90    if (mode() !== "existing") return;91    const res = await sendMessage({ type: "GET_ENTRIES" });92    const list = (res as { ok: boolean; data?: VaultEntry[] }).data ?? [];93    setExistingCount(list.length);94  }9596  void refreshExistingCount();9798  // ---------------------------------------------------------------------99  // PeachScript flow100  // ---------------------------------------------------------------------101102  async function handlePeachScriptImport() {103    setScriptError(null);104    setScriptResult(null);105    if (!scriptLooksValid()) {106      setScriptError("Paste your PeachScript first.");107      return;108    }109    if (!scriptPass().trim()) {110      setScriptError("Enter the backup password.");111      return;112    }113    setScriptWorking(true);114    try {115      const bytes = decodePeachScript(normalisePeachScript(scriptInput()));116      const payload = await parsePayload(bytes, scriptPass());117118      // Onboarding: create a fresh vault first.119      if (mode() === "onboarding") {120        if (!masterPasswordReady()) {121          throw new Error("MASTER_PASSWORD_REQUIRED");122        }123        const createRes = await sendMessage({124          type: "CREATE_VAULT",125          payload: { password: masterPassword() },126        });127        if (!(createRes as { ok: boolean }).ok) throw new Error("CREATE_VAULT_FAILED");128      }129130      const restoreRes = await sendMessage({131        type: "RESTORE_ENTRIES",132        payload: {133          entries: payload.entries,134          mode: "merge",135          settings: payload.settings,136        },137      });138      if (!(restoreRes as { ok: boolean }).ok) throw new Error("RESTORE_FAILED");139140      if (payload.license?.key) {141        await sendMessage({142          type: "ACTIVATE_LICENSE",143          payload: { key: payload.license.key },144        }).catch(() => {});145      }146147      setScriptResult({ entryCount: payload.entries.length });148      await props.onImport();149    } catch (err) {150      const code = err instanceof Error ? err.message : String(err);151      setScriptError(getImportErrorCopy(code));152    } finally {153      setScriptWorking(false);154    }155  }156157  // ---------------------------------------------------------------------158  // File flow (Bitwarden / 1Password / etc.)159  // ---------------------------------------------------------------------160161  async function parseSource(text: string, password?: string) {162    try {163      const result = await parseImportSource(text, password);164      setPreview(result);165      setError(null);166      if (result.format === "unknown") {167        setError(168          "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."169        );170      }171      return result;172    } catch (err) {173      const message = err instanceof Error ? err.message : String(err);174      setPreview(null);175      setError(getImportErrorCopy(message));176      return null;177    }178  }179180  async function handleFileSelect(event: Event) {181    const file = (event.target as HTMLInputElement).files?.[0];182    if (!file) return;183184    setPreview(null);185    setRawImport(null);186    setBackupPassword("");187    setError(null);188    setAwaitingReplaceConfirm(false);189190    const text = await file.text();191    setRawImport(text);192    await parseSource(text);193  }194195  async function handleUnlockBackup() {196    const raw = rawImport();197    if (!raw || !backupPassword().trim()) return;198    await parseSource(raw, backupPassword());199  }200201  async function countDuplicates(incoming: ImportParseResult["entries"]): Promise<number> {202    if (incoming.length === 0) return 0;203    const res = await sendMessage({ type: "GET_ENTRIES" });204    const existing = ((res as { ok: boolean; data?: VaultEntry[] }).data ?? []).filter((e) => !e.deletedAt);205    const keyOf = (title?: string, url?: string, username?: string) =>206      `${(title ?? "").toLowerCase()}|${(url ?? "").toLowerCase()}|${(username ?? "").toLowerCase()}`;207    const existingKeys = new Set(existing.map((e) => keyOf(e.title, e.url, e.username)));208    let dupes = 0;209    for (const entry of incoming) {210      const u = "username" in entry ? entry.username : undefined;211      const url = "url" in entry ? entry.url : undefined;212      if (existingKeys.has(keyOf(entry.title, url, u))) dupes += 1;213    }214    return dupes;215  }216217  async function handleImport() {218    const currentPreview = preview();219    if (!currentPreview) return;220221    if (needsBackupPassword() && !backupPassword().trim()) {222      setError("Enter the backup password used for this export.");223      return;224    }225226    if (needsMasterPassword()) {227      if (masterPassword().length < 8) {228        setError("Master password must be at least 8 characters.");229        return;230      }231      if (!masterPasswordReady()) {232        setError("Master passwords do not match.");233        return;234      }235    }236237    if (mode() === "existing" && mergeMode() === "replace" && !awaitingReplaceConfirm()) {238      setAwaitingReplaceConfirm(true);239      return;240    }241242    setImporting(true);243    setError(null);244    setDuplicateWarning(0);245    setImportProgress(null);246247    try {248      if (currentPreview.entries.length === 0) {249        throw new Error("NO_ENTRIES_IMPORTED");250      }251252      if (needsMasterPassword()) {253        const createResponse = await sendMessage({254          type: "CREATE_VAULT",255          payload: { password: masterPassword() },256        });257        if (!(createResponse as { ok: boolean }).ok) {258          throw new Error("CREATE_VAULT_FAILED");259        }260      }261262      if (mode() === "existing" && mergeMode() === "replace") {263        const clearRes = await sendMessage({ type: "CLEAR_VAULT_ENTRIES" });264        if (!(clearRes as { ok: boolean }).ok) {265          throw new Error("CLEAR_VAULT_FAILED");266        }267      }268269      let dupes = 0;270      if (mode() === "existing" && mergeMode() === "add") {271        dupes = await countDuplicates(currentPreview.entries);272      }273274      let imported = 0;275      setImportProgress({ current: 0, total: currentPreview.entries.length });276277      const batchRes = await sendMessage({278        type: "BATCH_ADD_ENTRIES",279        payload: { entries: currentPreview.entries },280      });281282      if ((batchRes as { ok: boolean }).ok) {283        imported = ((batchRes as { ok: boolean; data?: VaultEntry[] }).data ?? []).length;284        setImportProgress({ current: imported, total: currentPreview.entries.length });285      } else {286        for (const entry of currentPreview.entries) {287          const response = await sendMessage({288            type: "ADD_ENTRY",289            payload: entry,290          });291          if ((response as { ok: boolean }).ok) imported += 1;292          setImportProgress({ current: imported, total: currentPreview.entries.length });293        }294      }295296      if (imported === 0) {297        throw new Error("NO_ENTRIES_IMPORTED");298      }299300      if (currentPreview.licenseKey) {301        await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key: currentPreview.licenseKey } }).catch(() => {});302      }303304      setDuplicateWarning(dupes);305      await props.onImport();306    } catch (err) {307      const message = err instanceof Error ? err.message : String(err);308      setError(getImportErrorCopy(message));309      setImporting(false);310      setImportProgress(null);311      return;312    }313314    setImporting(false);315    setImportProgress(null);316  }317318  return (319    <div class={mode() === "onboarding" ? "peach-lock-view" : "peach-overlay-view ov-enter-right"}>320      <Show321        when={mode() === "onboarding"}322        fallback={323          <div class="peach-overlay-header">324            <button325              class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"326              onClick={props.onBack}327              aria-label="Back"328            >329              <svg330                width="7" height="12" viewBox="0 0 7 12"331                fill="none" stroke="currentColor"332                stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round"333              >334                <path d="M6 1L1 6L6 11" />335              </svg>336            </button>337            <span class="peach-overlay-title">import</span>338            <div class="peach-overlay-spacer"></div>339          </div>340        }341      >342        <div class="peach-lock-view-header">343          <PeachWordmark class="peach-wordmark-wrap" />344          <button345            class="peach-lock-restore-link"346            onClick={props.onBack}347            disabled={importing() || scriptWorking()}348          >349            ‹ back350          </button>351        </div>352      </Show>353354      <div class="peach-settings-body">355        <Show when={mode() === "onboarding" && props.replacesExistingVault}>356          <div class="peach-settings-group">357            <div class="peach-restore-warning">358              Restoring now replaces the locked local vault on this device with359              a fresh one from your backup or export file.360            </div>361          </div>362        </Show>363364        {/* PeachScript paste — primary path */}365        <div class="peach-settings-group peach-restore-group">366          <p class="peach-settings-label">paste peachscript</p>367          <p class="peach-restore-copy">368            {mode() === "onboarding"369              ? "Paste a PeachScript backup from another Peach device to restore your vault."370              : "Paste a PeachScript backup from another Peach device. We'll merge it into your vault."}371          </p>372          <textarea373            class="peach-script-input"374            placeholder="paste PeachScript here…"375            value={scriptInput()}376            onInput={(e) => setScriptInput((e.target as HTMLTextAreaElement).value)}377          />378          <input379            class="peach-restore-input"380            type="password"381            placeholder="PeachScript password"382            value={scriptPass()}383            onInput={(e) => setScriptPass((e.target as HTMLInputElement).value)}384            style="margin-top:8px"385          />386          <Show when={mode() === "onboarding"}>387            <div class="peach-auth-grid" style="margin-top:8px">388              <input389                class="peach-restore-input"390                type="password"391                placeholder="new master password"392                value={masterPassword()}393                onInput={(e) => setMasterPassword((e.target as HTMLInputElement).value)}394              />395              <input396                class="peach-restore-input"397                type="password"398                placeholder="confirm master password"399                value={masterConfirm()}400                onInput={(e) => setMasterConfirm((e.target as HTMLInputElement).value)}401              />402            </div>403          </Show>404          <div class="peach-settings-actions" style="margin-top:8px">405            <button406              class="peach-btn peach-btn--subtle peach-btn--sm"407              onClick={() => void handlePeachScriptImport()}408              disabled={409                scriptWorking() ||410                !scriptLooksValid() ||411                !scriptPass().trim() ||412                (mode() === "onboarding" && !masterPasswordReady())413              }414            >415              {scriptWorking() ? "decoding…" : "Restore from PeachScript"}416            </button>417          </div>418          <Show when={scriptError()}>419            <div class="peach-restore-error" style="margin-top:8px">{scriptError()}</div>420          </Show>421          <Show when={scriptResult()}>422            <div class="peach-export-success" style="margin-top:8px">423              <span class="peach-export-success-check">✓</span>424              <span>Restored {scriptResult()!.entryCount} entr{scriptResult()!.entryCount === 1 ? "y" : "ies"}.</span>425            </div>426          </Show>427        </div>428429        {/* File import — for migrating from other managers */}430        <div class="peach-settings-group peach-restore-group">431          <p class="peach-settings-label">import a file</p>432          <p class="peach-restore-copy">433            Bitwarden, 1Password, Proton Pass, Chrome / Edge / Safari CSV, FIDO434            credential-exchange passkeys, or a Peach JSON backup.435          </p>436437          <div class="peach-provider-chips">438            <span class="peach-provider-chip">Peach JSON</span>439            <span class="peach-provider-chip">FIDO passkeys</span>440            <span class="peach-provider-chip">Proton Pass CSV/JSON</span>441            <span class="peach-provider-chip">Bitwarden CSV/JSON</span>442            <span class="peach-provider-chip">1Password JSON</span>443            <span class="peach-provider-chip">Chrome / Edge / Safari CSV</span>444          </div>445446          <input447            class="peach-restore-file"448            type="file"449            accept=".csv,.json"450            onChange={(event) => void handleFileSelect(event)}451            style="margin-top:8px"452          />453        </div>454455        <Show when={needsBackupPassword()}>456          <div class="peach-settings-group">457            <p class="peach-settings-label">unlock backup</p>458            <p class="peach-restore-copy">459              Enter the password used when you created this encrypted Peach JSON460              backup.461            </p>462            <input463              class="peach-restore-input"464              type="password"465              placeholder="backup password"466              value={backupPassword()}467              onInput={(event) =>468                setBackupPassword((event.target as HTMLInputElement).value)469              }470              onBlur={() => { if (backupPassword().trim()) void handleUnlockBackup(); }}471              onKeyDown={(event) => {472                if (event.key === "Enter") {473                  event.preventDefault();474                  void handleUnlockBackup();475                }476              }}477            />478            <div class="peach-settings-actions">479              <button480                class="peach-btn peach-btn--subtle peach-btn--sm"481                onClick={() => void handleUnlockBackup()}482                disabled={!backupPassword().trim()}483              >484                Decrypt →485              </button>486            </div>487          </div>488        </Show>489490        <Show when={preview() && !needsBackupPassword() && preview()!.format !== "unknown"}>491          <div class="peach-settings-group">492            <p class="peach-settings-label">{preview()!.label}</p>493            <div class="peach-import-summary">494              Found <strong>{preview()!.entries.length}</strong>{" "}495              entr{preview()!.entries.length === 1 ? "y" : "ies"}496              {preview()!.skippedItems > 0497                ? `. ${preview()!.skippedItems} skipped because no password was present.`498                : "."}499            </div>500501            <div class="peach-import-preview">502              {preview()!.entries.slice(0, 8).map((entry) => (503                <div class="peach-import-preview-row">504                  <span class="peach-import-preview-title">{entry.title}</span>505                  <span class="peach-import-preview-meta">506                    {entry.type === "passkey"507                      ? `${entry.userName || "passkey"} • ${entry.rpId || "site"}`508                      : entry.type === "note"509                        ? "secure note"510                        : entry.username || entry.url || "password item"}511                  </span>512                </div>513              ))}514              <Show when={preview()!.entries.length > 8}>515                <div class="peach-import-preview-more">516                  ...and {preview()!.entries.length - 8} more517                </div>518              </Show>519            </div>520          </div>521        </Show>522523        <Show when={mode() === "existing" && preview() && !needsBackupPassword() && preview()!.format !== "unknown" && preview()!.entries.length > 0}>524          <div class="peach-settings-group">525            <p class="peach-settings-label">merge or replace</p>526            <label class="peach-import-choice">527              <input528                type="radio"529                name="merge-mode"530                value="add"531                checked={mergeMode() === "add"}532                onChange={() => { setMergeMode("add"); setAwaitingReplaceConfirm(false); }}533              />534              <span class="peach-import-choice-main">535                <span class="peach-import-choice-label">Add to existing vault</span>536                <span class="peach-import-choice-sub">537                  This will add {preview()!.entries.length} entries to your existing {existingCount()}.538                </span>539              </span>540            </label>541            <label class="peach-import-choice">542              <input543                type="radio"544                name="merge-mode"545                value="replace"546                checked={mergeMode() === "replace"}547                onChange={() => setMergeMode("replace")}548              />549              <span class="peach-import-choice-main">550                <span class="peach-import-choice-label">Replace vault</span>551                <span class="peach-import-choice-sub">552                  This will replace your {existingCount()} entries with {preview()!.entries.length} imported ones.553                </span>554              </span>555            </label>556            <Show when={awaitingReplaceConfirm()}>557              <div class="peach-restore-warning" style="margin-top:6px">558                Replace forever? This cannot be undone.559              </div>560            </Show>561          </div>562        </Show>563564        <Show when={needsMasterPassword() && preview() && !needsBackupPassword() && preview()!.format !== "unknown"}>565          <div class="peach-settings-group">566            <p class="peach-settings-label">new master password</p>567            <p class="peach-restore-copy">568              This password unlocks the Peach vault created on this device after569              import.570            </p>571            <div class="peach-auth-grid">572              <input573                class="peach-restore-input"574                type="password"575                placeholder="new master password"576                value={masterPassword()}577                onInput={(event) =>578                  setMasterPassword((event.target as HTMLInputElement).value)579                }580              />581              <input582                class="peach-restore-input"583                type="password"584                placeholder="confirm master password"585                value={masterConfirm()}586                onInput={(event) =>587                  setMasterConfirm((event.target as HTMLInputElement).value)588                }589              />590            </div>591          </div>592        </Show>593594        <Show when={error()}>595          <div class="peach-settings-group">596            <div class="peach-restore-error">{error()}</div>597          </div>598        </Show>599600        <Show when={importing() && importProgress()}>601          <div class="peach-settings-group">602            <div class="peach-import-summary">603              Importing... [{importProgress()!.current} / {importProgress()!.total}]604            </div>605          </div>606        </Show>607608        <Show when={duplicateWarning() > 0 && !importing()}>609          <div class="peach-settings-group">610            <div class="peach-restore-copy">611              {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.612            </div>613          </div>614        </Show>615616        <Show when={preview() && !needsBackupPassword() && preview()!.format !== "unknown"}>617          <div class="peach-settings-group">618            <div class="peach-settings-actions">619              <button620                class="peach-btn peach-btn--subtle peach-btn--sm"621                onClick={() => void handleImport()}622                disabled={623                  importing() ||624                  preview()!.entries.length === 0 ||625                  (needsMasterPassword() && !masterPasswordReady())626                }627              >628                {importing()629                  ? "importing..."630                  : mode() === "onboarding"631                    ? `Create vault + import ${preview()!.entries.length}`632                    : mergeMode() === "replace"633                      ? awaitingReplaceConfirm()634                        ? "Yes, replace forever"635                        : `Replace vault with ${preview()!.entries.length}`636                      : `Import ${preview()!.entries.length}`}637              </button>638              <button639                class="peach-btn peach-btn--ghost peach-btn--sm"640                onClick={props.onBack}641                disabled={importing()}642              >643                Cancel644              </button>645            </div>646          </div>647        </Show>648649        {/* Legacy CJK — collapsed, secondary */}650        <Show when={mode() === "existing"}>651          <div class="peach-settings-group">652            <details>653              <summary class="peach-legacy-cjk-summary">654                Have an old CJK phrase backup?655              </summary>656              <p class="peach-legacy-cjk-note">657                CJK phrase backups from earlier versions of Peach are still658                supported. After restoring, we recommend creating a fresh659                PeachScript or QR backup.660              </p>661              <div class="peach-settings-actions" style="margin-top:6px">662                <button663                  class="peach-btn peach-btn--ghost peach-btn--sm"664                  onClick={props.onOpenCjk}665                >666                  Restore from CJK phrase667                </button>668              </div>669            </details>670          </div>671        </Show>672      </div>673    </div>674  );675}676677function getImportErrorCopy(error: string): string {678  switch (error) {679    case "UNSUPPORTED_IMPORT_FORMAT":680      return "Unrecognised file format. Use CSV, Bitwarden JSON, Proton Pass JSON/CSV, or a Peach encrypted backup.";681    case "INVALID_EXPORT_PASSWORD":682    case "INVALID_PASSPHRASE":683      return "Wrong backup password. This is the password you chose when you created the backup.";684    case "INVALID_PEACH_EXPORT":685      return "This Peach backup file looks corrupted or incomplete.";686    case "INVALID_PEACHSCRIPT_CHARACTER":687      return "This doesn't look like PeachScript. Make sure you copied the entire backup string.";688    case "PAYLOAD_TOO_SHORT":689      return "PeachScript looks truncated. Copy the full backup string and try again.";690    case "BAD_MAGIC":691    case "UNSUPPORTED_VERSION":692      return "This PeachScript was created by a different version of Peach.";693    case "CHECKSUM_FAILED":694      return "PeachScript appears corrupted. Re-copy from the source device.";695    case "CREATE_VAULT_FAILED":696      return "Peach couldn't create a new local vault before importing.";697    case "CLEAR_VAULT_FAILED":698      return "Peach couldn't clear the existing vault before replacing it.";699    case "MASTER_PASSWORD_REQUIRED":700      return "Choose a master password for your new vault first.";701    case "RESTORE_FAILED":702      return "Restore failed. Your existing vault is unchanged.";703    case "NO_ENTRIES_IMPORTED":704      return "No importable password entries were found in that file.";705    default:706      return error;707  }708}