123import { 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";1819type ImportSubview = "menu" | "peachscript" | "file";20type ImportMergeMode = "add" | "replace";2122interface ImportProps {23 mode?: "existing" | "onboarding";24 replacesExistingVault?: boolean;25 onBack: () => void;26 onImport: () => void | Promise<void>;27}2829export function ImportView(props: ImportProps) {30 const [view, setView] = createSignal<ImportSubview>("menu");31 const mode = () => props.mode ?? "existing";32 const isOnboarding = () => mode() === "onboarding";3334 return (35 <div class={isOnboarding() ? "peach-lock-view" : "peach-overlay-view ov-enter-right"} style="display:flex">36 <Show when={view() === "menu"}>37 <ImportMenu38 mode={mode()}39 replacesExistingVault={props.replacesExistingVault}40 onBack={props.onBack}41 onPick={(v) => setView(v)}42 />43 </Show>44 <Show when={view() === "peachscript"}>45 <PeachScriptImport46 mode={mode()}47 replacesExistingVault={props.replacesExistingVault}48 onBack={() => setView("menu")}49 onImport={props.onImport}50 />51 </Show>52 <Show when={view() === "file"}>53 <FileImport54 mode={mode()}55 replacesExistingVault={props.replacesExistingVault}56 onBack={() => setView("menu")}57 onImport={props.onImport}58 />59 </Show>60 </div>61 );62}636465666768function ImportMenu(props: {69 mode: "existing" | "onboarding";70 replacesExistingVault?: boolean;71 onBack: () => void;72 onPick: (view: ImportSubview) => void;73}) {74 const isOnboarding = props.mode === "onboarding";75 return (76 <>77 <Show78 when={isOnboarding}79 fallback={<OverlayHeader title="import" onBack={props.onBack} />}80 >81 <div class="peach-lock-view-header">82 <PeachWordmark class="peach-wordmark-wrap" />83 <button class="peach-lock-restore-link" onClick={props.onBack}>84 ‹ back85 </button>86 </div>87 </Show>88 <div class="peach-settings-body peach-export-menu">89 <Show when={isOnboarding && props.replacesExistingVault}>90 <div class="peach-settings-group">91 <div class="peach-restore-warning">92 Restoring now replaces the locked local vault on this device with93 a fresh one from your backup or export file.94 </div>95 </div>96 </Show>9798 <div class="peach-settings-group peach-export-card peach-export-card--primary">99 <p class="peach-settings-label">peachscript</p>100 <p class="peach-export-card-copy">101 Paste a PeachScript backup from another Peach device.102 {props.mode === "existing" ? " We'll merge it into your vault." : ""}103 </p>104 <button105 class="peach-btn peach-btn--subtle peach-btn--sm"106 onClick={() => props.onPick("peachscript")}107 >108 Paste PeachScript →109 </button>110 </div>111112 <div class="peach-settings-group peach-export-card">113 <p class="peach-settings-label">backup file</p>114 <p class="peach-export-card-copy">115 A Peach JSON export, or a file from Bitwarden, Proton Pass,116 1Password, or a browser CSV (Chrome, Safari, Edge).117 </p>118 <button119 class="peach-btn peach-btn--subtle peach-btn--sm"120 onClick={() => props.onPick("file")}121 >122 Choose file →123 </button>124 </div>125 </div>126 </>127 );128}129130131132133134function PeachScriptImport(props: {135 mode: "existing" | "onboarding";136 replacesExistingVault?: boolean;137 onBack: () => void;138 onImport: () => void | Promise<void>;139}) {140 const [scriptInput, setScriptInput] = createSignal("");141 const [scriptPass, setScriptPass] = createSignal("");142 const [masterPassword, setMasterPassword] = createSignal("");143 const [masterConfirm, setMasterConfirm] = createSignal("");144 const [working, setWorking] = createSignal(false);145 const [error, setError] = createSignal<string | null>(null);146 const [result, setResult] = createSignal<{ entryCount: number } | null>(null);147148 const scriptLooksValid = createMemo(() => isPeachScript(scriptInput()));149 const isOnboarding = props.mode === "onboarding";150 const masterPasswordReady = createMemo(151 () => masterPassword().length >= 8 && masterPassword() === masterConfirm()152 );153154 async function handleImport() {155 setError(null);156 setResult(null);157 if (!scriptLooksValid()) {158 setError("Paste your PeachScript first.");159 return;160 }161 if (!scriptPass().trim()) {162 setError("Enter the backup password.");163 return;164 }165 setWorking(true);166 try {167 const bytes = decodePeachScript(normalisePeachScript(scriptInput()));168 const payload = await parsePayload(bytes, scriptPass());169170 if (isOnboarding) {171 if (!masterPasswordReady()) {172 throw new Error("MASTER_PASSWORD_REQUIRED");173 }174 const createRes = await sendMessage({175 type: "CREATE_VAULT",176 payload: { password: masterPassword() },177 });178 if (!(createRes as { ok: boolean }).ok) throw new Error("CREATE_VAULT_FAILED");179 }180181 const restoreRes = await sendMessage({182 type: "RESTORE_ENTRIES",183 payload: {184 entries: payload.entries,185 mode: "merge",186 settings: payload.settings,187 },188 });189 if (!(restoreRes as { ok: boolean }).ok) throw new Error("RESTORE_FAILED");190191 if (payload.license?.key) {192 await sendMessage({193 type: "ACTIVATE_LICENSE",194 payload: { key: payload.license.key },195 }).catch(() => {});196 }197198 setResult({ entryCount: payload.entries.length });199 await props.onImport();200 } catch (err) {201 const code = err instanceof Error ? err.message : String(err);202 setError(getImportErrorCopy(code));203 } finally {204 setWorking(false);205 }206 }207208 return (209 <>210 <SubviewHeader title="peachscript" onBack={props.onBack} mode={props.mode} />211 <div class="peach-settings-body">212 <Show when={isOnboarding && props.replacesExistingVault}>213 <div class="peach-settings-group">214 <div class="peach-restore-warning">215 Restoring now replaces the locked local vault on this device with216 a fresh one from your backup.217 </div>218 </div>219 </Show>220 <div class="peach-settings-group peach-export-card peach-export-card--primary">221 <p class="peach-settings-label">paste peachscript</p>222 <p class="peach-export-card-copy">223 {isOnboarding224 ? "Paste a PeachScript backup from another Peach device to restore your vault."225 : "We'll merge the entries into your existing vault."}226 </p>227 <textarea228 class="peach-script-input"229 placeholder="paste PeachScript here…"230 value={scriptInput()}231 onInput={(e) => setScriptInput((e.target as HTMLTextAreaElement).value)}232 />233 <input234 class="peach-restore-input"235 type="password"236 placeholder="PeachScript password"237 value={scriptPass()}238 onInput={(e) => setScriptPass((e.target as HTMLInputElement).value)}239 />240 <Show when={isOnboarding}>241 <div class="peach-auth-grid">242 <input243 class="peach-restore-input"244 type="password"245 placeholder="new master password"246 value={masterPassword()}247 onInput={(e) => setMasterPassword((e.target as HTMLInputElement).value)}248 />249 <input250 class="peach-restore-input"251 type="password"252 placeholder="confirm master password"253 value={masterConfirm()}254 onInput={(e) => setMasterConfirm((e.target as HTMLInputElement).value)}255 />256 </div>257 </Show>258 <button259 class="peach-btn peach-btn--subtle peach-btn--sm"260 onClick={() => void handleImport()}261 disabled={262 working() ||263 !scriptLooksValid() ||264 !scriptPass().trim() ||265 (isOnboarding && !masterPasswordReady())266 }267 >268 {working() ? "decoding…" : "Restore from PeachScript"}269 </button>270 <Show when={error()}>271 <div class="peach-restore-error">{error()}</div>272 </Show>273 <Show when={result()}>274 <div class="peach-export-success">275 <span class="peach-export-success-check">✓</span>276 <span>277 Restored {result()!.entryCount} entr278 {result()!.entryCount === 1 ? "y" : "ies"}.279 </span>280 </div>281 </Show>282 </div>283 </div>284 </>285 );286}287288289290291292function FileImport(props: {293 mode: "existing" | "onboarding";294 replacesExistingVault?: boolean;295 onBack: () => void;296 onImport: () => void | Promise<void>;297}) {298 const isOnboarding = props.mode === "onboarding";299 const needsMasterPassword = () => isOnboarding;300301 const [preview, setPreview] = createSignal<ImportParseResult | null>(null);302 const [rawImport, setRawImport] = createSignal<string | null>(null);303 const [backupPassword, setBackupPassword] = createSignal("");304 const [masterPassword, setMasterPassword] = createSignal("");305 const [masterConfirm, setMasterConfirm] = createSignal("");306 const [importing, setImporting] = createSignal(false);307 const [error, setError] = createSignal<string | null>(null);308 const [mergeMode, setMergeMode] = createSignal<ImportMergeMode>("add");309 const [awaitingReplaceConfirm, setAwaitingReplaceConfirm] = createSignal(false);310 const [importProgress, setImportProgress] = createSignal<{ current: number; total: number } | null>(null);311 const [duplicateWarning, setDuplicateWarning] = createSignal<number>(0);312 const [existingCount, setExistingCount] = createSignal<number>(0);313314 const needsBackupPassword = createMemo(() => preview()?.requiresPassword ?? false);315 const masterPasswordReady = createMemo(316 () => masterPassword().length >= 8 && masterPassword() === masterConfirm()317 );318319 async function refreshExistingCount() {320 if (isOnboarding) return;321 const res = await sendMessage({ type: "GET_ENTRIES" });322 const list = (res as { ok: boolean; data?: VaultEntry[] }).data ?? [];323 setExistingCount(list.length);324 }325 void refreshExistingCount();326327 async function parseSource(text: string, password?: string) {328 try {329 const result = await parseImportSource(text, password);330 setPreview(result);331 setError(null);332 if (result.format === "unknown") {333 setError(334 "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."335 );336 }337 return result;338 } catch (err) {339 const message = err instanceof Error ? err.message : String(err);340 setPreview(null);341 setError(getImportErrorCopy(message));342 return null;343 }344 }345346 async function handleFileSelect(event: Event) {347 const file = (event.target as HTMLInputElement).files?.[0];348 if (!file) return;349350 setPreview(null);351 setRawImport(null);352 setBackupPassword("");353 setError(null);354 setAwaitingReplaceConfirm(false);355356 const text = await file.text();357 setRawImport(text);358 await parseSource(text);359 }360361 async function handleUnlockBackup() {362 const raw = rawImport();363 if (!raw || !backupPassword().trim()) return;364 await parseSource(raw, backupPassword());365 }366367 async function countDuplicates(incoming: ImportParseResult["entries"]): Promise<number> {368 if (incoming.length === 0) return 0;369 const res = await sendMessage({ type: "GET_ENTRIES" });370 const existing = ((res as { ok: boolean; data?: VaultEntry[] }).data ?? []).filter((e) => !e.deletedAt);371 const keyOf = (title?: string, url?: string, username?: string) =>372 `${(title ?? "").toLowerCase()}|${(url ?? "").toLowerCase()}|${(username ?? "").toLowerCase()}`;373 const existingKeys = new Set(existing.map((e) => keyOf(e.title, e.url, e.username)));374 let dupes = 0;375 for (const entry of incoming) {376 const u = "username" in entry ? entry.username : undefined;377 const url = "url" in entry ? entry.url : undefined;378 if (existingKeys.has(keyOf(entry.title, url, u))) dupes += 1;379 }380 return dupes;381 }382383 async function handleImport() {384 const currentPreview = preview();385 if (!currentPreview) return;386387 if (needsBackupPassword() && !backupPassword().trim()) {388 setError("Enter the backup password used for this export.");389 return;390 }391392 if (needsMasterPassword()) {393 if (masterPassword().length < 8) {394 setError("Master password must be at least 8 characters.");395 return;396 }397 if (!masterPasswordReady()) {398 setError("Master passwords do not match.");399 return;400 }401 }402403 if (!isOnboarding && mergeMode() === "replace" && !awaitingReplaceConfirm()) {404 setAwaitingReplaceConfirm(true);405 return;406 }407408 setImporting(true);409 setError(null);410 setDuplicateWarning(0);411 setImportProgress(null);412413 try {414 if (currentPreview.entries.length === 0) {415 throw new Error("NO_ENTRIES_IMPORTED");416 }417418 if (needsMasterPassword()) {419 const createResponse = await sendMessage({420 type: "CREATE_VAULT",421 payload: { password: masterPassword() },422 });423 if (!(createResponse as { ok: boolean }).ok) {424 throw new Error("CREATE_VAULT_FAILED");425 }426 }427428 if (!isOnboarding && mergeMode() === "replace") {429 const clearRes = await sendMessage({ type: "CLEAR_VAULT_ENTRIES" });430 if (!(clearRes as { ok: boolean }).ok) {431 throw new Error("CLEAR_VAULT_FAILED");432 }433 }434435 let dupes = 0;436 if (!isOnboarding && mergeMode() === "add") {437 dupes = await countDuplicates(currentPreview.entries);438 }439440 let imported = 0;441 setImportProgress({ current: 0, total: currentPreview.entries.length });442443 const batchRes = await sendMessage({444 type: "BATCH_ADD_ENTRIES",445 payload: { entries: currentPreview.entries },446 });447448 if ((batchRes as { ok: boolean }).ok) {449 imported = ((batchRes as { ok: boolean; data?: VaultEntry[] }).data ?? []).length;450 setImportProgress({ current: imported, total: currentPreview.entries.length });451 } else {452 for (const entry of currentPreview.entries) {453 const response = await sendMessage({454 type: "ADD_ENTRY",455 payload: entry,456 });457 if ((response as { ok: boolean }).ok) imported += 1;458 setImportProgress({ current: imported, total: currentPreview.entries.length });459 }460 }461462 if (imported === 0) {463 throw new Error("NO_ENTRIES_IMPORTED");464 }465466 if (currentPreview.licenseKey) {467 await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key: currentPreview.licenseKey } }).catch(() => {});468 }469470 setDuplicateWarning(dupes);471 await props.onImport();472 } catch (err) {473 const message = err instanceof Error ? err.message : String(err);474 setError(getImportErrorCopy(message));475 setImporting(false);476 setImportProgress(null);477 return;478 }479480 setImporting(false);481 setImportProgress(null);482 }483484 return (485 <>486 <SubviewHeader title="backup file" onBack={props.onBack} mode={props.mode} />487 <div class="peach-settings-body">488 <Show when={isOnboarding && props.replacesExistingVault}>489 <div class="peach-settings-group">490 <div class="peach-restore-warning">491 Restoring now replaces the locked local vault on this device with492 a fresh one from your backup.493 </div>494 </div>495 </Show>496 <div class="peach-settings-group peach-export-card peach-export-card--primary">497 <p class="peach-settings-label">choose a file</p>498 <p class="peach-export-card-copy">499 Bitwarden, 1Password, Proton Pass, Chrome / Edge / Safari CSV, FIDO500 credential-exchange passkeys, or a Peach JSON backup.501 </p>502503 <div class="peach-provider-chips">504 <span class="peach-provider-chip">Peach JSON</span>505 <span class="peach-provider-chip">FIDO passkeys</span>506 <span class="peach-provider-chip">Proton Pass</span>507 <span class="peach-provider-chip">Bitwarden</span>508 <span class="peach-provider-chip">1Password</span>509 <span class="peach-provider-chip">Browser CSV</span>510 </div>511512 <input513 class="peach-restore-file"514 type="file"515 accept=".csv,.json"516 onChange={(event) => void handleFileSelect(event)}517 />518 </div>519520 <Show when={needsBackupPassword()}>521 <div class="peach-settings-group">522 <p class="peach-settings-label">unlock backup</p>523 <p class="peach-export-card-copy">524 Enter the password used when you created this encrypted Peach JSON525 backup.526 </p>527 <input528 class="peach-restore-input"529 type="password"530 placeholder="backup password"531 value={backupPassword()}532 onInput={(event) =>533 setBackupPassword((event.target as HTMLInputElement).value)534 }535 onBlur={() => { if (backupPassword().trim()) void handleUnlockBackup(); }}536 onKeyDown={(event) => {537 if (event.key === "Enter") {538 event.preventDefault();539 void handleUnlockBackup();540 }541 }}542 />543 <div class="peach-settings-actions">544 <button545 class="peach-btn peach-btn--subtle peach-btn--sm"546 onClick={() => void handleUnlockBackup()}547 disabled={!backupPassword().trim()}548 >549 Decrypt →550 </button>551 </div>552 </div>553 </Show>554555 <Show when={preview() && !needsBackupPassword() && preview()!.format !== "unknown"}>556 <div class="peach-settings-group">557 <p class="peach-settings-label">{preview()!.label}</p>558 <div class="peach-import-summary">559 Found <strong>{preview()!.entries.length}</strong>{" "}560 entr{preview()!.entries.length === 1 ? "y" : "ies"}561 {preview()!.skippedItems > 0562 ? `. ${preview()!.skippedItems} skipped because no password was present.`563 : "."}564 </div>565566 <div class="peach-import-preview">567 {preview()!.entries.slice(0, 8).map((entry) => (568 <div class="peach-import-preview-row">569 <span class="peach-import-preview-title">{entry.title}</span>570 <span class="peach-import-preview-meta">571 {entry.type === "passkey"572 ? `${entry.userName || "passkey"} • ${entry.rpId || "site"}`573 : entry.type === "note"574 ? "secure note"575 : entry.username || entry.url || "password item"}576 </span>577 </div>578 ))}579 <Show when={preview()!.entries.length > 8}>580 <div class="peach-import-preview-more">581 ...and {preview()!.entries.length - 8} more582 </div>583 </Show>584 </div>585 </div>586 </Show>587588 <Show when={!isOnboarding && preview() && !needsBackupPassword() && preview()!.format !== "unknown" && preview()!.entries.length > 0}>589 <div class="peach-settings-group">590 <p class="peach-settings-label">merge or replace</p>591 <label class="peach-import-choice">592 <input593 type="radio"594 name="merge-mode"595 value="add"596 checked={mergeMode() === "add"}597 onChange={() => { setMergeMode("add"); setAwaitingReplaceConfirm(false); }}598 />599 <span class="peach-import-choice-main">600 <span class="peach-import-choice-label">Add to existing vault</span>601 <span class="peach-import-choice-sub">602 This will add {preview()!.entries.length} entries to your existing {existingCount()}.603 </span>604 </span>605 </label>606 <label class="peach-import-choice">607 <input608 type="radio"609 name="merge-mode"610 value="replace"611 checked={mergeMode() === "replace"}612 onChange={() => setMergeMode("replace")}613 />614 <span class="peach-import-choice-main">615 <span class="peach-import-choice-label">Replace vault</span>616 <span class="peach-import-choice-sub">617 This will replace your {existingCount()} entries with {preview()!.entries.length} imported ones.618 </span>619 </span>620 </label>621 <Show when={awaitingReplaceConfirm()}>622 <div class="peach-restore-warning" style="margin-top:6px">623 Replace forever? This cannot be undone.624 </div>625 </Show>626 </div>627 </Show>628629 <Show when={needsMasterPassword() && preview() && !needsBackupPassword() && preview()!.format !== "unknown"}>630 <div class="peach-settings-group">631 <p class="peach-settings-label">new master password</p>632 <p class="peach-export-card-copy">633 This password unlocks the Peach vault created on this device after634 import.635 </p>636 <div class="peach-auth-grid">637 <input638 class="peach-restore-input"639 type="password"640 placeholder="new master password"641 value={masterPassword()}642 onInput={(event) =>643 setMasterPassword((event.target as HTMLInputElement).value)644 }645 />646 <input647 class="peach-restore-input"648 type="password"649 placeholder="confirm master password"650 value={masterConfirm()}651 onInput={(event) =>652 setMasterConfirm((event.target as HTMLInputElement).value)653 }654 />655 </div>656 </div>657 </Show>658659 <Show when={error()}>660 <div class="peach-settings-group">661 <div class="peach-restore-error">{error()}</div>662 </div>663 </Show>664665 <Show when={importing() && importProgress()}>666 <div class="peach-settings-group">667 <div class="peach-import-summary">668 Importing... [{importProgress()!.current} / {importProgress()!.total}]669 </div>670 </div>671 </Show>672673 <Show when={duplicateWarning() > 0 && !importing()}>674 <div class="peach-settings-group">675 <div class="peach-export-card-copy">676 {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.677 </div>678 </div>679 </Show>680681 <Show when={preview() && !needsBackupPassword() && preview()!.format !== "unknown"}>682 <div class="peach-settings-group">683 <div class="peach-settings-actions">684 <button685 class="peach-btn peach-btn--subtle peach-btn--sm"686 onClick={() => void handleImport()}687 disabled={688 importing() ||689 preview()!.entries.length === 0 ||690 (needsMasterPassword() && !masterPasswordReady())691 }692 >693 {importing()694 ? "importing..."695 : isOnboarding696 ? `Create vault + import ${preview()!.entries.length}`697 : mergeMode() === "replace"698 ? awaitingReplaceConfirm()699 ? "Yes, replace forever"700 : `Replace vault with ${preview()!.entries.length}`701 : `Import ${preview()!.entries.length}`}702 </button>703 </div>704 </div>705 </Show>706 </div>707 </>708 );709}710711712713714715function OverlayHeader(props: { title: string; onBack: () => void }) {716 return (717 <div class="peach-overlay-header">718 <button719 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"720 onClick={props.onBack}721 aria-label="Back"722 >723 <svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">724 <path d="M6 1L1 6L6 11" />725 </svg>726 </button>727 <span class="peach-overlay-title">{props.title}</span>728 <div class="peach-overlay-spacer"></div>729 </div>730 );731}732733function SubviewHeader(props: {734 title: string;735 onBack: () => void;736 mode: "existing" | "onboarding";737}) {738 739 740 return (741 <Show742 when={props.mode === "onboarding"}743 fallback={<OverlayHeader title={props.title} onBack={props.onBack} />}744 >745 <div class="peach-lock-view-header">746 <PeachWordmark class="peach-wordmark-wrap" />747 <button class="peach-lock-restore-link" onClick={props.onBack}>748 ‹ back749 </button>750 </div>751 </Show>752 );753}754755function getImportErrorCopy(error: string): string {756 switch (error) {757 case "UNSUPPORTED_IMPORT_FORMAT":758 return "Unrecognised file format. Use CSV, Bitwarden JSON, Proton Pass JSON/CSV, or a Peach encrypted backup.";759 case "INVALID_EXPORT_PASSWORD":760 case "INVALID_PASSPHRASE":761 return "Wrong backup password. This is the password you chose when you created the backup.";762 case "INVALID_PEACH_EXPORT":763 return "This Peach backup file looks corrupted or incomplete.";764 case "INVALID_PEACHSCRIPT_CHARACTER":765 return "This doesn't look like PeachScript. Make sure you copied the entire backup string.";766 case "PAYLOAD_TOO_SHORT":767 return "PeachScript looks truncated. Copy the full backup string and try again.";768 case "BAD_MAGIC":769 case "UNSUPPORTED_VERSION":770 return "This PeachScript was created by a different version of Peach.";771 case "CHECKSUM_FAILED":772 return "PeachScript appears corrupted. Re-copy from the source device.";773 case "CREATE_VAULT_FAILED":774 return "Peach couldn't create a new local vault before importing.";775 case "CLEAR_VAULT_FAILED":776 return "Peach couldn't clear the existing vault before replacing it.";777 case "MASTER_PASSWORD_REQUIRED":778 return "Choose a master password for your new vault first.";779 case "RESTORE_FAILED":780 return "Restore failed. Your existing vault is unchanged.";781 case "NO_ENTRIES_IMPORTED":782 return "No importable password entries were found in that file.";783 default:784 return error;785 }786}