Export.tsx

10.5 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 — the v1.4.21 CJK export regression silently stopped re-rendering5// because of this. See Projects.md §4.1.6import { createMemo, createSignal, Show } from "solid-js";7import { sendMessage } from "../App";8import type { VaultEntry } from "../../shared/types";9import { exportEntriesToPeachJson } from "../../shared/backup";10import { CJKExportView } from "./CJKExport";1112interface ExportProps {13  onBack: () => void;14  onRequiresUnlock?: () => void;15}1617const MIN_EXPORT_PASSWORD = 12;1819export function ExportView(props: ExportProps) {20  const [showCjkExport, setShowCjkExport] = createSignal(false);21  return (22    <Show when={showCjkExport()} fallback={<MainExportView onBack={props.onBack} onOpenCjk={() => setShowCjkExport(true)} />}>23      <CJKExportView24        onBack={() => setShowCjkExport(false)}25        onRequiresUnlock={props.onRequiresUnlock}26      />27    </Show>28  );29}3031function MainExportView(props: { onBack: () => void; onOpenCjk: () => void }) {32  const [exportPassword, setExportPassword] = createSignal("");33  const [exportPasswordConfirm, setExportPasswordConfirm] = createSignal("");34  const [exporting, setExporting] = createSignal(false);35  const [result, setResult] = createSignal<string | null>(null);36  const [error, setError] = createSignal<string | null>(null);37  const [showCsvWarning, setShowCsvWarning] = createSignal(false);3839  const passwordsMatch = createMemo(40    () => exportPassword().length >= MIN_EXPORT_PASSWORD41      && exportPassword() === exportPasswordConfirm()42  );4344  async function handleEncryptedExport() {45    setError(null);46    setResult(null);4748    if (!passwordsMatch()) {49      setError("Passwords do not match or are too short.");50      return;51    }5253    setExporting(true);5455    const response = await sendMessage({ type: "GET_ENTRIES" });56    const entries = (response as { ok: boolean; data?: VaultEntry[] }).data ?? [];5758    if (entries.length === 0) {59      setError("Your vault has no entries to export.");60      setExporting(false);61      return;62    }6364    const trashedCount = entries.filter((e) => !!e.deletedAt).length;65    const activeCount = entries.length - trashedCount;66    const countStr = trashedCount > 067      ? `${activeCount} entr${activeCount === 1 ? "y" : "ies"} · ${trashedCount} in trash`68      : `${entries.length} entr${entries.length === 1 ? "y" : "ies"}`;6970    try {71      const blob = await exportEntriesToPeachJson(entries, exportPassword());72      const filename = `peach-backup-${dateStamp()}.json`;73      downloadBlob(74        JSON.stringify(blob, null, 2),75        "application/json",76        filename77      );78      setResult(79        `Exported ${countStr}. Filename: ${filename}. Store this file and your export password somewhere safe — neither can be recovered.`80      );81      setExportPassword("");82      setExportPasswordConfirm("");83    } catch (err) {84      setError(err instanceof Error ? err.message : "Export failed.");85    } finally {86      setExporting(false);87    }88  }8990  async function handleCSVExport() {91    setShowCsvWarning(false);92    setExporting(true);93    setResult(null);94    setError(null);9596    const response = await sendMessage({ type: "GET_ENTRIES" });97    const entries = (response as { ok: boolean; data?: VaultEntry[] }).data ?? [];98    const active = entries.filter((e) => e.deletedAt === undefined && e.type !== "passkey");99    const excludedPasskeys = entries.filter((e) => e.deletedAt === undefined && e.type === "passkey").length;100101    if (active.length === 0) {102      setError("Your vault has no entries to export.");103      setExporting(false);104      return;105    }106107    const headers = ["type", "name", "url", "username", "password", "totp", "tags", "notes"];108    const rows = active.map((e) => {109      const type = e.type === "note" ? "note" : "login";110      return [111        type,112        e.title,113        e.url ?? "",114        e.username ?? "",115        e.password ?? "",116        e.totp ?? "",117        Array.isArray(e.tags) ? e.tags.join("|") : "",118        e.notes ?? "",119      ]120        // Flatten embedded newlines before RFC 4180 quote escaping so the121        // CSV stays single-line-per-row for tools that do not handle quoted122        // multi-line cells.123        .map((v) =>124          `"${String(v).replace(/\r?\n/g, " ").replace(/"/g, '""')}"`125        )126        .join(",");127    });128    const csv = [headers.join(","), ...rows].join("\n");129130    const filename = `peach-passwords-${dateStamp()}.csv`;131    try {132      downloadBlob(csv, "text/csv", filename);133      const suffix = excludedPasskeys > 0134        ? ` ${excludedPasskeys} passkey${excludedPasskeys === 1 ? "" : "s"} excluded from plaintext export.`135        : "";136      setResult(137        `Exported ${active.length} entr${active.length === 1 ? "y" : "ies"} as CSV. Filename: ${filename}.${suffix} Store this file somewhere safe — the passwords inside are in plain text.`138      );139    } catch (err) {140      setError(err instanceof Error ? err.message : "Export failed.");141    } finally {142      setExporting(false);143    }144  }145146  return (147    <div class="peach-overlay-view ov-enter-right" style="display:flex">148      <div class="peach-overlay-header">149        <button150          class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"151          onClick={props.onBack}152          aria-label="Back"153        >154          <svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">155            <path d="M6 1L1 6L6 11" />156          </svg>157        </button>158        <span class="peach-overlay-title">export</span>159        <div class="peach-overlay-spacer"></div>160      </div>161162      <div class="peach-settings-body">163164        {/* ── Backup phrase ── */}165        <div class="peach-settings-group peach-export-card peach-export-card--primary">166          <p class="peach-settings-label">backup phrase</p>167          <p class="peach-export-card-copy">168            Your vault as a string of characters you can copy anywhere — no file needed. Paste it into Peach on any device to restore.169          </p>170          <button171            class="peach-btn peach-btn--subtle peach-btn--sm"172            onClick={props.onOpenCjk}173          >174            Create backup phrase175          </button>176        </div>177178        {/* ── Encrypted file ── */}179        <div class="peach-settings-group peach-export-card">180          <p class="peach-settings-label">encrypted file</p>181          <p class="peach-export-card-copy">182            Download a file backup. Set a password to lock it — you'll need that password to restore.183          </p>184          <div class="peach-settings-stack peach-export-password-stack">185            <input186              class="peach-settings-input"187              type="password"188              placeholder={`backup password (min ${MIN_EXPORT_PASSWORD} chars)`}189              value={exportPassword()}190              onInput={(e) => setExportPassword((e.target as HTMLInputElement).value)}191            />192            <input193              id="export-confirm-password"194              class="peach-settings-input"195              type="password"196              placeholder="confirm backup password"197              value={exportPasswordConfirm()}198              onInput={(e) => setExportPasswordConfirm((e.target as HTMLInputElement).value)}199            />200            <Show when={exportPasswordConfirm().length > 0 && exportPassword() !== exportPasswordConfirm()}>201              <p class="peach-export-note" style="color:var(--peach-t2)">202                Passwords don't match203              </p>204            </Show>205            <Show when={exportPassword().length > 0 && exportPassword().length < MIN_EXPORT_PASSWORD}>206              <p class="peach-export-note" style="color:var(--peach-t2)">207                At least {MIN_EXPORT_PASSWORD} characters208              </p>209            </Show>210          </div>211          <button212            class="peach-btn peach-btn--subtle peach-btn--sm"213            onClick={() => void handleEncryptedExport()}214            disabled={exporting() || !passwordsMatch()}215          >216            {exporting() ? "saving…" : "Download backup file"}217          </button>218        </div>219220        {/* ── CSV ── */}221        <div class="peach-settings-group peach-export-card">222          <p class="peach-settings-label">CSV for other apps</p>223          <p class="peach-export-card-copy">224            Plain-text export for importing into another password manager. Passwords will be readable — delete the file once you're done.225          </p>226          <Show227            when={!showCsvWarning()}228            fallback={229              <div class="peach-settings-actions">230                <button231                  class="peach-btn peach-btn--ghost peach-btn--sm"232                  onClick={() => void handleCSVExport()}233                  disabled={exporting()}234                >235                  {exporting() ? "saving…" : "Download CSV"}236                </button>237                <button238                  class="peach-btn peach-btn--ghost peach-btn--sm"239                  onClick={() => setShowCsvWarning(false)}240                >241                  cancel242                </button>243              </div>244            }245          >246            <button247              class="peach-btn peach-btn--ghost peach-btn--sm"248              onClick={() => setShowCsvWarning(true)}249            >250              Export as CSV251            </button>252          </Show>253        </div>254255        <Show when={error()}>256          <div class="peach-settings-group">257            <div class="peach-restore-error">{error()}</div>258          </div>259        </Show>260261        <Show when={result()}>262          <div class="peach-settings-group">263            <div class="peach-export-success">264              <span class="peach-export-success-check">✓</span>265              <span>{result()}</span>266            </div>267          </div>268        </Show>269      </div>270    </div>271  );272}273274function dateStamp(): string {275  return new Date().toISOString().split("T")[0];276}277278function downloadBlob(content: string, mimeType: string, filename: string): void {279  try {280    const url = URL.createObjectURL(new Blob([content], { type: mimeType }));281    const a = document.createElement("a");282    a.href = url;283    a.download = filename;284    document.body.appendChild(a);285    a.click();286    document.body.removeChild(a);287    URL.revokeObjectURL(url);288  } catch (err) {289    console.error("[Peach] Download failed:", err);290    throw err;291  }292}