12345import { createMemo, createSignal, Show } from "solid-js";67import { sendMessage } from "../App";8import { buildPayload } from "../../crypto/peachPayload";9import { encodePeachScript } from "../../crypto/peachScript";10import {11 QR_MAX_BYTES,12 renderPeachQR,13 renderPeachQRDataURL,14} from "../../crypto/peachQR";15import type { PeachLicenseExport, VaultEntry } from "../../shared/types";1617type ExportSubview = "menu" | "peachscript" | "qr" | "csv";1819interface ExportProps {20 onBack: () => void;21}2223const MIN_BACKUP_PASSWORD = 8;2425export function ExportView(props: ExportProps) {26 const [view, setView] = createSignal<ExportSubview>("menu");27 return (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>42 );43}444546474849function ExportMenu(props: { onBack: () => void; onPick: (v: ExportSubview) => void }) {50 return (51 <>52 <OverlayHeader title="export" onBack={props.onBack} />53 <div class="peach-settings-body peach-export-menu">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>6768 <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>8182 <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}99100101102103104function 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);109 const [error, setError] = createSignal<string | null>(null);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);118119 const passReady = createMemo(120 () => pass().length >= MIN_BACKUP_PASSWORD && pass() === confirm()121 );122123 async function handleEncode() {124 setError(null);125 setScript(null);126 setCopied(false);127 if (!passReady()) {128 setError("Backup passwords don't match or are too short.");129 return;130 }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 }143144 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 }155156 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 peach-export-pass-group">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 peach-export-result-group">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}235236237238239240function 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;248249 const passReady = createMemo(250 () => pass().length >= MIN_BACKUP_PASSWORD && pass() === confirm()251 );252253 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);261 try {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;268 }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);277 } catch (err) {278 setError(err instanceof Error ? err.message : String(err));279 } finally {280 setWorking(false);281 }282 }283284 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 }294295 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}360361362363364365function 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);370371 async function handleExport() {372 setError(null);373 setDone(null);374 setWorking(true);375 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;384385 if (active.length === 0) {386 setError("Your vault has no entries to export.");387 return;388 }389390 const csv = buildCsv(active);391 const filename = `peach-export-${dateStamp()}.csv`;392 downloadBlob(csv, "text/csv", filename);393 const suffix = excludedPasskeys > 0394 ? ` ${excludedPasskeys} passkey${excludedPasskeys === 1 ? "" : "s"} excluded — passkeys can't be represented in CSV.`395 : "";396 setDone(397 `Exported ${active.length} entr${active.length === 1 ? "y" : "ies"} to ${filename}.${suffix}`398 );399 } catch (err) {400 setError(err instanceof Error ? err.message : String(err));401 } finally {402 setWorking(false);403 }404 }405406 return (407 <>408 <OverlayHeader title="export as CSV" onBack={props.onBack} />409 <div class="peach-settings-body">410 <div class="peach-settings-group peach-export-card">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.415 </div>416 <Show when={!acknowledged()}>417 <button418 class="peach-btn peach-btn--ghost peach-btn--sm"419 onClick={() => setAcknowledged(true)}420 >421 I understand — continue422 </button>423 </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>433 </div>434 <Show when={error()}>435 <div class="peach-settings-group">436 <div class="peach-restore-error">{error()}</div>437 </div>438 </Show>439 <Show when={done()}>440 <div class="peach-settings-group">441 <div class="peach-export-success">442 <span class="peach-export-success-check">✓</span>443 <span>{done()}</span>444 </div>445 </div>446 </Show>447 </div>448 </>449 );450}451452453454455456function 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>470 </div>471 );472}473474interface AssembledEnvelope {475 bytes: Uint8Array;476 entryCount: number;477 stats: { cards: number; passkeys: number; notes: number; logins: number };478}479480async function assembleEnvelope(passphrase: string): Promise<AssembledEnvelope> {481 const entriesRes = await sendMessage({ type: "GET_ENTRIES" });482 const entries = (entriesRes as { ok: boolean; data?: VaultEntry[] }).data ?? [];483484 const settingsRes = await sendMessage({ type: "GET_SETTINGS" });485 const settings = (settingsRes as { ok: boolean; data?: Record<string, unknown> }).data ?? {};486487 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 503 }504505 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}514515function 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}534535function 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}558559function dateStamp(): string {560 return new Date().toISOString().split("T")[0];561}562563function 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}571572function downloadBlob(content: string, mimeType: string, filename: string): void {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);581}