Import.tsx

19.9 KB · raw

1import { createMemo, createSignal, Show } from "solid-js";23import { sendMessage } from "../App";4import { PeachWordmark } from "../components/PeachMark";5import {6  parseImportSource,7  type ImportParseResult,8} from "../../shared/import";9import type { VaultEntry } from "../../shared/types";10import { CJKImportView } from "./CJKImport";1112interface ImportProps {13  mode?: "existing" | "onboarding";14  replacesExistingVault?: boolean;15  onBack: () => void;16  onImport: () => void | Promise<void>;17}1819type ImportMergeMode = "add" | "replace";2021export function ImportView(props: ImportProps) {22  const [showCjkImport, setShowCjkImport] = createSignal(false);23  return (24    <Show25      when={showCjkImport()}26      fallback={27        <MainImportView28          mode={props.mode}29          replacesExistingVault={props.replacesExistingVault}30          onBack={props.onBack}31          onImport={props.onImport}32          onOpenCjk={() => setShowCjkImport(true)}33        />34      }35    >36      <CJKImportView37        mode="existing"38        onBack={() => setShowCjkImport(false)}39        onDone={async () => {40          setShowCjkImport(false);41          await props.onImport();42        }}43      />44    </Show>45  );46}4748function MainImportView(props: ImportProps & { onOpenCjk: () => void }) {49  const mode = () => props.mode ?? "existing";5051  const [preview, setPreview] = createSignal<ImportParseResult | null>(null);52  const [rawImport, setRawImport] = createSignal<string | null>(null);53  const [backupPassword, setBackupPassword] = createSignal("");54  const [masterPassword, setMasterPassword] = createSignal("");55  const [masterConfirm, setMasterConfirm] = createSignal("");56  const [importing, setImporting] = createSignal(false);57  const [error, setError] = createSignal<string | null>(null);58  const [mergeMode, setMergeMode] = createSignal<ImportMergeMode>("add");59  const [awaitingReplaceConfirm, setAwaitingReplaceConfirm] = createSignal(false);60  const [importProgress, setImportProgress] = createSignal<{ current: number; total: number } | null>(null);61  const [duplicateWarning, setDuplicateWarning] = createSignal<number>(0);62  const [existingCount, setExistingCount] = createSignal<number>(0);6364  const needsBackupPassword = createMemo(() => preview()?.requiresPassword ?? false);65  const needsMasterPassword = createMemo(() => mode() === "onboarding");66  const masterPasswordReady = createMemo(67    () =>68      masterPassword().length >= 8 && masterPassword() === masterConfirm()69  );7071  async function refreshExistingCount() {72    if (mode() !== "existing") return;73    const res = await sendMessage({ type: "GET_ENTRIES" });74    const list = (res as { ok: boolean; data?: VaultEntry[] }).data ?? [];75    setExistingCount(list.length);76  }7778  void refreshExistingCount();7980  async function parseSource(text: string, password?: string) {81    try {82      const result = await parseImportSource(text, password);83      setPreview(result);84      setError(null);85      if (result.format === "unknown") {86        setError(87          "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."88        );89      }90      return result;91    } catch (err) {92      const message = err instanceof Error ? err.message : String(err);93      setPreview(null);94      setError(getImportErrorCopy(message));95      return null;96    }97  }9899  async function handleFileSelect(event: Event) {100    const file = (event.target as HTMLInputElement).files?.[0];101    if (!file) return;102103    setPreview(null);104    setRawImport(null);105    setBackupPassword("");106    setError(null);107    setAwaitingReplaceConfirm(false);108109    const text = await file.text();110    setRawImport(text);111    await parseSource(text);112  }113114  async function handleUnlockBackup() {115    const raw = rawImport();116    if (!raw || !backupPassword().trim()) return;117    await parseSource(raw, backupPassword());118  }119120  async function countDuplicates(incoming: ImportParseResult["entries"]): Promise<number> {121    if (incoming.length === 0) return 0;122    const res = await sendMessage({ type: "GET_ENTRIES" });123    const existing = ((res as { ok: boolean; data?: VaultEntry[] }).data ?? []).filter((e) => !e.deletedAt);124    const keyOf = (title?: string, url?: string, username?: string) =>125      `${(title ?? "").toLowerCase()}|${(url ?? "").toLowerCase()}|${(username ?? "").toLowerCase()}`;126    const existingKeys = new Set(existing.map((e) => keyOf(e.title, e.url, e.username)));127    let dupes = 0;128    for (const entry of incoming) {129      const u = "username" in entry ? entry.username : undefined;130      const url = "url" in entry ? entry.url : undefined;131      if (existingKeys.has(keyOf(entry.title, url, u))) dupes += 1;132    }133    return dupes;134  }135136  async function handleImport() {137    const currentPreview = preview();138    if (!currentPreview) return;139140    if (needsBackupPassword() && !backupPassword().trim()) {141      setError("Enter the backup password used for this export.");142      return;143    }144145    if (needsMasterPassword()) {146      if (masterPassword().length < 8) {147        setError("Master password must be at least 8 characters.");148        return;149      }150      if (!masterPasswordReady()) {151        setError("Master passwords do not match.");152        return;153      }154    }155156    if (mode() === "existing" && mergeMode() === "replace" && !awaitingReplaceConfirm()) {157      setAwaitingReplaceConfirm(true);158      return;159    }160161    setImporting(true);162    setError(null);163    setDuplicateWarning(0);164    setImportProgress(null);165166    try {167      // ⚠️  Guard: verify entries exist BEFORE touching any vault.168      // Without this, CREATE_VAULT would already have wiped the old vault169      // before we discover the file has nothing importable.170      if (currentPreview.entries.length === 0) {171        throw new Error("NO_ENTRIES_IMPORTED");172      }173174      if (needsMasterPassword()) {175        const createResponse = await sendMessage({176          type: "CREATE_VAULT",177          payload: { password: masterPassword() },178        });179        if (!(createResponse as { ok: boolean }).ok) {180          throw new Error("CREATE_VAULT_FAILED");181        }182      }183184      if (mode() === "existing" && mergeMode() === "replace") {185        const clearRes = await sendMessage({ type: "CLEAR_VAULT_ENTRIES" });186        if (!(clearRes as { ok: boolean }).ok) {187          throw new Error("CLEAR_VAULT_FAILED");188        }189      }190191      let dupes = 0;192      if (mode() === "existing" && mergeMode() === "add") {193        dupes = await countDuplicates(currentPreview.entries);194      }195196      let imported = 0;197      setImportProgress({ current: 0, total: currentPreview.entries.length });198199      const batchRes = await sendMessage({200        type: "BATCH_ADD_ENTRIES",201        payload: { entries: currentPreview.entries },202      });203204      if ((batchRes as { ok: boolean }).ok) {205        imported = ((batchRes as { ok: boolean; data?: VaultEntry[] }).data ?? []).length;206        setImportProgress({ current: imported, total: currentPreview.entries.length });207      } else {208        // Fallback to sequential209        for (const entry of currentPreview.entries) {210          const response = await sendMessage({211            type: "ADD_ENTRY",212            payload: entry,213          });214          if ((response as { ok: boolean }).ok) imported += 1;215          setImportProgress({ current: imported, total: currentPreview.entries.length });216        }217      }218219      if (imported === 0) {220        throw new Error("NO_ENTRIES_IMPORTED");221      }222223      setDuplicateWarning(dupes);224      await props.onImport();225    } catch (err) {226      const message = err instanceof Error ? err.message : String(err);227      setError(getImportErrorCopy(message));228      setImporting(false);229      setImportProgress(null);230      return;231    }232233    setImporting(false);234    setImportProgress(null);235  }236237  return (238    <div class={mode() === "onboarding" ? "peach-lock-view" : "peach-overlay-view ov-enter-right"}>239      <Show240        when={mode() === "onboarding"}241        fallback={242          <div class="peach-overlay-header">243            <button244              class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"245              onClick={props.onBack}246              aria-label="Back"247            >248              <svg249                width="7"250                height="12"251                viewBox="0 0 7 12"252                fill="none"253                stroke="currentColor"254                stroke-width="1.1"255                stroke-linecap="round"256                stroke-linejoin="round"257              >258                <path d="M6 1L1 6L6 11" />259              </svg>260            </button>261            <span class="peach-overlay-title">import</span>262            <div class="peach-overlay-spacer"></div>263          </div>264        }265      >266        <div class="peach-lock-view-header">267          <PeachWordmark class="peach-wordmark-wrap" />268          <button269            class="peach-lock-restore-link"270            onClick={props.onBack}271            disabled={importing()}272          >273            ‹ back274          </button>275        </div>276      </Show>277278      <div class="peach-settings-body">279        <div class="peach-settings-group peach-restore-group">280          <p class="peach-settings-label">281            {mode() === "onboarding" ? "bring data into peach" : "import passwords"}282          </p>283          <p class="peach-restore-copy">284            {mode() === "onboarding"285              ? "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."286              : "Supports Peach encrypted backups, FIDO credential-exchange passkeys, Proton Pass, Bitwarden, and common browser CSV exports."}287          </p>288289          <Show when={mode() === "onboarding" && props.replacesExistingVault}>290            <div class="peach-restore-warning">291              Restoring now replaces the locked local vault on this device with292              a fresh one from your backup or export file.293            </div>294          </Show>295296          <div class="peach-provider-chips">297            <span class="peach-provider-chip">Peach JSON</span>298            <span class="peach-provider-chip">FIDO passkeys</span>299            <span class="peach-provider-chip">Proton Pass CSV/JSON</span>300            <span class="peach-provider-chip">Bitwarden CSV/JSON</span>301            <span class="peach-provider-chip">1Password JSON</span>302            <span class="peach-provider-chip">Chrome / Edge / Safari CSV</span>303          </div>304        </div>305306        <Show when={mode() === "existing"}>307          <div class="peach-settings-group">308            <p class="peach-settings-label">restore from CJK phrase</p>309            <p class="peach-restore-copy">310              Use a Peach CJK backup phrase instead of a file.311            </p>312            <button313              class="peach-btn peach-btn--subtle peach-btn--sm"314              onClick={props.onOpenCjk}315            >316              Restore from CJK phrase317            </button>318          </div>319        </Show>320321        <div class="peach-settings-group">322          <p class="peach-settings-label">select file</p>323          <input324            class="peach-restore-file"325            type="file"326            accept=".csv,.json"327            onChange={(event) => void handleFileSelect(event)}328          />329        </div>330331        <Show when={needsBackupPassword()}>332          <div class="peach-settings-group">333            <p class="peach-settings-label">unlock backup</p>334            <p class="peach-restore-copy">335              Enter the password used when you created this encrypted Peach JSON336              backup.337            </p>338            <input339              class="peach-restore-input"340              type="password"341              placeholder="backup password"342              value={backupPassword()}343              onInput={(event) =>344                setBackupPassword((event.target as HTMLInputElement).value)345              }346              onBlur={() => { if (backupPassword().trim()) void handleUnlockBackup(); }}347              onKeyDown={(event) => {348                if (event.key === "Enter") {349                  event.preventDefault();350                  void handleUnlockBackup();351                }352              }}353            />354            <div class="peach-settings-actions">355              <button356                class="peach-btn peach-btn--subtle peach-btn--sm"357                onClick={() => void handleUnlockBackup()}358                disabled={!backupPassword().trim()}359              >360                Decrypt →361              </button>362            </div>363          </div>364        </Show>365366        <Show when={preview() && !needsBackupPassword() && preview()!.format !== "unknown"}>367          <div class="peach-settings-group">368            <p class="peach-settings-label">{preview()!.label}</p>369            <div class="peach-import-summary">370              Found <strong>{preview()!.entries.length}</strong>{" "}371              entr{preview()!.entries.length === 1 ? "y" : "ies"}372              {preview()!.skippedItems > 0373                ? `. ${preview()!.skippedItems} skipped because no password was present.`374                : "."}375            </div>376377            <div class="peach-import-preview">378              {preview()!.entries.slice(0, 8).map((entry) => (379                <div class="peach-import-preview-row">380                  <span class="peach-import-preview-title">{entry.title}</span>381                  <span class="peach-import-preview-meta">382                    {entry.type === "passkey"383                      ? `${entry.userName || "passkey"} • ${entry.rpId || "site"}`384                      : entry.type === "note"385                        ? "secure note"386                        : entry.username || entry.url || "password item"}387                  </span>388                </div>389              ))}390              <Show when={preview()!.entries.length > 8}>391                <div class="peach-import-preview-more">392                  ...and {preview()!.entries.length - 8} more393                </div>394              </Show>395            </div>396          </div>397        </Show>398399        <Show when={mode() === "existing" && preview() && !needsBackupPassword() && preview()!.format !== "unknown" && preview()!.entries.length > 0}>400          <div class="peach-settings-group">401            <p class="peach-settings-label">merge or replace</p>402            <label class="peach-import-choice">403              <input404                type="radio"405                name="merge-mode"406                value="add"407                checked={mergeMode() === "add"}408                onChange={() => { setMergeMode("add"); setAwaitingReplaceConfirm(false); }}409              />410              <span class="peach-import-choice-main">411                <span class="peach-import-choice-label">Add to existing vault</span>412                <span class="peach-import-choice-sub">413                  This will add {preview()!.entries.length} entries to your existing {existingCount()}.414                </span>415              </span>416            </label>417            <label class="peach-import-choice">418              <input419                type="radio"420                name="merge-mode"421                value="replace"422                checked={mergeMode() === "replace"}423                onChange={() => setMergeMode("replace")}424              />425              <span class="peach-import-choice-main">426                <span class="peach-import-choice-label">Replace vault</span>427                <span class="peach-import-choice-sub">428                  This will replace your {existingCount()} entries with {preview()!.entries.length} imported ones.429                </span>430              </span>431            </label>432            <Show when={awaitingReplaceConfirm()}>433              <div class="peach-restore-warning" style="margin-top:6px">434                Replace forever? This cannot be undone.435              </div>436            </Show>437          </div>438        </Show>439440        <Show when={needsMasterPassword() && preview() && !needsBackupPassword() && preview()!.format !== "unknown"}>441          <div class="peach-settings-group">442            <p class="peach-settings-label">new master password</p>443            <p class="peach-restore-copy">444              This password unlocks the Peach vault created on this device after445              import. It is separate from any export password.446            </p>447            <div class="peach-auth-grid">448              <input449                class="peach-restore-input"450                type="password"451                placeholder="new master password"452                value={masterPassword()}453                onInput={(event) =>454                  setMasterPassword((event.target as HTMLInputElement).value)455                }456              />457              <input458                class="peach-restore-input"459                type="password"460                placeholder="confirm master password"461                value={masterConfirm()}462                onInput={(event) =>463                  setMasterConfirm((event.target as HTMLInputElement).value)464                }465              />466            </div>467          </div>468        </Show>469470        <Show when={error()}>471          <div class="peach-settings-group">472            <div class="peach-restore-error">{error()}</div>473          </div>474        </Show>475476        <Show when={importing() && importProgress()}>477          <div class="peach-settings-group">478            <div class="peach-import-summary">479              Importing... [{importProgress()!.current} / {importProgress()!.total}]480            </div>481          </div>482        </Show>483484        <Show when={duplicateWarning() > 0 && !importing()}>485          <div class="peach-settings-group">486            <div class="peach-restore-copy">487              {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.488            </div>489          </div>490        </Show>491492        <Show when={preview() && !needsBackupPassword() && preview()!.format !== "unknown"}>493          <div class="peach-settings-group">494            <div class="peach-settings-actions">495              <button496                class="peach-btn peach-btn--subtle peach-btn--sm"497                onClick={() => void handleImport()}498                disabled={499                  importing() ||500                  preview()!.entries.length === 0 ||501                  (needsMasterPassword() && !masterPasswordReady())502                }503              >504                {importing()505                  ? "importing..."506                  : mode() === "onboarding"507                    ? `Create vault + import ${preview()!.entries.length}`508                    : mergeMode() === "replace"509                      ? awaitingReplaceConfirm()510                        ? "Yes, replace forever"511                        : `Replace vault with ${preview()!.entries.length}`512                      : `Import ${preview()!.entries.length}`}513              </button>514              <button515                class="peach-btn peach-btn--ghost peach-btn--sm"516                onClick={props.onBack}517                disabled={importing()}518              >519                Cancel520              </button>521            </div>522          </div>523        </Show>524      </div>525    </div>526  );527}528529function getImportErrorCopy(error: string): string {530  switch (error) {531    case "UNSUPPORTED_IMPORT_FORMAT":532      return "Unrecognised file format. Use CSV, Bitwarden JSON, Proton Pass JSON/CSV, or a Peach encrypted backup.";533    case "INVALID_EXPORT_PASSWORD":534      return "Incorrect backup password. This is the password you chose when you exported the Peach JSON backup.";535    case "INVALID_PEACH_EXPORT":536      return "This Peach backup file looks corrupted or incomplete.";537    case "CREATE_VAULT_FAILED":538      return "Peach couldn't create a new local vault before importing.";539    case "CLEAR_VAULT_FAILED":540      return "Peach couldn't clear the existing vault before replacing it.";541    case "NO_ENTRIES_IMPORTED":542      return "No importable password entries were found in that file.";543    default:544      return error;545  }546}