Export.tsx

21.2 KB · raw

1// 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.5import { 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 {12  QR_MAX_BYTES,13  renderPeachQR,14  renderPeachQRDataURL,15} from "../../crypto/peachQR";16import type { PeachLicenseExport, VaultEntry } from "../../shared/types";1718type ExportSubview = "menu" | "peachscript" | "qr" | "csv";1920interface ExportProps {21  onBack: () => void;22}2324const MIN_BACKUP_PASSWORD = 8;2526export function ExportView(props: ExportProps) {27  const [view, setView] = createSignal<ExportSubview>("menu");28  return (29    <div class="peach-overlay-view ov-enter-right" style="display:flex">30      <Show when={view() === "menu"}>31        <ExportMenu onBack={props.onBack} onPick={(v) => setView(v)} />32      </Show>33      <Show when={view() === "peachscript"}>34        <PeachScriptExport onBack={() => setView("menu")} />35      </Show>36      <Show when={view() === "qr"}>37        <QRExport onBack={() => setView("menu")} />38      </Show>39      <Show when={view() === "csv"}>40        <CSVExport onBack={() => setView("menu")} />41      </Show>42    </div>43  );44}4546// ---------------------------------------------------------------------------47// Menu48// ---------------------------------------------------------------------------4950function ExportMenu(props: { onBack: () => void; onPick: (v: ExportSubview) => void }) {51  return (52    <>53      <OverlayHeader title="export" onBack={props.onBack} />54      <div class="peach-settings-body peach-export-menu">55        <div class="peach-settings-group peach-export-card peach-export-card--primary">56          <p class="peach-settings-label">peachscript</p>57          <p class="peach-export-card-copy">58            Your vault as a string of Peach symbols you can paste into Peach59            on another device. Designed for Peach-to-Peach transfer.60          </p>61          <button62            class="peach-btn peach-btn--subtle peach-btn--sm"63            onClick={() => props.onPick("peachscript")}64          >65            Create PeachScript →66          </button>67        </div>6869        <div class="peach-settings-group peach-export-card">70          <p class="peach-settings-label">print backup</p>71          <p class="peach-export-card-copy">72            Save a QR code you can print and store. Scan it with Peach on any73            device to restore.74          </p>75          <button76            class="peach-btn peach-btn--subtle peach-btn--sm"77            onClick={() => props.onPick("qr")}78          >79            Save QR code →80          </button>81        </div>8283        <div class="peach-settings-group peach-export-card">84          <p class="peach-settings-label">CSV (leaving peach)</p>85          <p class="peach-export-card-copy">86            Plain-text export for moving into another password manager. Anyone87            with the file can read your passwords.88          </p>89          <button90            class="peach-btn peach-btn--ghost peach-btn--sm"91            onClick={() => props.onPick("csv")}92          >93            Export as CSV →94          </button>95        </div>96      </div>97    </>98  );99}100101// ---------------------------------------------------------------------------102// PeachScript103// ---------------------------------------------------------------------------104105function PeachScriptExport(props: { onBack: () => void }) {106  const [pass, setPass] = createSignal("");107  const [confirm, setConfirm] = createSignal("");108  const [working, setWorking] = createSignal(false);109  const [script, setScript] = createSignal<string | null>(null);110  const [error, setError] = createSignal<string | null>(null);111  const [entryCount, setEntryCount] = createSignal(0);112  const [stats, setStats] = createSignal<{113    cards: number;114    passkeys: number;115    notes: number;116    logins: number;117  }>({ cards: 0, passkeys: 0, notes: 0, logins: 0 });118  const [copied, setCopied] = createSignal(false);119120  const passReady = createMemo(121    () => pass().length >= MIN_BACKUP_PASSWORD && pass() === confirm()122  );123124  async function handleEncode() {125    setError(null);126    setScript(null);127    setCopied(false);128    if (!passReady()) {129      setError("Backup passwords don't match or are too short.");130      return;131    }132    setWorking(true);133    try {134      const env = await assembleEnvelope(pass());135      setEntryCount(env.entryCount);136      setStats(env.stats);137      setScript(encodePeachScript(env.bytes));138    } catch (err) {139      setError(err instanceof Error ? err.message : String(err));140    } finally {141      setWorking(false);142    }143  }144145  async function handleCopy() {146    const s = script();147    if (!s) return;148    try {149      await navigator.clipboard.writeText(s);150      setCopied(true);151      setTimeout(() => setCopied(false), 2000);152    } catch (err) {153      setError(err instanceof Error ? err.message : "Couldn't copy to clipboard.");154    }155  }156157  return (158    <>159      <OverlayHeader title="peachscript" onBack={props.onBack} />160      <div class="peach-settings-body">161        <Show162          when={script()}163          fallback={164            <div class="peach-settings-group peach-export-card peach-export-card--primary peach-export-pass-group">165              <p class="peach-settings-label">backup password</p>166              <p class="peach-export-card-copy">167                Choose a password to lock this PeachScript. You'll need it168                to restore on another device.169              </p>170              <div class="peach-settings-stack peach-export-password-stack">171                <input172                  ref={(el) => queueMicrotask(() => el.focus())}173                  class="peach-settings-input"174                  type="password"175                  placeholder={`backup password (min ${MIN_BACKUP_PASSWORD} chars)`}176                  value={pass()}177                  onInput={(e) => setPass((e.target as HTMLInputElement).value)}178                  onKeyDown={(e) => {179                    if (e.key === "Enter" && passReady() && !working()) {180                      e.preventDefault();181                      void handleEncode();182                    }183                  }}184                />185                <input186                  class="peach-settings-input"187                  type="password"188                  placeholder="confirm backup password"189                  value={confirm()}190                  onInput={(e) => setConfirm((e.target as HTMLInputElement).value)}191                  onKeyDown={(e) => {192                    if (e.key === "Enter" && passReady() && !working()) {193                      e.preventDefault();194                      void handleEncode();195                    }196                  }}197                />198                <Show when={confirm().length > 0 && pass() !== confirm()}>199                  <p class="peach-export-note" style="color:var(--peach-t2)">200                    Passwords don't match201                  </p>202                </Show>203                <Show when={pass().length > 0 && pass().length < MIN_BACKUP_PASSWORD}>204                  <p class="peach-export-note" style="color:var(--peach-t2)">205                    At least {MIN_BACKUP_PASSWORD} characters206                  </p>207                </Show>208              </div>209              <button210                class="peach-btn peach-btn--subtle peach-btn--sm"211                onClick={() => void handleEncode()}212                disabled={working() || !passReady()}213              >214                {working() ? "encoding…" : "Generate PeachScript"}215              </button>216            </div>217          }218        >219          <div class="peach-settings-group peach-export-card peach-export-card--primary peach-export-result-group">220            <p class="peach-settings-label">peachscript</p>221            <RuneCanvas runeString={script() ?? ""} width={288} height={240} />222            <p class="peach-export-card-copy" style="margin:0 0 8px">223              {entryCount()} entr{entryCount() === 1 ? "y" : "ies"}224              {stats().cards > 0 ? ` · ${stats().cards} card${stats().cards === 1 ? "" : "s"}` : ""}225              {stats().passkeys > 0 ? ` · ${stats().passkeys} passkey${stats().passkeys === 1 ? "" : "s"}` : ""}226              {stats().notes > 0 ? ` · ${stats().notes} note${stats().notes === 1 ? "" : "s"}` : ""}227            </p>228            <button229              class="peach-btn peach-btn--subtle peach-btn--sm"230              onClick={() => void handleCopy()}231            >232              {copied() ? "copied ✓" : "Copy PeachScript"}233            </button>234            <p class="peach-export-card-copy" style="margin-top:8px">235              Paste this into Peach on your other device to restore your236              vault. It won't work anywhere else.237            </p>238          </div>239        </Show>240        <Show when={error()}>241          <div class="peach-settings-group">242            <div class="peach-restore-error">{error()}</div>243          </div>244        </Show>245      </div>246    </>247  );248}249250// ---------------------------------------------------------------------------251// QR252// ---------------------------------------------------------------------------253254function QRExport(props: { onBack: () => void }) {255  const [pass, setPass] = createSignal("");256  const [confirm, setConfirm] = createSignal("");257  const [working, setWorking] = createSignal(false);258  const [pngUrl, setPngUrl] = createSignal<string | null>(null);259  const [error, setError] = createSignal<string | null>(null);260  const [entryCount, setEntryCount] = createSignal(0);261  let canvasHost: HTMLDivElement | undefined;262263  const passReady = createMemo(264    () => pass().length >= MIN_BACKUP_PASSWORD && pass() === confirm()265  );266267  async function handleEncode() {268    setError(null);269    setPngUrl(null);270    if (!passReady()) {271      setError("Backup passwords don't match or are too short.");272      return;273    }274    setWorking(true);275    try {276      const env = await assembleEnvelope(pass());277      if (env.bytes.length > QR_MAX_BYTES) {278        setError(279          `This vault is too large for a QR backup (${env.bytes.length} bytes). Use PeachScript instead.`280        );281        return;282      }283      setEntryCount(env.entryCount);284      const canvas = await renderPeachQR(env.bytes);285      const url = await renderPeachQRDataURL(env.bytes);286      if (canvasHost) {287        canvasHost.innerHTML = "";288        canvasHost.appendChild(canvas);289      }290      setPngUrl(url);291    } catch (err) {292      setError(err instanceof Error ? err.message : String(err));293    } finally {294      setWorking(false);295    }296  }297298  function handleSave() {299    const url = pngUrl();300    if (!url) return;301    const a = document.createElement("a");302    a.href = url;303    a.download = `peach-qr-backup-${dateStamp()}.png`;304    document.body.appendChild(a);305    a.click();306    document.body.removeChild(a);307  }308309  return (310    <>311      <OverlayHeader title="print backup" onBack={props.onBack} />312      <div class="peach-settings-body">313        <Show314          when={pngUrl()}315          fallback={316            <div class="peach-settings-group peach-export-card peach-export-card--primary">317              <p class="peach-settings-label">backup password</p>318              <p class="peach-export-card-copy">319                Set a password to lock this QR backup. You'll need it to320                restore — without it the printed code is unreadable.321              </p>322              <div class="peach-settings-stack peach-export-password-stack">323                <input324                  ref={(el) => queueMicrotask(() => el.focus())}325                  class="peach-settings-input"326                  type="password"327                  placeholder={`backup password (min ${MIN_BACKUP_PASSWORD} chars)`}328                  value={pass()}329                  onInput={(e) => setPass((e.target as HTMLInputElement).value)}330                  onKeyDown={(e) => {331                    if (e.key === "Enter" && passReady() && !working()) {332                      e.preventDefault();333                      void handleEncode();334                    }335                  }}336                />337                <input338                  class="peach-settings-input"339                  type="password"340                  placeholder="confirm backup password"341                  value={confirm()}342                  onInput={(e) => setConfirm((e.target as HTMLInputElement).value)}343                  onKeyDown={(e) => {344                    if (e.key === "Enter" && passReady() && !working()) {345                      e.preventDefault();346                      void handleEncode();347                    }348                  }}349                />350              </div>351              <button352                class="peach-btn peach-btn--subtle peach-btn--sm"353                onClick={() => void handleEncode()}354                disabled={working() || !passReady()}355              >356                {working() ? "encoding…" : "Generate QR code"}357              </button>358            </div>359          }360        >361          <div class="peach-settings-group peach-export-card peach-export-card--primary">362            <div class="peach-qr-canvas-wrap" ref={canvasHost} />363            <p class="peach-export-card-copy" style="text-align:center">364              {entryCount()} entr{entryCount() === 1 ? "y" : "ies"} · {monthYear()}365            </p>366            <button367              class="peach-btn peach-btn--subtle peach-btn--sm"368              onClick={handleSave}369            >370              Save PNG371            </button>372            <p class="peach-export-card-copy" style="margin-top:8px">373              Print this and keep it somewhere safe. Scan it with Peach to374              restore your vault on any device.375            </p>376          </div>377        </Show>378        <Show when={error()}>379          <div class="peach-settings-group">380            <div class="peach-restore-error">{error()}</div>381          </div>382        </Show>383      </div>384    </>385  );386}387388// ---------------------------------------------------------------------------389// CSV — non-skippable warning, every time, by design (spec §5.2).390// ---------------------------------------------------------------------------391392function CSVExport(props: { onBack: () => void }) {393  const [acknowledged, setAcknowledged] = createSignal(false);394  const [working, setWorking] = createSignal(false);395  const [error, setError] = createSignal<string | null>(null);396  const [done, setDone] = createSignal<string | null>(null);397398  async function handleExport() {399    setError(null);400    setDone(null);401    setWorking(true);402    try {403      const res = await sendMessage({ type: "GET_ENTRIES" });404      const entries = (res as { ok: boolean; data?: VaultEntry[] }).data ?? [];405      const active = entries.filter(406        (e) => e.deletedAt === undefined && e.type !== "passkey"407      );408      const excludedPasskeys = entries.filter(409        (e) => e.deletedAt === undefined && e.type === "passkey"410      ).length;411412      if (active.length === 0) {413        setError("Your vault has no entries to export.");414        return;415      }416417      const csv = buildCsv(active);418      const filename = `peach-export-${dateStamp()}.csv`;419      downloadBlob(csv, "text/csv", filename);420      const suffix = excludedPasskeys > 0421        ? ` ${excludedPasskeys} passkey${excludedPasskeys === 1 ? "" : "s"} excluded — passkeys can't be represented in CSV.`422        : "";423      setDone(424        `Exported ${active.length} entr${active.length === 1 ? "y" : "ies"} to ${filename}.${suffix}`425      );426    } catch (err) {427      setError(err instanceof Error ? err.message : String(err));428    } finally {429      setWorking(false);430    }431  }432433  return (434    <>435      <OverlayHeader title="export as CSV" onBack={props.onBack} />436      <div class="peach-settings-body">437        <div class="peach-settings-group peach-export-card">438          <div class="peach-csv-warning">439            This export is unencrypted. Anyone who gets this file can read440            your passwords. Only use this to move to another password441            manager — delete it once the import is done.442          </div>443          <Show when={!acknowledged()}>444            <button445              class="peach-btn peach-btn--ghost peach-btn--sm"446              onClick={() => setAcknowledged(true)}447            >448              I understand — continue449            </button>450          </Show>451          <Show when={acknowledged()}>452            <button453              class="peach-btn peach-btn--subtle peach-btn--sm"454              onClick={() => void handleExport()}455              disabled={working()}456            >457              {working() ? "exporting…" : "Download CSV"}458            </button>459          </Show>460        </div>461        <Show when={error()}>462          <div class="peach-settings-group">463            <div class="peach-restore-error">{error()}</div>464          </div>465        </Show>466        <Show when={done()}>467          <div class="peach-settings-group">468            <div class="peach-export-success">469              <span class="peach-export-success-check">✓</span>470              <span>{done()}</span>471            </div>472          </div>473        </Show>474      </div>475    </>476  );477}478479// ---------------------------------------------------------------------------480// Shared helpers481// ---------------------------------------------------------------------------482483function OverlayHeader(props: { title: string; onBack: () => void }) {484  return (485    <div class="peach-overlay-header">486      <button487        class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"488        onClick={props.onBack}489        aria-label="Back"490      >491        <svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">492          <path d="M6 1L1 6L6 11" />493        </svg>494      </button>495      <span class="peach-overlay-title">{props.title}</span>496      <div class="peach-overlay-spacer"></div>497    </div>498  );499}500501interface AssembledEnvelope {502  bytes: Uint8Array;503  entryCount: number;504  stats: { cards: number; passkeys: number; notes: number; logins: number };505}506507async function assembleEnvelope(passphrase: string): Promise<AssembledEnvelope> {508  const entriesRes = await sendMessage({ type: "GET_ENTRIES" });509  const entries = (entriesRes as { ok: boolean; data?: VaultEntry[] }).data ?? [];510511  const settingsRes = await sendMessage({ type: "GET_SETTINGS" });512  const settings = (settingsRes as { ok: boolean; data?: Record<string, unknown> }).data ?? {};513514  let license: PeachLicenseExport | undefined;515  try {516    const info = await sendMessage({ type: "GET_LICENSE" });517    const data = (info as { ok: boolean; data?: { valid?: boolean; plan?: string; issued?: string } }).data;518    if (data?.valid && data.plan === "pro") {519      const keyRes = await sendMessage({ type: "GET_LICENSE_KEY" });520      const keyStr = (keyRes as { ok: boolean; data?: string }).data;521      if (keyStr) {522        license = {523          key: keyStr,524          ...(data.issued ? { activatedAt: new Date(data.issued).getTime() } : {}),525        };526      }527    }528  } catch {529    // License absent — proceed without it.530  }531532  const stats = countTypes(entries);533  const bytes = await buildPayload({534    entries,535    settings: settings as Parameters<typeof buildPayload>[0]["settings"],536    license,537    passphrase,538  });539  return { bytes, entryCount: entries.length, stats };540}541542function countTypes(entries: VaultEntry[]): {543  cards: number;544  passkeys: number;545  notes: number;546  logins: number;547} {548  let cards = 0;549  let passkeys = 0;550  let notes = 0;551  let logins = 0;552  for (const e of entries) {553    if (e.deletedAt) continue;554    if (e.type === "card") cards++;555    else if (e.type === "passkey") passkeys++;556    else if (e.type === "note") notes++;557    else logins++;558  }559  return { cards, passkeys, notes, logins };560}561562function buildCsv(entries: VaultEntry[]): string {563  const headers = ["name", "url", "username", "password", "notes", "type", "tags", "totp"];564  const rows = entries.map((e) => {565    const type = e.type === "note" ? "note" : e.type === "card" ? "card" : "login";566    const username = e.type === "card" ? e.cardholderName ?? "" : e.username ?? "";567    const password = e.type === "card" ? e.cardNumber ?? "" : e.password ?? "";568    return [569      e.title,570      e.url ?? "",571      username,572      password,573      e.notes ?? "",574      type,575      Array.isArray(e.tags) ? e.tags.join("|") : "",576      e.totp ?? "",577    ]578      .map((v) =>579        `"${String(v).replace(/\r?\n/g, " ").replace(/"/g, '""')}"`580      )581      .join(",");582  });583  return [headers.join(","), ...rows].join("\n");584}585586function dateStamp(): string {587  return new Date().toISOString().split("T")[0];588}589590function monthYear(): string {591  const d = new Date();592  const months = [593    "January", "February", "March", "April", "May", "June",594    "July", "August", "September", "October", "November", "December",595  ];596  return `${months[d.getMonth()]} ${d.getFullYear()}`;597}598599function downloadBlob(content: string, mimeType: string, filename: string): void {600  const url = URL.createObjectURL(new Blob([content], { type: mimeType }));601  const a = document.createElement("a");602  a.href = url;603  a.download = filename;604  document.body.appendChild(a);605  a.click();606  document.body.removeChild(a);607  URL.revokeObjectURL(url);608}