Export.tsx

10.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  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 [exportPasswordConfirm, setExportPasswordConfirm] = createSignal("");29  const [exporting, setExporting] = createSignal(false);30  const [result, setResult] = createSignal<string | null>(null);31  const [error, setError] = createSignal<string | null>(null);32  const [showCsvWarning, setShowCsvWarning] = createSignal(false);3334  const passwordsMatch = createMemo(35    () => exportPassword().length >= MIN_EXPORT_PASSWORD36      && exportPassword() === exportPasswordConfirm()37  );3839  async function handleEncryptedExport() {40    setError(null);41    setResult(null);4243    if (!passwordsMatch()) {44      setError("Passwords do not match or are too short.");45      return;46    }4748    setExporting(true);4950    const response = await sendMessage({ type: "GET_ENTRIES" });51    const entries = (response as { ok: boolean; data?: VaultEntry[] }).data ?? [];5253    if (entries.length === 0) {54      setError("Your vault has no entries to export.");55      setExporting(false);56      return;57    }5859    const trashedCount = entries.filter((e) => !!e.deletedAt).length;60    const activeCount = entries.length - trashedCount;61    const countStr = trashedCount > 062      ? `${activeCount} entr${activeCount === 1 ? "y" : "ies"} · ${trashedCount} in trash`63      : `${entries.length} entr${entries.length === 1 ? "y" : "ies"}`;6465    try {66      const blob = await exportEntriesToPeachJson(entries, exportPassword());67      const filename = `peach-backup-${dateStamp()}.json`;68      downloadBlob(69        JSON.stringify(blob, null, 2),70        "application/json",71        filename72      );73      setResult(74        `Exported ${countStr}. Filename: ${filename}. Store this file and your export password somewhere safe — neither can be recovered.`75      );76      setExportPassword("");77      setExportPasswordConfirm("");78    } catch (err) {79      setError(err instanceof Error ? err.message : "Export failed.");80    } finally {81      setExporting(false);82    }83  }8485  async function handleCSVExport() {86    setShowCsvWarning(false);87    setExporting(true);88    setResult(null);89    setError(null);9091    const response = await sendMessage({ type: "GET_ENTRIES" });92    const entries = (response as { ok: boolean; data?: VaultEntry[] }).data ?? [];93    const active = entries.filter((e) => e.deletedAt === undefined && e.type !== "passkey");94    const excludedPasskeys = entries.filter((e) => e.deletedAt === undefined && e.type === "passkey").length;9596    if (active.length === 0) {97      setError("Your vault has no entries to export.");98      setExporting(false);99      return;100    }101102    const headers = ["type", "name", "url", "username", "password", "totp", "tags", "notes"];103    const rows = active.map((e) => {104      const type = e.type === "note" ? "note" : "login";105      return [106        type,107        e.title,108        e.url ?? "",109        e.username ?? "",110        e.password ?? "",111        e.totp ?? "",112        Array.isArray(e.tags) ? e.tags.join("|") : "",113        e.notes ?? "",114      ]115        // Flatten embedded newlines before RFC 4180 quote escaping so the116        // CSV stays single-line-per-row for tools that do not handle quoted117        // multi-line cells.118        .map((v) =>119          `"${String(v).replace(/\r?\n/g, " ").replace(/"/g, '""')}"`120        )121        .join(",");122    });123    const csv = [headers.join(","), ...rows].join("\n");124125    const filename = `peach-passwords-${dateStamp()}.csv`;126    try {127      downloadBlob(csv, "text/csv", filename);128      const suffix = excludedPasskeys > 0129        ? ` ${excludedPasskeys} passkey${excludedPasskeys === 1 ? "" : "s"} excluded from plaintext export.`130        : "";131      setResult(132        `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.`133      );134    } catch (err) {135      setError(err instanceof Error ? err.message : "Export failed.");136    } finally {137      setExporting(false);138    }139  }140141  return (142    <div class="peach-overlay-view ov-enter-right" style="display:flex">143      <div class="peach-overlay-header">144        <button145          class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"146          onClick={props.onBack}147          aria-label="Back"148        >149          <svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">150            <path d="M6 1L1 6L6 11" />151          </svg>152        </button>153        <span class="peach-overlay-title">export</span>154        <div class="peach-overlay-spacer"></div>155      </div>156157      <div class="peach-settings-body">158159        {/* ── Backup phrase ── */}160        <div class="peach-settings-group peach-export-card peach-export-card--primary">161          <p class="peach-settings-label">backup phrase</p>162          <p class="peach-export-card-copy">163            Your vault as a string of characters you can copy anywhere — no file needed. Paste it into Peach on any device to restore.164          </p>165          <button166            class="peach-btn peach-btn--subtle peach-btn--sm"167            onClick={props.onOpenCjk}168          >169            Create backup phrase170          </button>171        </div>172173        {/* ── Encrypted file ── */}174        <div class="peach-settings-group peach-export-card">175          <p class="peach-settings-label">encrypted file</p>176          <p class="peach-export-card-copy">177            Download a file backup. Set a password to lock it — you'll need that password to restore.178          </p>179          <div class="peach-settings-stack peach-export-password-stack">180            <input181              class="peach-settings-input"182              type="password"183              placeholder={`backup password (min ${MIN_EXPORT_PASSWORD} chars)`}184              value={exportPassword()}185              onInput={(e) => setExportPassword((e.target as HTMLInputElement).value)}186            />187            <input188              id="export-confirm-password"189              class="peach-settings-input"190              type="password"191              placeholder="confirm backup password"192              value={exportPasswordConfirm()}193              onInput={(e) => setExportPasswordConfirm((e.target as HTMLInputElement).value)}194            />195            <Show when={exportPasswordConfirm().length > 0 && exportPassword() !== exportPasswordConfirm()}>196              <p class="peach-export-note" style="color:var(--peach-t2)">197                Passwords don't match198              </p>199            </Show>200            <Show when={exportPassword().length > 0 && exportPassword().length < MIN_EXPORT_PASSWORD}>201              <p class="peach-export-note" style="color:var(--peach-t2)">202                At least {MIN_EXPORT_PASSWORD} characters203              </p>204            </Show>205          </div>206          <button207            class="peach-btn peach-btn--subtle peach-btn--sm"208            onClick={() => void handleEncryptedExport()}209            disabled={exporting() || !passwordsMatch()}210          >211            {exporting() ? "saving…" : "Download backup file"}212          </button>213        </div>214215        {/* ── CSV ── */}216        <div class="peach-settings-group peach-export-card">217          <p class="peach-settings-label">CSV for other apps</p>218          <p class="peach-export-card-copy">219            Plain-text export for importing into another password manager. Passwords will be readable — delete the file once you're done.220          </p>221          <Show222            when={!showCsvWarning()}223            fallback={224              <div class="peach-settings-actions">225                <button226                  class="peach-btn peach-btn--ghost peach-btn--sm"227                  onClick={() => void handleCSVExport()}228                  disabled={exporting()}229                >230                  {exporting() ? "saving…" : "Download CSV"}231                </button>232                <button233                  class="peach-btn peach-btn--ghost peach-btn--sm"234                  onClick={() => setShowCsvWarning(false)}235                >236                  cancel237                </button>238              </div>239            }240          >241            <button242              class="peach-btn peach-btn--ghost peach-btn--sm"243              onClick={() => setShowCsvWarning(true)}244            >245              Export as CSV246            </button>247          </Show>248        </div>249250        <Show when={error()}>251          <div class="peach-settings-group">252            <div class="peach-restore-error">{error()}</div>253          </div>254        </Show>255256        <Show when={result()}>257          <div class="peach-settings-group">258            <div class="peach-export-success">259              <span class="peach-export-success-check">✓</span>260              <span>{result()}</span>261            </div>262          </div>263        </Show>264      </div>265    </div>266  );267}268269function dateStamp(): string {270  return new Date().toISOString().split("T")[0];271}272273function downloadBlob(content: string, mimeType: string, filename: string): void {274  try {275    const url = URL.createObjectURL(new Blob([content], { type: mimeType }));276    const a = document.createElement("a");277    a.href = url;278    a.download = filename;279    document.body.appendChild(a);280    a.click();281    document.body.removeChild(a);282    URL.revokeObjectURL(url);283  } catch (err) {284    console.error("[Peach] Download failed:", err);285    throw err;286  }287}