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} from "../../crypto/peachScript";15import { parsePayload } from "../../crypto/peachPayload";16import { IMPORT_LIMITS } from "../../shared/import-limits";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 26 initialView?: ImportSubview;27 onBack: () => void;28 onImport: () => void | Promise<void>;29}3031function OnboardingMasterPasswordFields(props: {32 idPrefix: string;33 copy: string;34 masterPlaceholder: string;35 password: string;36 confirm: string;37 onPasswordInput: (value: string) => void;38 onConfirmInput: (value: string) => void;39}) {40 const [showPassword, setShowPassword] = createSignal(false);4142 return (43 <>44 <div class="peach-newvault-label-row">45 <label class="peach-label" for={`${props.idPrefix}-master-password`}>46 new master password47 </label>48 <button49 class="peach-lock-reveal-btn"50 type="button"51 onClick={() => setShowPassword((value) => !value)}52 aria-label={showPassword() ? "Hide passwords" : "Show passwords"}53 >54 <Show55 when={showPassword()}56 fallback={57 <svg width="15" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">58 <path d="M1 5C1 5 2.8 1 7 1C11.2 1 13 5 13 5C13 5 11.2 9 7 9C2.8 9 1 5 1 5Z" />59 <circle cx="7" cy="5" r="1.75" />60 </svg>61 }62 >63 <svg width="15" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">64 <path d="M1 2C3 4.5 5 6 7 6C9 6 11 4.5 13 2" />65 <path d="M3.5 5.5L2.5 7.5" />66 <path d="M7 6.5V8.5" />67 <path d="M10.5 5.5L11.5 7.5" />68 </svg>69 </Show>70 {showPassword() ? "hide" : "show"}71 </button>72 </div>73 <p class="peach-export-card-copy">{props.copy}</p>74 <div class="peach-settings-stack peach-export-password-stack">75 <input76 id={`${props.idPrefix}-master-password`}77 class="peach-settings-input"78 type={showPassword() ? "text" : "password"}79 placeholder={props.masterPlaceholder}80 value={props.password}81 onInput={(event) => props.onPasswordInput((event.target as HTMLInputElement).value)}82 />83 <input84 id={`${props.idPrefix}-confirm-password`}85 class="peach-settings-input"86 type={showPassword() ? "text" : "password"}87 placeholder="confirm master password"88 value={props.confirm}89 onInput={(event) => props.onConfirmInput((event.target as HTMLInputElement).value)}90 />91 <Show when={props.confirm.length > 0 && props.password !== props.confirm}>92 <p class="peach-export-note" style="color:var(--peach-t2)">93 Passwords don't match94 </p>95 </Show>96 </div>97 </>98 );99}100101export function ImportView(props: ImportProps) {102 const [view, setView] = createSignal<ImportSubview>(props.initialView ?? "menu");103 104 105 const [peachScriptPrefill, setPeachScriptPrefill] = createSignal<string>("");106 const mode = () => props.mode ?? "existing";107 const isOnboarding = () => mode() === "onboarding";108 109 110 const subviewBack = () => (props.initialView ? props.onBack() : setView("menu"));111112 return (113 <div class={isOnboarding() ? "peach-lock-view" : "peach-overlay-view ov-enter-right"} style="display:flex">114 <Show when={view() === "menu"}>115 <ImportMenu116 mode={mode()}117 replacesExistingVault={props.replacesExistingVault}118 onBack={props.onBack}119 onPick={(v) => setView(v)}120 />121 </Show>122 <Show when={view() === "peachscript"}>123 <PeachScriptImport124 mode={mode()}125 replacesExistingVault={props.replacesExistingVault}126 initialScript={peachScriptPrefill()}127 onBack={() => {128 setPeachScriptPrefill("");129 subviewBack();130 }}131 onImport={props.onImport}132 />133 </Show>134 <Show when={view() === "file"}>135 <FileImport136 mode={mode()}137 replacesExistingVault={props.replacesExistingVault}138 onBack={subviewBack}139 onImport={props.onImport}140 onLoadPeachFile={(content) => {141 setPeachScriptPrefill(content);142 setView("peachscript");143 }}144 />145 </Show>146 </div>147 );148}149150151152153154function ImportMenu(props: {155 mode: "existing" | "onboarding";156 replacesExistingVault?: boolean;157 onBack: () => void;158 onPick: (view: ImportSubview) => void;159}) {160 const isOnboarding = props.mode === "onboarding";161 return (162 <>163 <Show164 when={isOnboarding}165 fallback={<OverlayHeader title="import" onBack={props.onBack} />}166 >167 <div class="peach-lock-view-header">168 <PeachWordmark class="peach-wordmark-wrap" />169 <button class="peach-lock-restore-link" onClick={props.onBack}>170 ‹ back171 </button>172 </div>173 </Show>174 <div class="peach-settings-body peach-export-menu">175 <Show when={isOnboarding && props.replacesExistingVault}>176 <div class="peach-settings-group">177 <div class="peach-restore-warning">178 Restoring now replaces the locked local vault on this device with179 a fresh one from your backup or export file.180 </div>181 </div>182 </Show>183184 <div class="peach-settings-group peach-export-card peach-export-card--primary">185 <p class="peach-settings-label">peachscript</p>186 <p class="peach-export-card-copy">187 Paste a PeachScript backup from another Peach device.188 {props.mode === "existing" ? " We'll merge it into your vault." : ""}189 </p>190 <button191 class="peach-btn peach-btn--subtle peach-btn--sm"192 onClick={() => props.onPick("peachscript")}193 >194 Paste PeachScript →195 </button>196 </div>197198 <div class="peach-settings-group peach-export-card">199 <p class="peach-settings-label">backup file</p>200 <p class="peach-export-card-copy">201 A file from Bitwarden, Proton Pass,202 1Password, or a browser CSV (Chrome, Safari, Edge).203 </p>204 <button205 class="peach-btn peach-btn--subtle peach-btn--sm"206 onClick={() => props.onPick("file")}207 >208 Choose file →209 </button>210 </div>211 </div>212 </>213 );214}215216217218219220function PeachScriptImport(props: {221 mode: "existing" | "onboarding";222 replacesExistingVault?: boolean;223 224225 initialScript?: string;226 onBack: () => void;227 onImport: () => void | Promise<void>;228}) {229 const [scriptInput, setScriptInput] = createSignal(props.initialScript ?? "");230 const [scriptPass, setScriptPass] = createSignal("");231 const [masterPassword, setMasterPassword] = createSignal("");232 const [masterConfirm, setMasterConfirm] = createSignal("");233 const [working, setWorking] = createSignal(false);234 const [error, setError] = createSignal<string | null>(null);235 const [result, setResult] = createSignal<{ entryCount: number } | null>(null);236237 const scriptLooksValid = createMemo(() => isPeachScript(scriptInput()));238 const isOnboarding = props.mode === "onboarding";239 const masterPasswordReady = createMemo(240 () => masterPassword().length >= 8 && masterPassword() === masterConfirm()241 );242243 async function handleImport() {244 setError(null);245 setResult(null);246 if (!scriptLooksValid()) {247 setError("Paste your PeachScript first.");248 return;249 }250 if (!scriptPass().trim()) {251 setError("Enter the backup password.");252 return;253 }254 setWorking(true);255 try {256 257 258 const bytes = decodePeachScript(scriptInput());259 const payload = await parsePayload(bytes, scriptPass());260261 if (isOnboarding) {262 if (!masterPasswordReady()) {263 throw new Error("MASTER_PASSWORD_REQUIRED");264 }265 const createRes = await sendMessage({266 type: "CREATE_VAULT",267 payload: { password: masterPassword() },268 });269 if (!(createRes as { ok: boolean }).ok) throw new Error("CREATE_VAULT_FAILED");270 }271272 const restoreRes = await sendMessage({273 type: "RESTORE_ENTRIES",274 payload: {275 entries: payload.entries,276 mode: "merge",277 settings: payload.settings,278 },279 });280 const restoreEnvelope = restoreRes as { ok: boolean; error?: string };281 if (!restoreEnvelope.ok) {282 throw new Error(restoreEnvelope.error ?? "RESTORE_FAILED");283 }284285 if (payload.license?.key) {286 await sendMessage({287 type: "ACTIVATE_LICENSE",288 payload: { key: payload.license.key },289 }).catch(() => {});290 }291292 setResult({ entryCount: payload.entries.length });293 await props.onImport();294 } catch (err) {295 const code = err instanceof Error ? err.message : String(err);296 setError(getImportErrorCopy(code));297 } finally {298 setWorking(false);299 }300 }301302 return (303 <>304 <SubviewHeader title="peachscript" onBack={props.onBack} mode={props.mode} />305 <div class="peach-settings-body">306 <Show when={isOnboarding && props.replacesExistingVault}>307 <div class="peach-settings-group">308 <div class="peach-restore-warning">309 Restoring now replaces the locked local vault on this device with310 a fresh one from your backup.311 </div>312 </div>313 </Show>314 <div class="peach-settings-group peach-export-card peach-export-card--primary peach-export-pass-group">315 <p class="peach-settings-label">paste peachscript</p>316 <p class="peach-export-card-copy">317 {isOnboarding318 ? "Paste a PeachScript backup from another Peach device to restore your vault — or load a .peach file from disk."319 : "We'll merge the entries into your existing vault. You can paste PeachScript or load a .peach file."}320 </p>321 <textarea322 class="peach-script-input"323 placeholder="paste peachscript…"324 value={scriptInput()}325 onInput={(e) => setScriptInput((e.target as HTMLTextAreaElement).value)}326 />327 <label328 class="peach-btn peach-btn--ghost peach-btn--sm"329 style="display:inline-flex;align-items:center;justify-content:center;cursor:pointer;"330 >331 Load .peach file332 <input333 type="file"334 accept=".peach,application/x-peach,text/plain"335 style="display:none;"336 onChange={async (event) => {337 const input = event.currentTarget;338 const file = input.files?.[0];339 if (!file) return;340 try {341 342 if (file.size > IMPORT_LIMITS.MAX_JSON_BYTES) {343 setError("That file is too large.");344 return;345 }346 const text = await file.text();347 setScriptInput(text);348 } catch (err) {349 setError(err instanceof Error ? err.message : "Couldn't read file.");350 } finally {351 input.value = "";352 }353 }}354 />355 </label>356 <div class="peach-settings-stack peach-export-password-stack">357 <input358 class="peach-settings-input"359 type="password"360 placeholder="PeachScript password"361 value={scriptPass()}362 onInput={(e) => setScriptPass((e.target as HTMLInputElement).value)}363 />364 </div>365 <Show when={isOnboarding}>366 <OnboardingMasterPasswordFields367 idPrefix="peachscript"368 copy="Use at least 8 characters. This password unlocks Peach on this device after the restore, and both fields must match before Peach can create it."369 masterPlaceholder="new master password"370 password={masterPassword()}371 confirm={masterConfirm()}372 onPasswordInput={setMasterPassword}373 onConfirmInput={setMasterConfirm}374 />375 </Show>376 <button377 class="peach-btn peach-btn--subtle peach-btn--sm"378 onClick={() => void handleImport()}379 disabled={380 working() ||381 !scriptLooksValid() ||382 !scriptPass().trim() ||383 (isOnboarding && !masterPasswordReady())384 }385 >386 {working() ? "decoding…" : "Restore from PeachScript"}387 </button>388 <Show when={error()}>389 <div class="peach-restore-error">{error()}</div>390 </Show>391 <Show when={result()}>392 <div class="peach-export-success">393 <span class="peach-export-success-check">✓</span>394 <span>395 Restored {result()!.entryCount} entr396 {result()!.entryCount === 1 ? "y" : "ies"}.397 </span>398 </div>399 </Show>400 </div>401 </div>402 </>403 );404}405406407408409410function FileImport(props: {411 mode: "existing" | "onboarding";412 replacesExistingVault?: boolean;413 onBack: () => void;414 onImport: () => void | Promise<void>;415 416417 onLoadPeachFile?: (content: string) => void;418}) {419 const isOnboarding = props.mode === "onboarding";420 const needsMasterPassword = () => isOnboarding;421422 const [preview, setPreview] = createSignal<ImportParseResult | null>(null);423 const [masterPassword, setMasterPassword] = createSignal("");424 const [masterConfirm, setMasterConfirm] = createSignal("");425 const [importing, setImporting] = createSignal(false);426 const [error, setError] = createSignal<string | null>(null);427 const [mergeMode, setMergeMode] = createSignal<ImportMergeMode>("add");428 const [awaitingReplaceConfirm, setAwaitingReplaceConfirm] = createSignal(false);429 const [importProgress, setImportProgress] = createSignal<{ current: number; total: number } | null>(null);430 const [duplicateWarning, setDuplicateWarning] = createSignal<number>(0);431 const [existingCount, setExistingCount] = createSignal<number>(0);432433 const masterPasswordReady = createMemo(434 () => masterPassword().length >= 8 && masterPassword() === masterConfirm()435 );436437 async function refreshExistingCount() {438 if (isOnboarding) return;439 const res = await sendMessage({ type: "GET_ENTRIES" });440 const list = (res as { ok: boolean; data?: VaultEntry[] }).data ?? [];441 setExistingCount(list.length);442 }443 void refreshExistingCount();444445 async function parseSource(text: string) {446 try {447 const result = await parseImportSource(text);448 setPreview(result);449 setError(null);450 if (result.format === "unknown") {451 setError(452 "We couldn't recognise this file format. Peach supports: Bitwarden JSON/CSV, Proton Pass JSON/CSV, 1Password JSON, and generic browser CSV exports."453 );454 }455 return result;456 } catch (err) {457 const message = err instanceof Error ? err.message : String(err);458 setPreview(null);459 setError(getImportErrorCopy(message));460 return null;461 }462 }463464 async function handleFileSelect(event: Event) {465 const input = event.target as HTMLInputElement;466 const file = input.files?.[0];467 if (!file) return;468469 setPreview(null);470 setError(null);471 setAwaitingReplaceConfirm(false);472473 if (file.size > IMPORT_LIMITS.MAX_JSON_BYTES) {474 setError("That file is too large.");475 input.value = "";476 return;477 }478 const text = await file.text();479480 481 482 483 484 485 const looksLikePeach =486 file.name.toLowerCase().endsWith(".peach") || isPeachScript(text);487 if (looksLikePeach) {488 if (props.onLoadPeachFile) {489 props.onLoadPeachFile(text);490 input.value = "";491 return;492 }493 setError("This is a .peach file. Open the PeachScript card to import it.");494 return;495 }496497 await parseSource(text);498 }499500 async function countDuplicates(incoming: ImportParseResult["entries"]): Promise<number> {501 if (incoming.length === 0) return 0;502 const res = await sendMessage({ type: "GET_ENTRIES" });503 const existing = ((res as { ok: boolean; data?: VaultEntry[] }).data ?? []).filter((e) => !e.deletedAt);504 const keyOf = (title?: string, url?: string, username?: string) =>505 `${(title ?? "").toLowerCase()}|${(url ?? "").toLowerCase()}|${(username ?? "").toLowerCase()}`;506 const existingKeys = new Set(existing.map((e) => keyOf(e.title, e.url, e.username)));507 let dupes = 0;508 for (const entry of incoming) {509 const u = "username" in entry ? entry.username : undefined;510 const url = "url" in entry ? entry.url : undefined;511 if (existingKeys.has(keyOf(entry.title, url, u))) dupes += 1;512 }513 return dupes;514 }515516 async function handleImport() {517 const currentPreview = preview();518 if (!currentPreview) return;519520 if (needsMasterPassword()) {521 if (masterPassword().length < 8) {522 setError("Master password must be at least 8 characters.");523 return;524 }525 if (!masterPasswordReady()) {526 setError("Master passwords do not match.");527 return;528 }529 }530531 if (!isOnboarding && mergeMode() === "replace" && !awaitingReplaceConfirm()) {532 setAwaitingReplaceConfirm(true);533 return;534 }535536 setImporting(true);537 setError(null);538 setDuplicateWarning(0);539 setImportProgress(null);540541 try {542 if (currentPreview.entries.length === 0) {543 throw new Error("NO_ENTRIES_IMPORTED");544 }545546 if (needsMasterPassword()) {547 const createResponse = await sendMessage({548 type: "CREATE_VAULT",549 payload: { password: masterPassword() },550 });551 if (!(createResponse as { ok: boolean }).ok) {552 throw new Error("CREATE_VAULT_FAILED");553 }554 }555556 if (!isOnboarding && mergeMode() === "replace") {557 const clearRes = await sendMessage({ type: "CLEAR_VAULT_ENTRIES" });558 if (!(clearRes as { ok: boolean }).ok) {559 throw new Error("CLEAR_VAULT_FAILED");560 }561 }562563 let dupes = 0;564 if (!isOnboarding && mergeMode() === "add") {565 dupes = await countDuplicates(currentPreview.entries);566 }567568 let imported = 0;569 setImportProgress({ current: 0, total: currentPreview.entries.length });570571 const batchRes = await sendMessage({572 type: "BATCH_ADD_ENTRIES",573 payload: { entries: currentPreview.entries },574 });575576 if ((batchRes as { ok: boolean }).ok) {577 imported = ((batchRes as { ok: boolean; data?: VaultEntry[] }).data ?? []).length;578 setImportProgress({ current: imported, total: currentPreview.entries.length });579 } else {580 for (const entry of currentPreview.entries) {581 const response = await sendMessage({582 type: "ADD_ENTRY",583 payload: entry,584 });585 if ((response as { ok: boolean }).ok) imported += 1;586 setImportProgress({ current: imported, total: currentPreview.entries.length });587 }588 }589590 if (imported === 0) {591 throw new Error("NO_ENTRIES_IMPORTED");592 }593594 setDuplicateWarning(dupes);595 await props.onImport();596 } catch (err) {597 const message = err instanceof Error ? err.message : String(err);598 setError(getImportErrorCopy(message));599 setImporting(false);600 setImportProgress(null);601 return;602 }603604 setImporting(false);605 setImportProgress(null);606 }607608 return (609 <>610 <SubviewHeader title="backup file" onBack={props.onBack} mode={props.mode} />611 <div class="peach-settings-body">612 <Show when={isOnboarding && props.replacesExistingVault}>613 <div class="peach-settings-group">614 <div class="peach-restore-warning">615 Restoring now replaces the locked local vault on this device with616 a fresh one from your backup.617 </div>618 </div>619 </Show>620 <div class="peach-settings-group peach-export-card peach-export-card--primary">621 <p class="peach-settings-label">choose a file</p>622 <p class="peach-export-card-copy">623 Bitwarden, 1Password, Proton Pass, Chrome / Edge / Safari CSV, FIDO624 credential-exchange passkeys.625 </p>626627 <div class="peach-provider-chips">628 <span class="peach-provider-chip">FIDO passkeys</span>629 <span class="peach-provider-chip">Proton Pass</span>630 <span class="peach-provider-chip">Bitwarden</span>631 <span class="peach-provider-chip">1Password</span>632 <span class="peach-provider-chip">Browser CSV</span>633 </div>634635 <input636 class="peach-restore-file"637 type="file"638 accept=".csv,.json,.peach,application/x-peach"639 onChange={(event) => void handleFileSelect(event)}640 />641 </div>642643 <Show when={preview() && preview()!.format !== "unknown"}>644 <div class="peach-settings-group">645 <p class="peach-settings-label">{preview()!.label}</p>646 <div class="peach-import-summary">647 Found <strong>{preview()!.entries.length}</strong>{" "}648 entr{preview()!.entries.length === 1 ? "y" : "ies"}649 {preview()!.skippedItems > 0650 ? `. ${preview()!.skippedItems} skipped because no password was present.`651 : "."}652 </div>653654 <div class="peach-import-preview">655 {preview()!.entries.slice(0, 8).map((entry) => (656 <div class="peach-import-preview-row">657 <span class="peach-import-preview-title">{entry.title}</span>658 <span class="peach-import-preview-meta">659 {entry.passkey660 ? `${entry.passkey.userName} • ${entry.passkey.rpId} (passkey)`661 : entry.type === "note"662 ? "secure note"663 : entry.username || entry.url || "password item"}664 </span>665 </div>666 ))}667 <Show when={preview()!.entries.length > 8}>668 <div class="peach-import-preview-more">669 ...and {preview()!.entries.length - 8} more670 </div>671 </Show>672 </div>673 </div>674 </Show>675676 <Show when={!isOnboarding && preview() && preview()!.format !== "unknown" && preview()!.entries.length > 0}>677 <div class="peach-settings-group">678 <p class="peach-settings-label">merge or replace</p>679 <label class="peach-import-choice">680 <input681 type="radio"682 name="merge-mode"683 value="add"684 checked={mergeMode() === "add"}685 onChange={() => { setMergeMode("add"); setAwaitingReplaceConfirm(false); }}686 />687 <span class="peach-import-choice-main">688 <span class="peach-import-choice-label">Add to existing vault</span>689 <span class="peach-import-choice-sub">690 This will add {preview()!.entries.length} entries to your existing {existingCount()}.691 </span>692 </span>693 </label>694 <label class="peach-import-choice">695 <input696 type="radio"697 name="merge-mode"698 value="replace"699 checked={mergeMode() === "replace"}700 onChange={() => setMergeMode("replace")}701 />702 <span class="peach-import-choice-main">703 <span class="peach-import-choice-label">Replace vault</span>704 <span class="peach-import-choice-sub">705 This will replace your {existingCount()} entries with {preview()!.entries.length} imported ones.706 </span>707 </span>708 </label>709 <Show when={awaitingReplaceConfirm()}>710 <div class="peach-restore-warning" style="margin-top:6px">711 Replace forever? This cannot be undone.712 </div>713 </Show>714 </div>715 </Show>716717 <Show when={needsMasterPassword() && preview() && preview()!.format !== "unknown"}>718 <div class="peach-settings-group">719 <OnboardingMasterPasswordFields720 idPrefix="backup"721 copy="Use at least 8 characters. This password unlocks the Peach vault created on this device after import, and both fields must match before Peach can create it."722 masterPlaceholder="new master password"723 password={masterPassword()}724 confirm={masterConfirm()}725 onPasswordInput={setMasterPassword}726 onConfirmInput={setMasterConfirm}727 />728 </div>729 </Show>730731 <Show when={error()}>732 <div class="peach-settings-group">733 <div class="peach-restore-error">{error()}</div>734 </div>735 </Show>736737 <Show when={importing() && importProgress()}>738 <div class="peach-settings-group">739 <div class="peach-import-summary">740 Importing... [{importProgress()!.current} / {importProgress()!.total}]741 </div>742 </div>743 </Show>744745 <Show when={duplicateWarning() > 0 && !importing()}>746 <div class="peach-settings-group">747 <div class="peach-export-card-copy">748 {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.749 </div>750 </div>751 </Show>752753 <Show when={preview() && preview()!.format !== "unknown"}>754 <div class="peach-settings-group">755 <div class="peach-settings-actions">756 <button757 class="peach-btn peach-btn--subtle peach-btn--sm"758 onClick={() => void handleImport()}759 disabled={760 importing() ||761 preview()!.entries.length === 0 ||762 (needsMasterPassword() && !masterPasswordReady())763 }764 >765 {importing()766 ? "importing..."767 : isOnboarding768 ? `Create vault + import ${preview()!.entries.length}`769 : mergeMode() === "replace"770 ? awaitingReplaceConfirm()771 ? "Yes, replace forever"772 : `Replace vault with ${preview()!.entries.length}`773 : `Import ${preview()!.entries.length}`}774 </button>775 </div>776 </div>777 </Show>778 </div>779 </>780 );781}782783784785786787function OverlayHeader(props: { title: string; onBack: () => void }) {788 return (789 <div class="peach-overlay-header">790 <button791 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"792 onClick={props.onBack}793 aria-label="Back"794 >795 <svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">796 <path d="M6 1L1 6L6 11" />797 </svg>798 </button>799 <span class="peach-overlay-title">{props.title}</span>800 <div class="peach-overlay-spacer"></div>801 </div>802 );803}804805function SubviewHeader(props: {806 title: string;807 onBack: () => void;808 mode: "existing" | "onboarding";809}) {810 811 812 return (813 <Show814 when={props.mode === "onboarding"}815 fallback={<OverlayHeader title={props.title} onBack={props.onBack} />}816 >817 <div class="peach-lock-view-header">818 <PeachWordmark class="peach-wordmark-wrap" />819 <button class="peach-lock-restore-link" onClick={props.onBack}>820 ‹ back821 </button>822 </div>823 </Show>824 );825}826827function getImportErrorCopy(error: string): string {828 829 830 if (error.startsWith("IMPORT_TOO_LARGE")) {831 return "This file is too large to import (limit: 10 MiB for backups, 25 MiB for Codex PDFs). If this is a real PeachScript backup, please report it — backups never approach this size.";832 }833 switch (error) {834 case "UNSUPPORTED_IMPORT_FORMAT":835 return "Unrecognised file format. Use CSV, Bitwarden JSON, Proton Pass JSON/CSV, or a PeachScript backup.";836 case "INVALID_PASSPHRASE":837 return "Wrong backup password. This is the password you chose when you created the backup.";838 case "INVALID_PEACHSCRIPT_CHARACTER":839 return "This doesn't look like PeachScript. Make sure you copied the entire backup string.";840 case "PAYLOAD_TOO_SHORT":841 return "PeachScript looks truncated. Copy the full backup string and try again.";842 case "BAD_MAGIC":843 case "UNSUPPORTED_VERSION":844 return "This PeachScript was created by a different version of Peach.";845 case "CHECKSUM_FAILED":846 return "PeachScript appears corrupted. Re-copy from the source device.";847 case "CREATE_VAULT_FAILED":848 return "Peach couldn't create a new local vault before importing.";849 case "CLEAR_VAULT_FAILED":850 return "Peach couldn't clear the existing vault before replacing it.";851 case "MASTER_PASSWORD_REQUIRED":852 return "Choose a master password for your new vault first.";853 case "RESTORE_FAILED":854 return "Restore failed. Your existing vault is unchanged.";855 case "vault_locked":856 return "Peach is locked. Open Peach with your master password and try again.";857 case "refuse to replace with empty entry set":858 return "This PeachScript decoded to zero entries — refusing to wipe the vault.";859 case "NO_ENTRIES_IMPORTED":860 return "No importable password entries were found in that file.";861 case "PEACHSCRIPT_TOO_LARGE":862 return "That backup is too large.";863 default:864 865 return "Import failed. Please try again.";866 }867}