Import.tsx

20.4 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      // Apply license last — silent on failure, never blocks a successful import.228      if (currentPreview.licenseKey) {229        await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key: currentPreview.licenseKey } }).catch(() => {});230      }231232      setDuplicateWarning(dupes);233      await props.onImport();234    } catch (err) {235      const message = err instanceof Error ? err.message : String(err);236      setError(getImportErrorCopy(message));237      setImporting(false);238      setImportProgress(null);239      return;240    }241242    setImporting(false);243    setImportProgress(null);244  }245246  return (247    <div class={mode() === "onboarding" ? "peach-lock-view" : "peach-overlay-view ov-enter-right"}>248      <Show249        when={mode() === "onboarding"}250        fallback={251          <div class="peach-overlay-header">252            <button253              class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"254              onClick={props.onBack}255              aria-label="Back"256            >257              <svg258                width="7"259                height="12"260                viewBox="0 0 7 12"261                fill="none"262                stroke="currentColor"263                stroke-width="1.1"264                stroke-linecap="round"265                stroke-linejoin="round"266              >267                <path d="M6 1L1 6L6 11" />268              </svg>269            </button>270            <span class="peach-overlay-title">import</span>271            <div class="peach-overlay-spacer"></div>272          </div>273        }274      >275        <div class="peach-lock-view-header">276          <PeachWordmark class="peach-wordmark-wrap" />277          <button278            class="peach-lock-restore-link"279            onClick={props.onBack}280            disabled={importing()}281          >282            ‹ back283          </button>284        </div>285      </Show>286287      <div class="peach-settings-body">288        <div class="peach-settings-group peach-restore-group">289          <p class="peach-settings-label">290            {mode() === "onboarding" ? "bring data into peach" : "import passwords"}291          </p>292          <p class="peach-restore-copy">293            {mode() === "onboarding"294              ? "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."295              : "Supports Peach encrypted backups, FIDO credential-exchange passkeys, Proton Pass, Bitwarden, and common browser CSV exports."}296          </p>297298          <Show when={mode() === "onboarding" && props.replacesExistingVault}>299            <div class="peach-restore-warning">300              Restoring now replaces the locked local vault on this device with301              a fresh one from your backup or export file.302            </div>303          </Show>304305          <div class="peach-provider-chips">306            <span class="peach-provider-chip">Peach JSON</span>307            <span class="peach-provider-chip">FIDO passkeys</span>308            <span class="peach-provider-chip">Proton Pass CSV/JSON</span>309            <span class="peach-provider-chip">Bitwarden CSV/JSON</span>310            <span class="peach-provider-chip">1Password JSON</span>311            <span class="peach-provider-chip">Chrome / Edge / Safari CSV</span>312          </div>313        </div>314315        <Show when={mode() === "existing"}>316          <div class="peach-settings-group">317            <p class="peach-settings-label">restore from CJK phrase</p>318            <p class="peach-restore-copy">319              Use a Peach CJK backup phrase instead of a file.320            </p>321            <button322              class="peach-btn peach-btn--subtle peach-btn--sm"323              onClick={props.onOpenCjk}324            >325              Restore from CJK phrase326            </button>327          </div>328        </Show>329330        <div class="peach-settings-group">331          <p class="peach-settings-label">select file</p>332          <input333            class="peach-restore-file"334            type="file"335            accept=".csv,.json"336            onChange={(event) => void handleFileSelect(event)}337          />338        </div>339340        <Show when={needsBackupPassword()}>341          <div class="peach-settings-group">342            <p class="peach-settings-label">unlock backup</p>343            <p class="peach-restore-copy">344              Enter the password used when you created this encrypted Peach JSON345              backup.346            </p>347            <input348              class="peach-restore-input"349              type="password"350              placeholder="backup password"351              value={backupPassword()}352              onInput={(event) =>353                setBackupPassword((event.target as HTMLInputElement).value)354              }355              onBlur={() => { if (backupPassword().trim()) void handleUnlockBackup(); }}356              onKeyDown={(event) => {357                if (event.key === "Enter") {358                  event.preventDefault();359                  void handleUnlockBackup();360                }361              }}362            />363            <div class="peach-settings-actions">364              <button365                class="peach-btn peach-btn--subtle peach-btn--sm"366                onClick={() => void handleUnlockBackup()}367                disabled={!backupPassword().trim()}368              >369                Decrypt →370              </button>371            </div>372          </div>373        </Show>374375        <Show when={preview() && !needsBackupPassword() && preview()!.format !== "unknown"}>376          <div class="peach-settings-group">377            <p class="peach-settings-label">{preview()!.label}</p>378            <div class="peach-import-summary">379              Found <strong>{preview()!.entries.length}</strong>{" "}380              entr{preview()!.entries.length === 1 ? "y" : "ies"}381              {preview()!.skippedItems > 0382                ? `. ${preview()!.skippedItems} skipped because no password was present.`383                : "."}384            </div>385386            <div class="peach-import-preview">387              {preview()!.entries.slice(0, 8).map((entry) => (388                <div class="peach-import-preview-row">389                  <span class="peach-import-preview-title">{entry.title}</span>390                  <span class="peach-import-preview-meta">391                    {entry.type === "passkey"392                      ? `${entry.userName || "passkey"} • ${entry.rpId || "site"}`393                      : entry.type === "note"394                        ? "secure note"395                        : entry.username || entry.url || "password item"}396                  </span>397                </div>398              ))}399              <Show when={preview()!.entries.length > 8}>400                <div class="peach-import-preview-more">401                  ...and {preview()!.entries.length - 8} more402                </div>403              </Show>404            </div>405          </div>406        </Show>407408        <Show when={mode() === "existing" && preview() && !needsBackupPassword() && preview()!.format !== "unknown" && preview()!.entries.length > 0}>409          <div class="peach-settings-group">410            <p class="peach-settings-label">merge or replace</p>411            <label class="peach-import-choice">412              <input413                type="radio"414                name="merge-mode"415                value="add"416                checked={mergeMode() === "add"}417                onChange={() => { setMergeMode("add"); setAwaitingReplaceConfirm(false); }}418              />419              <span class="peach-import-choice-main">420                <span class="peach-import-choice-label">Add to existing vault</span>421                <span class="peach-import-choice-sub">422                  This will add {preview()!.entries.length} entries to your existing {existingCount()}.423                </span>424              </span>425            </label>426            <label class="peach-import-choice">427              <input428                type="radio"429                name="merge-mode"430                value="replace"431                checked={mergeMode() === "replace"}432                onChange={() => setMergeMode("replace")}433              />434              <span class="peach-import-choice-main">435                <span class="peach-import-choice-label">Replace vault</span>436                <span class="peach-import-choice-sub">437                  This will replace your {existingCount()} entries with {preview()!.entries.length} imported ones.438                </span>439              </span>440            </label>441            <Show when={awaitingReplaceConfirm()}>442              <div class="peach-restore-warning" style="margin-top:6px">443                Replace forever? This cannot be undone.444              </div>445            </Show>446          </div>447        </Show>448449        <Show when={needsMasterPassword() && preview() && !needsBackupPassword() && preview()!.format !== "unknown"}>450          <div class="peach-settings-group">451            <p class="peach-settings-label">new master password</p>452            <p class="peach-restore-copy">453              This password unlocks the Peach vault created on this device after454              import. It is separate from any export password.455            </p>456            <div class="peach-auth-grid">457              <input458                class="peach-restore-input"459                type="password"460                placeholder="new master password"461                value={masterPassword()}462                onInput={(event) =>463                  setMasterPassword((event.target as HTMLInputElement).value)464                }465              />466              <input467                class="peach-restore-input"468                type="password"469                placeholder="confirm master password"470                value={masterConfirm()}471                onInput={(event) =>472                  setMasterConfirm((event.target as HTMLInputElement).value)473                }474              />475            </div>476          </div>477        </Show>478479        <Show when={error()}>480          <div class="peach-settings-group">481            <div class="peach-restore-error">{error()}</div>482          </div>483        </Show>484485        <Show when={importing() && importProgress()}>486          <div class="peach-settings-group">487            <div class="peach-import-summary">488              Importing... [{importProgress()!.current} / {importProgress()!.total}]489            </div>490          </div>491        </Show>492493        <Show when={duplicateWarning() > 0 && !importing()}>494          <div class="peach-settings-group">495            <div class="peach-restore-copy">496              {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.497            </div>498          </div>499        </Show>500501        <Show when={preview() && !needsBackupPassword() && preview()!.format !== "unknown"}>502          <div class="peach-settings-group">503            <div class="peach-settings-actions">504              <button505                class="peach-btn peach-btn--subtle peach-btn--sm"506                onClick={() => void handleImport()}507                disabled={508                  importing() ||509                  preview()!.entries.length === 0 ||510                  (needsMasterPassword() && !masterPasswordReady())511                }512              >513                {importing()514                  ? "importing..."515                  : mode() === "onboarding"516                    ? `Create vault + import ${preview()!.entries.length}`517                    : mergeMode() === "replace"518                      ? awaitingReplaceConfirm()519                        ? "Yes, replace forever"520                        : `Replace vault with ${preview()!.entries.length}`521                      : `Import ${preview()!.entries.length}`}522              </button>523              <button524                class="peach-btn peach-btn--ghost peach-btn--sm"525                onClick={props.onBack}526                disabled={importing()}527              >528                Cancel529              </button>530            </div>531          </div>532        </Show>533      </div>534    </div>535  );536}537538function getImportErrorCopy(error: string): string {539  switch (error) {540    case "UNSUPPORTED_IMPORT_FORMAT":541      return "Unrecognised file format. Use CSV, Bitwarden JSON, Proton Pass JSON/CSV, or a Peach encrypted backup.";542    case "INVALID_EXPORT_PASSWORD":543      return "Incorrect backup password. This is the password you chose when you exported the Peach JSON backup.";544    case "INVALID_PEACH_EXPORT":545      return "This Peach backup file looks corrupted or incomplete.";546    case "CREATE_VAULT_FAILED":547      return "Peach couldn't create a new local vault before importing.";548    case "CLEAR_VAULT_FAILED":549      return "Peach couldn't clear the existing vault before replacing it.";550    case "NO_ENTRIES_IMPORTED":551      return "No importable password entries were found in that file.";552    default:553      return error;554  }555}