Export.tsx

9.2 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}1011const MIN_EXPORT_PASSWORD = 12;1213export function ExportView(props: ExportProps) {14  const [showCjkExport, setShowCjkExport] = createSignal(false);15  return (16    <Show when={showCjkExport()} fallback={<MainExportView onBack={props.onBack} onOpenCjk={() => setShowCjkExport(true)} />}>17      <CJKExportView onBack={() => setShowCjkExport(false)} />18    </Show>19  );20}2122function MainExportView(props: { onBack: () => void; onOpenCjk: () => void }) {23  const [exportPassword, setExportPassword] = createSignal("");24  const [exporting, setExporting] = createSignal(false);25  const [result, setResult] = createSignal<string | null>(null);26  const [error, setError] = createSignal<string | null>(null);27  const [showCsvWarning, setShowCsvWarning] = createSignal(false);2829  const passwordIsValid = createMemo(30    () => exportPassword().length >= MIN_EXPORT_PASSWORD31  );3233  async function handleEncryptedExport() {34    setError(null);35    setResult(null);3637    if (exportPassword().length < MIN_EXPORT_PASSWORD) {38      setError(`Export password must be at least ${MIN_EXPORT_PASSWORD} characters.`);39      return;40    }4142    setExporting(true);4344    const response = await sendMessage({ type: "GET_ENTRIES" });45    const entries = (response as { ok: boolean; data?: VaultEntry[] }).data ?? [];46    const active = entries.filter((e) => !e.deletedAt);4748    if (active.length === 0) {49      setError("Your vault has no entries to export.");50      setExporting(false);51      return;52    }5354    try {55      const blob = await exportEntriesToPeachJson(active, exportPassword());56      const filename = `peach-backup-${dateStamp()}.json`;57      downloadBlob(58        JSON.stringify(blob, null, 2),59        "application/json",60        filename61      );62      setResult(63        `Exported ${active.length} entr${active.length === 1 ? "y" : "ies"}. Filename: ${filename}. Store this file and your export password somewhere safe — neither can be recovered.`64      );65      setExportPassword("");66    } catch (err) {67      setError(err instanceof Error ? err.message : "Export failed.");68    } finally {69      setExporting(false);70    }71  }7273  async function handleCSVExport() {74    setShowCsvWarning(false);75    setExporting(true);76    setResult(null);77    setError(null);7879    const response = await sendMessage({ type: "GET_ENTRIES" });80    const entries = (response as { ok: boolean; data?: VaultEntry[] }).data ?? [];81    const active = entries.filter((e) => e.deletedAt === undefined && e.type !== "passkey");82    const excludedPasskeys = entries.filter((e) => e.deletedAt === undefined && e.type === "passkey").length;8384    if (active.length === 0) {85      setError("Your vault has no entries to export.");86      setExporting(false);87      return;88    }8990    const headers = ["type", "name", "url", "username", "password", "totp", "tags", "notes"];91    const rows = active.map((e) => {92      const type = e.type === "note" ? "note" : "login";93      return [94        type,95        e.title,96        e.url ?? "",97        e.username ?? "",98        e.password ?? "",99        e.totp ?? "",100        Array.isArray(e.tags) ? e.tags.join("|") : "",101        e.notes ?? "",102      ]103        // Flatten embedded newlines before RFC 4180 quote escaping so the104        // CSV stays single-line-per-row for tools that do not handle quoted105        // multi-line cells.106        .map((v) =>107          `"${String(v).replace(/\r?\n/g, " ").replace(/"/g, '""')}"`108        )109        .join(",");110    });111    const csv = [headers.join(","), ...rows].join("\n");112113    const filename = `peach-passwords-${dateStamp()}.csv`;114    try {115      downloadBlob(csv, "text/csv", filename);116      const suffix = excludedPasskeys > 0117        ? ` ${excludedPasskeys} passkey${excludedPasskeys === 1 ? "" : "s"} excluded from plaintext export.`118        : "";119      setResult(120        `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.`121      );122    } catch (err) {123      setError(err instanceof Error ? err.message : "Export failed.");124    } finally {125      setExporting(false);126    }127  }128129  return (130    <div class="peach-overlay-view ov-enter-right" style="display:flex">131      <div class="peach-overlay-header">132        <button133          class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"134          onClick={props.onBack}135          aria-label="Back"136        >137          <svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">138            <path d="M6 1L1 6L6 11" />139          </svg>140        </button>141        <span class="peach-overlay-title">export</span>142        <div class="peach-overlay-spacer"></div>143      </div>144145      <div class="peach-settings-body">146147        {/* ── Backup phrase ── */}148        <div class="peach-settings-group peach-export-card peach-export-card--primary">149          <p class="peach-settings-label">backup phrase</p>150          <p class="peach-export-card-copy">151            Your vault as a string of characters you can copy anywhere — no file needed. Paste it into Peach on any device to restore.152          </p>153          <button154            class="peach-btn peach-btn--subtle peach-btn--sm"155            onClick={props.onOpenCjk}156          >157            Create backup phrase158          </button>159        </div>160161        {/* ── Encrypted file ── */}162        <div class="peach-settings-group peach-export-card">163          <p class="peach-settings-label">encrypted file</p>164          <p class="peach-export-card-copy">165            Download a file backup. Set a password to lock it — you'll need that password to restore.166          </p>167          <div class="peach-settings-stack peach-export-password-stack">168            <input169              class="peach-settings-input"170              type="password"171              placeholder={`backup password (min ${MIN_EXPORT_PASSWORD} chars)`}172              value={exportPassword()}173              onInput={(e) => setExportPassword((e.target as HTMLInputElement).value)}174            />175            <Show when={exportPassword().length > 0 && exportPassword().length < MIN_EXPORT_PASSWORD}>176              <p class="peach-export-note" style="color:var(--peach-t2)">177                At least {MIN_EXPORT_PASSWORD} characters178              </p>179            </Show>180          </div>181          <button182            class="peach-btn peach-btn--subtle peach-btn--sm"183            onClick={() => void handleEncryptedExport()}184            disabled={exporting() || !passwordIsValid()}185          >186            {exporting() ? "saving…" : "Download backup file"}187          </button>188        </div>189190        {/* ── CSV ── */}191        <div class="peach-settings-group peach-export-card">192          <p class="peach-settings-label">CSV for other apps</p>193          <p class="peach-export-card-copy">194            Plain-text export for importing into another password manager. Passwords will be readable — delete the file once you're done.195          </p>196          <Show197            when={!showCsvWarning()}198            fallback={199              <div class="peach-settings-actions">200                <button201                  class="peach-btn peach-btn--ghost peach-btn--sm"202                  onClick={() => void handleCSVExport()}203                  disabled={exporting()}204                >205                  {exporting() ? "saving…" : "Download CSV"}206                </button>207                <button208                  class="peach-btn peach-btn--ghost peach-btn--sm"209                  onClick={() => setShowCsvWarning(false)}210                >211                  cancel212                </button>213              </div>214            }215          >216            <button217              class="peach-btn peach-btn--ghost peach-btn--sm"218              onClick={() => setShowCsvWarning(true)}219            >220              Export as CSV221            </button>222          </Show>223        </div>224225        <Show when={error()}>226          <div class="peach-settings-group">227            <div class="peach-restore-error">{error()}</div>228          </div>229        </Show>230231        <Show when={result()}>232          <div class="peach-settings-group">233            <div class="peach-export-success">234              <span class="peach-export-success-check">✓</span>235              <span>{result()}</span>236            </div>237          </div>238        </Show>239      </div>240    </div>241  );242}243244function dateStamp(): string {245  return new Date().toISOString().split("T")[0];246}247248function downloadBlob(content: string, mimeType: string, filename: string): void {249  try {250    const url = URL.createObjectURL(new Blob([content], { type: mimeType }));251    const a = document.createElement("a");252    a.href = url;253    a.download = filename;254    document.body.appendChild(a);255    a.click();256    document.body.removeChild(a);257    URL.revokeObjectURL(url);258  } catch (err) {259    console.error("[Peach] Download failed:", err);260    throw err;261  }262}