Export.tsx

23.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 { 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 Mayan 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 Mayan 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    // Filename per the verification spec: peach-backup-YYYY-MM-DD.peach,164    // hyphens only, lowercase extension, date-only (not time-of-day).165    const blob = new Blob([s], { type: "application/x-peach;charset=utf-8" });166    const url = URL.createObjectURL(blob);167    const date = new Date().toISOString().slice(0, 10);168    const a = document.createElement("a");169    a.href = url;170    a.download = `peach-backup-${date}.peach`;171    document.body.appendChild(a);172    a.click();173    document.body.removeChild(a);174    setTimeout(() => URL.revokeObjectURL(url), 5000);175  }176177  return (178    <>179      <OverlayHeader title="peachscript" onBack={props.onBack} />180      <div class="peach-settings-body">181        <Show182          when={script()}183          fallback={184            <div class="peach-settings-group peach-export-card peach-export-card--primary peach-export-pass-group">185              <p class="peach-settings-label">backup password</p>186              <p class="peach-export-card-copy">187                Choose a password to lock this PeachScript. You'll need it188                to restore on another device.189              </p>190              <div class="peach-settings-stack peach-export-password-stack">191                <input192                  ref={(el) => queueMicrotask(() => el.focus())}193                  class="peach-settings-input"194                  type="password"195                  placeholder={`backup password (min ${MIN_BACKUP_PASSWORD} chars)`}196                  value={pass()}197                  onInput={(e) => setPass((e.target as HTMLInputElement).value)}198                  onKeyDown={(e) => {199                    if (e.key === "Enter" && passReady() && !working()) {200                      e.preventDefault();201                      void handleEncode();202                    }203                  }}204                />205                <input206                  class="peach-settings-input"207                  type="password"208                  placeholder="confirm backup password"209                  value={confirm()}210                  onInput={(e) => setConfirm((e.target as HTMLInputElement).value)}211                  onKeyDown={(e) => {212                    if (e.key === "Enter" && passReady() && !working()) {213                      e.preventDefault();214                      void handleEncode();215                    }216                  }}217                />218                <Show when={confirm().length > 0 && pass() !== confirm()}>219                  <p class="peach-export-note" style="color:var(--peach-t2)">220                    Passwords don't match221                  </p>222                </Show>223                <Show when={pass().length > 0 && pass().length < MIN_BACKUP_PASSWORD}>224                  <p class="peach-export-note" style="color:var(--peach-t2)">225                    At least {MIN_BACKUP_PASSWORD} characters226                  </p>227                </Show>228              </div>229              <button230                class="peach-btn peach-btn--subtle peach-btn--sm"231                onClick={() => void handleEncode()}232                disabled={working() || !passReady()}233              >234                {working() ? "encoding…" : "Generate PeachScript"}235              </button>236            </div>237          }238        >239          <div class="peach-settings-group peach-export-card peach-export-card--primary peach-export-result-group">240            <p class="peach-settings-label">peachscript</p>241            <RuneCanvas runeString={script() ?? ""} width={288} height={240} />242            <p class="peach-export-card-copy" style="margin:0 0 8px">243              {entryCount()} entr{entryCount() === 1 ? "y" : "ies"}244              {stats().cards > 0 ? ` · ${stats().cards} card${stats().cards === 1 ? "" : "s"}` : ""}245              {stats().passkeys > 0 ? ` · ${stats().passkeys} passkey${stats().passkeys === 1 ? "" : "s"}` : ""}246              {stats().notes > 0 ? ` · ${stats().notes} note${stats().notes === 1 ? "" : "s"}` : ""}247            </p>248            <div style="display:flex;gap:8px;">249              <button250                class="peach-btn peach-btn--subtle peach-btn--sm"251                style="flex:1;"252                onClick={() => void handleCopy()}253              >254                {copied() ? "copied ✓" : "Copy"}255              </button>256              <button257                class="peach-btn peach-btn--subtle peach-btn--sm"258                style="flex:1;"259                onClick={handleDownload}260              >261                Download .peach262              </button>263            </div>264            <p class="peach-export-card-copy" style="margin-top:8px">265              Paste the script — or import the .peach file — into Peach on266              your other device to restore your vault. Won't work anywhere else.267            </p>268          </div>269        </Show>270        <Show when={error()}>271          <div class="peach-settings-group">272            <div class="peach-restore-error">{error()}</div>273          </div>274        </Show>275      </div>276    </>277  );278}279280// ---------------------------------------------------------------------------281// QR282// ---------------------------------------------------------------------------283284function QRExport(props: { onBack: () => void }) {285  const [pass, setPass] = createSignal("");286  const [confirm, setConfirm] = createSignal("");287  const [working, setWorking] = createSignal(false);288  const [pngUrl, setPngUrl] = createSignal<string | null>(null);289  const [error, setError] = createSignal<string | null>(null);290  const [entryCount, setEntryCount] = createSignal(0);291  let canvasHost: HTMLDivElement | undefined;292293  const passReady = createMemo(294    () => pass().length >= MIN_BACKUP_PASSWORD && pass() === confirm()295  );296297  async function handleEncode() {298    setError(null);299    setPngUrl(null);300    if (!passReady()) {301      setError("Backup passwords don't match or are too short.");302      return;303    }304    setWorking(true);305    try {306      const env = await assembleEnvelope(pass());307      if (env.bytes.length > QR_MAX_BYTES) {308        setError(309          `This vault is too large for a Mayan code backup (${env.bytes.length} bytes). Use PeachScript instead.`310        );311        return;312      }313      setEntryCount(env.entryCount);314      const canvas = await renderPeachQR(env.bytes);315      const url = await renderPeachQRDataURL(env.bytes);316      if (canvasHost) {317        canvasHost.innerHTML = "";318        canvasHost.appendChild(canvas);319      }320      setPngUrl(url);321    } catch (err) {322      setError(err instanceof Error ? err.message : String(err));323    } finally {324      setWorking(false);325    }326  }327328  function handleSave() {329    const url = pngUrl();330    if (!url) return;331    const a = document.createElement("a");332    a.href = url;333    a.download = `peach-mayan-backup-${dateStamp()}.png`;334    document.body.appendChild(a);335    a.click();336    document.body.removeChild(a);337  }338339  return (340    <>341      <OverlayHeader title="print backup" onBack={props.onBack} />342      <div class="peach-settings-body">343        <Show344          when={pngUrl()}345          fallback={346            <div class="peach-settings-group peach-export-card peach-export-card--primary">347              <p class="peach-settings-label">backup password</p>348              <p class="peach-export-card-copy">349                Set a password to lock this Mayan code backup. You'll need it350                to restore — without it the printed code is unreadable.351              </p>352              <div class="peach-settings-stack peach-export-password-stack">353                <input354                  ref={(el) => queueMicrotask(() => el.focus())}355                  class="peach-settings-input"356                  type="password"357                  placeholder={`backup password (min ${MIN_BACKUP_PASSWORD} chars)`}358                  value={pass()}359                  onInput={(e) => setPass((e.target as HTMLInputElement).value)}360                  onKeyDown={(e) => {361                    if (e.key === "Enter" && passReady() && !working()) {362                      e.preventDefault();363                      void handleEncode();364                    }365                  }}366                />367                <input368                  class="peach-settings-input"369                  type="password"370                  placeholder="confirm backup password"371                  value={confirm()}372                  onInput={(e) => setConfirm((e.target as HTMLInputElement).value)}373                  onKeyDown={(e) => {374                    if (e.key === "Enter" && passReady() && !working()) {375                      e.preventDefault();376                      void handleEncode();377                    }378                  }}379                />380              </div>381              <button382                class="peach-btn peach-btn--subtle peach-btn--sm"383                onClick={() => void handleEncode()}384                disabled={working() || !passReady()}385              >386                {working() ? "encoding…" : "Generate Mayan code"}387              </button>388            </div>389          }390        >391          <div class="peach-settings-group peach-export-card peach-export-card--primary">392            <div class="peach-qr-canvas-wrap" ref={canvasHost} />393            <p class="peach-export-card-copy" style="text-align:center">394              {entryCount()} entr{entryCount() === 1 ? "y" : "ies"} · {monthYear()}395            </p>396            <button397              class="peach-btn peach-btn--subtle peach-btn--sm"398              onClick={handleSave}399            >400              Save PNG401            </button>402            <p class="peach-export-card-copy" style="margin-top:8px">403              Print this and keep it somewhere safe. Scan it with Peach to404              restore your vault on any device.405            </p>406          </div>407        </Show>408        <Show when={error()}>409          <div class="peach-settings-group">410            <div class="peach-restore-error">{error()}</div>411          </div>412        </Show>413      </div>414    </>415  );416}417418// ---------------------------------------------------------------------------419// CSV — non-skippable warning, every time, by design (spec §5.2).420// ---------------------------------------------------------------------------421422function CSVExport(props: { onBack: () => void }) {423  const [acknowledged, setAcknowledged] = createSignal(false);424  const [working, setWorking] = createSignal(false);425  const [error, setError] = createSignal<string | null>(null);426  const [done, setDone] = createSignal<string | null>(null);427428  async function handleExport() {429    setError(null);430    setDone(null);431    setWorking(true);432    try {433      const res = await sendMessage({ type: "GET_ENTRIES" });434      const entries = (res as { ok: boolean; data?: VaultEntry[] }).data ?? [];435      // Passkeys are device-bound credentials and cannot be moved between436      // devices via export. Strip the passkey sub-object out of any login437      // entry that carries one; the entry itself still exports if it has a438      // password too. Passkey-only entries (no password) are skipped entirely.439      const active = entries440        .filter((e) => e.deletedAt === undefined)441        .filter((e) => !(e.passkey && !e.password))442        .map((e) => (e.passkey ? { ...e, passkey: undefined } : e));443      const excludedPasskeys = entries.filter(444        (e) => e.deletedAt === undefined && e.passkey445      ).length;446447      if (active.length === 0) {448        setError("Your vault has no entries to export.");449        return;450      }451452      const csv = buildCsv(active);453      const filename = `peach-export-${dateStamp()}.csv`;454      downloadBlob(csv, "text/csv", filename);455      const suffix = excludedPasskeys > 0456        ? ` ${excludedPasskeys} passkey${excludedPasskeys === 1 ? "" : "s"} excluded — passkeys can't be represented in CSV.`457        : "";458      setDone(459        `Exported ${active.length} entr${active.length === 1 ? "y" : "ies"} to ${filename}.${suffix}`460      );461    } catch (err) {462      setError(err instanceof Error ? err.message : String(err));463    } finally {464      setWorking(false);465    }466  }467468  return (469    <>470      <OverlayHeader title="export as CSV" onBack={props.onBack} />471      <div class="peach-settings-body">472        <div class="peach-settings-group peach-export-card">473          <div class="peach-csv-warning">474            This export is unencrypted. Anyone who gets this file can read475            your passwords. Only use this to move to another password476            manager — delete it once the import is done.477          </div>478          <Show when={!acknowledged()}>479            <button480              class="peach-btn peach-btn--ghost peach-btn--sm"481              onClick={() => setAcknowledged(true)}482            >483              I understand — continue484            </button>485          </Show>486          <Show when={acknowledged()}>487            <button488              class="peach-btn peach-btn--subtle peach-btn--sm"489              onClick={() => void handleExport()}490              disabled={working()}491            >492              {working() ? "exporting…" : "Download CSV"}493            </button>494          </Show>495        </div>496        <Show when={error()}>497          <div class="peach-settings-group">498            <div class="peach-restore-error">{error()}</div>499          </div>500        </Show>501        <Show when={done()}>502          <div class="peach-settings-group">503            <div class="peach-export-success">504              <span class="peach-export-success-check">✓</span>505              <span>{done()}</span>506            </div>507          </div>508        </Show>509      </div>510    </>511  );512}513514// ---------------------------------------------------------------------------515// Shared helpers516// ---------------------------------------------------------------------------517518function OverlayHeader(props: { title: string; onBack: () => void }) {519  return (520    <div class="peach-overlay-header">521      <button522        class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"523        onClick={props.onBack}524        aria-label="Back"525      >526        <svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">527          <path d="M6 1L1 6L6 11" />528        </svg>529      </button>530      <span class="peach-overlay-title">{props.title}</span>531      <div class="peach-overlay-spacer"></div>532    </div>533  );534}535536interface AssembledEnvelope {537  bytes: Uint8Array;538  entryCount: number;539  stats: { cards: number; passkeys: number; notes: number; logins: number };540}541542async function assembleEnvelope(passphrase: string): Promise<AssembledEnvelope> {543  const entriesRes = await sendMessage({ type: "GET_ENTRIES" });544  const entries = (entriesRes as { ok: boolean; data?: VaultEntry[] }).data ?? [];545546  const settingsRes = await sendMessage({ type: "GET_SETTINGS" });547  const settings = (settingsRes as { ok: boolean; data?: Record<string, unknown> }).data ?? {};548549  let license: PeachLicenseExport | undefined;550  try {551    const info = await sendMessage({ type: "GET_LICENSE" });552    const data = (info as { ok: boolean; data?: { valid?: boolean; plan?: string; issued?: string } }).data;553    if (data?.valid && data.plan === "pro") {554      const keyRes = await sendMessage({ type: "GET_LICENSE_KEY" });555      const keyStr = (keyRes as { ok: boolean; data?: string }).data;556      if (keyStr) {557        license = {558          key: keyStr,559          ...(data.issued ? { activatedAt: new Date(data.issued).getTime() } : {}),560        };561      }562    }563  } catch {564    // License absent — proceed without it.565  }566567  const stats = countTypes(entries);568  const bytes = await buildPayload({569    entries,570    settings: settings as Parameters<typeof buildPayload>[0]["settings"],571    license,572    passphrase,573  });574  return { bytes, entryCount: entries.length, stats };575}576577function countTypes(entries: VaultEntry[]): {578  cards: number;579  passkeys: number;580  notes: number;581  logins: number;582} {583  let cards = 0;584  let passkeys = 0;585  let notes = 0;586  let logins = 0;587  for (const e of entries) {588    if (e.deletedAt) continue;589    if (e.passkey) {590      passkeys++;591      // A login that ALSO has a password counts toward the login total too —592      // exporting it preserves the password (the passkey gets stripped).593      if (e.type === "login" && e.password) logins++;594      continue;595    }596    if (e.type === "card") cards++;597    else if (e.type === "note") notes++;598    else logins++;599  }600  return { cards, passkeys, notes, logins };601}602603function buildCsv(entries: VaultEntry[]): string {604  const headers = ["name", "url", "username", "password", "notes", "type", "tags", "totp"];605  const rows = entries.map((e) => {606    const type = e.type === "note" ? "note" : e.type === "card" ? "card" : "login";607    const username = e.type === "card" ? e.cardholderName ?? "" : e.username ?? "";608    const password = e.type === "card" ? e.cardNumber ?? "" : e.password ?? "";609    return [610      e.title,611      e.url ?? "",612      username,613      password,614      e.notes ?? "",615      type,616      Array.isArray(e.tags) ? e.tags.join("|") : "",617      e.totp ?? "",618    ]619      .map((v) =>620        `"${String(v).replace(/\r?\n/g, " ").replace(/"/g, '""')}"`621      )622      .join(",");623  });624  return [headers.join(","), ...rows].join("\n");625}626627function dateStamp(): string {628  return new Date().toISOString().split("T")[0];629}630631function monthYear(): string {632  const d = new Date();633  const months = [634    "January", "February", "March", "April", "May", "June",635    "July", "August", "September", "October", "November", "December",636  ];637  return `${months[d.getMonth()]} ${d.getFullYear()}`;638}639640function downloadBlob(content: string, mimeType: string, filename: string): void {641  const url = URL.createObjectURL(new Blob([content], { type: mimeType }));642  const a = document.createElement("a");643  a.href = url;644  a.download = filename;645  document.body.appendChild(a);646  a.click();647  document.body.removeChild(a);648  URL.revokeObjectURL(url);649}