Export.tsx

20.1 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 {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}4445// ---------------------------------------------------------------------------46// Menu47// ---------------------------------------------------------------------------4849function 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}99100// ---------------------------------------------------------------------------101// PeachScript102// ---------------------------------------------------------------------------103104function 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}235236// ---------------------------------------------------------------------------237// QR238// ---------------------------------------------------------------------------239240function 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}360361// ---------------------------------------------------------------------------362// CSV — non-skippable warning, every time, by design (spec §5.2).363// ---------------------------------------------------------------------------364365function 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}451452// ---------------------------------------------------------------------------453// Shared helpers454// ---------------------------------------------------------------------------455456function 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    // License absent — proceed without it.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}