Export.tsx

9.5 KB · raw

1import { createMemo, createSignal, Show } from "solid-js";2import { sendMessage } from "../App";3import type { VaultEntry } from "../../shared/types";4import { exportEntriesToPeachJson } from "../../shared/backup";5import { CJKExportView } from "./CJKExport";67interface ExportProps {8  onBack: () => void;9  onRequiresUnlock?: () => void;10}1112const MIN_EXPORT_PASSWORD = 12;1314export function ExportView(props: ExportProps) {15  const [showCjkExport, setShowCjkExport] = createSignal(false);16  return (17    <Show when={showCjkExport()} fallback={<MainExportView onBack={props.onBack} onOpenCjk={() => setShowCjkExport(true)} />}>18      <CJKExportView19        onBack={() => setShowCjkExport(false)}20        onRequiresUnlock={props.onRequiresUnlock}21      />22    </Show>23  );24}2526function MainExportView(props: { onBack: () => void; onOpenCjk: () => void }) {27  const [exportPassword, setExportPassword] = createSignal("");28  const [exporting, setExporting] = createSignal(false);29  const [result, setResult] = createSignal<string | null>(null);30  const [error, setError] = createSignal<string | null>(null);31  const [showCsvWarning, setShowCsvWarning] = createSignal(false);3233  const passwordIsValid = createMemo(34    () => exportPassword().length >= MIN_EXPORT_PASSWORD35  );3637  async function handleEncryptedExport() {38    setError(null);39    setResult(null);4041    if (exportPassword().length < MIN_EXPORT_PASSWORD) {42      setError(`Export password must be at least ${MIN_EXPORT_PASSWORD} characters.`);43      return;44    }4546    setExporting(true);4748    const response = await sendMessage({ type: "GET_ENTRIES" });49    const entries = (response as { ok: boolean; data?: VaultEntry[] }).data ?? [];5051    if (entries.length === 0) {52      setError("Your vault has no entries to export.");53      setExporting(false);54      return;55    }5657    const trashedCount = entries.filter((e) => !!e.deletedAt).length;58    const activeCount = entries.length - trashedCount;59    const countStr = trashedCount > 060      ? `${activeCount} entr${activeCount === 1 ? "y" : "ies"} · ${trashedCount} in trash`61      : `${entries.length} entr${entries.length === 1 ? "y" : "ies"}`;6263    try {64      const blob = await exportEntriesToPeachJson(entries, exportPassword());65      const filename = `peach-backup-${dateStamp()}.json`;66      downloadBlob(67        JSON.stringify(blob, null, 2),68        "application/json",69        filename70      );71      setResult(72        `Exported ${countStr}. Filename: ${filename}. Store this file and your export password somewhere safe — neither can be recovered.`73      );74      setExportPassword("");75    } catch (err) {76      setError(err instanceof Error ? err.message : "Export failed.");77    } finally {78      setExporting(false);79    }80  }8182  async function handleCSVExport() {83    setShowCsvWarning(false);84    setExporting(true);85    setResult(null);86    setError(null);8788    const response = await sendMessage({ type: "GET_ENTRIES" });89    const entries = (response as { ok: boolean; data?: VaultEntry[] }).data ?? [];90    const active = entries.filter((e) => e.deletedAt === undefined && e.type !== "passkey");91    const excludedPasskeys = entries.filter((e) => e.deletedAt === undefined && e.type === "passkey").length;9293    if (active.length === 0) {94      setError("Your vault has no entries to export.");95      setExporting(false);96      return;97    }9899    const headers = ["type", "name", "url", "username", "password", "totp", "tags", "notes"];100    const rows = active.map((e) => {101      const type = e.type === "note" ? "note" : "login";102      return [103        type,104        e.title,105        e.url ?? "",106        e.username ?? "",107        e.password ?? "",108        e.totp ?? "",109        Array.isArray(e.tags) ? e.tags.join("|") : "",110        e.notes ?? "",111      ]112        // Flatten embedded newlines before RFC 4180 quote escaping so the113        // CSV stays single-line-per-row for tools that do not handle quoted114        // multi-line cells.115        .map((v) =>116          `"${String(v).replace(/\r?\n/g, " ").replace(/"/g, '""')}"`117        )118        .join(",");119    });120    const csv = [headers.join(","), ...rows].join("\n");121122    const filename = `peach-passwords-${dateStamp()}.csv`;123    try {124      downloadBlob(csv, "text/csv", filename);125      const suffix = excludedPasskeys > 0126        ? ` ${excludedPasskeys} passkey${excludedPasskeys === 1 ? "" : "s"} excluded from plaintext export.`127        : "";128      setResult(129        `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.`130      );131    } catch (err) {132      setError(err instanceof Error ? err.message : "Export failed.");133    } finally {134      setExporting(false);135    }136  }137138  return (139    <div class="peach-overlay-view ov-enter-right" style="display:flex">140      <div class="peach-overlay-header">141        <button142          class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"143          onClick={props.onBack}144          aria-label="Back"145        >146          <svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">147            <path d="M6 1L1 6L6 11" />148          </svg>149        </button>150        <span class="peach-overlay-title">export</span>151        <div class="peach-overlay-spacer"></div>152      </div>153154      <div class="peach-settings-body">155156        {/* ── Backup phrase ── */}157        <div class="peach-settings-group peach-export-card peach-export-card--primary">158          <p class="peach-settings-label">backup phrase</p>159          <p class="peach-export-card-copy">160            Your vault as a string of characters you can copy anywhere — no file needed. Paste it into Peach on any device to restore.161          </p>162          <button163            class="peach-btn peach-btn--subtle peach-btn--sm"164            onClick={props.onOpenCjk}165          >166            Create backup phrase167          </button>168        </div>169170        {/* ── Encrypted file ── */}171        <div class="peach-settings-group peach-export-card">172          <p class="peach-settings-label">encrypted file</p>173          <p class="peach-export-card-copy">174            Download a file backup. Set a password to lock it — you'll need that password to restore.175          </p>176          <div class="peach-settings-stack peach-export-password-stack">177            <input178              class="peach-settings-input"179              type="password"180              placeholder={`backup password (min ${MIN_EXPORT_PASSWORD} chars)`}181              value={exportPassword()}182              onInput={(e) => setExportPassword((e.target as HTMLInputElement).value)}183            />184            <Show when={exportPassword().length > 0 && exportPassword().length < MIN_EXPORT_PASSWORD}>185              <p class="peach-export-note" style="color:var(--peach-t2)">186                At least {MIN_EXPORT_PASSWORD} characters187              </p>188            </Show>189          </div>190          <button191            class="peach-btn peach-btn--subtle peach-btn--sm"192            onClick={() => void handleEncryptedExport()}193            disabled={exporting() || !passwordIsValid()}194          >195            {exporting() ? "saving…" : "Download backup file"}196          </button>197        </div>198199        {/* ── CSV ── */}200        <div class="peach-settings-group peach-export-card">201          <p class="peach-settings-label">CSV for other apps</p>202          <p class="peach-export-card-copy">203            Plain-text export for importing into another password manager. Passwords will be readable — delete the file once you're done.204          </p>205          <Show206            when={!showCsvWarning()}207            fallback={208              <div class="peach-settings-actions">209                <button210                  class="peach-btn peach-btn--ghost peach-btn--sm"211                  onClick={() => void handleCSVExport()}212                  disabled={exporting()}213                >214                  {exporting() ? "saving…" : "Download CSV"}215                </button>216                <button217                  class="peach-btn peach-btn--ghost peach-btn--sm"218                  onClick={() => setShowCsvWarning(false)}219                >220                  cancel221                </button>222              </div>223            }224          >225            <button226              class="peach-btn peach-btn--ghost peach-btn--sm"227              onClick={() => setShowCsvWarning(true)}228            >229              Export as CSV230            </button>231          </Show>232        </div>233234        <Show when={error()}>235          <div class="peach-settings-group">236            <div class="peach-restore-error">{error()}</div>237          </div>238        </Show>239240        <Show when={result()}>241          <div class="peach-settings-group">242            <div class="peach-export-success">243              <span class="peach-export-success-check">✓</span>244              <span>{result()}</span>245            </div>246          </div>247        </Show>248      </div>249    </div>250  );251}252253function dateStamp(): string {254  return new Date().toISOString().split("T")[0];255}256257function downloadBlob(content: string, mimeType: string, filename: string): void {258  try {259    const url = URL.createObjectURL(new Blob([content], { type: mimeType }));260    const a = document.createElement("a");261    a.href = url;262    a.download = filename;263    document.body.appendChild(a);264    a.click();265    document.body.removeChild(a);266    URL.revokeObjectURL(url);267  } catch (err) {268    console.error("[Peach] Download failed:", err);269    throw err;270  }271}