Export.tsx

23.4 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 { encodeMayan, MAX_PAYLOAD_BYTES as MAYAN_MAX_BYTES } from "../../crypto/mayan";12import type { PeachLicenseExport, VaultEntry } from "../../shared/types";1314type ExportSubview = "menu" | "peachscript" | "qr" | "csv";1516interface ExportProps {17  onBack: () => void;18}1920const MIN_BACKUP_PASSWORD = 8;2122export function ExportView(props: ExportProps) {23  const [view, setView] = createSignal<ExportSubview>("menu");24  return (25    <div class="peach-overlay-view ov-enter-right" style="display:flex">26      <Show when={view() === "menu"}>27        <ExportMenu onBack={props.onBack} onPick={(v) => setView(v)} />28      </Show>29      <Show when={view() === "peachscript"}>30        <PeachScriptExport onBack={() => setView("menu")} />31      </Show>32      <Show when={view() === "qr"}>33        <QRExport onBack={() => setView("menu")} />34      </Show>35      <Show when={view() === "csv"}>36        <CSVExport onBack={() => setView("menu")} />37      </Show>38    </div>39  );40}4142// ---------------------------------------------------------------------------43// Menu44// ---------------------------------------------------------------------------4546function ExportMenu(props: { onBack: () => void; onPick: (v: ExportSubview) => void }) {47  return (48    <>49      <OverlayHeader title="export" onBack={props.onBack} />50      <div class="peach-settings-body peach-export-menu">51        <div class="peach-settings-group peach-export-card peach-export-card--primary">52          <p class="peach-settings-label">peachscript</p>53          <p class="peach-export-card-copy">54            Your vault as a string of Peach symbols you can paste into Peach55            on another device. Designed for Peach-to-Peach transfer.56          </p>57          <button58            class="peach-btn peach-btn--subtle peach-btn--sm"59            onClick={() => props.onPick("peachscript")}60          >61            Create PeachScript →62          </button>63        </div>6465        <div class="peach-settings-group peach-export-card">66          <p class="peach-settings-label">print backup</p>67          <p class="peach-export-card-copy">68            Save a Mayan code you can print and store. Scan it with Peach on any69            device to restore.70          </p>71          <button72            class="peach-btn peach-btn--subtle peach-btn--sm"73            onClick={() => props.onPick("qr")}74          >75            Save Mayan code →76          </button>77        </div>7879        <div class="peach-settings-group peach-export-card">80          <p class="peach-settings-label">CSV (leaving peach)</p>81          <p class="peach-export-card-copy">82            Plain-text export for moving into another password manager. Anyone83            with the file can read your passwords.84          </p>85          <button86            class="peach-btn peach-btn--ghost peach-btn--sm"87            onClick={() => props.onPick("csv")}88          >89            Export as CSV →90          </button>91        </div>92      </div>93    </>94  );95}9697// ---------------------------------------------------------------------------98// PeachScript99// ---------------------------------------------------------------------------100101function PeachScriptExport(props: { onBack: () => void }) {102  const [pass, setPass] = createSignal("");103  const [confirm, setConfirm] = createSignal("");104  const [working, setWorking] = createSignal(false);105  const [script, setScript] = createSignal<string | null>(null);106  const [error, setError] = createSignal<string | null>(null);107  const [entryCount, setEntryCount] = createSignal(0);108  const [stats, setStats] = createSignal<{109    cards: number;110    passkeys: number;111    notes: number;112    logins: number;113  }>({ cards: 0, passkeys: 0, notes: 0, logins: 0 });114  const [copied, setCopied] = createSignal(false);115116  const passReady = createMemo(117    () => pass().length >= MIN_BACKUP_PASSWORD && pass() === confirm()118  );119120  async function handleEncode() {121    setError(null);122    setScript(null);123    setCopied(false);124    if (!passReady()) {125      setError("Backup passwords don't match or are too short.");126      return;127    }128    setWorking(true);129    try {130      const env = await assembleEnvelope(pass());131      setEntryCount(env.entryCount);132      setStats(env.stats);133      setScript(encodePeachScript(env.bytes));134    } catch (err) {135      setError(err instanceof Error ? err.message : String(err));136    } finally {137      setWorking(false);138    }139  }140141  async function handleCopy() {142    const s = script();143    if (!s) return;144    try {145      await navigator.clipboard.writeText(s);146      setCopied(true);147      setTimeout(() => setCopied(false), 2000);148    } catch (err) {149      setError(err instanceof Error ? err.message : "Couldn't copy to clipboard.");150    }151  }152153  function handleDownload() {154    const s = script();155    if (!s) return;156    // .peach file = the same PeachScript text, in a single file. The PUA157    // codepoints render as runes only when paired with PeachScript-Regular.otf;158    // outside Peach the file looks like opaque bytes, which is the point.159    // Filename per the verification spec: peach-backup-YYYY-MM-DD.peach,160    // hyphens only, lowercase extension, date-only (not time-of-day).161    const blob = new Blob([s], { type: "application/x-peach;charset=utf-8" });162    const url = URL.createObjectURL(blob);163    const date = new Date().toISOString().slice(0, 10);164    const a = document.createElement("a");165    a.href = url;166    a.download = `peach-backup-${date}.peach`;167    document.body.appendChild(a);168    a.click();169    document.body.removeChild(a);170    setTimeout(() => URL.revokeObjectURL(url), 5000);171  }172173  return (174    <>175      <OverlayHeader title="peachscript" onBack={props.onBack} />176      <div class="peach-settings-body">177        <Show178          when={script()}179          fallback={180            <div class="peach-settings-group peach-export-card peach-export-card--primary peach-export-pass-group">181              <p class="peach-settings-label">backup password</p>182              <p class="peach-export-card-copy">183                Choose a password to lock this PeachScript. You'll need it184                to restore on another device.185              </p>186              <div class="peach-settings-stack peach-export-password-stack">187                <input188                  ref={(el) => queueMicrotask(() => el.focus())}189                  class="peach-settings-input"190                  type="password"191                  placeholder={`backup password (min ${MIN_BACKUP_PASSWORD} chars)`}192                  value={pass()}193                  onInput={(e) => setPass((e.target as HTMLInputElement).value)}194                  onKeyDown={(e) => {195                    if (e.key === "Enter" && passReady() && !working()) {196                      e.preventDefault();197                      void handleEncode();198                    }199                  }}200                />201                <input202                  class="peach-settings-input"203                  type="password"204                  placeholder="confirm backup password"205                  value={confirm()}206                  onInput={(e) => setConfirm((e.target as HTMLInputElement).value)}207                  onKeyDown={(e) => {208                    if (e.key === "Enter" && passReady() && !working()) {209                      e.preventDefault();210                      void handleEncode();211                    }212                  }}213                />214                <Show when={confirm().length > 0 && pass() !== confirm()}>215                  <p class="peach-export-note" style="color:var(--peach-t2)">216                    Passwords don't match217                  </p>218                </Show>219                <Show when={pass().length > 0 && pass().length < MIN_BACKUP_PASSWORD}>220                  <p class="peach-export-note" style="color:var(--peach-t2)">221                    At least {MIN_BACKUP_PASSWORD} characters222                  </p>223                </Show>224              </div>225              <button226                class="peach-btn peach-btn--subtle peach-btn--sm"227                onClick={() => void handleEncode()}228                disabled={working() || !passReady()}229              >230                {working() ? "encoding…" : "Generate PeachScript"}231              </button>232            </div>233          }234        >235          <div class="peach-settings-group peach-export-card peach-export-card--primary peach-export-result-group">236            <p class="peach-settings-label">peachscript</p>237            <RuneCanvas runeString={script() ?? ""} width={288} height={240} />238            <p class="peach-export-card-copy" style="margin:0 0 8px">239              {entryCount()} entr{entryCount() === 1 ? "y" : "ies"}240              {stats().cards > 0 ? ` · ${stats().cards} card${stats().cards === 1 ? "" : "s"}` : ""}241              {stats().passkeys > 0 ? ` · ${stats().passkeys} passkey${stats().passkeys === 1 ? "" : "s"}` : ""}242              {stats().notes > 0 ? ` · ${stats().notes} note${stats().notes === 1 ? "" : "s"}` : ""}243            </p>244            <div style="display:flex;gap:8px;">245              <button246                class="peach-btn peach-btn--subtle peach-btn--sm"247                style="flex:1;"248                onClick={() => void handleCopy()}249              >250                {copied() ? "copied ✓" : "Copy"}251              </button>252              <button253                class="peach-btn peach-btn--subtle peach-btn--sm"254                style="flex:1;"255                onClick={handleDownload}256              >257                Download .peach258              </button>259            </div>260            <p class="peach-export-card-copy" style="margin-top:8px">261              Paste the script — or import the .peach file — into Peach on262              your other device to restore your vault. Won't work anywhere else.263            </p>264          </div>265        </Show>266        <Show when={error()}>267          <div class="peach-settings-group">268            <div class="peach-restore-error">{error()}</div>269          </div>270        </Show>271      </div>272    </>273  );274}275276// ---------------------------------------------------------------------------277// QR278// ---------------------------------------------------------------------------279280function QRExport(props: { onBack: () => void }) {281  const [pass, setPass] = createSignal("");282  const [confirm, setConfirm] = createSignal("");283  const [working, setWorking] = createSignal(false);284  const [pngUrl, setPngUrl] = createSignal<string | null>(null);285  const [error, setError] = createSignal<string | null>(null);286  const [entryCount, setEntryCount] = createSignal(0);287  let canvasHost: HTMLDivElement | undefined;288289  const passReady = createMemo(290    () => pass().length >= MIN_BACKUP_PASSWORD && pass() === confirm()291  );292293  async function handleEncode() {294    setError(null);295    setPngUrl(null);296    if (!passReady()) {297      setError("Backup passwords don't match or are too short.");298      return;299    }300    setWorking(true);301    try {302      const env = await assembleEnvelope(pass());303      if (env.bytes.length > MAYAN_MAX_BYTES) {304        setError(305          `This vault is too large for a Mayan code backup (${env.bytes.length} bytes). Use PeachScript instead.`,306        );307        return;308      }309      setEntryCount(env.entryCount);310      // On-screen render at 3px/module; download render at 4px/module.311      const screen = encodeMayan(env.bytes, 3);312      const print = encodeMayan(env.bytes, 4);313      if (!screen.printable) {314        // Visible warning but do not abort — user explicitly chose Mayan.315        setError(316          `Mayan symbol is ${screen.symbolSize}×${screen.symbolSize} modules — too large for one A4 page. Save as PNG for digital backup.`,317        );318      }319      if (canvasHost) {320        canvasHost.innerHTML = "";321        canvasHost.appendChild(screen.canvas);322      }323      setPngUrl(print.canvas.toDataURL("image/png"));324    } catch (err) {325      setError(err instanceof Error ? err.message : String(err));326    } finally {327      setWorking(false);328    }329  }330331  function handleSave() {332    const url = pngUrl();333    if (!url) return;334    const a = document.createElement("a");335    a.href = url;336    a.download = `peach-mayan-backup-${dateStamp()}.png`;337    document.body.appendChild(a);338    a.click();339    document.body.removeChild(a);340  }341342  return (343    <>344      <OverlayHeader title="print backup" onBack={props.onBack} />345      <div class="peach-settings-body">346        <Show347          when={pngUrl()}348          fallback={349            <div class="peach-settings-group peach-export-card peach-export-card--primary">350              <p class="peach-settings-label">backup password</p>351              <p class="peach-export-card-copy">352                Set a password to lock this Mayan code backup. You'll need it353                to restore — without it the printed code is unreadable.354              </p>355              <div class="peach-settings-stack peach-export-password-stack">356                <input357                  ref={(el) => queueMicrotask(() => el.focus())}358                  class="peach-settings-input"359                  type="password"360                  placeholder={`backup password (min ${MIN_BACKUP_PASSWORD} chars)`}361                  value={pass()}362                  onInput={(e) => setPass((e.target as HTMLInputElement).value)}363                  onKeyDown={(e) => {364                    if (e.key === "Enter" && passReady() && !working()) {365                      e.preventDefault();366                      void handleEncode();367                    }368                  }}369                />370                <input371                  class="peach-settings-input"372                  type="password"373                  placeholder="confirm backup password"374                  value={confirm()}375                  onInput={(e) => setConfirm((e.target as HTMLInputElement).value)}376                  onKeyDown={(e) => {377                    if (e.key === "Enter" && passReady() && !working()) {378                      e.preventDefault();379                      void handleEncode();380                    }381                  }}382                />383              </div>384              <button385                class="peach-btn peach-btn--subtle peach-btn--sm"386                onClick={() => void handleEncode()}387                disabled={working() || !passReady()}388              >389                {working() ? "encoding…" : "Generate Mayan code"}390              </button>391            </div>392          }393        >394          <div class="peach-settings-group peach-export-card peach-export-card--primary">395            <div class="peach-qr-canvas-wrap" ref={canvasHost} />396            <p class="peach-export-card-copy" style="text-align:center">397              {entryCount()} entr{entryCount() === 1 ? "y" : "ies"} · {monthYear()}398            </p>399            <button400              class="peach-btn peach-btn--subtle peach-btn--sm"401              onClick={handleSave}402            >403              Save PNG404            </button>405            <p class="peach-export-card-copy" style="margin-top:8px">406              Print this and keep it somewhere safe. Scan it with Peach to407              restore your vault on any device.408            </p>409          </div>410        </Show>411        <Show when={error()}>412          <div class="peach-settings-group">413            <div class="peach-restore-error">{error()}</div>414          </div>415        </Show>416      </div>417    </>418  );419}420421// ---------------------------------------------------------------------------422// CSV — non-skippable warning, every time, by design (spec §5.2).423// ---------------------------------------------------------------------------424425function CSVExport(props: { onBack: () => void }) {426  const [acknowledged, setAcknowledged] = createSignal(false);427  const [working, setWorking] = createSignal(false);428  const [error, setError] = createSignal<string | null>(null);429  const [done, setDone] = createSignal<string | null>(null);430431  async function handleExport() {432    setError(null);433    setDone(null);434    setWorking(true);435    try {436      const res = await sendMessage({ type: "GET_ENTRIES" });437      const entries = (res as { ok: boolean; data?: VaultEntry[] }).data ?? [];438      // Passkeys are device-bound credentials and cannot be moved between439      // devices via export. Strip the passkey sub-object out of any login440      // entry that carries one; the entry itself still exports if it has a441      // password too. Passkey-only entries (no password) are skipped entirely.442      const active = entries443        .filter((e) => e.deletedAt === undefined)444        .filter((e) => !(e.passkey && !e.password))445        .map((e) => (e.passkey ? { ...e, passkey: undefined } : e));446      const excludedPasskeys = entries.filter(447        (e) => e.deletedAt === undefined && e.passkey448      ).length;449450      if (active.length === 0) {451        setError("Your vault has no entries to export.");452        return;453      }454455      const csv = buildCsv(active);456      const filename = `peach-export-${dateStamp()}.csv`;457      downloadBlob(csv, "text/csv", filename);458      const suffix = excludedPasskeys > 0459        ? ` ${excludedPasskeys} passkey${excludedPasskeys === 1 ? "" : "s"} excluded — passkeys can't be represented in CSV.`460        : "";461      setDone(462        `Exported ${active.length} entr${active.length === 1 ? "y" : "ies"} to ${filename}.${suffix}`463      );464    } catch (err) {465      setError(err instanceof Error ? err.message : String(err));466    } finally {467      setWorking(false);468    }469  }470471  return (472    <>473      <OverlayHeader title="export as CSV" onBack={props.onBack} />474      <div class="peach-settings-body">475        <div class="peach-settings-group peach-export-card">476          <div class="peach-csv-warning">477            This export is unencrypted. Anyone who gets this file can read478            your passwords. Only use this to move to another password479            manager — delete it once the import is done.480          </div>481          <Show when={!acknowledged()}>482            <button483              class="peach-btn peach-btn--ghost peach-btn--sm"484              onClick={() => setAcknowledged(true)}485            >486              I understand — continue487            </button>488          </Show>489          <Show when={acknowledged()}>490            <button491              class="peach-btn peach-btn--subtle peach-btn--sm"492              onClick={() => void handleExport()}493              disabled={working()}494            >495              {working() ? "exporting…" : "Download CSV"}496            </button>497          </Show>498        </div>499        <Show when={error()}>500          <div class="peach-settings-group">501            <div class="peach-restore-error">{error()}</div>502          </div>503        </Show>504        <Show when={done()}>505          <div class="peach-settings-group">506            <div class="peach-export-success">507              <span class="peach-export-success-check">✓</span>508              <span>{done()}</span>509            </div>510          </div>511        </Show>512      </div>513    </>514  );515}516517// ---------------------------------------------------------------------------518// Shared helpers519// ---------------------------------------------------------------------------520521function OverlayHeader(props: { title: string; onBack: () => void }) {522  return (523    <div class="peach-overlay-header">524      <button525        class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"526        onClick={props.onBack}527        aria-label="Back"528      >529        <svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">530          <path d="M6 1L1 6L6 11" />531        </svg>532      </button>533      <span class="peach-overlay-title">{props.title}</span>534      <div class="peach-overlay-spacer"></div>535    </div>536  );537}538539interface AssembledEnvelope {540  bytes: Uint8Array;541  entryCount: number;542  stats: { cards: number; passkeys: number; notes: number; logins: number };543}544545async function assembleEnvelope(passphrase: string): Promise<AssembledEnvelope> {546  const entriesRes = await sendMessage({ type: "GET_ENTRIES" });547  const entries = (entriesRes as { ok: boolean; data?: VaultEntry[] }).data ?? [];548549  const settingsRes = await sendMessage({ type: "GET_SETTINGS" });550  const settings = (settingsRes as { ok: boolean; data?: Record<string, unknown> }).data ?? {};551552  let license: PeachLicenseExport | undefined;553  try {554    const info = await sendMessage({ type: "GET_LICENSE" });555    const data = (info as { ok: boolean; data?: { valid?: boolean; plan?: string; issued?: string } }).data;556    if (data?.valid && data.plan === "pro") {557      const keyRes = await sendMessage({ type: "GET_LICENSE_KEY" });558      const keyStr = (keyRes as { ok: boolean; data?: string }).data;559      if (keyStr) {560        license = {561          key: keyStr,562          ...(data.issued ? { activatedAt: new Date(data.issued).getTime() } : {}),563        };564      }565    }566  } catch {567    // License absent — proceed without it.568  }569570  const stats = countTypes(entries);571  const bytes = await buildPayload({572    entries,573    settings: settings as Parameters<typeof buildPayload>[0]["settings"],574    license,575    passphrase,576  });577  return { bytes, entryCount: entries.length, stats };578}579580function countTypes(entries: VaultEntry[]): {581  cards: number;582  passkeys: number;583  notes: number;584  logins: number;585} {586  let cards = 0;587  let passkeys = 0;588  let notes = 0;589  let logins = 0;590  for (const e of entries) {591    if (e.deletedAt) continue;592    if (e.passkey) {593      passkeys++;594      // A login that ALSO has a password counts toward the login total too —595      // exporting it preserves the password (the passkey gets stripped).596      if (e.type === "login" && e.password) logins++;597      continue;598    }599    if (e.type === "card") cards++;600    else if (e.type === "note") notes++;601    else logins++;602  }603  return { cards, passkeys, notes, logins };604}605606function buildCsv(entries: VaultEntry[]): string {607  const headers = ["name", "url", "username", "password", "notes", "type", "tags", "totp"];608  const rows = entries.map((e) => {609    const type = e.type === "note" ? "note" : e.type === "card" ? "card" : "login";610    const username = e.type === "card" ? e.cardholderName ?? "" : e.username ?? "";611    const password = e.type === "card" ? e.cardNumber ?? "" : e.password ?? "";612    return [613      e.title,614      e.url ?? "",615      username,616      password,617      e.notes ?? "",618      type,619      Array.isArray(e.tags) ? e.tags.join("|") : "",620      e.totp ?? "",621    ]622      .map((v) =>623        `"${String(v).replace(/\r?\n/g, " ").replace(/"/g, '""')}"`624      )625      .join(",");626  });627  return [headers.join(","), ...rows].join("\n");628}629630function dateStamp(): string {631  return new Date().toISOString().split("T")[0];632}633634function monthYear(): string {635  const d = new Date();636  const months = [637    "January", "February", "March", "April", "May", "June",638    "July", "August", "September", "October", "November", "December",639  ];640  return `${months[d.getMonth()]} ${d.getFullYear()}`;641}642643function downloadBlob(content: string, mimeType: string, filename: string): void {644  const url = URL.createObjectURL(new Blob([content], { type: mimeType }));645  const a = document.createElement("a");646  a.href = url;647  a.download = filename;648  document.body.appendChild(a);649  a.click();650  document.body.removeChild(a);651  URL.revokeObjectURL(url);652}