Export.tsx

23.6 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.paperSize === "A3") {314        setError(315          `Mayan symbol is ${screen.symbolSize}×${screen.symbolSize} modules — print on A3 paper for best results.`,316        );317      } else if (screen.paperSize === "A3-required") {318        setError(319          `Mayan symbol is ${screen.symbolSize}×${screen.symbolSize} modules — A3 paper required to print at full resolution. Save as PNG for digital backup.`,320        );321      }322      if (canvasHost) {323        canvasHost.innerHTML = "";324        canvasHost.appendChild(screen.canvas);325      }326      setPngUrl(print.canvas.toDataURL("image/png"));327    } catch (err) {328      setError(err instanceof Error ? err.message : String(err));329    } finally {330      setWorking(false);331    }332  }333334  function handleSave() {335    const url = pngUrl();336    if (!url) return;337    const a = document.createElement("a");338    a.href = url;339    a.download = `peach-mayan-backup-${dateStamp()}.png`;340    document.body.appendChild(a);341    a.click();342    document.body.removeChild(a);343  }344345  return (346    <>347      <OverlayHeader title="print backup" onBack={props.onBack} />348      <div class="peach-settings-body">349        <Show350          when={pngUrl()}351          fallback={352            <div class="peach-settings-group peach-export-card peach-export-card--primary">353              <p class="peach-settings-label">backup password</p>354              <p class="peach-export-card-copy">355                Set a password to lock this Mayan code backup. You'll need it356                to restore — without it the printed code is unreadable.357              </p>358              <div class="peach-settings-stack peach-export-password-stack">359                <input360                  ref={(el) => queueMicrotask(() => el.focus())}361                  class="peach-settings-input"362                  type="password"363                  placeholder={`backup password (min ${MIN_BACKUP_PASSWORD} chars)`}364                  value={pass()}365                  onInput={(e) => setPass((e.target as HTMLInputElement).value)}366                  onKeyDown={(e) => {367                    if (e.key === "Enter" && passReady() && !working()) {368                      e.preventDefault();369                      void handleEncode();370                    }371                  }}372                />373                <input374                  class="peach-settings-input"375                  type="password"376                  placeholder="confirm backup password"377                  value={confirm()}378                  onInput={(e) => setConfirm((e.target as HTMLInputElement).value)}379                  onKeyDown={(e) => {380                    if (e.key === "Enter" && passReady() && !working()) {381                      e.preventDefault();382                      void handleEncode();383                    }384                  }}385                />386              </div>387              <button388                class="peach-btn peach-btn--subtle peach-btn--sm"389                onClick={() => void handleEncode()}390                disabled={working() || !passReady()}391              >392                {working() ? "encoding…" : "Generate Mayan code"}393              </button>394            </div>395          }396        >397          <div class="peach-settings-group peach-export-card peach-export-card--primary">398            <div class="peach-qr-canvas-wrap" ref={canvasHost} />399            <p class="peach-export-card-copy" style="text-align:center">400              {entryCount()} entr{entryCount() === 1 ? "y" : "ies"} · {monthYear()}401            </p>402            <button403              class="peach-btn peach-btn--subtle peach-btn--sm"404              onClick={handleSave}405            >406              Save PNG407            </button>408            <p class="peach-export-card-copy" style="margin-top:8px">409              Print this and keep it somewhere safe. Scan it with Peach to410              restore your vault on any device.411            </p>412          </div>413        </Show>414        <Show when={error()}>415          <div class="peach-settings-group">416            <div class="peach-restore-error">{error()}</div>417          </div>418        </Show>419      </div>420    </>421  );422}423424// ---------------------------------------------------------------------------425// CSV — non-skippable warning, every time, by design (spec §5.2).426// ---------------------------------------------------------------------------427428function CSVExport(props: { onBack: () => void }) {429  const [acknowledged, setAcknowledged] = createSignal(false);430  const [working, setWorking] = createSignal(false);431  const [error, setError] = createSignal<string | null>(null);432  const [done, setDone] = createSignal<string | null>(null);433434  async function handleExport() {435    setError(null);436    setDone(null);437    setWorking(true);438    try {439      const res = await sendMessage({ type: "GET_ENTRIES" });440      const entries = (res as { ok: boolean; data?: VaultEntry[] }).data ?? [];441      // Passkeys are device-bound credentials and cannot be moved between442      // devices via export. Strip the passkey sub-object out of any login443      // entry that carries one; the entry itself still exports if it has a444      // password too. Passkey-only entries (no password) are skipped entirely.445      const active = entries446        .filter((e) => e.deletedAt === undefined)447        .filter((e) => !(e.passkey && !e.password))448        .map((e) => (e.passkey ? { ...e, passkey: undefined } : e));449      const excludedPasskeys = entries.filter(450        (e) => e.deletedAt === undefined && e.passkey451      ).length;452453      if (active.length === 0) {454        setError("Your vault has no entries to export.");455        return;456      }457458      const csv = buildCsv(active);459      const filename = `peach-export-${dateStamp()}.csv`;460      downloadBlob(csv, "text/csv", filename);461      const suffix = excludedPasskeys > 0462        ? ` ${excludedPasskeys} passkey${excludedPasskeys === 1 ? "" : "s"} excluded — passkeys can't be represented in CSV.`463        : "";464      setDone(465        `Exported ${active.length} entr${active.length === 1 ? "y" : "ies"} to ${filename}.${suffix}`466      );467    } catch (err) {468      setError(err instanceof Error ? err.message : String(err));469    } finally {470      setWorking(false);471    }472  }473474  return (475    <>476      <OverlayHeader title="export as CSV" onBack={props.onBack} />477      <div class="peach-settings-body">478        <div class="peach-settings-group peach-export-card">479          <div class="peach-csv-warning">480            This export is unencrypted. Anyone who gets this file can read481            your passwords. Only use this to move to another password482            manager — delete it once the import is done.483          </div>484          <Show when={!acknowledged()}>485            <button486              class="peach-btn peach-btn--ghost peach-btn--sm"487              onClick={() => setAcknowledged(true)}488            >489              I understand — continue490            </button>491          </Show>492          <Show when={acknowledged()}>493            <button494              class="peach-btn peach-btn--subtle peach-btn--sm"495              onClick={() => void handleExport()}496              disabled={working()}497            >498              {working() ? "exporting…" : "Download CSV"}499            </button>500          </Show>501        </div>502        <Show when={error()}>503          <div class="peach-settings-group">504            <div class="peach-restore-error">{error()}</div>505          </div>506        </Show>507        <Show when={done()}>508          <div class="peach-settings-group">509            <div class="peach-export-success">510              <span class="peach-export-success-check">✓</span>511              <span>{done()}</span>512            </div>513          </div>514        </Show>515      </div>516    </>517  );518}519520// ---------------------------------------------------------------------------521// Shared helpers522// ---------------------------------------------------------------------------523524function OverlayHeader(props: { title: string; onBack: () => void }) {525  return (526    <div class="peach-overlay-header">527      <button528        class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"529        onClick={props.onBack}530        aria-label="Back"531      >532        <svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">533          <path d="M6 1L1 6L6 11" />534        </svg>535      </button>536      <span class="peach-overlay-title">{props.title}</span>537      <div class="peach-overlay-spacer"></div>538    </div>539  );540}541542interface AssembledEnvelope {543  bytes: Uint8Array;544  entryCount: number;545  stats: { cards: number; passkeys: number; notes: number; logins: number };546}547548async function assembleEnvelope(passphrase: string): Promise<AssembledEnvelope> {549  const entriesRes = await sendMessage({ type: "GET_ENTRIES" });550  const entries = (entriesRes as { ok: boolean; data?: VaultEntry[] }).data ?? [];551552  const settingsRes = await sendMessage({ type: "GET_SETTINGS" });553  const settings = (settingsRes as { ok: boolean; data?: Record<string, unknown> }).data ?? {};554555  let license: PeachLicenseExport | undefined;556  try {557    const info = await sendMessage({ type: "GET_LICENSE" });558    const data = (info as { ok: boolean; data?: { valid?: boolean; plan?: string; issued?: string } }).data;559    if (data?.valid && data.plan === "pro") {560      const keyRes = await sendMessage({ type: "GET_LICENSE_KEY" });561      const keyStr = (keyRes as { ok: boolean; data?: string }).data;562      if (keyStr) {563        license = {564          key: keyStr,565          ...(data.issued ? { activatedAt: new Date(data.issued).getTime() } : {}),566        };567      }568    }569  } catch {570    // License absent — proceed without it.571  }572573  const stats = countTypes(entries);574  const bytes = await buildPayload({575    entries,576    settings: settings as Parameters<typeof buildPayload>[0]["settings"],577    license,578    passphrase,579  });580  return { bytes, entryCount: entries.length, stats };581}582583function countTypes(entries: VaultEntry[]): {584  cards: number;585  passkeys: number;586  notes: number;587  logins: number;588} {589  let cards = 0;590  let passkeys = 0;591  let notes = 0;592  let logins = 0;593  for (const e of entries) {594    if (e.deletedAt) continue;595    if (e.passkey) {596      passkeys++;597      // A login that ALSO has a password counts toward the login total too —598      // exporting it preserves the password (the passkey gets stripped).599      if (e.type === "login" && e.password) logins++;600      continue;601    }602    if (e.type === "card") cards++;603    else if (e.type === "note") notes++;604    else logins++;605  }606  return { cards, passkeys, notes, logins };607}608609function buildCsv(entries: VaultEntry[]): string {610  const headers = ["name", "url", "username", "password", "notes", "type", "tags", "totp"];611  const rows = entries.map((e) => {612    const type = e.type === "note" ? "note" : e.type === "card" ? "card" : "login";613    const username = e.type === "card" ? e.cardholderName ?? "" : e.username ?? "";614    const password = e.type === "card" ? e.cardNumber ?? "" : e.password ?? "";615    return [616      e.title,617      e.url ?? "",618      username,619      password,620      e.notes ?? "",621      type,622      Array.isArray(e.tags) ? e.tags.join("|") : "",623      e.totp ?? "",624    ]625      .map((v) =>626        `"${String(v).replace(/\r?\n/g, " ").replace(/"/g, '""')}"`627      )628      .join(",");629  });630  return [headers.join(","), ...rows].join("\n");631}632633function dateStamp(): string {634  return new Date().toISOString().split("T")[0];635}636637function monthYear(): string {638  const d = new Date();639  const months = [640    "January", "February", "March", "April", "May", "June",641    "July", "August", "September", "October", "November", "December",642  ];643  return `${months[d.getMonth()]} ${d.getFullYear()}`;644}645646function downloadBlob(content: string, mimeType: string, filename: string): void {647  const url = URL.createObjectURL(new Blob([content], { type: mimeType }));648  const a = document.createElement("a");649  a.href = url;650  a.download = filename;651  document.body.appendChild(a);652  a.click();653  document.body.removeChild(a);654  URL.revokeObjectURL(url);655}