Import.tsx

20.2 KB · raw

1// Signal-conditioned views in this file must use <Show>, never a2// signal-conditioned early return (`if (signal()) return <X/>`). Early3// returns in SolidJS run once at mount and never re-execute when signals4// change — see Projects.md §4.1.5import { createMemo, createSignal, Show } from "solid-js";67import { sendMessage } from "../App";8import { PeachWordmark } from "../components/PeachMark";9import {10  parseImportSource,11  type ImportParseResult,12} from "../../shared/import";13import type { VaultEntry } from "../../shared/types";14import { CJKImportView } from "./CJKImport";1516interface ImportProps {17  mode?: "existing" | "onboarding";18  replacesExistingVault?: boolean;19  onBack: () => void;20  onImport: () => void | Promise<void>;21}2223type ImportMergeMode = "add" | "replace";2425export function ImportView(props: ImportProps) {26  const [showCjkImport, setShowCjkImport] = createSignal(false);27  return (28    <Show29      when={showCjkImport()}30      fallback={31        <MainImportView32          mode={props.mode}33          replacesExistingVault={props.replacesExistingVault}34          onBack={props.onBack}35          onImport={props.onImport}36          onOpenCjk={() => setShowCjkImport(true)}37        />38      }39    >40      <CJKImportView41        mode="existing"42        onBack={() => setShowCjkImport(false)}43        onDone={async () => {44          setShowCjkImport(false);45          await props.onImport();46        }}47      />48    </Show>49  );50}5152function MainImportView(props: ImportProps & { onOpenCjk: () => void }) {53  const mode = () => props.mode ?? "existing";5455  const [preview, setPreview] = createSignal<ImportParseResult | null>(null);56  const [rawImport, setRawImport] = createSignal<string | null>(null);57  const [backupPassword, setBackupPassword] = createSignal("");58  const [masterPassword, setMasterPassword] = createSignal("");59  const [masterConfirm, setMasterConfirm] = createSignal("");60  const [importing, setImporting] = createSignal(false);61  const [error, setError] = createSignal<string | null>(null);62  const [mergeMode, setMergeMode] = createSignal<ImportMergeMode>("add");63  const [awaitingReplaceConfirm, setAwaitingReplaceConfirm] = createSignal(false);64  const [importProgress, setImportProgress] = createSignal<{ current: number; total: number } | null>(null);65  const [duplicateWarning, setDuplicateWarning] = createSignal<number>(0);66  const [existingCount, setExistingCount] = createSignal<number>(0);6768  const needsBackupPassword = createMemo(() => preview()?.requiresPassword ?? false);69  const needsMasterPassword = createMemo(() => mode() === "onboarding");70  const masterPasswordReady = createMemo(71    () =>72      masterPassword().length >= 8 && masterPassword() === masterConfirm()73  );7475  async function refreshExistingCount() {76    if (mode() !== "existing") return;77    const res = await sendMessage({ type: "GET_ENTRIES" });78    const list = (res as { ok: boolean; data?: VaultEntry[] }).data ?? [];79    setExistingCount(list.length);80  }8182  void refreshExistingCount();8384  async function parseSource(text: string, password?: string) {85    try {86      const result = await parseImportSource(text, password);87      setPreview(result);88      setError(null);89      if (result.format === "unknown") {90        setError(91          "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."92        );93      }94      return result;95    } catch (err) {96      const message = err instanceof Error ? err.message : String(err);97      setPreview(null);98      setError(getImportErrorCopy(message));99      return null;100    }101  }102103  async function handleFileSelect(event: Event) {104    const file = (event.target as HTMLInputElement).files?.[0];105    if (!file) return;106107    setPreview(null);108    setRawImport(null);109    setBackupPassword("");110    setError(null);111    setAwaitingReplaceConfirm(false);112113    const text = await file.text();114    setRawImport(text);115    await parseSource(text);116  }117118  async function handleUnlockBackup() {119    const raw = rawImport();120    if (!raw || !backupPassword().trim()) return;121    await parseSource(raw, backupPassword());122  }123124  async function countDuplicates(incoming: ImportParseResult["entries"]): Promise<number> {125    if (incoming.length === 0) return 0;126    const res = await sendMessage({ type: "GET_ENTRIES" });127    const existing = ((res as { ok: boolean; data?: VaultEntry[] }).data ?? []).filter((e) => !e.deletedAt);128    const keyOf = (title?: string, url?: string, username?: string) =>129      `${(title ?? "").toLowerCase()}|${(url ?? "").toLowerCase()}|${(username ?? "").toLowerCase()}`;130    const existingKeys = new Set(existing.map((e) => keyOf(e.title, e.url, e.username)));131    let dupes = 0;132    for (const entry of incoming) {133      const u = "username" in entry ? entry.username : undefined;134      const url = "url" in entry ? entry.url : undefined;135      if (existingKeys.has(keyOf(entry.title, url, u))) dupes += 1;136    }137    return dupes;138  }139140  async function handleImport() {141    const currentPreview = preview();142    if (!currentPreview) return;143144    if (needsBackupPassword() && !backupPassword().trim()) {145      setError("Enter the backup password used for this export.");146      return;147    }148149    if (needsMasterPassword()) {150      if (masterPassword().length < 8) {151        setError("Master password must be at least 8 characters.");152        return;153      }154      if (!masterPasswordReady()) {155        setError("Master passwords do not match.");156        return;157      }158    }159160    if (mode() === "existing" && mergeMode() === "replace" && !awaitingReplaceConfirm()) {161      setAwaitingReplaceConfirm(true);162      return;163    }164165    setImporting(true);166    setError(null);167    setDuplicateWarning(0);168    setImportProgress(null);169170    try {171      // ⚠️  Guard: verify entries exist BEFORE touching any vault.172      // Without this, CREATE_VAULT would already have wiped the old vault173      // before we discover the file has nothing importable.174      if (currentPreview.entries.length === 0) {175        throw new Error("NO_ENTRIES_IMPORTED");176      }177178      if (needsMasterPassword()) {179        const createResponse = await sendMessage({180          type: "CREATE_VAULT",181          payload: { password: masterPassword() },182        });183        if (!(createResponse as { ok: boolean }).ok) {184          throw new Error("CREATE_VAULT_FAILED");185        }186      }187188      if (mode() === "existing" && mergeMode() === "replace") {189        const clearRes = await sendMessage({ type: "CLEAR_VAULT_ENTRIES" });190        if (!(clearRes as { ok: boolean }).ok) {191          throw new Error("CLEAR_VAULT_FAILED");192        }193      }194195      let dupes = 0;196      if (mode() === "existing" && mergeMode() === "add") {197        dupes = await countDuplicates(currentPreview.entries);198      }199200      let imported = 0;201      setImportProgress({ current: 0, total: currentPreview.entries.length });202203      const batchRes = await sendMessage({204        type: "BATCH_ADD_ENTRIES",205        payload: { entries: currentPreview.entries },206      });207208      if ((batchRes as { ok: boolean }).ok) {209        imported = ((batchRes as { ok: boolean; data?: VaultEntry[] }).data ?? []).length;210        setImportProgress({ current: imported, total: currentPreview.entries.length });211      } else {212        // Fallback to sequential213        for (const entry of currentPreview.entries) {214          const response = await sendMessage({215            type: "ADD_ENTRY",216            payload: entry,217          });218          if ((response as { ok: boolean }).ok) imported += 1;219          setImportProgress({ current: imported, total: currentPreview.entries.length });220        }221      }222223      if (imported === 0) {224        throw new Error("NO_ENTRIES_IMPORTED");225      }226227      setDuplicateWarning(dupes);228      await props.onImport();229    } catch (err) {230      const message = err instanceof Error ? err.message : String(err);231      setError(getImportErrorCopy(message));232      setImporting(false);233      setImportProgress(null);234      return;235    }236237    setImporting(false);238    setImportProgress(null);239  }240241  return (242    <div class={mode() === "onboarding" ? "peach-lock-view" : "peach-overlay-view ov-enter-right"}>243      <Show244        when={mode() === "onboarding"}245        fallback={246          <div class="peach-overlay-header">247            <button248              class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"249              onClick={props.onBack}250              aria-label="Back"251            >252              <svg253                width="7"254                height="12"255                viewBox="0 0 7 12"256                fill="none"257                stroke="currentColor"258                stroke-width="1.1"259                stroke-linecap="round"260                stroke-linejoin="round"261              >262                <path d="M6 1L1 6L6 11" />263              </svg>264            </button>265            <span class="peach-overlay-title">import</span>266            <div class="peach-overlay-spacer"></div>267          </div>268        }269      >270        <div class="peach-lock-view-header">271          <PeachWordmark class="peach-wordmark-wrap" />272          <button273            class="peach-lock-restore-link"274            onClick={props.onBack}275            disabled={importing()}276          >277            ‹ back278          </button>279        </div>280      </Show>281282      <div class="peach-settings-body">283        <div class="peach-settings-group peach-restore-group">284          <p class="peach-settings-label">285            {mode() === "onboarding" ? "bring data into peach" : "import passwords"}286          </p>287          <p class="peach-restore-copy">288            {mode() === "onboarding"289              ? "Import a Peach backup, FIDO credential-exchange export, Proton Pass export, Bitwarden export, or a common browser CSV. We'll create a fresh local vault first."290              : "Supports Peach encrypted backups, FIDO credential-exchange passkeys, Proton Pass, Bitwarden, and common browser CSV exports."}291          </p>292293          <Show when={mode() === "onboarding" && props.replacesExistingVault}>294            <div class="peach-restore-warning">295              Restoring now replaces the locked local vault on this device with296              a fresh one from your backup or export file.297            </div>298          </Show>299300          <div class="peach-provider-chips">301            <span class="peach-provider-chip">Peach JSON</span>302            <span class="peach-provider-chip">FIDO passkeys</span>303            <span class="peach-provider-chip">Proton Pass CSV/JSON</span>304            <span class="peach-provider-chip">Bitwarden CSV/JSON</span>305            <span class="peach-provider-chip">1Password JSON</span>306            <span class="peach-provider-chip">Chrome / Edge / Safari CSV</span>307          </div>308        </div>309310        <Show when={mode() === "existing"}>311          <div class="peach-settings-group">312            <p class="peach-settings-label">restore from CJK phrase</p>313            <p class="peach-restore-copy">314              Use a Peach CJK backup phrase instead of a file.315            </p>316            <button317              class="peach-btn peach-btn--subtle peach-btn--sm"318              onClick={props.onOpenCjk}319            >320              Restore from CJK phrase321            </button>322          </div>323        </Show>324325        <div class="peach-settings-group">326          <p class="peach-settings-label">select file</p>327          <input328            class="peach-restore-file"329            type="file"330            accept=".csv,.json"331            onChange={(event) => void handleFileSelect(event)}332          />333        </div>334335        <Show when={needsBackupPassword()}>336          <div class="peach-settings-group">337            <p class="peach-settings-label">unlock backup</p>338            <p class="peach-restore-copy">339              Enter the password used when you created this encrypted Peach JSON340              backup.341            </p>342            <input343              class="peach-restore-input"344              type="password"345              placeholder="backup password"346              value={backupPassword()}347              onInput={(event) =>348                setBackupPassword((event.target as HTMLInputElement).value)349              }350              onBlur={() => { if (backupPassword().trim()) void handleUnlockBackup(); }}351              onKeyDown={(event) => {352                if (event.key === "Enter") {353                  event.preventDefault();354                  void handleUnlockBackup();355                }356              }}357            />358            <div class="peach-settings-actions">359              <button360                class="peach-btn peach-btn--subtle peach-btn--sm"361                onClick={() => void handleUnlockBackup()}362                disabled={!backupPassword().trim()}363              >364                Decrypt →365              </button>366            </div>367          </div>368        </Show>369370        <Show when={preview() && !needsBackupPassword() && preview()!.format !== "unknown"}>371          <div class="peach-settings-group">372            <p class="peach-settings-label">{preview()!.label}</p>373            <div class="peach-import-summary">374              Found <strong>{preview()!.entries.length}</strong>{" "}375              entr{preview()!.entries.length === 1 ? "y" : "ies"}376              {preview()!.skippedItems > 0377                ? `. ${preview()!.skippedItems} skipped because no password was present.`378                : "."}379            </div>380381            <div class="peach-import-preview">382              {preview()!.entries.slice(0, 8).map((entry) => (383                <div class="peach-import-preview-row">384                  <span class="peach-import-preview-title">{entry.title}</span>385                  <span class="peach-import-preview-meta">386                    {entry.type === "passkey"387                      ? `${entry.userName || "passkey"} • ${entry.rpId || "site"}`388                      : entry.type === "note"389                        ? "secure note"390                        : entry.username || entry.url || "password item"}391                  </span>392                </div>393              ))}394              <Show when={preview()!.entries.length > 8}>395                <div class="peach-import-preview-more">396                  ...and {preview()!.entries.length - 8} more397                </div>398              </Show>399            </div>400          </div>401        </Show>402403        <Show when={mode() === "existing" && preview() && !needsBackupPassword() && preview()!.format !== "unknown" && preview()!.entries.length > 0}>404          <div class="peach-settings-group">405            <p class="peach-settings-label">merge or replace</p>406            <label class="peach-import-choice">407              <input408                type="radio"409                name="merge-mode"410                value="add"411                checked={mergeMode() === "add"}412                onChange={() => { setMergeMode("add"); setAwaitingReplaceConfirm(false); }}413              />414              <span class="peach-import-choice-main">415                <span class="peach-import-choice-label">Add to existing vault</span>416                <span class="peach-import-choice-sub">417                  This will add {preview()!.entries.length} entries to your existing {existingCount()}.418                </span>419              </span>420            </label>421            <label class="peach-import-choice">422              <input423                type="radio"424                name="merge-mode"425                value="replace"426                checked={mergeMode() === "replace"}427                onChange={() => setMergeMode("replace")}428              />429              <span class="peach-import-choice-main">430                <span class="peach-import-choice-label">Replace vault</span>431                <span class="peach-import-choice-sub">432                  This will replace your {existingCount()} entries with {preview()!.entries.length} imported ones.433                </span>434              </span>435            </label>436            <Show when={awaitingReplaceConfirm()}>437              <div class="peach-restore-warning" style="margin-top:6px">438                Replace forever? This cannot be undone.439              </div>440            </Show>441          </div>442        </Show>443444        <Show when={needsMasterPassword() && preview() && !needsBackupPassword() && preview()!.format !== "unknown"}>445          <div class="peach-settings-group">446            <p class="peach-settings-label">new master password</p>447            <p class="peach-restore-copy">448              This password unlocks the Peach vault created on this device after449              import. It is separate from any export password.450            </p>451            <div class="peach-auth-grid">452              <input453                class="peach-restore-input"454                type="password"455                placeholder="new master password"456                value={masterPassword()}457                onInput={(event) =>458                  setMasterPassword((event.target as HTMLInputElement).value)459                }460              />461              <input462                class="peach-restore-input"463                type="password"464                placeholder="confirm master password"465                value={masterConfirm()}466                onInput={(event) =>467                  setMasterConfirm((event.target as HTMLInputElement).value)468                }469              />470            </div>471          </div>472        </Show>473474        <Show when={error()}>475          <div class="peach-settings-group">476            <div class="peach-restore-error">{error()}</div>477          </div>478        </Show>479480        <Show when={importing() && importProgress()}>481          <div class="peach-settings-group">482            <div class="peach-import-summary">483              Importing... [{importProgress()!.current} / {importProgress()!.total}]484            </div>485          </div>486        </Show>487488        <Show when={duplicateWarning() > 0 && !importing()}>489          <div class="peach-settings-group">490            <div class="peach-restore-copy">491              {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.492            </div>493          </div>494        </Show>495496        <Show when={preview() && !needsBackupPassword() && preview()!.format !== "unknown"}>497          <div class="peach-settings-group">498            <div class="peach-settings-actions">499              <button500                class="peach-btn peach-btn--subtle peach-btn--sm"501                onClick={() => void handleImport()}502                disabled={503                  importing() ||504                  preview()!.entries.length === 0 ||505                  (needsMasterPassword() && !masterPasswordReady())506                }507              >508                {importing()509                  ? "importing..."510                  : mode() === "onboarding"511                    ? `Create vault + import ${preview()!.entries.length}`512                    : mergeMode() === "replace"513                      ? awaitingReplaceConfirm()514                        ? "Yes, replace forever"515                        : `Replace vault with ${preview()!.entries.length}`516                      : `Import ${preview()!.entries.length}`}517              </button>518              <button519                class="peach-btn peach-btn--ghost peach-btn--sm"520                onClick={props.onBack}521                disabled={importing()}522              >523                Cancel524              </button>525            </div>526          </div>527        </Show>528      </div>529    </div>530  );531}532533function getImportErrorCopy(error: string): string {534  switch (error) {535    case "UNSUPPORTED_IMPORT_FORMAT":536      return "Unrecognised file format. Use CSV, Bitwarden JSON, Proton Pass JSON/CSV, or a Peach encrypted backup.";537    case "INVALID_EXPORT_PASSWORD":538      return "Incorrect backup password. This is the password you chose when you exported the Peach JSON backup.";539    case "INVALID_PEACH_EXPORT":540      return "This Peach backup file looks corrupted or incomplete.";541    case "CREATE_VAULT_FAILED":542      return "Peach couldn't create a new local vault before importing.";543    case "CLEAR_VAULT_FAILED":544      return "Peach couldn't clear the existing vault before replacing it.";545    case "NO_ENTRIES_IMPORTED":546      return "No importable password entries were found in that file.";547    default:548      return error;549  }550}