12345import { createMemo, createSignal, Show } from "solid-js";67import { sendMessage } from "../App";8import { buildPayload } from "../../crypto/peachPayload";9import { encodePeachScript } from "../../crypto/peachScript";10import { RuneCanvas } from "../components/RuneCanvas";11import type { PeachLicenseExport, VaultEntry } from "../../shared/types";1213type ExportSubview = "menu" | "peachscript" | "csv";1415interface ExportProps {16 onBack: () => void;17}181920212223type ZxcvbnFn = (pw: string) => { score: number; feedback: { warning: string | null; suggestions: string[] } };24let _zxcvbn: ZxcvbnFn | null = null;25async function getZxcvbn(): Promise<ZxcvbnFn> {26 if (_zxcvbn) return _zxcvbn;27 const [{ zxcvbn, zxcvbnOptions }, common, en] = await Promise.all([28 import("@zxcvbn-ts/core"),29 import("@zxcvbn-ts/language-common"),30 import("@zxcvbn-ts/language-en"),31 ]);32 zxcvbnOptions.setOptions({33 translations: en.translations,34 graphs: common.adjacencyGraphs,35 dictionary: { ...common.dictionary, ...en.dictionary },36 });37 _zxcvbn = (pw: string) => zxcvbn(pw);38 return _zxcvbn;39}4041424344const STRENGTH_REQUIRED = 3;4546export function ExportView(props: ExportProps) {47 const [view, setView] = createSignal<ExportSubview>("menu");48 return (49 <div class="peach-overlay-view ov-enter-right" style="display:flex">50 <Show when={view() === "menu"}>51 <ExportMenu onBack={props.onBack} onPick={(v) => setView(v)} />52 </Show>53 <Show when={view() === "peachscript"}>54 <PeachScriptExport onBack={() => setView("menu")} />55 </Show>56 <Show when={view() === "csv"}>57 <CSVExport onBack={() => setView("menu")} />58 </Show>59 </div>60 );61}626364656667function ExportMenu(props: { onBack: () => void; onPick: (v: ExportSubview) => void }) {68 return (69 <>70 <OverlayHeader title="export" onBack={props.onBack} />71 <div class="peach-settings-body peach-export-menu">72 <div class="peach-settings-group peach-export-card peach-export-card--primary">73 <p class="peach-settings-label">peachscript</p>74 <p class="peach-export-card-copy">75 Your vault as a string of Peach symbols you can paste into Peach76 on another device. Designed for Peach-to-Peach transfer.77 </p>78 <button79 class="peach-btn peach-btn--subtle peach-btn--sm"80 onClick={() => props.onPick("peachscript")}81 >82 Create PeachScript →83 </button>84 </div>8586 <div class="peach-settings-group peach-export-card">87 <p class="peach-settings-label">CSV (leaving peach)</p>88 <p class="peach-export-card-copy">89 Plain-text export for moving into another password manager. Anyone90 with the file can read your passwords.91 </p>92 <button93 class="peach-btn peach-btn--ghost peach-btn--sm"94 onClick={() => props.onPick("csv")}95 >96 Export as CSV →97 </button>98 </div>99 </div>100 </>101 );102}103104105106107108function PeachScriptExport(props: { onBack: () => void }) {109 const [pass, setPass] = createSignal("");110 const [confirm, setConfirm] = createSignal("");111 const [strengthScore, setStrengthScore] = createSignal<number>(0);112 const [strengthHint, setStrengthHint] = createSignal<string>("");113 const [working, setWorking] = createSignal(false);114 const [script, setScript] = createSignal<string | null>(null);115 const [error, setError] = createSignal<string | null>(null);116 const [entryCount, setEntryCount] = createSignal(0);117 const [stats, setStats] = createSignal<{118 cards: number;119 passkeys: number;120 notes: number;121 logins: number;122 }>({ cards: 0, passkeys: 0, notes: 0, logins: 0 });123 const [copied, setCopied] = createSignal(false);124125 const passReady = createMemo(126 () => strengthScore() >= STRENGTH_REQUIRED && pass() === confirm()127 );128129 function handlePassInput(value: string) {130 setPass(value);131 if (!value) {132 setStrengthScore(0);133 setStrengthHint("");134 return;135 }136 137 void getZxcvbn().then((zxcvbn) => {138 if (pass() !== value) return; 139 const result = zxcvbn(value);140 setStrengthScore(result.score);141 const hint = result.feedback.warning || result.feedback.suggestions[0] || "";142 setStrengthHint(hint);143 });144 }145146 async function handleEncode() {147 setError(null);148 setScript(null);149 setCopied(false);150 if (!passReady()) {151 setError("Backup passwords don't match or are too short.");152 return;153 }154 setWorking(true);155 try {156 const env = await assembleEnvelope(pass());157 setEntryCount(env.entryCount);158 setStats(env.stats);159 setScript(encodePeachScript(env.bytes));160 } catch (err) {161 setError(err instanceof Error ? err.message : String(err));162 } finally {163 setWorking(false);164 }165 }166167 async function handleCopy() {168 const s = script();169 if (!s) return;170 try {171 await navigator.clipboard.writeText(s);172 setCopied(true);173 setTimeout(() => setCopied(false), 2000);174 } catch (err) {175 setError(err instanceof Error ? err.message : "Couldn't copy to clipboard.");176 }177 }178179 function handleDownload() {180 const s = script();181 if (!s) return;182 183 184 185 186 187 const blob = new Blob([s], { type: "application/x-peach;charset=utf-8" });188 const url = URL.createObjectURL(blob);189 const date = new Date().toISOString().slice(0, 10);190 const a = document.createElement("a");191 a.href = url;192 a.download = `peach-backup-${date}.peach`;193 document.body.appendChild(a);194 a.click();195 document.body.removeChild(a);196 setTimeout(() => URL.revokeObjectURL(url), 5000);197 }198199 return (200 <>201 <OverlayHeader title="peachscript" onBack={props.onBack} />202 <div class="peach-settings-body">203 <Show204 when={script()}205 fallback={206 <div class="peach-settings-group peach-export-card peach-export-card--primary peach-export-pass-group">207 <p class="peach-settings-label">backup password</p>208 <p class="peach-export-card-copy">209 Choose a password to lock this PeachScript. You'll need it210 to restore on another device.211 </p>212 <div class="peach-settings-stack peach-export-password-stack">213 <input214 ref={(el) => queueMicrotask(() => el.focus())}215 class="peach-settings-input"216 type="password"217 placeholder="backup password"218 value={pass()}219 onInput={(e) => handlePassInput((e.target as HTMLInputElement).value)}220 onKeyDown={(e) => {221 if (e.key === "Enter" && passReady() && !working()) {222 e.preventDefault();223 void handleEncode();224 }225 }}226 />227 <Show when={pass().length > 0}>228 <div class="peach-export-strength-track">229 <div230 class="peach-export-strength-fill"231 style={{232 width: `${(strengthScore() / 4) * 100}%`,233 background: ["transparent","#e84040","#e8a040","#40a840","#40c840"][strengthScore()],234 }}235 />236 </div>237 <Show when={strengthScore() < STRENGTH_REQUIRED}>238 <p class="peach-export-strength-warn">239 {strengthHint() || "Choose a stronger backup password. This protects your vault if the file is ever found."}240 </p>241 </Show>242 <Show when={strengthScore() >= STRENGTH_REQUIRED && strengthHint()}>243 <p class="peach-export-strength-hint">{strengthHint()}</p>244 </Show>245 </Show>246 <input247 class="peach-settings-input"248 type="password"249 placeholder="confirm backup password"250 value={confirm()}251 onInput={(e) => setConfirm((e.target as HTMLInputElement).value)}252 onKeyDown={(e) => {253 if (e.key === "Enter" && passReady() && !working()) {254 e.preventDefault();255 void handleEncode();256 }257 }}258 />259 <Show when={confirm().length > 0 && pass() !== confirm()}>260 <p class="peach-export-note" style="color:var(--peach-t2)">261 Passwords don't match262 </p>263 </Show>264 </div>265 <button266 class="peach-btn peach-btn--subtle peach-btn--sm"267 onClick={() => void handleEncode()}268 disabled={working() || !passReady()}269 >270 {working() ? "encoding…" : "Generate PeachScript"}271 </button>272 </div>273 }274 >275 <div class="peach-settings-group peach-export-card peach-export-card--primary peach-export-result-group">276 <p class="peach-settings-label">peachscript</p>277 <RuneCanvas runeString={script() ?? ""} width={288} height={240} />278 <p class="peach-export-card-copy" style="margin:0 0 8px">279 {entryCount()} entr{entryCount() === 1 ? "y" : "ies"}280 {stats().cards > 0 ? ` · ${stats().cards} card${stats().cards === 1 ? "" : "s"}` : ""}281 {stats().passkeys > 0 ? ` · ${stats().passkeys} passkey${stats().passkeys === 1 ? "" : "s"}` : ""}282 {stats().notes > 0 ? ` · ${stats().notes} note${stats().notes === 1 ? "" : "s"}` : ""}283 </p>284 <div style="display:flex;gap:8px;">285 <button286 class="peach-btn peach-btn--subtle peach-btn--sm"287 style="flex:1;"288 onClick={() => void handleCopy()}289 >290 {copied() ? "copied ✓" : "Copy"}291 </button>292 <button293 class="peach-btn peach-btn--subtle peach-btn--sm"294 style="flex:1;"295 onClick={handleDownload}296 >297 Download .peach298 </button>299 </div>300 <p class="peach-export-card-copy" style="margin-top:8px">301 Paste the script — or import the .peach file — into Peach on302 your other device to restore your vault. Won't work anywhere else.303 </p>304 </div>305 </Show>306 <Show when={error()}>307 <div class="peach-settings-group">308 <div class="peach-restore-error">{error()}</div>309 </div>310 </Show>311 </div>312 </>313 );314}315316317318319320function CSVExport(props: { onBack: () => void }) {321 const [acknowledged, setAcknowledged] = createSignal(false);322 const [working, setWorking] = createSignal(false);323 const [error, setError] = createSignal<string | null>(null);324 const [done, setDone] = createSignal<string | null>(null);325326 async function handleExport() {327 setError(null);328 setDone(null);329 setWorking(true);330 try {331 const res = await sendMessage({ type: "GET_ENTRIES" });332 const entries = (res as { ok: boolean; data?: VaultEntry[] }).data ?? [];333 334 335 336 337 const active = entries338 .filter((e) => e.deletedAt === undefined)339 .filter((e) => !(e.passkey && !e.password))340 .map((e) => (e.passkey ? { ...e, passkey: undefined } : e));341 const excludedPasskeys = entries.filter(342 (e) => e.deletedAt === undefined && e.passkey343 ).length;344345 if (active.length === 0) {346 setError("Your vault has no entries to export.");347 return;348 }349350 const csv = buildCsv(active);351 const filename = `peach-export-${dateStamp()}.csv`;352 downloadBlob(csv, "text/csv", filename);353 const suffix = excludedPasskeys > 0354 ? ` ${excludedPasskeys} passkey${excludedPasskeys === 1 ? "" : "s"} excluded — passkeys can't be represented in CSV.`355 : "";356 setDone(357 `Exported ${active.length} entr${active.length === 1 ? "y" : "ies"} to ${filename}.${suffix}`358 );359 } catch (err) {360 setError(err instanceof Error ? err.message : String(err));361 } finally {362 setWorking(false);363 }364 }365366 return (367 <>368 <OverlayHeader title="export as CSV" onBack={props.onBack} />369 <div class="peach-settings-body">370 <div class="peach-settings-group peach-export-card">371 <div class="peach-csv-warning">372 This export is unencrypted. Anyone who gets this file can read373 your passwords. Only use this to move to another password374 manager — delete it once the import is done.375 </div>376 <Show when={!acknowledged()}>377 <button378 class="peach-btn peach-btn--ghost peach-btn--sm"379 onClick={() => setAcknowledged(true)}380 >381 I understand — continue382 </button>383 </Show>384 <Show when={acknowledged()}>385 <button386 class="peach-btn peach-btn--subtle peach-btn--sm"387 onClick={() => void handleExport()}388 disabled={working()}389 >390 {working() ? "exporting…" : "Download CSV"}391 </button>392 </Show>393 </div>394 <Show when={error()}>395 <div class="peach-settings-group">396 <div class="peach-restore-error">{error()}</div>397 </div>398 </Show>399 <Show when={done()}>400 <div class="peach-settings-group">401 <div class="peach-export-success">402 <span class="peach-export-success-check">✓</span>403 <span>{done()}</span>404 </div>405 </div>406 </Show>407 </div>408 </>409 );410}411412413414415416function OverlayHeader(props: { title: string; onBack: () => void }) {417 return (418 <div class="peach-overlay-header">419 <button420 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"421 onClick={props.onBack}422 aria-label="Back"423 >424 <svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">425 <path d="M6 1L1 6L6 11" />426 </svg>427 </button>428 <span class="peach-overlay-title">{props.title}</span>429 <div class="peach-overlay-spacer"></div>430 </div>431 );432}433434interface AssembledEnvelope {435 bytes: Uint8Array;436 entryCount: number;437 stats: { cards: number; passkeys: number; notes: number; logins: number };438}439440async function assembleEnvelope(passphrase: string): Promise<AssembledEnvelope> {441 const entriesRes = await sendMessage({ type: "GET_ENTRIES" });442 const entries = (entriesRes as { ok: boolean; data?: VaultEntry[] }).data ?? [];443444 const settingsRes = await sendMessage({ type: "GET_SETTINGS" });445 const settings = (settingsRes as { ok: boolean; data?: Record<string, unknown> }).data ?? {};446447 let license: PeachLicenseExport | undefined;448 try {449 const info = await sendMessage({ type: "GET_LICENSE" });450 const data = (info as { ok: boolean; data?: { valid?: boolean; plan?: string; issued?: string } }).data;451 if (data?.valid && data.plan === "pro") {452 const keyRes = await sendMessage({ type: "GET_LICENSE_KEY" });453 const keyStr = (keyRes as { ok: boolean; data?: string }).data;454 if (keyStr) {455 license = {456 key: keyStr,457 ...(data.issued ? { activatedAt: new Date(data.issued).getTime() } : {}),458 };459 }460 }461 } catch {462 463 }464465 const stats = countTypes(entries);466 const bytes = await buildPayload({467 entries,468 settings: settings as Parameters<typeof buildPayload>[0]["settings"],469 license,470 passphrase,471 });472 return { bytes, entryCount: entries.length, stats };473}474475function countTypes(entries: VaultEntry[]): {476 cards: number;477 passkeys: number;478 notes: number;479 logins: number;480} {481 let cards = 0;482 let passkeys = 0;483 let notes = 0;484 let logins = 0;485 for (const e of entries) {486 if (e.deletedAt) continue;487 if (e.passkey) {488 passkeys++;489 490 491 if (e.type === "login" && e.password) logins++;492 continue;493 }494 if (e.type === "card") cards++;495 else if (e.type === "note") notes++;496 else logins++;497 }498 return { cards, passkeys, notes, logins };499}500501502503504505506507export function csvSafe(value: string): string {508 return /^[=+\-;@\t\r]/.test(value) ? "'" + value : value;509}510511function buildCsv(entries: VaultEntry[]): string {512 const headers = ["name", "url", "username", "password", "notes", "type", "tags", "totp"];513 const rows = entries.map((e) => {514 const type = e.type === "note" ? "note" : e.type === "card" ? "card" : "login";515 const username = e.type === "card" ? e.cardholderName ?? "" : e.username ?? "";516 const password = e.type === "card" ? e.cardNumber ?? "" : e.password ?? "";517 return [518 e.title,519 e.url ?? "",520 username,521 password,522 e.notes ?? "",523 type,524 Array.isArray(e.tags) ? e.tags.join("|") : "",525 e.totp ?? "",526 ]527 .map((v) =>528 `"${csvSafe(String(v)).replace(/\r?\n/g, " ").replace(/"/g, '""')}"`529 )530 .join(",");531 });532 return [headers.join(","), ...rows].join("\n");533}534535function dateStamp(): string {536 return new Date().toISOString().split("T")[0];537}538539function downloadBlob(content: string, mimeType: string, filename: string): void {540 const url = URL.createObjectURL(new Blob([content], { type: mimeType }));541 const a = document.createElement("a");542 a.href = url;543 a.download = filename;544 document.body.appendChild(a);545 a.click();546 document.body.removeChild(a);547 URL.revokeObjectURL(url);548}