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 168 169 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 209 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}