Export.tsx

20.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 {12  QR_MAX_BYTES,13  renderPeachQR,14  renderPeachQRDataURL,15} from "../../crypto/peachQR";16import type { PeachLicenseExport, VaultEntry } from "../../shared/types";1718type ExportSubview = "menu" | "peachscript" | "qr" | "csv";1920interface ExportProps {21  onBack: () => void;22}2324const MIN_BACKUP_PASSWORD = 8;2526export function ExportView(props: ExportProps) {27  const [view, setView] = createSignal<ExportSubview>("menu");28  return (29    <div class="peach-overlay-view ov-enter-right" style="display:flex">30      <Show when={view() === "menu"}>31        <ExportMenu onBack={props.onBack} onPick={(v) => setView(v)} />32      </Show>33      <Show when={view() === "peachscript"}>34        <PeachScriptExport onBack={() => setView("menu")} />35      </Show>36      <Show when={view() === "qr"}>37        <QRExport onBack={() => setView("menu")} />38      </Show>39      <Show when={view() === "csv"}>40        <CSVExport onBack={() => setView("menu")} />41      </Show>42    </div>43  );44}4546// ---------------------------------------------------------------------------47// Menu48// ---------------------------------------------------------------------------4950function ExportMenu(props: { onBack: () => void; onPick: (v: ExportSubview) => void }) {51  return (52    <>53      <OverlayHeader title="export" onBack={props.onBack} />54      <div class="peach-settings-body peach-export-menu">55        <div class="peach-settings-group peach-export-card peach-export-card--primary">56          <p class="peach-settings-label">peachscript</p>57          <p class="peach-export-card-copy">58            Your vault as a string of Peach symbols you can paste into Peach59            on another device. Designed for Peach-to-Peach transfer.60          </p>61          <button62            class="peach-btn peach-btn--subtle peach-btn--sm"63            onClick={() => props.onPick("peachscript")}64          >65            Create PeachScript →66          </button>67        </div>6869        <div class="peach-settings-group peach-export-card">70          <p class="peach-settings-label">print backup</p>71          <p class="peach-export-card-copy">72            Save a QR code you can print and store. Scan it with Peach on any73            device to restore.74          </p>75          <button76            class="peach-btn peach-btn--subtle peach-btn--sm"77            onClick={() => props.onPick("qr")}78          >79            Save QR code →80          </button>81        </div>8283        <div class="peach-settings-group peach-export-card">84          <p class="peach-settings-label">CSV (leaving peach)</p>85          <p class="peach-export-card-copy">86            Plain-text export for moving into another password manager. Anyone87            with the file can read your passwords.88          </p>89          <button90            class="peach-btn peach-btn--ghost peach-btn--sm"91            onClick={() => props.onPick("csv")}92          >93            Export as CSV →94          </button>95        </div>96      </div>97    </>98  );99}100101// ---------------------------------------------------------------------------102// PeachScript103// ---------------------------------------------------------------------------104105function PeachScriptExport(props: { onBack: () => void }) {106  const [pass, setPass] = createSignal("");107  const [confirm, setConfirm] = createSignal("");108  const [working, setWorking] = createSignal(false);109  const [script, setScript] = createSignal<string | null>(null);110  const [error, setError] = createSignal<string | null>(null);111  const [entryCount, setEntryCount] = createSignal(0);112  const [stats, setStats] = createSignal<{113    cards: number;114    passkeys: number;115    notes: number;116    logins: number;117  }>({ cards: 0, passkeys: 0, notes: 0, logins: 0 });118  const [copied, setCopied] = createSignal(false);119120  const passReady = createMemo(121    () => pass().length >= MIN_BACKUP_PASSWORD && pass() === confirm()122  );123124  async function handleEncode() {125    setError(null);126    setScript(null);127    setCopied(false);128    if (!passReady()) {129      setError("Backup passwords don't match or are too short.");130      return;131    }132    setWorking(true);133    try {134      const env = await assembleEnvelope(pass());135      setEntryCount(env.entryCount);136      setStats(env.stats);137      setScript(encodePeachScript(env.bytes));138    } catch (err) {139      setError(err instanceof Error ? err.message : String(err));140    } finally {141      setWorking(false);142    }143  }144145  async function handleCopy() {146    const s = script();147    if (!s) return;148    try {149      await navigator.clipboard.writeText(s);150      setCopied(true);151      setTimeout(() => setCopied(false), 2000);152    } catch (err) {153      setError(err instanceof Error ? err.message : "Couldn't copy to clipboard.");154    }155  }156157  return (158    <>159      <OverlayHeader title="peachscript" onBack={props.onBack} />160      <div class="peach-settings-body">161        <Show162          when={script()}163          fallback={164            <div class="peach-settings-group peach-export-card peach-export-card--primary peach-export-pass-group">165              <p class="peach-settings-label">backup password</p>166              <p class="peach-export-card-copy">167                Choose a password to lock this PeachScript. You'll need it168                to restore on another device.169              </p>170              <div class="peach-settings-stack peach-export-password-stack">171                <input172                  class="peach-settings-input"173                  type="password"174                  placeholder={`backup password (min ${MIN_BACKUP_PASSWORD} chars)`}175                  value={pass()}176                  onInput={(e) => setPass((e.target as HTMLInputElement).value)}177                />178                <input179                  class="peach-settings-input"180                  type="password"181                  placeholder="confirm backup password"182                  value={confirm()}183                  onInput={(e) => setConfirm((e.target as HTMLInputElement).value)}184                />185                <Show when={confirm().length > 0 && pass() !== confirm()}>186                  <p class="peach-export-note" style="color:var(--peach-t2)">187                    Passwords don't match188                  </p>189                </Show>190                <Show when={pass().length > 0 && pass().length < MIN_BACKUP_PASSWORD}>191                  <p class="peach-export-note" style="color:var(--peach-t2)">192                    At least {MIN_BACKUP_PASSWORD} characters193                  </p>194                </Show>195              </div>196              <button197                class="peach-btn peach-btn--subtle peach-btn--sm"198                onClick={() => void handleEncode()}199                disabled={working() || !passReady()}200              >201                {working() ? "encoding…" : "Generate PeachScript"}202              </button>203            </div>204          }205        >206          <div class="peach-settings-group peach-export-card peach-export-card--primary peach-export-result-group">207            <p class="peach-settings-label">peachscript</p>208            <RuneCanvas runeString={script() ?? ""} width={288} height={240} />209            <p class="peach-export-card-copy" style="margin:0 0 8px">210              {entryCount()} entr{entryCount() === 1 ? "y" : "ies"}211              {stats().cards > 0 ? ` · ${stats().cards} card${stats().cards === 1 ? "" : "s"}` : ""}212              {stats().passkeys > 0 ? ` · ${stats().passkeys} passkey${stats().passkeys === 1 ? "" : "s"}` : ""}213              {stats().notes > 0 ? ` · ${stats().notes} note${stats().notes === 1 ? "" : "s"}` : ""}214            </p>215            <button216              class="peach-btn peach-btn--subtle peach-btn--sm"217              onClick={() => void handleCopy()}218            >219              {copied() ? "copied ✓" : "Copy PeachScript"}220            </button>221            <p class="peach-export-card-copy" style="margin-top:8px">222              Paste this into Peach on your other device to restore your223              vault. It won't work anywhere else.224            </p>225          </div>226        </Show>227        <Show when={error()}>228          <div class="peach-settings-group">229            <div class="peach-restore-error">{error()}</div>230          </div>231        </Show>232      </div>233    </>234  );235}236237// ---------------------------------------------------------------------------238// QR239// ---------------------------------------------------------------------------240241function QRExport(props: { onBack: () => void }) {242  const [pass, setPass] = createSignal("");243  const [confirm, setConfirm] = createSignal("");244  const [working, setWorking] = createSignal(false);245  const [pngUrl, setPngUrl] = createSignal<string | null>(null);246  const [error, setError] = createSignal<string | null>(null);247  const [entryCount, setEntryCount] = createSignal(0);248  let canvasHost: HTMLDivElement | undefined;249250  const passReady = createMemo(251    () => pass().length >= MIN_BACKUP_PASSWORD && pass() === confirm()252  );253254  async function handleEncode() {255    setError(null);256    setPngUrl(null);257    if (!passReady()) {258      setError("Backup passwords don't match or are too short.");259      return;260    }261    setWorking(true);262    try {263      const env = await assembleEnvelope(pass());264      if (env.bytes.length > QR_MAX_BYTES) {265        setError(266          `This vault is too large for a QR backup (${env.bytes.length} bytes). Use PeachScript instead.`267        );268        return;269      }270      setEntryCount(env.entryCount);271      const canvas = await renderPeachQR(env.bytes);272      const url = await renderPeachQRDataURL(env.bytes);273      if (canvasHost) {274        canvasHost.innerHTML = "";275        canvasHost.appendChild(canvas);276      }277      setPngUrl(url);278    } catch (err) {279      setError(err instanceof Error ? err.message : String(err));280    } finally {281      setWorking(false);282    }283  }284285  function handleSave() {286    const url = pngUrl();287    if (!url) return;288    const a = document.createElement("a");289    a.href = url;290    a.download = `peach-qr-backup-${dateStamp()}.png`;291    document.body.appendChild(a);292    a.click();293    document.body.removeChild(a);294  }295296  return (297    <>298      <OverlayHeader title="print backup" onBack={props.onBack} />299      <div class="peach-settings-body">300        <Show301          when={pngUrl()}302          fallback={303            <div class="peach-settings-group peach-export-card peach-export-card--primary">304              <p class="peach-settings-label">backup password</p>305              <p class="peach-export-card-copy">306                Set a password to lock this QR backup. You'll need it to307                restore — without it the printed code is unreadable.308              </p>309              <div class="peach-settings-stack peach-export-password-stack">310                <input311                  class="peach-settings-input"312                  type="password"313                  placeholder={`backup password (min ${MIN_BACKUP_PASSWORD} chars)`}314                  value={pass()}315                  onInput={(e) => setPass((e.target as HTMLInputElement).value)}316                />317                <input318                  class="peach-settings-input"319                  type="password"320                  placeholder="confirm backup password"321                  value={confirm()}322                  onInput={(e) => setConfirm((e.target as HTMLInputElement).value)}323                />324              </div>325              <button326                class="peach-btn peach-btn--subtle peach-btn--sm"327                onClick={() => void handleEncode()}328                disabled={working() || !passReady()}329              >330                {working() ? "encoding…" : "Generate QR code"}331              </button>332            </div>333          }334        >335          <div class="peach-settings-group peach-export-card peach-export-card--primary">336            <div class="peach-qr-canvas-wrap" ref={canvasHost} />337            <p class="peach-export-card-copy" style="text-align:center">338              {entryCount()} entr{entryCount() === 1 ? "y" : "ies"} · {monthYear()}339            </p>340            <button341              class="peach-btn peach-btn--subtle peach-btn--sm"342              onClick={handleSave}343            >344              Save PNG345            </button>346            <p class="peach-export-card-copy" style="margin-top:8px">347              Print this and keep it somewhere safe. Scan it with Peach to348              restore your vault on any device.349            </p>350          </div>351        </Show>352        <Show when={error()}>353          <div class="peach-settings-group">354            <div class="peach-restore-error">{error()}</div>355          </div>356        </Show>357      </div>358    </>359  );360}361362// ---------------------------------------------------------------------------363// CSV — non-skippable warning, every time, by design (spec §5.2).364// ---------------------------------------------------------------------------365366function CSVExport(props: { onBack: () => void }) {367  const [acknowledged, setAcknowledged] = createSignal(false);368  const [working, setWorking] = createSignal(false);369  const [error, setError] = createSignal<string | null>(null);370  const [done, setDone] = createSignal<string | null>(null);371372  async function handleExport() {373    setError(null);374    setDone(null);375    setWorking(true);376    try {377      const res = await sendMessage({ type: "GET_ENTRIES" });378      const entries = (res as { ok: boolean; data?: VaultEntry[] }).data ?? [];379      const active = entries.filter(380        (e) => e.deletedAt === undefined && e.type !== "passkey"381      );382      const excludedPasskeys = entries.filter(383        (e) => e.deletedAt === undefined && e.type === "passkey"384      ).length;385386      if (active.length === 0) {387        setError("Your vault has no entries to export.");388        return;389      }390391      const csv = buildCsv(active);392      const filename = `peach-export-${dateStamp()}.csv`;393      downloadBlob(csv, "text/csv", filename);394      const suffix = excludedPasskeys > 0395        ? ` ${excludedPasskeys} passkey${excludedPasskeys === 1 ? "" : "s"} excluded — passkeys can't be represented in CSV.`396        : "";397      setDone(398        `Exported ${active.length} entr${active.length === 1 ? "y" : "ies"} to ${filename}.${suffix}`399      );400    } catch (err) {401      setError(err instanceof Error ? err.message : String(err));402    } finally {403      setWorking(false);404    }405  }406407  return (408    <>409      <OverlayHeader title="export as CSV" onBack={props.onBack} />410      <div class="peach-settings-body">411        <div class="peach-settings-group peach-export-card">412          <div class="peach-csv-warning">413            This export is unencrypted. Anyone who gets this file can read414            your passwords. Only use this to move to another password415            manager — delete it once the import is done.416          </div>417          <Show when={!acknowledged()}>418            <button419              class="peach-btn peach-btn--ghost peach-btn--sm"420              onClick={() => setAcknowledged(true)}421            >422              I understand — continue423            </button>424          </Show>425          <Show when={acknowledged()}>426            <button427              class="peach-btn peach-btn--subtle peach-btn--sm"428              onClick={() => void handleExport()}429              disabled={working()}430            >431              {working() ? "exporting…" : "Download CSV"}432            </button>433          </Show>434        </div>435        <Show when={error()}>436          <div class="peach-settings-group">437            <div class="peach-restore-error">{error()}</div>438          </div>439        </Show>440        <Show when={done()}>441          <div class="peach-settings-group">442            <div class="peach-export-success">443              <span class="peach-export-success-check">✓</span>444              <span>{done()}</span>445            </div>446          </div>447        </Show>448      </div>449    </>450  );451}452453// ---------------------------------------------------------------------------454// Shared helpers455// ---------------------------------------------------------------------------456457function OverlayHeader(props: { title: string; onBack: () => void }) {458  return (459    <div class="peach-overlay-header">460      <button461        class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"462        onClick={props.onBack}463        aria-label="Back"464      >465        <svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">466          <path d="M6 1L1 6L6 11" />467        </svg>468      </button>469      <span class="peach-overlay-title">{props.title}</span>470      <div class="peach-overlay-spacer"></div>471    </div>472  );473}474475interface AssembledEnvelope {476  bytes: Uint8Array;477  entryCount: number;478  stats: { cards: number; passkeys: number; notes: number; logins: number };479}480481async function assembleEnvelope(passphrase: string): Promise<AssembledEnvelope> {482  const entriesRes = await sendMessage({ type: "GET_ENTRIES" });483  const entries = (entriesRes as { ok: boolean; data?: VaultEntry[] }).data ?? [];484485  const settingsRes = await sendMessage({ type: "GET_SETTINGS" });486  const settings = (settingsRes as { ok: boolean; data?: Record<string, unknown> }).data ?? {};487488  let license: PeachLicenseExport | undefined;489  try {490    const info = await sendMessage({ type: "GET_LICENSE" });491    const data = (info as { ok: boolean; data?: { valid?: boolean; plan?: string; issued?: string } }).data;492    if (data?.valid && data.plan === "pro") {493      const keyRes = await sendMessage({ type: "GET_LICENSE_KEY" });494      const keyStr = (keyRes as { ok: boolean; data?: string }).data;495      if (keyStr) {496        license = {497          key: keyStr,498          ...(data.issued ? { activatedAt: new Date(data.issued).getTime() } : {}),499        };500      }501    }502  } catch {503    // License absent — proceed without it.504  }505506  const stats = countTypes(entries);507  const bytes = await buildPayload({508    entries,509    settings: settings as Parameters<typeof buildPayload>[0]["settings"],510    license,511    passphrase,512  });513  return { bytes, entryCount: entries.length, stats };514}515516function countTypes(entries: VaultEntry[]): {517  cards: number;518  passkeys: number;519  notes: number;520  logins: number;521} {522  let cards = 0;523  let passkeys = 0;524  let notes = 0;525  let logins = 0;526  for (const e of entries) {527    if (e.deletedAt) continue;528    if (e.type === "card") cards++;529    else if (e.type === "passkey") passkeys++;530    else if (e.type === "note") notes++;531    else logins++;532  }533  return { cards, passkeys, notes, logins };534}535536function buildCsv(entries: VaultEntry[]): string {537  const headers = ["name", "url", "username", "password", "notes", "type", "tags", "totp"];538  const rows = entries.map((e) => {539    const type = e.type === "note" ? "note" : e.type === "card" ? "card" : "login";540    const username = e.type === "card" ? e.cardholderName ?? "" : e.username ?? "";541    const password = e.type === "card" ? e.cardNumber ?? "" : e.password ?? "";542    return [543      e.title,544      e.url ?? "",545      username,546      password,547      e.notes ?? "",548      type,549      Array.isArray(e.tags) ? e.tags.join("|") : "",550      e.totp ?? "",551    ]552      .map((v) =>553        `"${String(v).replace(/\r?\n/g, " ").replace(/"/g, '""')}"`554      )555      .join(",");556  });557  return [headers.join(","), ...rows].join("\n");558}559560function dateStamp(): string {561  return new Date().toISOString().split("T")[0];562}563564function monthYear(): string {565  const d = new Date();566  const months = [567    "January", "February", "March", "April", "May", "June",568    "July", "August", "September", "October", "November", "December",569  ];570  return `${months[d.getMonth()]} ${d.getFullYear()}`;571}572573function downloadBlob(content: string, mimeType: string, filename: string): void {574  const url = URL.createObjectURL(new Blob([content], { type: mimeType }));575  const a = document.createElement("a");576  a.href = url;577  a.download = filename;578  document.body.appendChild(a);579  a.click();580  document.body.removeChild(a);581  URL.revokeObjectURL(url);582}