964d7e074a21
rewrite Export.tsx and Import.tsx as unified screens — PeachScript primary, QR + CSV secondary, legacy CJK collapsed in import
- time
- 2026-04-27 16:07 UTC · 6 months ago
- machine
- koh2
- parent
- ab37b5888aca
↓ peach-browser-964d7e07.tar.gzatlas at this save
files changed6 files
src/popup/App.tsx+1 −1src/popup/styles/popup.css+70 −0src/popup/styles/vars.css+8 −0src/popup/views/Export.tsx+499 −225src/popup/views/Import.tsx+200 −47src/popup/views/contracts.test.tsx+12 −66src/popup/App.tsx+1 −1
@@ -1011,7 +1011,7 @@10111011 </Show>10121012 <Show when={view() === "export"}>10131013 <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1014- <ExportView onBack={handleBack} onRequiresUnlock={handleLock} />1014+ <ExportView onBack={handleBack} />10151015 </Suspense>10161016 </Show>10171017 <Show when={view() === "health"}>src/popup/styles/popup.css+70 −0
@@ -1575,6 +1575,76 @@15751575 display: grid;15761576 gap: 10px;15771577 }1578+.peach-script-string {1579+ font-family: "PeachScript", monospace;1580+ font-size: 11px;1581+ line-height: 1.9;1582+ letter-spacing: 0.06em;1583+ color: var(--peach-t0);1584+ word-break: break-all;1585+ user-select: all;1586+ background: var(--peach-input-bg, var(--surface-card));1587+ border: 1px solid var(--peach-t3);1588+ border-radius: 6px;1589+ padding: 12px;1590+ margin: 0 0 8px;1591+ max-height: 180px;1592+ overflow-y: auto;1593+ white-space: pre-wrap;1594+}1595+.peach-script-input {1596+ font-family: "PeachScript", monospace;1597+ font-size: 11px;1598+ line-height: 1.9;1599+ letter-spacing: 0.06em;1600+ color: var(--peach-t0);1601+ background: var(--peach-input-bg, var(--surface-card));1602+ border: 1px solid var(--peach-t3);1603+ border-radius: 6px;1604+ padding: 12px;1605+ width: 100%;1606+ min-height: 96px;1607+ resize: vertical;1608+ word-break: break-all;1609+}1610+.peach-qr-canvas-wrap {1611+ display: flex;1612+ justify-content: center;1613+ padding: 12px 0 6px;1614+}1615+.peach-qr-canvas-wrap canvas {1616+ display: block;1617+ background: #ffffff;1618+ border-radius: 6px;1619+ max-width: 100%;1620+ height: auto;1621+}1622+.peach-csv-warning {1623+ background: color-mix(in srgb, var(--peach-breach) 14%, transparent);1624+ border: 1px solid color-mix(in srgb, var(--peach-breach) 40%, transparent);1625+ color: var(--peach-t0);1626+ border-radius: 6px;1627+ padding: 10px 12px;1628+ margin-bottom: 8px;1629+ font-size: 12px;1630+ line-height: 1.45;1631+}1632+.peach-legacy-cjk-summary {1633+ cursor: pointer;1634+ color: var(--peach-t2);1635+ font-family: "IBM Plex Mono", monospace;1636+ font-size: 10px;1637+ text-transform: uppercase;1638+ letter-spacing: 0.14em;1639+ padding: 6px 0;1640+}1641+.peach-legacy-cjk-note {1642+ color: var(--peach-t2);1643+ font-size: 11px;1644+ line-height: 1.5;1645+ margin: 6px 0 0;1646+}1647+15781648 .peach-export-card--primary {15791649 background:15801650 linear-gradient(180deg, color-mix(in srgb, var(--peach-accent) 8%, transparent), color-mix(in srgb, var(--peach-accent) 2%, transparent));src/popup/styles/vars.css+8 −0
@@ -70,6 +70,14 @@7070 font-display: optional;7171 }7272 73+@font-face {74+ font-family: "PeachScript";75+ font-weight: 400;76+ font-style: normal;77+ src: url("/fonts/PeachScript-Regular.otf") format("opentype");78+ font-display: block;79+}80+7381 :root {7482 --bg: #0C0A06;7583 --s1: #131109;src/popup/views/Export.tsx+499 −225
@@ -1,307 +1,581 @@11 // Signal-conditioned views in this file must use <Show>, never a22 // signal-conditioned early return (`if (signal()) return <X/>`). Early33 // returns in SolidJS run once at mount and never re-execute when signals4-// change — the v1.4.21 CJK export regression silently stopped re-rendering5-// because of this. See Projects.md §4.1.4+// change — see Projects.md §4.1.65 import { createMemo, createSignal, Show } from "solid-js";6+77 import { sendMessage } from "../App";8+import { buildPayload } from "../../crypto/peachPayload";9+import { encodePeachScript } from "../../crypto/peachScript";10+import {11+ QR_MAX_BYTES,12+ renderPeachQR,13+ renderPeachQRDataURL,14+} from "../../crypto/peachQR";815 import type { PeachLicenseExport, VaultEntry } from "../../shared/types";9-import { exportEntriesToPeachJson } from "../../shared/backup";10-import { CJKExportView } from "./CJKExport";1116 17+type ExportSubview = "menu" | "peachscript" | "qr" | "csv";18+1219 interface ExportProps {1320 onBack: () => void;14- onRequiresUnlock?: () => void;1521 }1622 17-const MIN_EXPORT_PASSWORD = 12;23+const MIN_BACKUP_PASSWORD = 8;1824 1925 export function ExportView(props: ExportProps) {20- const [showCjkExport, setShowCjkExport] = createSignal(false);26+ const [view, setView] = createSignal<ExportSubview>("menu");2127 return (22- <Show when={showCjkExport()} fallback={<MainExportView onBack={props.onBack} onOpenCjk={() => setShowCjkExport(true)} />}>23- <CJKExportView24- onBack={() => setShowCjkExport(false)}25- onRequiresUnlock={props.onRequiresUnlock}26- />27- </Show>28+ <div class="peach-overlay-view ov-enter-right" style="display:flex">29+ <Show when={view() === "menu"}>30+ <ExportMenu onBack={props.onBack} onPick={(v) => setView(v)} />31+ </Show>32+ <Show when={view() === "peachscript"}>33+ <PeachScriptExport onBack={() => setView("menu")} />34+ </Show>35+ <Show when={view() === "qr"}>36+ <QRExport onBack={() => setView("menu")} />37+ </Show>38+ <Show when={view() === "csv"}>39+ <CSVExport onBack={() => setView("menu")} />40+ </Show>41+ </div>2842 );2943 }3044 31-function MainExportView(props: { onBack: () => void; onOpenCjk: () => void }) {32- const [exportPassword, setExportPassword] = createSignal("");33- const [exportPasswordConfirm, setExportPasswordConfirm] = createSignal("");34- const [exporting, setExporting] = createSignal(false);35- const [result, setResult] = createSignal<string | null>(null);45+// ---------------------------------------------------------------------------46+// Menu47+// ---------------------------------------------------------------------------48+49+function ExportMenu(props: { onBack: () => void; onPick: (v: ExportSubview) => void }) {50+ return (51+ <>52+ <OverlayHeader title="export" onBack={props.onBack} />53+ <div class="peach-settings-body">54+ <div class="peach-settings-group peach-export-card peach-export-card--primary">55+ <p class="peach-settings-label">peachscript</p>56+ <p class="peach-export-card-copy">57+ Your vault as a string of Peach symbols you can paste into Peach58+ on another device. Designed for Peach-to-Peach transfer.59+ </p>60+ <button61+ class="peach-btn peach-btn--subtle peach-btn--sm"62+ onClick={() => props.onPick("peachscript")}63+ >64+ Create PeachScript →65+ </button>66+ </div>67+68+ <div class="peach-settings-group peach-export-card">69+ <p class="peach-settings-label">print backup</p>70+ <p class="peach-export-card-copy">71+ Save a QR code you can print and store. Scan it with Peach on any72+ device to restore.73+ </p>74+ <button75+ class="peach-btn peach-btn--subtle peach-btn--sm"76+ onClick={() => props.onPick("qr")}77+ >78+ Save QR code →79+ </button>80+ </div>81+82+ <div class="peach-settings-group peach-export-card">83+ <p class="peach-settings-label">CSV (leaving peach)</p>84+ <p class="peach-export-card-copy">85+ Plain-text export for moving into another password manager. Anyone86+ with the file can read your passwords.87+ </p>88+ <button89+ class="peach-btn peach-btn--ghost peach-btn--sm"90+ onClick={() => props.onPick("csv")}91+ >92+ Export as CSV →93+ </button>94+ </div>95+ </div>96+ </>97+ );98+}99+100+// ---------------------------------------------------------------------------101+// PeachScript102+// ---------------------------------------------------------------------------103+104+function PeachScriptExport(props: { onBack: () => void }) {105+ const [pass, setPass] = createSignal("");106+ const [confirm, setConfirm] = createSignal("");107+ const [working, setWorking] = createSignal(false);108+ const [script, setScript] = createSignal<string | null>(null);36109 const [error, setError] = createSignal<string | null>(null);37- const [showCsvWarning, setShowCsvWarning] = createSignal(false);110+ const [entryCount, setEntryCount] = createSignal(0);111+ const [stats, setStats] = createSignal<{112+ cards: number;113+ passkeys: number;114+ notes: number;115+ logins: number;116+ }>({ cards: 0, passkeys: 0, notes: 0, logins: 0 });117+ const [copied, setCopied] = createSignal(false);38118 39- const passwordsMatch = createMemo(40- () => exportPassword().length >= MIN_EXPORT_PASSWORD41- && exportPassword() === exportPasswordConfirm()119+ const passReady = createMemo(120+ () => pass().length >= MIN_BACKUP_PASSWORD && pass() === confirm()42121 );43122 44- async function handleEncryptedExport() {123+ async function handleEncode() {45124 setError(null);46- setResult(null);47-48- if (!passwordsMatch()) {49- setError("Passwords do not match or are too short.");125+ setScript(null);126+ setCopied(false);127+ if (!passReady()) {128+ setError("Backup passwords don't match or are too short.");50129 return;51130 }131+ setWorking(true);132+ try {133+ const env = await assembleEnvelope(pass());134+ setEntryCount(env.entryCount);135+ setStats(env.stats);136+ setScript(encodePeachScript(env.bytes));137+ } catch (err) {138+ setError(err instanceof Error ? err.message : String(err));139+ } finally {140+ setWorking(false);141+ }142+ }52143 53- setExporting(true);144+ async function handleCopy() {145+ const s = script();146+ if (!s) return;147+ try {148+ await navigator.clipboard.writeText(s);149+ setCopied(true);150+ setTimeout(() => setCopied(false), 2000);151+ } catch (err) {152+ setError(err instanceof Error ? err.message : "Couldn't copy to clipboard.");153+ }154+ }54155 55- const response = await sendMessage({ type: "GET_ENTRIES" });56- const entries = (response as { ok: boolean; data?: VaultEntry[] }).data ?? [];156+ return (157+ <>158+ <OverlayHeader title="peachscript" onBack={props.onBack} />159+ <div class="peach-settings-body">160+ <Show161+ when={script()}162+ fallback={163+ <div class="peach-settings-group peach-export-card peach-export-card--primary">164+ <p class="peach-settings-label">backup password</p>165+ <p class="peach-export-card-copy">166+ Choose a password to lock this PeachScript. You'll need it167+ to restore on another device.168+ </p>169+ <div class="peach-settings-stack peach-export-password-stack">170+ <input171+ class="peach-settings-input"172+ type="password"173+ placeholder={`backup password (min ${MIN_BACKUP_PASSWORD} chars)`}174+ value={pass()}175+ onInput={(e) => setPass((e.target as HTMLInputElement).value)}176+ />177+ <input178+ class="peach-settings-input"179+ type="password"180+ placeholder="confirm backup password"181+ value={confirm()}182+ onInput={(e) => setConfirm((e.target as HTMLInputElement).value)}183+ />184+ <Show when={confirm().length > 0 && pass() !== confirm()}>185+ <p class="peach-export-note" style="color:var(--peach-t2)">186+ Passwords don't match187+ </p>188+ </Show>189+ <Show when={pass().length > 0 && pass().length < MIN_BACKUP_PASSWORD}>190+ <p class="peach-export-note" style="color:var(--peach-t2)">191+ At least {MIN_BACKUP_PASSWORD} characters192+ </p>193+ </Show>194+ </div>195+ <button196+ class="peach-btn peach-btn--subtle peach-btn--sm"197+ onClick={() => void handleEncode()}198+ disabled={working() || !passReady()}199+ >200+ {working() ? "encoding…" : "Generate PeachScript"}201+ </button>202+ </div>203+ }204+ >205+ <div class="peach-settings-group peach-export-card peach-export-card--primary">206+ <p class="peach-settings-label">peachscript</p>207+ <div class="peach-script-string">{script()}</div>208+ <p class="peach-export-card-copy" style="margin:0 0 8px">209+ {entryCount()} entr{entryCount() === 1 ? "y" : "ies"}210+ {stats().cards > 0 ? ` · ${stats().cards} card${stats().cards === 1 ? "" : "s"}` : ""}211+ {stats().passkeys > 0 ? ` · ${stats().passkeys} passkey${stats().passkeys === 1 ? "" : "s"}` : ""}212+ {stats().notes > 0 ? ` · ${stats().notes} note${stats().notes === 1 ? "" : "s"}` : ""}213+ </p>214+ <button215+ class="peach-btn peach-btn--subtle peach-btn--sm"216+ onClick={() => void handleCopy()}217+ >218+ {copied() ? "copied ✓" : "Copy PeachScript"}219+ </button>220+ <p class="peach-export-card-copy" style="margin-top:8px">221+ Paste this into Peach on your other device to restore your222+ vault. It won't work anywhere else.223+ </p>224+ </div>225+ </Show>226+ <Show when={error()}>227+ <div class="peach-settings-group">228+ <div class="peach-restore-error">{error()}</div>229+ </div>230+ </Show>231+ </div>232+ </>233+ );234+}57235 58- if (entries.length === 0) {59- setError("Your vault has no entries to export.");60- setExporting(false);61- return;62- }236+// ---------------------------------------------------------------------------237+// QR238+// ---------------------------------------------------------------------------63239 64- const trashedCount = entries.filter((e) => !!e.deletedAt).length;65- const activeCount = entries.length - trashedCount;66- const countStr = trashedCount > 067- ? `${activeCount} entr${activeCount === 1 ? "y" : "ies"} · ${trashedCount} in trash`68- : `${entries.length} entr${entries.length === 1 ? "y" : "ies"}`;240+function QRExport(props: { onBack: () => void }) {241+ const [pass, setPass] = createSignal("");242+ const [confirm, setConfirm] = createSignal("");243+ const [working, setWorking] = createSignal(false);244+ const [pngUrl, setPngUrl] = createSignal<string | null>(null);245+ const [error, setError] = createSignal<string | null>(null);246+ const [entryCount, setEntryCount] = createSignal(0);247+ let canvasHost: HTMLDivElement | undefined;69248 249+ const passReady = createMemo(250+ () => pass().length >= MIN_BACKUP_PASSWORD && pass() === confirm()251+ );252+253+ async function handleEncode() {254+ setError(null);255+ setPngUrl(null);256+ if (!passReady()) {257+ setError("Backup passwords don't match or are too short.");258+ return;259+ }260+ setWorking(true);70261 try {71- // Include license in the export if Pro, so a restore reactivates automatically.72- let license: PeachLicenseExport | undefined;73- const licenseRes = await sendMessage({ type: "GET_LICENSE" });74- const licenseInfo = (licenseRes as { ok: boolean; data?: { valid?: boolean; plan?: string; issued?: string } }).data;75- if (licenseInfo?.valid && licenseInfo.plan === "pro") {76- const keyRes = await sendMessage({ type: "GET_LICENSE_KEY" });77- const keyStr = (keyRes as { ok: boolean; data?: string }).data;78- if (keyStr) {79- license = {80- key: keyStr,81- ...(licenseInfo.issued ? { activatedAt: new Date(licenseInfo.issued).getTime() } : {}),82- };83- }262+ const env = await assembleEnvelope(pass());263+ if (env.bytes.length > QR_MAX_BYTES) {264+ setError(265+ `This vault is too large for a QR backup (${env.bytes.length} bytes). Use PeachScript instead.`266+ );267+ return;84268 }85-86- const blob = await exportEntriesToPeachJson(entries, exportPassword(), license);87- const filename = `peach-backup-${dateStamp()}.json`;88- downloadBlob(89- JSON.stringify(blob, null, 2),90- "application/json",91- filename92- );93- setResult(94- `Exported ${countStr}. Filename: ${filename}. Store this file and your export password somewhere safe — neither can be recovered.`95- );96- setExportPassword("");97- setExportPasswordConfirm("");269+ setEntryCount(env.entryCount);270+ const canvas = await renderPeachQR(env.bytes);271+ const url = await renderPeachQRDataURL(env.bytes);272+ if (canvasHost) {273+ canvasHost.innerHTML = "";274+ canvasHost.appendChild(canvas);275+ }276+ setPngUrl(url);98277 } catch (err) {99- setError(err instanceof Error ? err.message : "Export failed.");278+ setError(err instanceof Error ? err.message : String(err));100279 } finally {101- setExporting(false);280+ setWorking(false);102281 }103282 }104283 105- async function handleCSVExport() {106- setShowCsvWarning(false);107- setExporting(true);108- setResult(null);109- setError(null);284+ function handleSave() {285+ const url = pngUrl();286+ if (!url) return;287+ const a = document.createElement("a");288+ a.href = url;289+ a.download = `peach-qr-backup-${dateStamp()}.png`;290+ document.body.appendChild(a);291+ a.click();292+ document.body.removeChild(a);293+ }110294 111- const response = await sendMessage({ type: "GET_ENTRIES" });112- const entries = (response as { ok: boolean; data?: VaultEntry[] }).data ?? [];113- const active = entries.filter((e) => e.deletedAt === undefined && e.type !== "passkey");114- const excludedPasskeys = entries.filter((e) => e.deletedAt === undefined && e.type === "passkey").length;295+ return (296+ <>297+ <OverlayHeader title="print backup" onBack={props.onBack} />298+ <div class="peach-settings-body">299+ <Show300+ when={pngUrl()}301+ fallback={302+ <div class="peach-settings-group peach-export-card peach-export-card--primary">303+ <p class="peach-settings-label">backup password</p>304+ <p class="peach-export-card-copy">305+ Set a password to lock this QR backup. You'll need it to306+ restore — without it the printed code is unreadable.307+ </p>308+ <div class="peach-settings-stack peach-export-password-stack">309+ <input310+ class="peach-settings-input"311+ type="password"312+ placeholder={`backup password (min ${MIN_BACKUP_PASSWORD} chars)`}313+ value={pass()}314+ onInput={(e) => setPass((e.target as HTMLInputElement).value)}315+ />316+ <input317+ class="peach-settings-input"318+ type="password"319+ placeholder="confirm backup password"320+ value={confirm()}321+ onInput={(e) => setConfirm((e.target as HTMLInputElement).value)}322+ />323+ </div>324+ <button325+ class="peach-btn peach-btn--subtle peach-btn--sm"326+ onClick={() => void handleEncode()}327+ disabled={working() || !passReady()}328+ >329+ {working() ? "encoding…" : "Generate QR code"}330+ </button>331+ </div>332+ }333+ >334+ <div class="peach-settings-group peach-export-card peach-export-card--primary">335+ <div class="peach-qr-canvas-wrap" ref={canvasHost} />336+ <p class="peach-export-card-copy" style="text-align:center">337+ {entryCount()} entr{entryCount() === 1 ? "y" : "ies"} · {monthYear()}338+ </p>339+ <button340+ class="peach-btn peach-btn--subtle peach-btn--sm"341+ onClick={handleSave}342+ >343+ Save PNG344+ </button>345+ <p class="peach-export-card-copy" style="margin-top:8px">346+ Print this and keep it somewhere safe. Scan it with Peach to347+ restore your vault on any device.348+ </p>349+ </div>350+ </Show>351+ <Show when={error()}>352+ <div class="peach-settings-group">353+ <div class="peach-restore-error">{error()}</div>354+ </div>355+ </Show>356+ </div>357+ </>358+ );359+}115360 116- if (active.length === 0) {117- setError("Your vault has no entries to export.");118- setExporting(false);119- return;120- }361+// ---------------------------------------------------------------------------362+// CSV — non-skippable warning, every time, by design (spec §5.2).363+// ---------------------------------------------------------------------------121364 122- const headers = ["type", "name", "url", "username", "password", "totp", "tags", "notes"];123- const rows = active.map((e) => {124- const type = e.type === "note" ? "note" : "login";125- return [126- type,127- e.title,128- e.url ?? "",129- e.username ?? "",130- e.password ?? "",131- e.totp ?? "",132- Array.isArray(e.tags) ? e.tags.join("|") : "",133- e.notes ?? "",134- ]135- // Flatten embedded newlines before RFC 4180 quote escaping so the136- // CSV stays single-line-per-row for tools that do not handle quoted137- // multi-line cells.138- .map((v) =>139- `"${String(v).replace(/\r?\n/g, " ").replace(/"/g, '""')}"`140- )141- .join(",");142- });143- const csv = [headers.join(","), ...rows].join("\n");365+function CSVExport(props: { onBack: () => void }) {366+ const [acknowledged, setAcknowledged] = createSignal(false);367+ const [working, setWorking] = createSignal(false);368+ const [error, setError] = createSignal<string | null>(null);369+ const [done, setDone] = createSignal<string | null>(null);144370 145- const filename = `peach-passwords-${dateStamp()}.csv`;371+ async function handleExport() {372+ setError(null);373+ setDone(null);374+ setWorking(true);146375 try {376+ const res = await sendMessage({ type: "GET_ENTRIES" });377+ const entries = (res as { ok: boolean; data?: VaultEntry[] }).data ?? [];378+ const active = entries.filter(379+ (e) => e.deletedAt === undefined && e.type !== "passkey"380+ );381+ const excludedPasskeys = entries.filter(382+ (e) => e.deletedAt === undefined && e.type === "passkey"383+ ).length;384+385+ if (active.length === 0) {386+ setError("Your vault has no entries to export.");387+ return;388+ }389+390+ const csv = buildCsv(active);391+ const filename = `peach-export-${dateStamp()}.csv`;147392 downloadBlob(csv, "text/csv", filename);148393 const suffix = excludedPasskeys > 0149- ? ` ${excludedPasskeys} passkey${excludedPasskeys === 1 ? "" : "s"} excluded from plaintext export.`394+ ? ` ${excludedPasskeys} passkey${excludedPasskeys === 1 ? "" : "s"} excluded — passkeys can't be represented in CSV.`150395 : "";151- setResult(152- `Exported ${active.length} entr${active.length === 1 ? "y" : "ies"} as CSV. Filename: ${filename}.${suffix} Store this file somewhere safe — the passwords inside are in plain text.`396+ setDone(397+ `Exported ${active.length} entr${active.length === 1 ? "y" : "ies"} to ${filename}.${suffix}`153398 );154399 } catch (err) {155- setError(err instanceof Error ? err.message : "Export failed.");400+ setError(err instanceof Error ? err.message : String(err));156401 } finally {157- setExporting(false);402+ setWorking(false);158403 }159404 }160405 161406 return (162- <div class="peach-overlay-view ov-enter-right" style="display:flex">163- <div class="peach-overlay-header">164- <button165- class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"166- onClick={props.onBack}167- aria-label="Back"168- >169- <svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">170- <path d="M6 1L1 6L6 11" />171- </svg>172- </button>173- <span class="peach-overlay-title">export</span>174- <div class="peach-overlay-spacer"></div>175- </div>176-407+ <>408+ <OverlayHeader title="export as CSV" onBack={props.onBack} />177409 <div class="peach-settings-body">178-179- {/* ── Backup phrase ── */}180- <div class="peach-settings-group peach-export-card peach-export-card--primary">181- <p class="peach-settings-label">backup phrase</p>182- <p class="peach-export-card-copy">183- Your vault as a string of characters you can copy anywhere — no file needed. Paste it into Peach on any device to restore.184- </p>185- <button186- class="peach-btn peach-btn--subtle peach-btn--sm"187- onClick={props.onOpenCjk}188- >189- Create backup phrase190- </button>191- </div>192-193- {/* ── Encrypted file ── */}194410 <div class="peach-settings-group peach-export-card">195- <p class="peach-settings-label">encrypted file</p>196- <p class="peach-export-card-copy">197- Download a file backup. Set a password to lock it — you'll need that password to restore.198- </p>199- <div class="peach-settings-stack peach-export-password-stack">200- <input201- class="peach-settings-input"202- type="password"203- placeholder={`backup password (min ${MIN_EXPORT_PASSWORD} chars)`}204- value={exportPassword()}205- onInput={(e) => setExportPassword((e.target as HTMLInputElement).value)}206- />207- <input208- id="export-confirm-password"209- class="peach-settings-input"210- type="password"211- placeholder="confirm backup password"212- value={exportPasswordConfirm()}213- onInput={(e) => setExportPasswordConfirm((e.target as HTMLInputElement).value)}214- />215- <Show when={exportPasswordConfirm().length > 0 && exportPassword() !== exportPasswordConfirm()}>216- <p class="peach-export-note" style="color:var(--peach-t2)">217- Passwords don't match218- </p>219- </Show>220- <Show when={exportPassword().length > 0 && exportPassword().length < MIN_EXPORT_PASSWORD}>221- <p class="peach-export-note" style="color:var(--peach-t2)">222- At least {MIN_EXPORT_PASSWORD} characters223- </p>224- </Show>411+ <div class="peach-csv-warning">412+ This export is unencrypted. Anyone who gets this file can read413+ your passwords. Only use this to move to another password414+ manager — delete it once the import is done.225415 </div>226- <button227- class="peach-btn peach-btn--subtle peach-btn--sm"228- onClick={() => void handleEncryptedExport()}229- disabled={exporting() || !passwordsMatch()}230- >231- {exporting() ? "saving…" : "Download backup file"}232- </button>233- </div>234-235- {/* ── CSV ── */}236- <div class="peach-settings-group peach-export-card">237- <p class="peach-settings-label">CSV for other apps</p>238- <p class="peach-export-card-copy">239- Plain-text export for importing into another password manager. Passwords will be readable — delete the file once you're done.240- </p>241- <Show242- when={!showCsvWarning()}243- fallback={244- <div class="peach-settings-actions">245- <button246- class="peach-btn peach-btn--ghost peach-btn--sm"247- onClick={() => void handleCSVExport()}248- disabled={exporting()}249- >250- {exporting() ? "saving…" : "Download CSV"}251- </button>252- <button253- class="peach-btn peach-btn--ghost peach-btn--sm"254- onClick={() => setShowCsvWarning(false)}255- >256- cancel257- </button>258- </div>259- }260- >416+ <Show when={!acknowledged()}>261417 <button262418 class="peach-btn peach-btn--ghost peach-btn--sm"263- onClick={() => setShowCsvWarning(true)}419+ onClick={() => setAcknowledged(true)}264420 >265- Export as CSV421+ I understand — continue266422 </button>267423 </Show>424+ <Show when={acknowledged()}>425+ <button426+ class="peach-btn peach-btn--subtle peach-btn--sm"427+ onClick={() => void handleExport()}428+ disabled={working()}429+ >430+ {working() ? "exporting…" : "Download CSV"}431+ </button>432+ </Show>268433 </div>269-270434 <Show when={error()}>271435 <div class="peach-settings-group">272436 <div class="peach-restore-error">{error()}</div>273437 </div>274438 </Show>275-276- <Show when={result()}>439+ <Show when={done()}>277440 <div class="peach-settings-group">278441 <div class="peach-export-success">279442 <span class="peach-export-success-check">✓</span>280- <span>{result()}</span>443+ <span>{done()}</span>281444 </div>282445 </div>283446 </Show>284447 </div>448+ </>449+ );450+}451+452+// ---------------------------------------------------------------------------453+// Shared helpers454+// ---------------------------------------------------------------------------455+456+function OverlayHeader(props: { title: string; onBack: () => void }) {457+ return (458+ <div class="peach-overlay-header">459+ <button460+ class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"461+ onClick={props.onBack}462+ aria-label="Back"463+ >464+ <svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">465+ <path d="M6 1L1 6L6 11" />466+ </svg>467+ </button>468+ <span class="peach-overlay-title">{props.title}</span>469+ <div class="peach-overlay-spacer"></div>285470 </div>286471 );287472 }288473 474+interface AssembledEnvelope {475+ bytes: Uint8Array;476+ entryCount: number;477+ stats: { cards: number; passkeys: number; notes: number; logins: number };478+}479+480+async function assembleEnvelope(passphrase: string): Promise<AssembledEnvelope> {481+ const entriesRes = await sendMessage({ type: "GET_ENTRIES" });482+ const entries = (entriesRes as { ok: boolean; data?: VaultEntry[] }).data ?? [];483+484+ const settingsRes = await sendMessage({ type: "GET_SETTINGS" });485+ const settings = (settingsRes as { ok: boolean; data?: Record<string, unknown> }).data ?? {};486+487+ let license: PeachLicenseExport | undefined;488+ try {489+ const info = await sendMessage({ type: "GET_LICENSE" });490+ const data = (info as { ok: boolean; data?: { valid?: boolean; plan?: string; issued?: string } }).data;491+ if (data?.valid && data.plan === "pro") {492+ const keyRes = await sendMessage({ type: "GET_LICENSE_KEY" });493+ const keyStr = (keyRes as { ok: boolean; data?: string }).data;494+ if (keyStr) {495+ license = {496+ key: keyStr,497+ ...(data.issued ? { activatedAt: new Date(data.issued).getTime() } : {}),498+ };499+ }500+ }501+ } catch {502+ // License absent — proceed without it.503+ }504+505+ const stats = countTypes(entries);506+ const bytes = await buildPayload({507+ entries,508+ settings: settings as Parameters<typeof buildPayload>[0]["settings"],509+ license,510+ passphrase,511+ });512+ return { bytes, entryCount: entries.length, stats };513+}514+515+function countTypes(entries: VaultEntry[]): {516+ cards: number;517+ passkeys: number;518+ notes: number;519+ logins: number;520+} {521+ let cards = 0;522+ let passkeys = 0;523+ let notes = 0;524+ let logins = 0;525+ for (const e of entries) {526+ if (e.deletedAt) continue;527+ if (e.type === "card") cards++;528+ else if (e.type === "passkey") passkeys++;529+ else if (e.type === "note") notes++;530+ else logins++;531+ }532+ return { cards, passkeys, notes, logins };533+}534+535+function buildCsv(entries: VaultEntry[]): string {536+ const headers = ["name", "url", "username", "password", "notes", "type", "tags", "totp"];537+ const rows = entries.map((e) => {538+ const type = e.type === "note" ? "note" : e.type === "card" ? "card" : "login";539+ const username = e.type === "card" ? e.cardholderName ?? "" : e.username ?? "";540+ const password = e.type === "card" ? e.cardNumber ?? "" : e.password ?? "";541+ return [542+ e.title,543+ e.url ?? "",544+ username,545+ password,546+ e.notes ?? "",547+ type,548+ Array.isArray(e.tags) ? e.tags.join("|") : "",549+ e.totp ?? "",550+ ]551+ .map((v) =>552+ `"${String(v).replace(/\r?\n/g, " ").replace(/"/g, '""')}"`553+ )554+ .join(",");555+ });556+ return [headers.join(","), ...rows].join("\n");557+}558+289559 function dateStamp(): string {290560 return new Date().toISOString().split("T")[0];291561 }292562 563+function monthYear(): string {564+ const d = new Date();565+ const months = [566+ "January", "February", "March", "April", "May", "June",567+ "July", "August", "September", "October", "November", "December",568+ ];569+ return `${months[d.getMonth()]} ${d.getFullYear()}`;570+}571+293572 function downloadBlob(content: string, mimeType: string, filename: string): void {294- try {295- const url = URL.createObjectURL(new Blob([content], { type: mimeType }));296- const a = document.createElement("a");297- a.href = url;298- a.download = filename;299- document.body.appendChild(a);300- a.click();301- document.body.removeChild(a);302- URL.revokeObjectURL(url);303- } catch (err) {304- console.error("[Peach] Download failed:", err);305- throw err;306- }573+ const url = URL.createObjectURL(new Blob([content], { type: mimeType }));574+ const a = document.createElement("a");575+ a.href = url;576+ a.download = filename;577+ document.body.appendChild(a);578+ a.click();579+ document.body.removeChild(a);580+ URL.revokeObjectURL(url);307581 }src/popup/views/Import.tsx+200 −47
@@ -1,7 +1,5 @@11 // Signal-conditioned views in this file must use <Show>, never a2-// signal-conditioned early return (`if (signal()) return <X/>`). Early3-// returns in SolidJS run once at mount and never re-execute when signals4-// change — see Projects.md §4.1.2+// signal-conditioned early return — see Projects.md §4.1.53 import { createMemo, createSignal, Show } from "solid-js";64 75 import { sendMessage } from "../App";@@ -10,6 +8,12 @@108 parseImportSource,119 type ImportParseResult,1210 } from "../../shared/import";11+import {12+ decodePeachScript,13+ isPeachScript,14+ normalisePeachScript,15+} from "../../crypto/peachScript";16+import { parsePayload } from "../../crypto/peachPayload";1317 import type { VaultEntry } from "../../shared/types";1418 import { CJKImportView } from "./CJKImport";1519 @@ -52,6 +56,14 @@5256 function MainImportView(props: ImportProps & { onOpenCjk: () => void }) {5357 const mode = () => props.mode ?? "existing";5458 59+ // PeachScript paste flow60+ const [scriptInput, setScriptInput] = createSignal("");61+ const [scriptPass, setScriptPass] = createSignal("");62+ const [scriptWorking, setScriptWorking] = createSignal(false);63+ const [scriptError, setScriptError] = createSignal<string | null>(null);64+ const [scriptResult, setScriptResult] = createSignal<{ entryCount: number } | null>(null);65+66+ // File flow (existing format detection)5567 const [preview, setPreview] = createSignal<ImportParseResult | null>(null);5668 const [rawImport, setRawImport] = createSignal<string | null>(null);5769 const [backupPassword, setBackupPassword] = createSignal("");@@ -72,6 +84,8 @@7284 masterPassword().length >= 8 && masterPassword() === masterConfirm()7385 );7486 87+ const scriptLooksValid = createMemo(() => isPeachScript(scriptInput()));88+7589 async function refreshExistingCount() {7690 if (mode() !== "existing") return;7791 const res = await sendMessage({ type: "GET_ENTRIES" });@@ -81,6 +95,69 @@8195 8296 void refreshExistingCount();8397 98+ // ---------------------------------------------------------------------99+ // PeachScript flow100+ // ---------------------------------------------------------------------101+102+ async function handlePeachScriptImport() {103+ setScriptError(null);104+ setScriptResult(null);105+ if (!scriptLooksValid()) {106+ setScriptError("Paste your PeachScript first.");107+ return;108+ }109+ if (!scriptPass().trim()) {110+ setScriptError("Enter the backup password.");111+ return;112+ }113+ setScriptWorking(true);114+ try {115+ const bytes = decodePeachScript(normalisePeachScript(scriptInput()));116+ const payload = await parsePayload(bytes, scriptPass());117+118+ // Onboarding: create a fresh vault first.119+ if (mode() === "onboarding") {120+ if (!masterPasswordReady()) {121+ throw new Error("MASTER_PASSWORD_REQUIRED");122+ }123+ const createRes = await sendMessage({124+ type: "CREATE_VAULT",125+ payload: { password: masterPassword() },126+ });127+ if (!(createRes as { ok: boolean }).ok) throw new Error("CREATE_VAULT_FAILED");128+ }129+130+ const restoreRes = await sendMessage({131+ type: "RESTORE_ENTRIES",132+ payload: {133+ entries: payload.entries,134+ mode: "merge",135+ settings: payload.settings,136+ },137+ });138+ if (!(restoreRes as { ok: boolean }).ok) throw new Error("RESTORE_FAILED");139+140+ if (payload.license?.key) {141+ await sendMessage({142+ type: "ACTIVATE_LICENSE",143+ payload: { key: payload.license.key },144+ }).catch(() => {});145+ }146+147+ setScriptResult({ entryCount: payload.entries.length });148+ await props.onImport();149+ } catch (err) {150+ const code = err instanceof Error ? err.message : String(err);151+ setScriptError(getImportErrorCopy(code));152+ } finally {153+ setScriptWorking(false);154+ }155+ }156+157+ // ---------------------------------------------------------------------158+ // File flow (Bitwarden / 1Password / etc.)159+ // ---------------------------------------------------------------------160+84161 async function parseSource(text: string, password?: string) {85162 try {86163 const result = await parseImportSource(text, password);@@ -168,9 +245,6 @@168245 setImportProgress(null);169246 170247 try {171- // ⚠️ Guard: verify entries exist BEFORE touching any vault.172- // Without this, CREATE_VAULT would already have wiped the old vault173- // before we discover the file has nothing importable.174248 if (currentPreview.entries.length === 0) {175249 throw new Error("NO_ENTRIES_IMPORTED");176250 }@@ -209,7 +283,6 @@209283 imported = ((batchRes as { ok: boolean; data?: VaultEntry[] }).data ?? []).length;210284 setImportProgress({ current: imported, total: currentPreview.entries.length });211285 } else {212- // Fallback to sequential213286 for (const entry of currentPreview.entries) {214287 const response = await sendMessage({215288 type: "ADD_ENTRY",@@ -224,7 +297,6 @@224297 throw new Error("NO_ENTRIES_IMPORTED");225298 }226299 227- // Apply license last — silent on failure, never blocks a successful import.228300 if (currentPreview.licenseKey) {229301 await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key: currentPreview.licenseKey } }).catch(() => {});230302 }@@ -255,14 +327,9 @@255327 aria-label="Back"256328 >257329 <svg258- width="7"259- height="12"260- viewBox="0 0 7 12"261- fill="none"262- stroke="currentColor"263- stroke-width="1.1"264- stroke-linecap="round"265- stroke-linejoin="round"330+ width="7" height="12" viewBox="0 0 7 12"331+ fill="none" stroke="currentColor"332+ stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round"266333 >267334 <path d="M6 1L1 6L6 11" />268335 </svg>@@ -277,7 +344,7 @@277344 <button278345 class="peach-lock-restore-link"279346 onClick={props.onBack}280- disabled={importing()}347+ disabled={importing() || scriptWorking()}281348 >282349 ‹ back283350 </button>@@ -285,23 +352,88 @@285352 </Show>286353 287354 <div class="peach-settings-body">355+ <Show when={mode() === "onboarding" && props.replacesExistingVault}>356+ <div class="peach-settings-group">357+ <div class="peach-restore-warning">358+ Restoring now replaces the locked local vault on this device with359+ a fresh one from your backup or export file.360+ </div>361+ </div>362+ </Show>363+364+ {/* PeachScript paste — primary path */}288365 <div class="peach-settings-group peach-restore-group">289- <p class="peach-settings-label">290- {mode() === "onboarding" ? "bring data into peach" : "import passwords"}291- </p>366+ <p class="peach-settings-label">paste peachscript</p>292367 <p class="peach-restore-copy">293368 {mode() === "onboarding"294- ? "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."295- : "Supports Peach encrypted backups, FIDO credential-exchange passkeys, Proton Pass, Bitwarden, and common browser CSV exports."}369+ ? "Paste a PeachScript backup from another Peach device to restore your vault."370+ : "Paste a PeachScript backup from another Peach device. We'll merge it into your vault."}296371 </p>297-298- <Show when={mode() === "onboarding" && props.replacesExistingVault}>299- <div class="peach-restore-warning">300- Restoring now replaces the locked local vault on this device with301- a fresh one from your backup or export file.372+ <textarea373+ class="peach-script-input"374+ placeholder="paste PeachScript here…"375+ value={scriptInput()}376+ onInput={(e) => setScriptInput((e.target as HTMLTextAreaElement).value)}377+ />378+ <input379+ class="peach-restore-input"380+ type="password"381+ placeholder="PeachScript password"382+ value={scriptPass()}383+ onInput={(e) => setScriptPass((e.target as HTMLInputElement).value)}384+ style="margin-top:8px"385+ />386+ <Show when={mode() === "onboarding"}>387+ <div class="peach-auth-grid" style="margin-top:8px">388+ <input389+ class="peach-restore-input"390+ type="password"391+ placeholder="new master password"392+ value={masterPassword()}393+ onInput={(e) => setMasterPassword((e.target as HTMLInputElement).value)}394+ />395+ <input396+ class="peach-restore-input"397+ type="password"398+ placeholder="confirm master password"399+ value={masterConfirm()}400+ onInput={(e) => setMasterConfirm((e.target as HTMLInputElement).value)}401+ />302402 </div>303403 </Show>404+ <div class="peach-settings-actions" style="margin-top:8px">405+ <button406+ class="peach-btn peach-btn--subtle peach-btn--sm"407+ onClick={() => void handlePeachScriptImport()}408+ disabled={409+ scriptWorking() ||410+ !scriptLooksValid() ||411+ !scriptPass().trim() ||412+ (mode() === "onboarding" && !masterPasswordReady())413+ }414+ >415+ {scriptWorking() ? "decoding…" : "Restore from PeachScript"}416+ </button>417+ </div>418+ <Show when={scriptError()}>419+ <div class="peach-restore-error" style="margin-top:8px">{scriptError()}</div>420+ </Show>421+ <Show when={scriptResult()}>422+ <div class="peach-export-success" style="margin-top:8px">423+ <span class="peach-export-success-check">✓</span>424+ <span>Restored {scriptResult()!.entryCount} entr{scriptResult()!.entryCount === 1 ? "y" : "ies"}.</span>425+ </div>426+ </Show>427+ </div>304428 429+ {/* File import — for migrating from other managers */}430+ <div class="peach-settings-group peach-restore-group">431+ <p class="peach-settings-label">import a file</p>432+ <p class="peach-restore-copy">433+ Bitwarden, 1Password, Proton Pass, Chrome / Edge / Safari CSV, FIDO434+ credential-exchange passkeys, or a Peach JSON backup.435+ </p>436+305437 <div class="peach-provider-chips">306438 <span class="peach-provider-chip">Peach JSON</span>307439 <span class="peach-provider-chip">FIDO passkeys</span>@@ -310,30 +442,13 @@310442 <span class="peach-provider-chip">1Password JSON</span>311443 <span class="peach-provider-chip">Chrome / Edge / Safari CSV</span>312444 </div>313- </div>314445 315- <Show when={mode() === "existing"}>316- <div class="peach-settings-group">317- <p class="peach-settings-label">restore from CJK phrase</p>318- <p class="peach-restore-copy">319- Use a Peach CJK backup phrase instead of a file.320- </p>321- <button322- class="peach-btn peach-btn--subtle peach-btn--sm"323- onClick={props.onOpenCjk}324- >325- Restore from CJK phrase326- </button>327- </div>328- </Show>329-330- <div class="peach-settings-group">331- <p class="peach-settings-label">select file</p>332446 <input333447 class="peach-restore-file"334448 type="file"335449 accept=".csv,.json"336450 onChange={(event) => void handleFileSelect(event)}451+ style="margin-top:8px"337452 />338453 </div>339454 @@ -451,7 +566,7 @@451566 <p class="peach-settings-label">new master password</p>452567 <p class="peach-restore-copy">453568 This password unlocks the Peach vault created on this device after454- import. It is separate from any export password.569+ import.455570 </p>456571 <div class="peach-auth-grid">457572 <input@@ -530,6 +645,30 @@530645 </div>531646 </div>532647 </Show>648+649+ {/* Legacy CJK — collapsed, secondary */}650+ <Show when={mode() === "existing"}>651+ <div class="peach-settings-group">652+ <details>653+ <summary class="peach-legacy-cjk-summary">654+ Have an old CJK phrase backup?655+ </summary>656+ <p class="peach-legacy-cjk-note">657+ CJK phrase backups from earlier versions of Peach are still658+ supported. After restoring, we recommend creating a fresh659+ PeachScript or QR backup.660+ </p>661+ <div class="peach-settings-actions" style="margin-top:6px">662+ <button663+ class="peach-btn peach-btn--ghost peach-btn--sm"664+ onClick={props.onOpenCjk}665+ >666+ Restore from CJK phrase667+ </button>668+ </div>669+ </details>670+ </div>671+ </Show>533672 </div>534673 </div>535674 );@@ -540,13 +679,27 @@540679 case "UNSUPPORTED_IMPORT_FORMAT":541680 return "Unrecognised file format. Use CSV, Bitwarden JSON, Proton Pass JSON/CSV, or a Peach encrypted backup.";542681 case "INVALID_EXPORT_PASSWORD":543- return "Incorrect backup password. This is the password you chose when you exported the Peach JSON backup.";682+ case "INVALID_PASSPHRASE":683+ return "Wrong backup password. This is the password you chose when you created the backup.";544684 case "INVALID_PEACH_EXPORT":545685 return "This Peach backup file looks corrupted or incomplete.";686+ case "INVALID_PEACHSCRIPT_CHARACTER":687+ return "This doesn't look like PeachScript. Make sure you copied the entire backup string.";688+ case "PAYLOAD_TOO_SHORT":689+ return "PeachScript looks truncated. Copy the full backup string and try again.";690+ case "BAD_MAGIC":691+ case "UNSUPPORTED_VERSION":692+ return "This PeachScript was created by a different version of Peach.";693+ case "CHECKSUM_FAILED":694+ return "PeachScript appears corrupted. Re-copy from the source device.";546695 case "CREATE_VAULT_FAILED":547696 return "Peach couldn't create a new local vault before importing.";548697 case "CLEAR_VAULT_FAILED":549698 return "Peach couldn't clear the existing vault before replacing it.";699+ case "MASTER_PASSWORD_REQUIRED":700+ return "Choose a master password for your new vault first.";701+ case "RESTORE_FAILED":702+ return "Restore failed. Your existing vault is unchanged.";550703 case "NO_ENTRIES_IMPORTED":551704 return "No importable password entries were found in that file.";552705 default:src/popup/views/contracts.test.tsx+12 −66
@@ -236,74 +236,15 @@236236 expect(onUnlock).toHaveBeenCalled();237237 });238238 239- it("Export keeps encrypted export disabled until the password meets the minimum length", async () => {239+ it("Export menu surfaces the three v2.5.0 backup formats", async () => {240240 const container = mount(<ExportView onBack={vi.fn()} />);241-242- const password = container.querySelector('input[placeholder*="backup password"]') as HTMLInputElement;243- const confirm = container.querySelector("#export-confirm-password") as HTMLInputElement;244- const button = Array.from(container.querySelectorAll("button")).find(245- (candidate) => candidate.textContent?.trim() === "Download backup file"246- ) as HTMLButtonElement;247-248- inputText(password, "too-short");249- inputText(confirm, "too-short");250241 await flushView();251242 252- expect(button.disabled).toBe(true);253- expect(exportEntriesToPeachJsonMock).not.toHaveBeenCalled();243+ expect(container.textContent).toContain("PeachScript");244+ expect(container.textContent).toContain("print backup");245+ expect(container.textContent).toContain("CSV");254246 });255247 256- it("Export keeps encrypted export disabled when passwords do not match", async () => {257- const container = mount(<ExportView onBack={vi.fn()} />);258-259- const password = container.querySelector('input[placeholder*="backup password"]') as HTMLInputElement;260- const confirm = container.querySelector("#export-confirm-password") as HTMLInputElement;261- const button = Array.from(container.querySelectorAll("button")).find(262- (candidate) => candidate.textContent?.trim() === "Download backup file"263- ) as HTMLButtonElement;264-265- inputText(password, "correct horse battery staple");266- inputText(confirm, "different horse battery staple");267- await flushView();268-269- expect(button.disabled).toBe(true);270- expect(exportEntriesToPeachJsonMock).not.toHaveBeenCalled();271- });272-273- it("Export requests entries and sends the correct payload to the encrypted backup exporter", async () => {274- sendMessageMock.mockResolvedValue({275- ok: true,276- data: [sampleEntry(), sampleEntry({ id: "deleted", deletedAt: 2 })],277- });278- exportEntriesToPeachJsonMock.mockResolvedValue({279- version: 2,280- type: "peach-export",281- });282- const container = mount(<ExportView onBack={vi.fn()} />);283-284- const password = container.querySelector('input[placeholder*="backup password"]') as HTMLInputElement;285- const confirm = container.querySelector("#export-confirm-password") as HTMLInputElement;286- const button = Array.from(container.querySelectorAll("button")).find(287- (candidate) => candidate.textContent?.trim() === "Download backup file"288- ) as HTMLButtonElement;289-290- inputText(password, "correct horse battery staple");291- inputText(confirm, "correct horse battery staple");292- await flushView();293- button.click();294- await flushView();295-296- expect(sendMessageMock).toHaveBeenCalledWith({ type: "GET_ENTRIES" });297- expect(exportEntriesToPeachJsonMock).toHaveBeenCalledWith(298- [299- expect.objectContaining({ id: "entry-1" }),300- expect.objectContaining({ id: "deleted", deletedAt: 2 }),301- ],302- "correct horse battery staple",303- undefined304- );305- });306-307248 it("Settings requires current, new, and confirm passwords before enabling save", async () => {308249 sendMessageMock.mockResolvedValue({ ok: true, data: null });309250 const container = mount(@@ -733,9 +674,14 @@733674 expect(createButton.disabled).toBe(true);734675 expect(container.textContent).toContain("Restoring now replaces the locked local vault");735676 736- const inputs = container.querySelectorAll('input[type="password"]');737- inputText(inputs[0] as HTMLInputElement, "fresh-master-password");738- inputText(inputs[1] as HTMLInputElement, "fresh-master-password");677+ const masterInput = container.querySelector(678+ 'input[placeholder="new master password"]'679+ ) as HTMLInputElement;680+ const confirmInput = container.querySelector(681+ 'input[placeholder="confirm master password"]'682+ ) as HTMLInputElement;683+ inputText(masterInput, "fresh-master-password");684+ inputText(confirmInput, "fresh-master-password");739685 await flushView();740686 741687 expect(createButton.disabled).toBe(false);