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 { IMPORT_LIMITS } from "../../shared/import-limits";18import type { VaultEntry } from "../../shared/types";1920type ImportSubview = "menu" | "peachscript" | "file";21type ImportMergeMode = "add" | "replace";2223interface ImportProps {24 mode?: "existing" | "onboarding";25 replacesExistingVault?: boolean;26 27 initialView?: ImportSubview;28 onBack: () => void;29 onImport: () => void | Promise<void>;30}3132function OnboardingMasterPasswordFields(props: {33 idPrefix: string;34 copy: string;35 masterPlaceholder: string;36 password: string;37 confirm: string;38 onPasswordInput: (value: string) => void;39 onConfirmInput: (value: string) => void;40}) {41 const [showPassword, setShowPassword] = createSignal(false);4243 return (44 <>45 <div class="peach-newvault-label-row">46 <label class="peach-label" for={`${props.idPrefix}-master-password`}>47 new master password48 </label>49 <button50 class="peach-lock-reveal-btn"51 type="button"52 onClick={() => setShowPassword((value) => !value)}53 aria-label={showPassword() ? "Hide passwords" : "Show passwords"}54 >55 <Show56 when={showPassword()}57 fallback={58 <svg width="15" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">59 <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" />60 <circle cx="7" cy="5" r="1.75" />61 </svg>62 }63 >64 <svg width="15" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">65 <path d="M1 2C3 4.5 5 6 7 6C9 6 11 4.5 13 2" />66 <path d="M3.5 5.5L2.5 7.5" />67 <path d="M7 6.5V8.5" />68 <path d="M10.5 5.5L11.5 7.5" />69 </svg>70 </Show>71 {showPassword() ? "hide" : "show"}72 </button>73 </div>74 <p class="peach-export-card-copy">{props.copy}</p>75 <div class="peach-settings-stack peach-export-password-stack">76 <input77 id={`${props.idPrefix}-master-password`}78 class="peach-settings-input"79 type={showPassword() ? "text" : "password"}80 placeholder={props.masterPlaceholder}81 value={props.password}82 onInput={(event) => props.onPasswordInput((event.target as HTMLInputElement).value)}83 />84 <input85 id={`${props.idPrefix}-confirm-password`}86 class="peach-settings-input"87 type={showPassword() ? "text" : "password"}88 placeholder="confirm master password"89 value={props.confirm}90 onInput={(event) => props.onConfirmInput((event.target as HTMLInputElement).value)}91 />92 <Show when={props.confirm.length > 0 && props.password !== props.confirm}>93 <p class="peach-export-note" style="color:var(--peach-t2)">94 Passwords don't match95 </p>96 </Show>97 </div>98 </>99 );100}101102export function ImportView(props: ImportProps) {103 const [view, setView] = createSignal<ImportSubview>(props.initialView ?? "menu");104 105 106 const [peachScriptPrefill, setPeachScriptPrefill] = createSignal<string>("");107 const mode = () => props.mode ?? "existing";108 const isOnboarding = () => mode() === "onboarding";109 110 111 const subviewBack = () => (props.initialView ? props.onBack() : setView("menu"));112113 return (114 <div class={isOnboarding() ? "peach-lock-view" : "peach-overlay-view ov-enter-right"} style="display:flex">115 <Show when={view() === "menu"}>116 <ImportMenu117 mode={mode()}118 replacesExistingVault={props.replacesExistingVault}119 onBack={props.onBack}120 onPick={(v) => setView(v)}121 />122 </Show>123 <Show when={view() === "peachscript"}>124 <PeachScriptImport125 mode={mode()}126 replacesExistingVault={props.replacesExistingVault}127 initialScript={peachScriptPrefill()}128 onBack={() => {129 setPeachScriptPrefill("");130 subviewBack();131 }}132 onImport={props.onImport}133 />134 </Show>135 <Show when={view() === "file"}>136 <FileImport137 mode={mode()}138 replacesExistingVault={props.replacesExistingVault}139 onBack={subviewBack}140 onImport={props.onImport}141 onLoadPeachFile={(content) => {142 setPeachScriptPrefill(content);143 setView("peachscript");144 }}145 />146 </Show>147 </div>148 );149}150151152153154155function ImportMenu(props: {156 mode: "existing" | "onboarding";157 replacesExistingVault?: boolean;158 onBack: () => void;159 onPick: (view: ImportSubview) => void;160}) {161 const isOnboarding = props.mode === "onboarding";162 return (163 <>164 <Show165 when={isOnboarding}166 fallback={<OverlayHeader title="import" onBack={props.onBack} />}167 >168 <div class="peach-lock-view-header">169 <PeachWordmark class="peach-wordmark-wrap" />170 <button class="peach-lock-restore-link" onClick={props.onBack}>171 ‹ back172 </button>173 </div>174 </Show>175 <div class="peach-settings-body peach-export-menu">176 <Show when={isOnboarding && props.replacesExistingVault}>177 <div class="peach-settings-group">178 <div class="peach-restore-warning">179 Restoring now replaces the locked local vault on this device with180 a fresh one from your backup or export file.181 </div>182 </div>183 </Show>184185 <div class="peach-settings-group peach-export-card peach-export-card--primary">186 <p class="peach-settings-label">peachscript</p>187 <p class="peach-export-card-copy">188 Paste a PeachScript backup from another Peach device.189 {props.mode === "existing" ? " We'll merge it into your vault." : ""}190 </p>191 <button192 class="peach-btn peach-btn--subtle peach-btn--sm"193 onClick={() => props.onPick("peachscript")}194 >195 Paste PeachScript →196 </button>197 </div>198199 <div class="peach-settings-group peach-export-card">200 <p class="peach-settings-label">backup file</p>201 <p class="peach-export-card-copy">202 A file from Bitwarden, Proton Pass,203 1Password, or a browser CSV (Chrome, Safari, Edge).204 </p>205 <button206 class="peach-btn peach-btn--subtle peach-btn--sm"207 onClick={() => props.onPick("file")}208 >209 Choose file →210 </button>211 </div>212 </div>213 </>214 );215}216217218219220221function PeachScriptImport(props: {222 mode: "existing" | "onboarding";223 replacesExistingVault?: boolean;224 225226 initialScript?: string;227 onBack: () => void;228 onImport: () => void | Promise<void>;229}) {230 const [scriptInput, setScriptInput] = createSignal(props.initialScript ?? "");231 const [scriptPass, setScriptPass] = createSignal("");232 const [masterPassword, setMasterPassword] = createSignal("");233 const [masterConfirm, setMasterConfirm] = createSignal("");234 const [working, setWorking] = createSignal(false);235 const [error, setError] = createSignal<string | null>(null);236 const [result, setResult] = createSignal<{ entryCount: number } | null>(null);237238 const scriptLooksValid = createMemo(() => isPeachScript(scriptInput()));239 const isOnboarding = props.mode === "onboarding";240 const masterPasswordReady = createMemo(241 () => masterPassword().length >= 8 && masterPassword() === masterConfirm()242 );243244 async function handleImport() {245 setError(null);246 setResult(null);247 if (!scriptLooksValid()) {248 setError("Paste your PeachScript first.");249 return;250 }251 if (!scriptPass().trim()) {252 setError("Enter the backup password.");253 return;254 }255 setWorking(true);256 try {257 const bytes = decodePeachScript(normalisePeachScript(scriptInput()));258 const payload = await parsePayload(bytes, scriptPass());259260 if (isOnboarding) {261 if (!masterPasswordReady()) {262 throw new Error("MASTER_PASSWORD_REQUIRED");263 }264 const createRes = await sendMessage({265 type: "CREATE_VAULT",266 payload: { password: masterPassword() },267 });268 if (!(createRes as { ok: boolean }).ok) throw new Error("CREATE_VAULT_FAILED");269 }270271 const restoreRes = await sendMessage({272 type: "RESTORE_ENTRIES",273 payload: {274 entries: payload.entries,275 mode: "merge",276 settings: payload.settings,277 },278 });279 const restoreEnvelope = restoreRes as { ok: boolean; error?: string };280 if (!restoreEnvelope.ok) {281 throw new Error(restoreEnvelope.error ?? "RESTORE_FAILED");282 }283284 if (payload.license?.key) {285 await sendMessage({286 type: "ACTIVATE_LICENSE",287 payload: { key: payload.license.key },288 }).catch(() => {});289 }290291 setResult({ entryCount: payload.entries.length });292 await props.onImport();293 } catch (err) {294 const code = err instanceof Error ? err.message : String(err);295 setError(getImportErrorCopy(code));296 } finally {297 setWorking(false);298 }299 }300301 return (302 <>303 <SubviewHeader title="peachscript" onBack={props.onBack} mode={props.mode} />304 <div class="peach-settings-body">305 <Show when={isOnboarding && props.replacesExistingVault}>306 <div class="peach-settings-group">307 <div class="peach-restore-warning">308 Restoring now replaces the locked local vault on this device with309 a fresh one from your backup.310 </div>311 </div>312 </Show>313 <div class="peach-settings-group peach-export-card peach-export-card--primary peach-export-pass-group">314 <p class="peach-settings-label">paste peachscript</p>315 <p class="peach-export-card-copy">316 {isOnboarding317 ? "Paste a PeachScript backup from another Peach device to restore your vault — or load a .peach file from disk."318 : "We'll merge the entries into your existing vault. You can paste PeachScript or load a .peach file."}319 </p>320 <textarea321 class="peach-script-input"322 placeholder="paste peachscript…"323 value={scriptInput()}324 onInput={(e) => setScriptInput((e.target as HTMLTextAreaElement).value)}325 />326 <label327 class="peach-btn peach-btn--ghost peach-btn--sm"328 style="display:inline-flex;align-items:center;justify-content:center;cursor:pointer;"329 >330 Load .peach file331 <input332 type="file"333 accept=".peach,application/x-peach,text/plain"334 style="display:none;"335 onChange={async (event) => {336 const input = event.currentTarget;337 const file = input.files?.[0];338 if (!file) return;339 try {340 341 if (file.size > IMPORT_LIMITS.MAX_JSON_BYTES) {342 setError("That file is too large.");343 return;344 }345 const text = await file.text();346 setScriptInput(text);347 } catch (err) {348 setError(err instanceof Error ? err.message : "Couldn't read file.");349 } finally {350 input.value = "";351 }352 }}353 />354 </label>355 <div class="peach-settings-stack peach-export-password-stack">356 <input357 class="peach-settings-input"358 type="password"359 placeholder="PeachScript password"360 value={scriptPass()}361 onInput={(e) => setScriptPass((e.target as HTMLInputElement).value)}362 />363 </div>364 <Show when={isOnboarding}>365 <OnboardingMasterPasswordFields366 idPrefix="peachscript"367 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."368 masterPlaceholder="new master password"369 password={masterPassword()}370 confirm={masterConfirm()}371 onPasswordInput={setMasterPassword}372 onConfirmInput={setMasterConfirm}373 />374 </Show>375 <button376 class="peach-btn peach-btn--subtle peach-btn--sm"377 onClick={() => void handleImport()}378 disabled={379 working() ||380 !scriptLooksValid() ||381 !scriptPass().trim() ||382 (isOnboarding && !masterPasswordReady())383 }384 >385 {working() ? "decoding…" : "Restore from PeachScript"}386 </button>387 <Show when={error()}>388 <div class="peach-restore-error">{error()}</div>389 </Show>390 <Show when={result()}>391 <div class="peach-export-success">392 <span class="peach-export-success-check">✓</span>393 <span>394 Restored {result()!.entryCount} entr395 {result()!.entryCount === 1 ? "y" : "ies"}.396 </span>397 </div>398 </Show>399 </div>400 </div>401 </>402 );403}404405406407408409function FileImport(props: {410 mode: "existing" | "onboarding";411 replacesExistingVault?: boolean;412 onBack: () => void;413 onImport: () => void | Promise<void>;414 415416 onLoadPeachFile?: (content: string) => void;417}) {418 const isOnboarding = props.mode === "onboarding";419 const needsMasterPassword = () => isOnboarding;420421 const [preview, setPreview] = createSignal<ImportParseResult | null>(null);422 const [masterPassword, setMasterPassword] = createSignal("");423 const [masterConfirm, setMasterConfirm] = createSignal("");424 const [importing, setImporting] = createSignal(false);425 const [error, setError] = createSignal<string | null>(null);426 const [mergeMode, setMergeMode] = createSignal<ImportMergeMode>("add");427 const [awaitingReplaceConfirm, setAwaitingReplaceConfirm] = createSignal(false);428 const [importProgress, setImportProgress] = createSignal<{ current: number; total: number } | null>(null);429 const [duplicateWarning, setDuplicateWarning] = createSignal<number>(0);430 const [existingCount, setExistingCount] = createSignal<number>(0);431432 const masterPasswordReady = createMemo(433 () => masterPassword().length >= 8 && masterPassword() === masterConfirm()434 );435436 async function refreshExistingCount() {437 if (isOnboarding) return;438 const res = await sendMessage({ type: "GET_ENTRIES" });439 const list = (res as { ok: boolean; data?: VaultEntry[] }).data ?? [];440 setExistingCount(list.length);441 }442 void refreshExistingCount();443444 async function parseSource(text: string) {445 try {446 const result = await parseImportSource(text);447 setPreview(result);448 setError(null);449 if (result.format === "unknown") {450 setError(451 "We couldn't recognise this file format. Peach supports: Bitwarden JSON/CSV, Proton Pass JSON/CSV, 1Password JSON, and generic browser CSV exports."452 );453 }454 return result;455 } catch (err) {456 const message = err instanceof Error ? err.message : String(err);457 setPreview(null);458 setError(getImportErrorCopy(message));459 return null;460 }461 }462463 async function handleFileSelect(event: Event) {464 const input = event.target as HTMLInputElement;465 const file = input.files?.[0];466 if (!file) return;467468 setPreview(null);469 setError(null);470 setAwaitingReplaceConfirm(false);471472 if (file.size > IMPORT_LIMITS.MAX_JSON_BYTES) {473 setError("That file is too large.");474 input.value = "";475 return;476 }477 const text = await file.text();478479 480 481 482 483 484 const looksLikePeach =485 file.name.toLowerCase().endsWith(".peach") || isPeachScript(text);486 if (looksLikePeach) {487 if (props.onLoadPeachFile) {488 props.onLoadPeachFile(text);489 input.value = "";490 return;491 }492 setError("This is a .peach file. Open the PeachScript card to import it.");493 return;494 }495496 await parseSource(text);497 }498499 async function countDuplicates(incoming: ImportParseResult["entries"]): Promise<number> {500 if (incoming.length === 0) return 0;501 const res = await sendMessage({ type: "GET_ENTRIES" });502 const existing = ((res as { ok: boolean; data?: VaultEntry[] }).data ?? []).filter((e) => !e.deletedAt);503 const keyOf = (title?: string, url?: string, username?: string) =>504 `${(title ?? "").toLowerCase()}|${(url ?? "").toLowerCase()}|${(username ?? "").toLowerCase()}`;505 const existingKeys = new Set(existing.map((e) => keyOf(e.title, e.url, e.username)));506 let dupes = 0;507 for (const entry of incoming) {508 const u = "username" in entry ? entry.username : undefined;509 const url = "url" in entry ? entry.url : undefined;510 if (existingKeys.has(keyOf(entry.title, url, u))) dupes += 1;511 }512 return dupes;513 }514515 async function handleImport() {516 const currentPreview = preview();517 if (!currentPreview) return;518519 if (needsMasterPassword()) {520 if (masterPassword().length < 8) {521 setError("Master password must be at least 8 characters.");522 return;523 }524 if (!masterPasswordReady()) {525 setError("Master passwords do not match.");526 return;527 }528 }529530 if (!isOnboarding && mergeMode() === "replace" && !awaitingReplaceConfirm()) {531 setAwaitingReplaceConfirm(true);532 return;533 }534535 setImporting(true);536 setError(null);537 setDuplicateWarning(0);538 setImportProgress(null);539540 try {541 if (currentPreview.entries.length === 0) {542 throw new Error("NO_ENTRIES_IMPORTED");543 }544545 if (needsMasterPassword()) {546 const createResponse = await sendMessage({547 type: "CREATE_VAULT",548 payload: { password: masterPassword() },549 });550 if (!(createResponse as { ok: boolean }).ok) {551 throw new Error("CREATE_VAULT_FAILED");552 }553 }554555 if (!isOnboarding && mergeMode() === "replace") {556 const clearRes = await sendMessage({ type: "CLEAR_VAULT_ENTRIES" });557 if (!(clearRes as { ok: boolean }).ok) {558 throw new Error("CLEAR_VAULT_FAILED");559 }560 }561562 let dupes = 0;563 if (!isOnboarding && mergeMode() === "add") {564 dupes = await countDuplicates(currentPreview.entries);565 }566567 let imported = 0;568 setImportProgress({ current: 0, total: currentPreview.entries.length });569570 const batchRes = await sendMessage({571 type: "BATCH_ADD_ENTRIES",572 payload: { entries: currentPreview.entries },573 });574575 if ((batchRes as { ok: boolean }).ok) {576 imported = ((batchRes as { ok: boolean; data?: VaultEntry[] }).data ?? []).length;577 setImportProgress({ current: imported, total: currentPreview.entries.length });578 } else {579 for (const entry of currentPreview.entries) {580 const response = await sendMessage({581 type: "ADD_ENTRY",582 payload: entry,583 });584 if ((response as { ok: boolean }).ok) imported += 1;585 setImportProgress({ current: imported, total: currentPreview.entries.length });586 }587 }588589 if (imported === 0) {590 throw new Error("NO_ENTRIES_IMPORTED");591 }592593 setDuplicateWarning(dupes);594 await props.onImport();595 } catch (err) {596 const message = err instanceof Error ? err.message : String(err);597 setError(getImportErrorCopy(message));598 setImporting(false);599 setImportProgress(null);600 return;601 }602603 setImporting(false);604 setImportProgress(null);605 }606607 return (608 <>609 <SubviewHeader title="backup file" onBack={props.onBack} mode={props.mode} />610 <div class="peach-settings-body">611 <Show when={isOnboarding && props.replacesExistingVault}>612 <div class="peach-settings-group">613 <div class="peach-restore-warning">614 Restoring now replaces the locked local vault on this device with615 a fresh one from your backup.616 </div>617 </div>618 </Show>619 <div class="peach-settings-group peach-export-card peach-export-card--primary">620 <p class="peach-settings-label">choose a file</p>621 <p class="peach-export-card-copy">622 Bitwarden, 1Password, Proton Pass, Chrome / Edge / Safari CSV, FIDO623 credential-exchange passkeys.624 </p>625626 <div class="peach-provider-chips">627 <span class="peach-provider-chip">FIDO passkeys</span>628 <span class="peach-provider-chip">Proton Pass</span>629 <span class="peach-provider-chip">Bitwarden</span>630 <span class="peach-provider-chip">1Password</span>631 <span class="peach-provider-chip">Browser CSV</span>632 </div>633634 <input635 class="peach-restore-file"636 type="file"637 accept=".csv,.json,.peach,application/x-peach"638 onChange={(event) => void handleFileSelect(event)}639 />640 </div>641642 <Show when={preview() && preview()!.format !== "unknown"}>643 <div class="peach-settings-group">644 <p class="peach-settings-label">{preview()!.label}</p>645 <div class="peach-import-summary">646 Found <strong>{preview()!.entries.length}</strong>{" "}647 entr{preview()!.entries.length === 1 ? "y" : "ies"}648 {preview()!.skippedItems > 0649 ? `. ${preview()!.skippedItems} skipped because no password was present.`650 : "."}651 </div>652653 <div class="peach-import-preview">654 {preview()!.entries.slice(0, 8).map((entry) => (655 <div class="peach-import-preview-row">656 <span class="peach-import-preview-title">{entry.title}</span>657 <span class="peach-import-preview-meta">658 {entry.passkey659 ? `${entry.passkey.userName} • ${entry.passkey.rpId} (passkey)`660 : entry.type === "note"661 ? "secure note"662 : entry.username || entry.url || "password item"}663 </span>664 </div>665 ))}666 <Show when={preview()!.entries.length > 8}>667 <div class="peach-import-preview-more">668 ...and {preview()!.entries.length - 8} more669 </div>670 </Show>671 </div>672 </div>673 </Show>674675 <Show when={!isOnboarding && preview() && preview()!.format !== "unknown" && preview()!.entries.length > 0}>676 <div class="peach-settings-group">677 <p class="peach-settings-label">merge or replace</p>678 <label class="peach-import-choice">679 <input680 type="radio"681 name="merge-mode"682 value="add"683 checked={mergeMode() === "add"}684 onChange={() => { setMergeMode("add"); setAwaitingReplaceConfirm(false); }}685 />686 <span class="peach-import-choice-main">687 <span class="peach-import-choice-label">Add to existing vault</span>688 <span class="peach-import-choice-sub">689 This will add {preview()!.entries.length} entries to your existing {existingCount()}.690 </span>691 </span>692 </label>693 <label class="peach-import-choice">694 <input695 type="radio"696 name="merge-mode"697 value="replace"698 checked={mergeMode() === "replace"}699 onChange={() => setMergeMode("replace")}700 />701 <span class="peach-import-choice-main">702 <span class="peach-import-choice-label">Replace vault</span>703 <span class="peach-import-choice-sub">704 This will replace your {existingCount()} entries with {preview()!.entries.length} imported ones.705 </span>706 </span>707 </label>708 <Show when={awaitingReplaceConfirm()}>709 <div class="peach-restore-warning" style="margin-top:6px">710 Replace forever? This cannot be undone.711 </div>712 </Show>713 </div>714 </Show>715716 <Show when={needsMasterPassword() && preview() && preview()!.format !== "unknown"}>717 <div class="peach-settings-group">718 <OnboardingMasterPasswordFields719 idPrefix="backup"720 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."721 masterPlaceholder="new master password"722 password={masterPassword()}723 confirm={masterConfirm()}724 onPasswordInput={setMasterPassword}725 onConfirmInput={setMasterConfirm}726 />727 </div>728 </Show>729730 <Show when={error()}>731 <div class="peach-settings-group">732 <div class="peach-restore-error">{error()}</div>733 </div>734 </Show>735736 <Show when={importing() && importProgress()}>737 <div class="peach-settings-group">738 <div class="peach-import-summary">739 Importing... [{importProgress()!.current} / {importProgress()!.total}]740 </div>741 </div>742 </Show>743744 <Show when={duplicateWarning() > 0 && !importing()}>745 <div class="peach-settings-group">746 <div class="peach-export-card-copy">747 {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.748 </div>749 </div>750 </Show>751752 <Show when={preview() && preview()!.format !== "unknown"}>753 <div class="peach-settings-group">754 <div class="peach-settings-actions">755 <button756 class="peach-btn peach-btn--subtle peach-btn--sm"757 onClick={() => void handleImport()}758 disabled={759 importing() ||760 preview()!.entries.length === 0 ||761 (needsMasterPassword() && !masterPasswordReady())762 }763 >764 {importing()765 ? "importing..."766 : isOnboarding767 ? `Create vault + import ${preview()!.entries.length}`768 : mergeMode() === "replace"769 ? awaitingReplaceConfirm()770 ? "Yes, replace forever"771 : `Replace vault with ${preview()!.entries.length}`772 : `Import ${preview()!.entries.length}`}773 </button>774 </div>775 </div>776 </Show>777 </div>778 </>779 );780}781782783784785786function OverlayHeader(props: { title: string; onBack: () => void }) {787 return (788 <div class="peach-overlay-header">789 <button790 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"791 onClick={props.onBack}792 aria-label="Back"793 >794 <svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">795 <path d="M6 1L1 6L6 11" />796 </svg>797 </button>798 <span class="peach-overlay-title">{props.title}</span>799 <div class="peach-overlay-spacer"></div>800 </div>801 );802}803804function SubviewHeader(props: {805 title: string;806 onBack: () => void;807 mode: "existing" | "onboarding";808}) {809 810 811 return (812 <Show813 when={props.mode === "onboarding"}814 fallback={<OverlayHeader title={props.title} onBack={props.onBack} />}815 >816 <div class="peach-lock-view-header">817 <PeachWordmark class="peach-wordmark-wrap" />818 <button class="peach-lock-restore-link" onClick={props.onBack}>819 ‹ back820 </button>821 </div>822 </Show>823 );824}825826function getImportErrorCopy(error: string): string {827 828 829 if (error.startsWith("IMPORT_TOO_LARGE")) {830 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.";831 }832 switch (error) {833 case "UNSUPPORTED_IMPORT_FORMAT":834 return "Unrecognised file format. Use CSV, Bitwarden JSON, Proton Pass JSON/CSV, or a PeachScript backup.";835 case "INVALID_PASSPHRASE":836 return "Wrong backup password. This is the password you chose when you created the backup.";837 case "INVALID_PEACHSCRIPT_CHARACTER":838 return "This doesn't look like PeachScript. Make sure you copied the entire backup string.";839 case "PAYLOAD_TOO_SHORT":840 return "PeachScript looks truncated. Copy the full backup string and try again.";841 case "BAD_MAGIC":842 case "UNSUPPORTED_VERSION":843 return "This PeachScript was created by a different version of Peach.";844 case "CHECKSUM_FAILED":845 return "PeachScript appears corrupted. Re-copy from the source device.";846 case "CREATE_VAULT_FAILED":847 return "Peach couldn't create a new local vault before importing.";848 case "CLEAR_VAULT_FAILED":849 return "Peach couldn't clear the existing vault before replacing it.";850 case "MASTER_PASSWORD_REQUIRED":851 return "Choose a master password for your new vault first.";852 case "RESTORE_FAILED":853 return "Restore failed. Your existing vault is unchanged.";854 case "vault_locked":855 return "Peach is locked. Open Peach with your master password and try again.";856 case "refuse to replace with empty entry set":857 return "This PeachScript decoded to zero entries — refusing to wipe the vault.";858 case "NO_ENTRIES_IMPORTED":859 return "No importable password entries were found in that file.";860 case "PEACHSCRIPT_TOO_LARGE":861 return "That backup is too large.";862 default:863 864 return "Import failed. Please try again.";865 }866}