Export.tsx

17.2 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 type { PeachLicenseExport, VaultEntry } from "../../shared/types";1213type ExportSubview = "menu" | "peachscript" | "csv";1415interface ExportProps {16  onBack: () => void;17}1819const MIN_BACKUP_PASSWORD = 8;2021export function ExportView(props: ExportProps) {22  const [view, setView] = createSignal<ExportSubview>("menu");23  return (24    <div class="peach-overlay-view ov-enter-right" style="display:flex">25      <Show when={view() === "menu"}>26        <ExportMenu onBack={props.onBack} onPick={(v) => setView(v)} />27      </Show>28      <Show when={view() === "peachscript"}>29        <PeachScriptExport onBack={() => setView("menu")} />30      </Show>31      <Show when={view() === "csv"}>32        <CSVExport onBack={() => setView("menu")} />33      </Show>34    </div>35  );36}3738// ---------------------------------------------------------------------------39// Menu40// ---------------------------------------------------------------------------4142function ExportMenu(props: { onBack: () => void; onPick: (v: ExportSubview) => void }) {43  return (44    <>45      <OverlayHeader title="export" onBack={props.onBack} />46      <div class="peach-settings-body peach-export-menu">47        <div class="peach-settings-group peach-export-card peach-export-card--primary">48          <p class="peach-settings-label">peachscript</p>49          <p class="peach-export-card-copy">50            Your vault as a string of Peach symbols you can paste into Peach51            on another device. Designed for Peach-to-Peach transfer.52          </p>53          <button54            class="peach-btn peach-btn--subtle peach-btn--sm"55            onClick={() => props.onPick("peachscript")}56          >57            Create PeachScript →58          </button>59        </div>6061        <div class="peach-settings-group peach-export-card">62          <p class="peach-settings-label">CSV (leaving peach)</p>63          <p class="peach-export-card-copy">64            Plain-text export for moving into another password manager. Anyone65            with the file can read your passwords.66          </p>67          <button68            class="peach-btn peach-btn--ghost peach-btn--sm"69            onClick={() => props.onPick("csv")}70          >71            Export as CSV →72          </button>73        </div>74      </div>75    </>76  );77}7879// ---------------------------------------------------------------------------80// PeachScript81// ---------------------------------------------------------------------------8283function PeachScriptExport(props: { onBack: () => void }) {84  const [pass, setPass] = createSignal("");85  const [confirm, setConfirm] = createSignal("");86  const [working, setWorking] = createSignal(false);87  const [script, setScript] = createSignal<string | null>(null);88  const [error, setError] = createSignal<string | null>(null);89  const [entryCount, setEntryCount] = createSignal(0);90  const [stats, setStats] = createSignal<{91    cards: number;92    passkeys: number;93    notes: number;94    logins: number;95  }>({ cards: 0, passkeys: 0, notes: 0, logins: 0 });96  const [copied, setCopied] = createSignal(false);9798  const passReady = createMemo(99    () => pass().length >= MIN_BACKUP_PASSWORD && pass() === confirm()100  );101102  async function handleEncode() {103    setError(null);104    setScript(null);105    setCopied(false);106    if (!passReady()) {107      setError("Backup passwords don't match or are too short.");108      return;109    }110    setWorking(true);111    try {112      const env = await assembleEnvelope(pass());113      setEntryCount(env.entryCount);114      setStats(env.stats);115      setScript(encodePeachScript(env.bytes));116    } catch (err) {117      setError(err instanceof Error ? err.message : String(err));118    } finally {119      setWorking(false);120    }121  }122123  async function handleCopy() {124    const s = script();125    if (!s) return;126    try {127      await navigator.clipboard.writeText(s);128      setCopied(true);129      setTimeout(() => setCopied(false), 2000);130    } catch (err) {131      setError(err instanceof Error ? err.message : "Couldn't copy to clipboard.");132    }133  }134135  function handleDownload() {136    const s = script();137    if (!s) return;138    // .peach file = the same PeachScript text, in a single file. The PUA139    // codepoints render as runes only when paired with PeachScript-Regular.otf;140    // outside Peach the file looks like opaque bytes, which is the point.141    // Filename per the verification spec: peach-backup-YYYY-MM-DD.peach,142    // hyphens only, lowercase extension, date-only (not time-of-day).143    const blob = new Blob([s], { type: "application/x-peach;charset=utf-8" });144    const url = URL.createObjectURL(blob);145    const date = new Date().toISOString().slice(0, 10);146    const a = document.createElement("a");147    a.href = url;148    a.download = `peach-backup-${date}.peach`;149    document.body.appendChild(a);150    a.click();151    document.body.removeChild(a);152    setTimeout(() => URL.revokeObjectURL(url), 5000);153  }154155  return (156    <>157      <OverlayHeader title="peachscript" onBack={props.onBack} />158      <div class="peach-settings-body">159        <Show160          when={script()}161          fallback={162            <div class="peach-settings-group peach-export-card peach-export-card--primary peach-export-pass-group">163              <p class="peach-settings-label">backup password</p>164              <p class="peach-export-card-copy">165                Choose a password to lock this PeachScript. You'll need it166                to restore on another device.167              </p>168              <div class="peach-settings-stack peach-export-password-stack">169                <input170                  ref={(el) => queueMicrotask(() => el.focus())}171                  class="peach-settings-input"172                  type="password"173                  placeholder={`backup password (min ${MIN_BACKUP_PASSWORD} chars)`}174                  value={pass()}175                  onInput={(e) => setPass((e.target as HTMLInputElement).value)}176                  onKeyDown={(e) => {177                    if (e.key === "Enter" && passReady() && !working()) {178                      e.preventDefault();179                      void handleEncode();180                    }181                  }}182                />183                <input184                  class="peach-settings-input"185                  type="password"186                  placeholder="confirm backup password"187                  value={confirm()}188                  onInput={(e) => setConfirm((e.target as HTMLInputElement).value)}189                  onKeyDown={(e) => {190                    if (e.key === "Enter" && passReady() && !working()) {191                      e.preventDefault();192                      void handleEncode();193                    }194                  }}195                />196                <Show when={confirm().length > 0 && pass() !== confirm()}>197                  <p class="peach-export-note" style="color:var(--peach-t2)">198                    Passwords don't match199                  </p>200                </Show>201                <Show when={pass().length > 0 && pass().length < MIN_BACKUP_PASSWORD}>202                  <p class="peach-export-note" style="color:var(--peach-t2)">203                    At least {MIN_BACKUP_PASSWORD} characters204                  </p>205                </Show>206              </div>207              <button208                class="peach-btn peach-btn--subtle peach-btn--sm"209                onClick={() => void handleEncode()}210                disabled={working() || !passReady()}211              >212                {working() ? "encoding…" : "Generate PeachScript"}213              </button>214            </div>215          }216        >217          <div class="peach-settings-group peach-export-card peach-export-card--primary peach-export-result-group">218            <p class="peach-settings-label">peachscript</p>219            <RuneCanvas runeString={script() ?? ""} width={288} height={240} />220            <p class="peach-export-card-copy" style="margin:0 0 8px">221              {entryCount()} entr{entryCount() === 1 ? "y" : "ies"}222              {stats().cards > 0 ? ` · ${stats().cards} card${stats().cards === 1 ? "" : "s"}` : ""}223              {stats().passkeys > 0 ? ` · ${stats().passkeys} passkey${stats().passkeys === 1 ? "" : "s"}` : ""}224              {stats().notes > 0 ? ` · ${stats().notes} note${stats().notes === 1 ? "" : "s"}` : ""}225            </p>226            <div style="display:flex;gap:8px;">227              <button228                class="peach-btn peach-btn--subtle peach-btn--sm"229                style="flex:1;"230                onClick={() => void handleCopy()}231              >232                {copied() ? "copied ✓" : "Copy"}233              </button>234              <button235                class="peach-btn peach-btn--subtle peach-btn--sm"236                style="flex:1;"237                onClick={handleDownload}238              >239                Download .peach240              </button>241            </div>242            <p class="peach-export-card-copy" style="margin-top:8px">243              Paste the script — or import the .peach file — into Peach on244              your other device to restore your vault. Won't work anywhere else.245            </p>246          </div>247        </Show>248        <Show when={error()}>249          <div class="peach-settings-group">250            <div class="peach-restore-error">{error()}</div>251          </div>252        </Show>253      </div>254    </>255  );256}257258// ---------------------------------------------------------------------------259// CSV — non-skippable warning, every time, by design (spec §5.2).260// ---------------------------------------------------------------------------261262function CSVExport(props: { onBack: () => void }) {263  const [acknowledged, setAcknowledged] = createSignal(false);264  const [working, setWorking] = createSignal(false);265  const [error, setError] = createSignal<string | null>(null);266  const [done, setDone] = createSignal<string | null>(null);267268  async function handleExport() {269    setError(null);270    setDone(null);271    setWorking(true);272    try {273      const res = await sendMessage({ type: "GET_ENTRIES" });274      const entries = (res as { ok: boolean; data?: VaultEntry[] }).data ?? [];275      // Passkeys are device-bound credentials and cannot be moved between276      // devices via export. Strip the passkey sub-object out of any login277      // entry that carries one; the entry itself still exports if it has a278      // password too. Passkey-only entries (no password) are skipped entirely.279      const active = entries280        .filter((e) => e.deletedAt === undefined)281        .filter((e) => !(e.passkey && !e.password))282        .map((e) => (e.passkey ? { ...e, passkey: undefined } : e));283      const excludedPasskeys = entries.filter(284        (e) => e.deletedAt === undefined && e.passkey285      ).length;286287      if (active.length === 0) {288        setError("Your vault has no entries to export.");289        return;290      }291292      const csv = buildCsv(active);293      const filename = `peach-export-${dateStamp()}.csv`;294      downloadBlob(csv, "text/csv", filename);295      const suffix = excludedPasskeys > 0296        ? ` ${excludedPasskeys} passkey${excludedPasskeys === 1 ? "" : "s"} excluded — passkeys can't be represented in CSV.`297        : "";298      setDone(299        `Exported ${active.length} entr${active.length === 1 ? "y" : "ies"} to ${filename}.${suffix}`300      );301    } catch (err) {302      setError(err instanceof Error ? err.message : String(err));303    } finally {304      setWorking(false);305    }306  }307308  return (309    <>310      <OverlayHeader title="export as CSV" onBack={props.onBack} />311      <div class="peach-settings-body">312        <div class="peach-settings-group peach-export-card">313          <div class="peach-csv-warning">314            This export is unencrypted. Anyone who gets this file can read315            your passwords. Only use this to move to another password316            manager — delete it once the import is done.317          </div>318          <Show when={!acknowledged()}>319            <button320              class="peach-btn peach-btn--ghost peach-btn--sm"321              onClick={() => setAcknowledged(true)}322            >323              I understand — continue324            </button>325          </Show>326          <Show when={acknowledged()}>327            <button328              class="peach-btn peach-btn--subtle peach-btn--sm"329              onClick={() => void handleExport()}330              disabled={working()}331            >332              {working() ? "exporting…" : "Download CSV"}333            </button>334          </Show>335        </div>336        <Show when={error()}>337          <div class="peach-settings-group">338            <div class="peach-restore-error">{error()}</div>339          </div>340        </Show>341        <Show when={done()}>342          <div class="peach-settings-group">343            <div class="peach-export-success">344              <span class="peach-export-success-check">✓</span>345              <span>{done()}</span>346            </div>347          </div>348        </Show>349      </div>350    </>351  );352}353354// ---------------------------------------------------------------------------355// Shared helpers356// ---------------------------------------------------------------------------357358function OverlayHeader(props: { title: string; onBack: () => void }) {359  return (360    <div class="peach-overlay-header">361      <button362        class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"363        onClick={props.onBack}364        aria-label="Back"365      >366        <svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">367          <path d="M6 1L1 6L6 11" />368        </svg>369      </button>370      <span class="peach-overlay-title">{props.title}</span>371      <div class="peach-overlay-spacer"></div>372    </div>373  );374}375376interface AssembledEnvelope {377  bytes: Uint8Array;378  entryCount: number;379  stats: { cards: number; passkeys: number; notes: number; logins: number };380}381382async function assembleEnvelope(passphrase: string): Promise<AssembledEnvelope> {383  const entriesRes = await sendMessage({ type: "GET_ENTRIES" });384  const entries = (entriesRes as { ok: boolean; data?: VaultEntry[] }).data ?? [];385386  const settingsRes = await sendMessage({ type: "GET_SETTINGS" });387  const settings = (settingsRes as { ok: boolean; data?: Record<string, unknown> }).data ?? {};388389  let license: PeachLicenseExport | undefined;390  try {391    const info = await sendMessage({ type: "GET_LICENSE" });392    const data = (info as { ok: boolean; data?: { valid?: boolean; plan?: string; issued?: string } }).data;393    if (data?.valid && data.plan === "pro") {394      const keyRes = await sendMessage({ type: "GET_LICENSE_KEY" });395      const keyStr = (keyRes as { ok: boolean; data?: string }).data;396      if (keyStr) {397        license = {398          key: keyStr,399          ...(data.issued ? { activatedAt: new Date(data.issued).getTime() } : {}),400        };401      }402    }403  } catch {404    // License absent — proceed without it.405  }406407  const stats = countTypes(entries);408  const bytes = await buildPayload({409    entries,410    settings: settings as Parameters<typeof buildPayload>[0]["settings"],411    license,412    passphrase,413  });414  return { bytes, entryCount: entries.length, stats };415}416417function countTypes(entries: VaultEntry[]): {418  cards: number;419  passkeys: number;420  notes: number;421  logins: number;422} {423  let cards = 0;424  let passkeys = 0;425  let notes = 0;426  let logins = 0;427  for (const e of entries) {428    if (e.deletedAt) continue;429    if (e.passkey) {430      passkeys++;431      // A login that ALSO has a password counts toward the login total too —432      // exporting it preserves the password (the passkey gets stripped).433      if (e.type === "login" && e.password) logins++;434      continue;435    }436    if (e.type === "card") cards++;437    else if (e.type === "note") notes++;438    else logins++;439  }440  return { cards, passkeys, notes, logins };441}442443function buildCsv(entries: VaultEntry[]): string {444  const headers = ["name", "url", "username", "password", "notes", "type", "tags", "totp"];445  const rows = entries.map((e) => {446    const type = e.type === "note" ? "note" : e.type === "card" ? "card" : "login";447    const username = e.type === "card" ? e.cardholderName ?? "" : e.username ?? "";448    const password = e.type === "card" ? e.cardNumber ?? "" : e.password ?? "";449    return [450      e.title,451      e.url ?? "",452      username,453      password,454      e.notes ?? "",455      type,456      Array.isArray(e.tags) ? e.tags.join("|") : "",457      e.totp ?? "",458    ]459      .map((v) =>460        `"${String(v).replace(/\r?\n/g, " ").replace(/"/g, '""')}"`461      )462      .join(",");463  });464  return [headers.join(","), ...rows].join("\n");465}466467function dateStamp(): string {468  return new Date().toISOString().split("T")[0];469}470471function downloadBlob(content: string, mimeType: string, filename: string): void {472  const url = URL.createObjectURL(new Blob([content], { type: mimeType }));473  const a = document.createElement("a");474  a.href = url;475  a.download = filename;476  document.body.appendChild(a);477  a.click();478  document.body.removeChild(a);479  URL.revokeObjectURL(url);480}