Export.tsx

23.0 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  function handleDownload() {158    const s = script();159    if (!s) return;160    // .peach file = the same PeachScript text, in a single file. The PUA161    // codepoints render as runes only when paired with PeachScript-Regular.otf;162    // outside Peach the file looks like opaque bytes, which is the point.163    const blob = new Blob([s], { type: "application/x-peach;charset=utf-8" });164    const url = URL.createObjectURL(blob);165    const stamp = new Date()166      .toISOString()167      .replace(/[:T]/g, "-")168      .replace(/\..*$/, "");169    const a = document.createElement("a");170    a.href = url;171    a.download = `peach-vault-${stamp}.peach`;172    document.body.appendChild(a);173    a.click();174    document.body.removeChild(a);175    setTimeout(() => URL.revokeObjectURL(url), 5000);176  }177178  return (179    <>180      <OverlayHeader title="peachscript" onBack={props.onBack} />181      <div class="peach-settings-body">182        <Show183          when={script()}184          fallback={185            <div class="peach-settings-group peach-export-card peach-export-card--primary peach-export-pass-group">186              <p class="peach-settings-label">backup password</p>187              <p class="peach-export-card-copy">188                Choose a password to lock this PeachScript. You'll need it189                to restore on another device.190              </p>191              <div class="peach-settings-stack peach-export-password-stack">192                <input193                  ref={(el) => queueMicrotask(() => el.focus())}194                  class="peach-settings-input"195                  type="password"196                  placeholder={`backup password (min ${MIN_BACKUP_PASSWORD} chars)`}197                  value={pass()}198                  onInput={(e) => setPass((e.target as HTMLInputElement).value)}199                  onKeyDown={(e) => {200                    if (e.key === "Enter" && passReady() && !working()) {201                      e.preventDefault();202                      void handleEncode();203                    }204                  }}205                />206                <input207                  class="peach-settings-input"208                  type="password"209                  placeholder="confirm backup password"210                  value={confirm()}211                  onInput={(e) => setConfirm((e.target as HTMLInputElement).value)}212                  onKeyDown={(e) => {213                    if (e.key === "Enter" && passReady() && !working()) {214                      e.preventDefault();215                      void handleEncode();216                    }217                  }}218                />219                <Show when={confirm().length > 0 && pass() !== confirm()}>220                  <p class="peach-export-note" style="color:var(--peach-t2)">221                    Passwords don't match222                  </p>223                </Show>224                <Show when={pass().length > 0 && pass().length < MIN_BACKUP_PASSWORD}>225                  <p class="peach-export-note" style="color:var(--peach-t2)">226                    At least {MIN_BACKUP_PASSWORD} characters227                  </p>228                </Show>229              </div>230              <button231                class="peach-btn peach-btn--subtle peach-btn--sm"232                onClick={() => void handleEncode()}233                disabled={working() || !passReady()}234              >235                {working() ? "encoding…" : "Generate PeachScript"}236              </button>237            </div>238          }239        >240          <div class="peach-settings-group peach-export-card peach-export-card--primary peach-export-result-group">241            <p class="peach-settings-label">peachscript</p>242            <RuneCanvas runeString={script() ?? ""} width={288} height={240} />243            <p class="peach-export-card-copy" style="margin:0 0 8px">244              {entryCount()} entr{entryCount() === 1 ? "y" : "ies"}245              {stats().cards > 0 ? ` · ${stats().cards} card${stats().cards === 1 ? "" : "s"}` : ""}246              {stats().passkeys > 0 ? ` · ${stats().passkeys} passkey${stats().passkeys === 1 ? "" : "s"}` : ""}247              {stats().notes > 0 ? ` · ${stats().notes} note${stats().notes === 1 ? "" : "s"}` : ""}248            </p>249            <div style="display:flex;gap:8px;">250              <button251                class="peach-btn peach-btn--subtle peach-btn--sm"252                style="flex:1;"253                onClick={() => void handleCopy()}254              >255                {copied() ? "copied ✓" : "Copy"}256              </button>257              <button258                class="peach-btn peach-btn--subtle peach-btn--sm"259                style="flex:1;"260                onClick={handleDownload}261              >262                Download .peach263              </button>264            </div>265            <p class="peach-export-card-copy" style="margin-top:8px">266              Paste the script — or import the .peach file — into Peach on267              your other device to restore your vault. Won't work anywhere else.268            </p>269          </div>270        </Show>271        <Show when={error()}>272          <div class="peach-settings-group">273            <div class="peach-restore-error">{error()}</div>274          </div>275        </Show>276      </div>277    </>278  );279}280281// ---------------------------------------------------------------------------282// QR283// ---------------------------------------------------------------------------284285function QRExport(props: { onBack: () => void }) {286  const [pass, setPass] = createSignal("");287  const [confirm, setConfirm] = createSignal("");288  const [working, setWorking] = createSignal(false);289  const [pngUrl, setPngUrl] = createSignal<string | null>(null);290  const [error, setError] = createSignal<string | null>(null);291  const [entryCount, setEntryCount] = createSignal(0);292  let canvasHost: HTMLDivElement | undefined;293294  const passReady = createMemo(295    () => pass().length >= MIN_BACKUP_PASSWORD && pass() === confirm()296  );297298  async function handleEncode() {299    setError(null);300    setPngUrl(null);301    if (!passReady()) {302      setError("Backup passwords don't match or are too short.");303      return;304    }305    setWorking(true);306    try {307      const env = await assembleEnvelope(pass());308      if (env.bytes.length > QR_MAX_BYTES) {309        setError(310          `This vault is too large for a QR backup (${env.bytes.length} bytes). Use PeachScript instead.`311        );312        return;313      }314      setEntryCount(env.entryCount);315      const canvas = await renderPeachQR(env.bytes);316      const url = await renderPeachQRDataURL(env.bytes);317      if (canvasHost) {318        canvasHost.innerHTML = "";319        canvasHost.appendChild(canvas);320      }321      setPngUrl(url);322    } catch (err) {323      setError(err instanceof Error ? err.message : String(err));324    } finally {325      setWorking(false);326    }327  }328329  function handleSave() {330    const url = pngUrl();331    if (!url) return;332    const a = document.createElement("a");333    a.href = url;334    a.download = `peach-qr-backup-${dateStamp()}.png`;335    document.body.appendChild(a);336    a.click();337    document.body.removeChild(a);338  }339340  return (341    <>342      <OverlayHeader title="print backup" onBack={props.onBack} />343      <div class="peach-settings-body">344        <Show345          when={pngUrl()}346          fallback={347            <div class="peach-settings-group peach-export-card peach-export-card--primary">348              <p class="peach-settings-label">backup password</p>349              <p class="peach-export-card-copy">350                Set a password to lock this QR backup. You'll need it to351                restore — without it the printed code is unreadable.352              </p>353              <div class="peach-settings-stack peach-export-password-stack">354                <input355                  ref={(el) => queueMicrotask(() => el.focus())}356                  class="peach-settings-input"357                  type="password"358                  placeholder={`backup password (min ${MIN_BACKUP_PASSWORD} chars)`}359                  value={pass()}360                  onInput={(e) => setPass((e.target as HTMLInputElement).value)}361                  onKeyDown={(e) => {362                    if (e.key === "Enter" && passReady() && !working()) {363                      e.preventDefault();364                      void handleEncode();365                    }366                  }}367                />368                <input369                  class="peach-settings-input"370                  type="password"371                  placeholder="confirm backup password"372                  value={confirm()}373                  onInput={(e) => setConfirm((e.target as HTMLInputElement).value)}374                  onKeyDown={(e) => {375                    if (e.key === "Enter" && passReady() && !working()) {376                      e.preventDefault();377                      void handleEncode();378                    }379                  }}380                />381              </div>382              <button383                class="peach-btn peach-btn--subtle peach-btn--sm"384                onClick={() => void handleEncode()}385                disabled={working() || !passReady()}386              >387                {working() ? "encoding…" : "Generate QR code"}388              </button>389            </div>390          }391        >392          <div class="peach-settings-group peach-export-card peach-export-card--primary">393            <div class="peach-qr-canvas-wrap" ref={canvasHost} />394            <p class="peach-export-card-copy" style="text-align:center">395              {entryCount()} entr{entryCount() === 1 ? "y" : "ies"} · {monthYear()}396            </p>397            <button398              class="peach-btn peach-btn--subtle peach-btn--sm"399              onClick={handleSave}400            >401              Save PNG402            </button>403            <p class="peach-export-card-copy" style="margin-top:8px">404              Print this and keep it somewhere safe. Scan it with Peach to405              restore your vault on any device.406            </p>407          </div>408        </Show>409        <Show when={error()}>410          <div class="peach-settings-group">411            <div class="peach-restore-error">{error()}</div>412          </div>413        </Show>414      </div>415    </>416  );417}418419// ---------------------------------------------------------------------------420// CSV — non-skippable warning, every time, by design (spec §5.2).421// ---------------------------------------------------------------------------422423function CSVExport(props: { onBack: () => void }) {424  const [acknowledged, setAcknowledged] = createSignal(false);425  const [working, setWorking] = createSignal(false);426  const [error, setError] = createSignal<string | null>(null);427  const [done, setDone] = createSignal<string | null>(null);428429  async function handleExport() {430    setError(null);431    setDone(null);432    setWorking(true);433    try {434      const res = await sendMessage({ type: "GET_ENTRIES" });435      const entries = (res as { ok: boolean; data?: VaultEntry[] }).data ?? [];436      // Passkeys are device-bound credentials and cannot be moved between437      // devices via export. Strip the passkey sub-object out of any login438      // entry that carries one; the entry itself still exports if it has a439      // password too. Passkey-only entries (no password) are skipped entirely.440      const active = entries441        .filter((e) => e.deletedAt === undefined)442        .filter((e) => !(e.passkey && !e.password))443        .map((e) => (e.passkey ? { ...e, passkey: undefined } : e));444      const excludedPasskeys = entries.filter(445        (e) => e.deletedAt === undefined && e.passkey446      ).length;447448      if (active.length === 0) {449        setError("Your vault has no entries to export.");450        return;451      }452453      const csv = buildCsv(active);454      const filename = `peach-export-${dateStamp()}.csv`;455      downloadBlob(csv, "text/csv", filename);456      const suffix = excludedPasskeys > 0457        ? ` ${excludedPasskeys} passkey${excludedPasskeys === 1 ? "" : "s"} excluded — passkeys can't be represented in CSV.`458        : "";459      setDone(460        `Exported ${active.length} entr${active.length === 1 ? "y" : "ies"} to ${filename}.${suffix}`461      );462    } catch (err) {463      setError(err instanceof Error ? err.message : String(err));464    } finally {465      setWorking(false);466    }467  }468469  return (470    <>471      <OverlayHeader title="export as CSV" onBack={props.onBack} />472      <div class="peach-settings-body">473        <div class="peach-settings-group peach-export-card">474          <div class="peach-csv-warning">475            This export is unencrypted. Anyone who gets this file can read476            your passwords. Only use this to move to another password477            manager — delete it once the import is done.478          </div>479          <Show when={!acknowledged()}>480            <button481              class="peach-btn peach-btn--ghost peach-btn--sm"482              onClick={() => setAcknowledged(true)}483            >484              I understand — continue485            </button>486          </Show>487          <Show when={acknowledged()}>488            <button489              class="peach-btn peach-btn--subtle peach-btn--sm"490              onClick={() => void handleExport()}491              disabled={working()}492            >493              {working() ? "exporting…" : "Download CSV"}494            </button>495          </Show>496        </div>497        <Show when={error()}>498          <div class="peach-settings-group">499            <div class="peach-restore-error">{error()}</div>500          </div>501        </Show>502        <Show when={done()}>503          <div class="peach-settings-group">504            <div class="peach-export-success">505              <span class="peach-export-success-check">✓</span>506              <span>{done()}</span>507            </div>508          </div>509        </Show>510      </div>511    </>512  );513}514515// ---------------------------------------------------------------------------516// Shared helpers517// ---------------------------------------------------------------------------518519function OverlayHeader(props: { title: string; onBack: () => void }) {520  return (521    <div class="peach-overlay-header">522      <button523        class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"524        onClick={props.onBack}525        aria-label="Back"526      >527        <svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">528          <path d="M6 1L1 6L6 11" />529        </svg>530      </button>531      <span class="peach-overlay-title">{props.title}</span>532      <div class="peach-overlay-spacer"></div>533    </div>534  );535}536537interface AssembledEnvelope {538  bytes: Uint8Array;539  entryCount: number;540  stats: { cards: number; passkeys: number; notes: number; logins: number };541}542543async function assembleEnvelope(passphrase: string): Promise<AssembledEnvelope> {544  const entriesRes = await sendMessage({ type: "GET_ENTRIES" });545  const entries = (entriesRes as { ok: boolean; data?: VaultEntry[] }).data ?? [];546547  const settingsRes = await sendMessage({ type: "GET_SETTINGS" });548  const settings = (settingsRes as { ok: boolean; data?: Record<string, unknown> }).data ?? {};549550  let license: PeachLicenseExport | undefined;551  try {552    const info = await sendMessage({ type: "GET_LICENSE" });553    const data = (info as { ok: boolean; data?: { valid?: boolean; plan?: string; issued?: string } }).data;554    if (data?.valid && data.plan === "pro") {555      const keyRes = await sendMessage({ type: "GET_LICENSE_KEY" });556      const keyStr = (keyRes as { ok: boolean; data?: string }).data;557      if (keyStr) {558        license = {559          key: keyStr,560          ...(data.issued ? { activatedAt: new Date(data.issued).getTime() } : {}),561        };562      }563    }564  } catch {565    // License absent — proceed without it.566  }567568  const stats = countTypes(entries);569  const bytes = await buildPayload({570    entries,571    settings: settings as Parameters<typeof buildPayload>[0]["settings"],572    license,573    passphrase,574  });575  return { bytes, entryCount: entries.length, stats };576}577578function countTypes(entries: VaultEntry[]): {579  cards: number;580  passkeys: number;581  notes: number;582  logins: number;583} {584  let cards = 0;585  let passkeys = 0;586  let notes = 0;587  let logins = 0;588  for (const e of entries) {589    if (e.deletedAt) continue;590    if (e.passkey) {591      passkeys++;592      // A login that ALSO has a password counts toward the login total too —593      // exporting it preserves the password (the passkey gets stripped).594      if (e.type === "login" && e.password) logins++;595      continue;596    }597    if (e.type === "card") cards++;598    else if (e.type === "note") notes++;599    else logins++;600  }601  return { cards, passkeys, notes, logins };602}603604function buildCsv(entries: VaultEntry[]): string {605  const headers = ["name", "url", "username", "password", "notes", "type", "tags", "totp"];606  const rows = entries.map((e) => {607    const type = e.type === "note" ? "note" : e.type === "card" ? "card" : "login";608    const username = e.type === "card" ? e.cardholderName ?? "" : e.username ?? "";609    const password = e.type === "card" ? e.cardNumber ?? "" : e.password ?? "";610    return [611      e.title,612      e.url ?? "",613      username,614      password,615      e.notes ?? "",616      type,617      Array.isArray(e.tags) ? e.tags.join("|") : "",618      e.totp ?? "",619    ]620      .map((v) =>621        `"${String(v).replace(/\r?\n/g, " ").replace(/"/g, '""')}"`622      )623      .join(",");624  });625  return [headers.join(","), ...rows].join("\n");626}627628function dateStamp(): string {629  return new Date().toISOString().split("T")[0];630}631632function monthYear(): string {633  const d = new Date();634  const months = [635    "January", "February", "March", "April", "May", "June",636    "July", "August", "September", "October", "November", "December",637  ];638  return `${months[d.getMonth()]} ${d.getFullYear()}`;639}640641function downloadBlob(content: string, mimeType: string, filename: string): void {642  const url = URL.createObjectURL(new Blob([content], { type: mimeType }));643  const a = document.createElement("a");644  a.href = url;645  a.download = filename;646  document.body.appendChild(a);647  a.click();648  document.body.removeChild(a);649  URL.revokeObjectURL(url);650}