Export.tsx

11.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 — 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 { PeachLicenseExport, 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      // Include license in the export if Pro, so a restore reactivates automatically.72      let license: PeachLicenseExport | undefined;73      const licenseRes = await sendMessage({ type: "GET_LICENSE" });74      const licenseInfo = (licenseRes as { ok: boolean; data?: { valid?: boolean; plan?: string; issued?: string } }).data;75      if (licenseInfo?.valid && licenseInfo.plan === "pro") {76        const keyRes = await sendMessage({ type: "GET_LICENSE_KEY" });77        const keyStr = (keyRes as { ok: boolean; data?: string }).data;78        if (keyStr) {79          license = {80            key: keyStr,81            ...(licenseInfo.issued ? { activatedAt: new Date(licenseInfo.issued).getTime() } : {}),82          };83        }84      }8586      const blob = await exportEntriesToPeachJson(entries, exportPassword(), license);87      const filename = `peach-backup-${dateStamp()}.json`;88      downloadBlob(89        JSON.stringify(blob, null, 2),90        "application/json",91        filename92      );93      setResult(94        `Exported ${countStr}. Filename: ${filename}. Store this file and your export password somewhere safe — neither can be recovered.`95      );96      setExportPassword("");97      setExportPasswordConfirm("");98    } catch (err) {99      setError(err instanceof Error ? err.message : "Export failed.");100    } finally {101      setExporting(false);102    }103  }104105  async function handleCSVExport() {106    setShowCsvWarning(false);107    setExporting(true);108    setResult(null);109    setError(null);110111    const response = await sendMessage({ type: "GET_ENTRIES" });112    const entries = (response as { ok: boolean; data?: VaultEntry[] }).data ?? [];113    const active = entries.filter((e) => e.deletedAt === undefined && e.type !== "passkey");114    const excludedPasskeys = entries.filter((e) => e.deletedAt === undefined && e.type === "passkey").length;115116    if (active.length === 0) {117      setError("Your vault has no entries to export.");118      setExporting(false);119      return;120    }121122    const headers = ["type", "name", "url", "username", "password", "totp", "tags", "notes"];123    const rows = active.map((e) => {124      const type = e.type === "note" ? "note" : "login";125      return [126        type,127        e.title,128        e.url ?? "",129        e.username ?? "",130        e.password ?? "",131        e.totp ?? "",132        Array.isArray(e.tags) ? e.tags.join("|") : "",133        e.notes ?? "",134      ]135        // Flatten embedded newlines before RFC 4180 quote escaping so the136        // CSV stays single-line-per-row for tools that do not handle quoted137        // multi-line cells.138        .map((v) =>139          `"${String(v).replace(/\r?\n/g, " ").replace(/"/g, '""')}"`140        )141        .join(",");142    });143    const csv = [headers.join(","), ...rows].join("\n");144145    const filename = `peach-passwords-${dateStamp()}.csv`;146    try {147      downloadBlob(csv, "text/csv", filename);148      const suffix = excludedPasskeys > 0149        ? ` ${excludedPasskeys} passkey${excludedPasskeys === 1 ? "" : "s"} excluded from plaintext export.`150        : "";151      setResult(152        `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.`153      );154    } catch (err) {155      setError(err instanceof Error ? err.message : "Export failed.");156    } finally {157      setExporting(false);158    }159  }160161  return (162    <div class="peach-overlay-view ov-enter-right" style="display:flex">163      <div class="peach-overlay-header">164        <button165          class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"166          onClick={props.onBack}167          aria-label="Back"168        >169          <svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">170            <path d="M6 1L1 6L6 11" />171          </svg>172        </button>173        <span class="peach-overlay-title">export</span>174        <div class="peach-overlay-spacer"></div>175      </div>176177      <div class="peach-settings-body">178179        {/* ── Backup phrase ── */}180        <div class="peach-settings-group peach-export-card peach-export-card--primary">181          <p class="peach-settings-label">backup phrase</p>182          <p class="peach-export-card-copy">183            Your vault as a string of characters you can copy anywhere — no file needed. Paste it into Peach on any device to restore.184          </p>185          <button186            class="peach-btn peach-btn--subtle peach-btn--sm"187            onClick={props.onOpenCjk}188          >189            Create backup phrase190          </button>191        </div>192193        {/* ── Encrypted file ── */}194        <div class="peach-settings-group peach-export-card">195          <p class="peach-settings-label">encrypted file</p>196          <p class="peach-export-card-copy">197            Download a file backup. Set a password to lock it — you'll need that password to restore.198          </p>199          <div class="peach-settings-stack peach-export-password-stack">200            <input201              class="peach-settings-input"202              type="password"203              placeholder={`backup password (min ${MIN_EXPORT_PASSWORD} chars)`}204              value={exportPassword()}205              onInput={(e) => setExportPassword((e.target as HTMLInputElement).value)}206            />207            <input208              id="export-confirm-password"209              class="peach-settings-input"210              type="password"211              placeholder="confirm backup password"212              value={exportPasswordConfirm()}213              onInput={(e) => setExportPasswordConfirm((e.target as HTMLInputElement).value)}214            />215            <Show when={exportPasswordConfirm().length > 0 && exportPassword() !== exportPasswordConfirm()}>216              <p class="peach-export-note" style="color:var(--peach-t2)">217                Passwords don't match218              </p>219            </Show>220            <Show when={exportPassword().length > 0 && exportPassword().length < MIN_EXPORT_PASSWORD}>221              <p class="peach-export-note" style="color:var(--peach-t2)">222                At least {MIN_EXPORT_PASSWORD} characters223              </p>224            </Show>225          </div>226          <button227            class="peach-btn peach-btn--subtle peach-btn--sm"228            onClick={() => void handleEncryptedExport()}229            disabled={exporting() || !passwordsMatch()}230          >231            {exporting() ? "saving…" : "Download backup file"}232          </button>233        </div>234235        {/* ── CSV ── */}236        <div class="peach-settings-group peach-export-card">237          <p class="peach-settings-label">CSV for other apps</p>238          <p class="peach-export-card-copy">239            Plain-text export for importing into another password manager. Passwords will be readable — delete the file once you're done.240          </p>241          <Show242            when={!showCsvWarning()}243            fallback={244              <div class="peach-settings-actions">245                <button246                  class="peach-btn peach-btn--ghost peach-btn--sm"247                  onClick={() => void handleCSVExport()}248                  disabled={exporting()}249                >250                  {exporting() ? "saving…" : "Download CSV"}251                </button>252                <button253                  class="peach-btn peach-btn--ghost peach-btn--sm"254                  onClick={() => setShowCsvWarning(false)}255                >256                  cancel257                </button>258              </div>259            }260          >261            <button262              class="peach-btn peach-btn--ghost peach-btn--sm"263              onClick={() => setShowCsvWarning(true)}264            >265              Export as CSV266            </button>267          </Show>268        </div>269270        <Show when={error()}>271          <div class="peach-settings-group">272            <div class="peach-restore-error">{error()}</div>273          </div>274        </Show>275276        <Show when={result()}>277          <div class="peach-settings-group">278            <div class="peach-export-success">279              <span class="peach-export-success-check">✓</span>280              <span>{result()}</span>281            </div>282          </div>283        </Show>284      </div>285    </div>286  );287}288289function dateStamp(): string {290  return new Date().toISOString().split("T")[0];291}292293function downloadBlob(content: string, mimeType: string, filename: string): void {294  try {295    const url = URL.createObjectURL(new Blob([content], { type: mimeType }));296    const a = document.createElement("a");297    a.href = url;298    a.download = filename;299    document.body.appendChild(a);300    a.click();301    document.body.removeChild(a);302    URL.revokeObjectURL(url);303  } catch (err) {304    console.error("[Peach] Download failed:", err);305    throw err;306  }307}