Import.tsx

24.7 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";1819interface ImportProps {20  mode?: "existing" | "onboarding";21  replacesExistingVault?: boolean;22  onBack: () => void;23  onImport: () => void | Promise<void>;24}2526type ImportMergeMode = "add" | "replace";2728export function ImportView(props: ImportProps) {29  return (30    <MainImportView31      mode={props.mode}32      replacesExistingVault={props.replacesExistingVault}33      onBack={props.onBack}34      onImport={props.onImport}35    />36  );37}3839function MainImportView(props: ImportProps) {40  const mode = () => props.mode ?? "existing";4142  // PeachScript paste flow43  const [scriptInput, setScriptInput] = createSignal("");44  const [scriptPass, setScriptPass] = createSignal("");45  const [scriptWorking, setScriptWorking] = createSignal(false);46  const [scriptError, setScriptError] = createSignal<string | null>(null);47  const [scriptResult, setScriptResult] = createSignal<{ entryCount: number } | null>(null);4849  // File flow (existing format detection)50  const [preview, setPreview] = createSignal<ImportParseResult | null>(null);51  const [rawImport, setRawImport] = createSignal<string | null>(null);52  const [backupPassword, setBackupPassword] = createSignal("");53  const [masterPassword, setMasterPassword] = createSignal("");54  const [masterConfirm, setMasterConfirm] = createSignal("");55  const [importing, setImporting] = createSignal(false);56  const [error, setError] = createSignal<string | null>(null);57  const [mergeMode, setMergeMode] = createSignal<ImportMergeMode>("add");58  const [awaitingReplaceConfirm, setAwaitingReplaceConfirm] = createSignal(false);59  const [importProgress, setImportProgress] = createSignal<{ current: number; total: number } | null>(null);60  const [duplicateWarning, setDuplicateWarning] = createSignal<number>(0);61  const [existingCount, setExistingCount] = createSignal<number>(0);6263  const needsBackupPassword = createMemo(() => preview()?.requiresPassword ?? false);64  const needsMasterPassword = createMemo(() => mode() === "onboarding");65  const masterPasswordReady = createMemo(66    () =>67      masterPassword().length >= 8 && masterPassword() === masterConfirm()68  );6970  const scriptLooksValid = createMemo(() => isPeachScript(scriptInput()));7172  async function refreshExistingCount() {73    if (mode() !== "existing") return;74    const res = await sendMessage({ type: "GET_ENTRIES" });75    const list = (res as { ok: boolean; data?: VaultEntry[] }).data ?? [];76    setExistingCount(list.length);77  }7879  void refreshExistingCount();8081  // ---------------------------------------------------------------------82  // PeachScript flow83  // ---------------------------------------------------------------------8485  async function handlePeachScriptImport() {86    setScriptError(null);87    setScriptResult(null);88    if (!scriptLooksValid()) {89      setScriptError("Paste your PeachScript first.");90      return;91    }92    if (!scriptPass().trim()) {93      setScriptError("Enter the backup password.");94      return;95    }96    setScriptWorking(true);97    try {98      const bytes = decodePeachScript(normalisePeachScript(scriptInput()));99      const payload = await parsePayload(bytes, scriptPass());100101      // Onboarding: create a fresh vault first.102      if (mode() === "onboarding") {103        if (!masterPasswordReady()) {104          throw new Error("MASTER_PASSWORD_REQUIRED");105        }106        const createRes = await sendMessage({107          type: "CREATE_VAULT",108          payload: { password: masterPassword() },109        });110        if (!(createRes as { ok: boolean }).ok) throw new Error("CREATE_VAULT_FAILED");111      }112113      const restoreRes = await sendMessage({114        type: "RESTORE_ENTRIES",115        payload: {116          entries: payload.entries,117          mode: "merge",118          settings: payload.settings,119        },120      });121      if (!(restoreRes as { ok: boolean }).ok) throw new Error("RESTORE_FAILED");122123      if (payload.license?.key) {124        await sendMessage({125          type: "ACTIVATE_LICENSE",126          payload: { key: payload.license.key },127        }).catch(() => {});128      }129130      setScriptResult({ entryCount: payload.entries.length });131      await props.onImport();132    } catch (err) {133      const code = err instanceof Error ? err.message : String(err);134      setScriptError(getImportErrorCopy(code));135    } finally {136      setScriptWorking(false);137    }138  }139140  // ---------------------------------------------------------------------141  // File flow (Bitwarden / 1Password / etc.)142  // ---------------------------------------------------------------------143144  async function parseSource(text: string, password?: string) {145    try {146      const result = await parseImportSource(text, password);147      setPreview(result);148      setError(null);149      if (result.format === "unknown") {150        setError(151          "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."152        );153      }154      return result;155    } catch (err) {156      const message = err instanceof Error ? err.message : String(err);157      setPreview(null);158      setError(getImportErrorCopy(message));159      return null;160    }161  }162163  async function handleFileSelect(event: Event) {164    const file = (event.target as HTMLInputElement).files?.[0];165    if (!file) return;166167    setPreview(null);168    setRawImport(null);169    setBackupPassword("");170    setError(null);171    setAwaitingReplaceConfirm(false);172173    const text = await file.text();174    setRawImport(text);175    await parseSource(text);176  }177178  async function handleUnlockBackup() {179    const raw = rawImport();180    if (!raw || !backupPassword().trim()) return;181    await parseSource(raw, backupPassword());182  }183184  async function countDuplicates(incoming: ImportParseResult["entries"]): Promise<number> {185    if (incoming.length === 0) return 0;186    const res = await sendMessage({ type: "GET_ENTRIES" });187    const existing = ((res as { ok: boolean; data?: VaultEntry[] }).data ?? []).filter((e) => !e.deletedAt);188    const keyOf = (title?: string, url?: string, username?: string) =>189      `${(title ?? "").toLowerCase()}|${(url ?? "").toLowerCase()}|${(username ?? "").toLowerCase()}`;190    const existingKeys = new Set(existing.map((e) => keyOf(e.title, e.url, e.username)));191    let dupes = 0;192    for (const entry of incoming) {193      const u = "username" in entry ? entry.username : undefined;194      const url = "url" in entry ? entry.url : undefined;195      if (existingKeys.has(keyOf(entry.title, url, u))) dupes += 1;196    }197    return dupes;198  }199200  async function handleImport() {201    const currentPreview = preview();202    if (!currentPreview) return;203204    if (needsBackupPassword() && !backupPassword().trim()) {205      setError("Enter the backup password used for this export.");206      return;207    }208209    if (needsMasterPassword()) {210      if (masterPassword().length < 8) {211        setError("Master password must be at least 8 characters.");212        return;213      }214      if (!masterPasswordReady()) {215        setError("Master passwords do not match.");216        return;217      }218    }219220    if (mode() === "existing" && mergeMode() === "replace" && !awaitingReplaceConfirm()) {221      setAwaitingReplaceConfirm(true);222      return;223    }224225    setImporting(true);226    setError(null);227    setDuplicateWarning(0);228    setImportProgress(null);229230    try {231      if (currentPreview.entries.length === 0) {232        throw new Error("NO_ENTRIES_IMPORTED");233      }234235      if (needsMasterPassword()) {236        const createResponse = await sendMessage({237          type: "CREATE_VAULT",238          payload: { password: masterPassword() },239        });240        if (!(createResponse as { ok: boolean }).ok) {241          throw new Error("CREATE_VAULT_FAILED");242        }243      }244245      if (mode() === "existing" && mergeMode() === "replace") {246        const clearRes = await sendMessage({ type: "CLEAR_VAULT_ENTRIES" });247        if (!(clearRes as { ok: boolean }).ok) {248          throw new Error("CLEAR_VAULT_FAILED");249        }250      }251252      let dupes = 0;253      if (mode() === "existing" && mergeMode() === "add") {254        dupes = await countDuplicates(currentPreview.entries);255      }256257      let imported = 0;258      setImportProgress({ current: 0, total: currentPreview.entries.length });259260      const batchRes = await sendMessage({261        type: "BATCH_ADD_ENTRIES",262        payload: { entries: currentPreview.entries },263      });264265      if ((batchRes as { ok: boolean }).ok) {266        imported = ((batchRes as { ok: boolean; data?: VaultEntry[] }).data ?? []).length;267        setImportProgress({ current: imported, total: currentPreview.entries.length });268      } else {269        for (const entry of currentPreview.entries) {270          const response = await sendMessage({271            type: "ADD_ENTRY",272            payload: entry,273          });274          if ((response as { ok: boolean }).ok) imported += 1;275          setImportProgress({ current: imported, total: currentPreview.entries.length });276        }277      }278279      if (imported === 0) {280        throw new Error("NO_ENTRIES_IMPORTED");281      }282283      if (currentPreview.licenseKey) {284        await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key: currentPreview.licenseKey } }).catch(() => {});285      }286287      setDuplicateWarning(dupes);288      await props.onImport();289    } catch (err) {290      const message = err instanceof Error ? err.message : String(err);291      setError(getImportErrorCopy(message));292      setImporting(false);293      setImportProgress(null);294      return;295    }296297    setImporting(false);298    setImportProgress(null);299  }300301  return (302    <div class={mode() === "onboarding" ? "peach-lock-view" : "peach-overlay-view ov-enter-right"}>303      <Show304        when={mode() === "onboarding"}305        fallback={306          <div class="peach-overlay-header">307            <button308              class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"309              onClick={props.onBack}310              aria-label="Back"311            >312              <svg313                width="7" height="12" viewBox="0 0 7 12"314                fill="none" stroke="currentColor"315                stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round"316              >317                <path d="M6 1L1 6L6 11" />318              </svg>319            </button>320            <span class="peach-overlay-title">import</span>321            <div class="peach-overlay-spacer"></div>322          </div>323        }324      >325        <div class="peach-lock-view-header">326          <PeachWordmark class="peach-wordmark-wrap" />327          <button328            class="peach-lock-restore-link"329            onClick={props.onBack}330            disabled={importing() || scriptWorking()}331          >332            ‹ back333          </button>334        </div>335      </Show>336337      <div class="peach-settings-body">338        <Show when={mode() === "onboarding" && props.replacesExistingVault}>339          <div class="peach-settings-group">340            <div class="peach-restore-warning">341              Restoring now replaces the locked local vault on this device with342              a fresh one from your backup or export file.343            </div>344          </div>345        </Show>346347        {/* PeachScript paste — primary path */}348        <div class="peach-settings-group peach-restore-group">349          <p class="peach-settings-label">paste peachscript</p>350          <p class="peach-restore-copy">351            {mode() === "onboarding"352              ? "Paste a PeachScript backup from another Peach device to restore your vault."353              : "Paste a PeachScript backup from another Peach device. We'll merge it into your vault."}354          </p>355          <textarea356            class="peach-script-input"357            placeholder="paste PeachScript here…"358            value={scriptInput()}359            onInput={(e) => setScriptInput((e.target as HTMLTextAreaElement).value)}360          />361          <input362            class="peach-restore-input"363            type="password"364            placeholder="PeachScript password"365            value={scriptPass()}366            onInput={(e) => setScriptPass((e.target as HTMLInputElement).value)}367            style="margin-top:8px"368          />369          <Show when={mode() === "onboarding"}>370            <div class="peach-auth-grid" style="margin-top:8px">371              <input372                class="peach-restore-input"373                type="password"374                placeholder="new master password"375                value={masterPassword()}376                onInput={(e) => setMasterPassword((e.target as HTMLInputElement).value)}377              />378              <input379                class="peach-restore-input"380                type="password"381                placeholder="confirm master password"382                value={masterConfirm()}383                onInput={(e) => setMasterConfirm((e.target as HTMLInputElement).value)}384              />385            </div>386          </Show>387          <div class="peach-settings-actions" style="margin-top:8px">388            <button389              class="peach-btn peach-btn--subtle peach-btn--sm"390              onClick={() => void handlePeachScriptImport()}391              disabled={392                scriptWorking() ||393                !scriptLooksValid() ||394                !scriptPass().trim() ||395                (mode() === "onboarding" && !masterPasswordReady())396              }397            >398              {scriptWorking() ? "decoding…" : "Restore from PeachScript"}399            </button>400          </div>401          <Show when={scriptError()}>402            <div class="peach-restore-error" style="margin-top:8px">{scriptError()}</div>403          </Show>404          <Show when={scriptResult()}>405            <div class="peach-export-success" style="margin-top:8px">406              <span class="peach-export-success-check">✓</span>407              <span>Restored {scriptResult()!.entryCount} entr{scriptResult()!.entryCount === 1 ? "y" : "ies"}.</span>408            </div>409          </Show>410        </div>411412        {/* File import — for migrating from other managers */}413        <div class="peach-settings-group peach-restore-group">414          <p class="peach-settings-label">import a file</p>415          <p class="peach-restore-copy">416            Bitwarden, 1Password, Proton Pass, Chrome / Edge / Safari CSV, FIDO417            credential-exchange passkeys, or a Peach JSON backup.418          </p>419420          <div class="peach-provider-chips">421            <span class="peach-provider-chip">Peach JSON</span>422            <span class="peach-provider-chip">FIDO passkeys</span>423            <span class="peach-provider-chip">Proton Pass CSV/JSON</span>424            <span class="peach-provider-chip">Bitwarden CSV/JSON</span>425            <span class="peach-provider-chip">1Password JSON</span>426            <span class="peach-provider-chip">Chrome / Edge / Safari CSV</span>427          </div>428429          <input430            class="peach-restore-file"431            type="file"432            accept=".csv,.json"433            onChange={(event) => void handleFileSelect(event)}434            style="margin-top:8px"435          />436        </div>437438        <Show when={needsBackupPassword()}>439          <div class="peach-settings-group">440            <p class="peach-settings-label">unlock backup</p>441            <p class="peach-restore-copy">442              Enter the password used when you created this encrypted Peach JSON443              backup.444            </p>445            <input446              class="peach-restore-input"447              type="password"448              placeholder="backup password"449              value={backupPassword()}450              onInput={(event) =>451                setBackupPassword((event.target as HTMLInputElement).value)452              }453              onBlur={() => { if (backupPassword().trim()) void handleUnlockBackup(); }}454              onKeyDown={(event) => {455                if (event.key === "Enter") {456                  event.preventDefault();457                  void handleUnlockBackup();458                }459              }}460            />461            <div class="peach-settings-actions">462              <button463                class="peach-btn peach-btn--subtle peach-btn--sm"464                onClick={() => void handleUnlockBackup()}465                disabled={!backupPassword().trim()}466              >467                Decrypt →468              </button>469            </div>470          </div>471        </Show>472473        <Show when={preview() && !needsBackupPassword() && preview()!.format !== "unknown"}>474          <div class="peach-settings-group">475            <p class="peach-settings-label">{preview()!.label}</p>476            <div class="peach-import-summary">477              Found <strong>{preview()!.entries.length}</strong>{" "}478              entr{preview()!.entries.length === 1 ? "y" : "ies"}479              {preview()!.skippedItems > 0480                ? `. ${preview()!.skippedItems} skipped because no password was present.`481                : "."}482            </div>483484            <div class="peach-import-preview">485              {preview()!.entries.slice(0, 8).map((entry) => (486                <div class="peach-import-preview-row">487                  <span class="peach-import-preview-title">{entry.title}</span>488                  <span class="peach-import-preview-meta">489                    {entry.type === "passkey"490                      ? `${entry.userName || "passkey"} • ${entry.rpId || "site"}`491                      : entry.type === "note"492                        ? "secure note"493                        : entry.username || entry.url || "password item"}494                  </span>495                </div>496              ))}497              <Show when={preview()!.entries.length > 8}>498                <div class="peach-import-preview-more">499                  ...and {preview()!.entries.length - 8} more500                </div>501              </Show>502            </div>503          </div>504        </Show>505506        <Show when={mode() === "existing" && preview() && !needsBackupPassword() && preview()!.format !== "unknown" && preview()!.entries.length > 0}>507          <div class="peach-settings-group">508            <p class="peach-settings-label">merge or replace</p>509            <label class="peach-import-choice">510              <input511                type="radio"512                name="merge-mode"513                value="add"514                checked={mergeMode() === "add"}515                onChange={() => { setMergeMode("add"); setAwaitingReplaceConfirm(false); }}516              />517              <span class="peach-import-choice-main">518                <span class="peach-import-choice-label">Add to existing vault</span>519                <span class="peach-import-choice-sub">520                  This will add {preview()!.entries.length} entries to your existing {existingCount()}.521                </span>522              </span>523            </label>524            <label class="peach-import-choice">525              <input526                type="radio"527                name="merge-mode"528                value="replace"529                checked={mergeMode() === "replace"}530                onChange={() => setMergeMode("replace")}531              />532              <span class="peach-import-choice-main">533                <span class="peach-import-choice-label">Replace vault</span>534                <span class="peach-import-choice-sub">535                  This will replace your {existingCount()} entries with {preview()!.entries.length} imported ones.536                </span>537              </span>538            </label>539            <Show when={awaitingReplaceConfirm()}>540              <div class="peach-restore-warning" style="margin-top:6px">541                Replace forever? This cannot be undone.542              </div>543            </Show>544          </div>545        </Show>546547        <Show when={needsMasterPassword() && preview() && !needsBackupPassword() && preview()!.format !== "unknown"}>548          <div class="peach-settings-group">549            <p class="peach-settings-label">new master password</p>550            <p class="peach-restore-copy">551              This password unlocks the Peach vault created on this device after552              import.553            </p>554            <div class="peach-auth-grid">555              <input556                class="peach-restore-input"557                type="password"558                placeholder="new master password"559                value={masterPassword()}560                onInput={(event) =>561                  setMasterPassword((event.target as HTMLInputElement).value)562                }563              />564              <input565                class="peach-restore-input"566                type="password"567                placeholder="confirm master password"568                value={masterConfirm()}569                onInput={(event) =>570                  setMasterConfirm((event.target as HTMLInputElement).value)571                }572              />573            </div>574          </div>575        </Show>576577        <Show when={error()}>578          <div class="peach-settings-group">579            <div class="peach-restore-error">{error()}</div>580          </div>581        </Show>582583        <Show when={importing() && importProgress()}>584          <div class="peach-settings-group">585            <div class="peach-import-summary">586              Importing... [{importProgress()!.current} / {importProgress()!.total}]587            </div>588          </div>589        </Show>590591        <Show when={duplicateWarning() > 0 && !importing()}>592          <div class="peach-settings-group">593            <div class="peach-restore-copy">594              {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.595            </div>596          </div>597        </Show>598599        <Show when={preview() && !needsBackupPassword() && preview()!.format !== "unknown"}>600          <div class="peach-settings-group">601            <div class="peach-settings-actions">602              <button603                class="peach-btn peach-btn--subtle peach-btn--sm"604                onClick={() => void handleImport()}605                disabled={606                  importing() ||607                  preview()!.entries.length === 0 ||608                  (needsMasterPassword() && !masterPasswordReady())609                }610              >611                {importing()612                  ? "importing..."613                  : mode() === "onboarding"614                    ? `Create vault + import ${preview()!.entries.length}`615                    : mergeMode() === "replace"616                      ? awaitingReplaceConfirm()617                        ? "Yes, replace forever"618                        : `Replace vault with ${preview()!.entries.length}`619                      : `Import ${preview()!.entries.length}`}620              </button>621              <button622                class="peach-btn peach-btn--ghost peach-btn--sm"623                onClick={props.onBack}624                disabled={importing()}625              >626                Cancel627              </button>628            </div>629          </div>630        </Show>631632      </div>633    </div>634  );635}636637function getImportErrorCopy(error: string): string {638  switch (error) {639    case "UNSUPPORTED_IMPORT_FORMAT":640      return "Unrecognised file format. Use CSV, Bitwarden JSON, Proton Pass JSON/CSV, or a Peach encrypted backup.";641    case "INVALID_EXPORT_PASSWORD":642    case "INVALID_PASSPHRASE":643      return "Wrong backup password. This is the password you chose when you created the backup.";644    case "INVALID_PEACH_EXPORT":645      return "This Peach backup file looks corrupted or incomplete.";646    case "INVALID_PEACHSCRIPT_CHARACTER":647      return "This doesn't look like PeachScript. Make sure you copied the entire backup string.";648    case "PAYLOAD_TOO_SHORT":649      return "PeachScript looks truncated. Copy the full backup string and try again.";650    case "BAD_MAGIC":651    case "UNSUPPORTED_VERSION":652      return "This PeachScript was created by a different version of Peach.";653    case "CHECKSUM_FAILED":654      return "PeachScript appears corrupted. Re-copy from the source device.";655    case "CREATE_VAULT_FAILED":656      return "Peach couldn't create a new local vault before importing.";657    case "CLEAR_VAULT_FAILED":658      return "Peach couldn't clear the existing vault before replacing it.";659    case "MASTER_PASSWORD_REQUIRED":660      return "Choose a master password for your new vault first.";661    case "RESTORE_FAILED":662      return "Restore failed. Your existing vault is unchanged.";663    case "NO_ENTRIES_IMPORTED":664      return "No importable password entries were found in that file.";665    default:666      return error;667  }668}