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  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 ?? [];50    const active = entries.filter((e) => !e.deletedAt);5152    if (active.length === 0) {53      setError("Your vault has no entries to export.");54      setExporting(false);55      return;56    }5758    try {59      const blob = await exportEntriesToPeachJson(active, exportPassword());60      const filename = `peach-backup-${dateStamp()}.json`;61      downloadBlob(62        JSON.stringify(blob, null, 2),63        "application/json",64        filename65      );66      setResult(67        `Exported ${active.length} entr${active.length === 1 ? "y" : "ies"}. Filename: ${filename}. Store this file and your export password somewhere safe — neither can be recovered.`68      );69      setExportPassword("");70    } catch (err) {71      setError(err instanceof Error ? err.message : "Export failed.");72    } finally {73      setExporting(false);74    }75  }7677  async function handleCSVExport() {78    setShowCsvWarning(false);79    setExporting(true);80    setResult(null);81    setError(null);8283    const response = await sendMessage({ type: "GET_ENTRIES" });84    const entries = (response as { ok: boolean; data?: VaultEntry[] }).data ?? [];85    const active = entries.filter((e) => e.deletedAt === undefined && e.type !== "passkey");86    const excludedPasskeys = entries.filter((e) => e.deletedAt === undefined && e.type === "passkey").length;8788    if (active.length === 0) {89      setError("Your vault has no entries to export.");90      setExporting(false);91      return;92    }9394    const headers = ["type", "name", "url", "username", "password", "totp", "tags", "notes"];95    const rows = active.map((e) => {96      const type = e.type === "note" ? "note" : "login";97      return [98        type,99        e.title,100        e.url ?? "",101        e.username ?? "",102        e.password ?? "",103        e.totp ?? "",104        Array.isArray(e.tags) ? e.tags.join("|") : "",105        e.notes ?? "",106      ]107        // Flatten embedded newlines before RFC 4180 quote escaping so the108        // CSV stays single-line-per-row for tools that do not handle quoted109        // multi-line cells.110        .map((v) =>111          `"${String(v).replace(/\r?\n/g, " ").replace(/"/g, '""')}"`112        )113        .join(",");114    });115    const csv = [headers.join(","), ...rows].join("\n");116117    const filename = `peach-passwords-${dateStamp()}.csv`;118    try {119      downloadBlob(csv, "text/csv", filename);120      const suffix = excludedPasskeys > 0121        ? ` ${excludedPasskeys} passkey${excludedPasskeys === 1 ? "" : "s"} excluded from plaintext export.`122        : "";123      setResult(124        `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.`125      );126    } catch (err) {127      setError(err instanceof Error ? err.message : "Export failed.");128    } finally {129      setExporting(false);130    }131  }132133  return (134    <div class="peach-overlay-view ov-enter-right" style="display:flex">135      <div class="peach-overlay-header">136        <button137          class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"138          onClick={props.onBack}139          aria-label="Back"140        >141          <svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">142            <path d="M6 1L1 6L6 11" />143          </svg>144        </button>145        <span class="peach-overlay-title">export</span>146        <div class="peach-overlay-spacer"></div>147      </div>148149      <div class="peach-settings-body">150151        {/* ── Backup phrase ── */}152        <div class="peach-settings-group peach-export-card peach-export-card--primary">153          <p class="peach-settings-label">backup phrase</p>154          <p class="peach-export-card-copy">155            Your vault as a string of characters you can copy anywhere — no file needed. Paste it into Peach on any device to restore.156          </p>157          <button158            class="peach-btn peach-btn--subtle peach-btn--sm"159            onClick={props.onOpenCjk}160          >161            Create backup phrase162          </button>163        </div>164165        {/* ── Encrypted file ── */}166        <div class="peach-settings-group peach-export-card">167          <p class="peach-settings-label">encrypted file</p>168          <p class="peach-export-card-copy">169            Download a file backup. Set a password to lock it — you'll need that password to restore.170          </p>171          <div class="peach-settings-stack peach-export-password-stack">172            <input173              class="peach-settings-input"174              type="password"175              placeholder={`backup password (min ${MIN_EXPORT_PASSWORD} chars)`}176              value={exportPassword()}177              onInput={(e) => setExportPassword((e.target as HTMLInputElement).value)}178            />179            <Show when={exportPassword().length > 0 && exportPassword().length < MIN_EXPORT_PASSWORD}>180              <p class="peach-export-note" style="color:var(--peach-t2)">181                At least {MIN_EXPORT_PASSWORD} characters182              </p>183            </Show>184          </div>185          <button186            class="peach-btn peach-btn--subtle peach-btn--sm"187            onClick={() => void handleEncryptedExport()}188            disabled={exporting() || !passwordIsValid()}189          >190            {exporting() ? "saving…" : "Download backup file"}191          </button>192        </div>193194        {/* ── CSV ── */}195        <div class="peach-settings-group peach-export-card">196          <p class="peach-settings-label">CSV for other apps</p>197          <p class="peach-export-card-copy">198            Plain-text export for importing into another password manager. Passwords will be readable — delete the file once you're done.199          </p>200          <Show201            when={!showCsvWarning()}202            fallback={203              <div class="peach-settings-actions">204                <button205                  class="peach-btn peach-btn--ghost peach-btn--sm"206                  onClick={() => void handleCSVExport()}207                  disabled={exporting()}208                >209                  {exporting() ? "saving…" : "Download CSV"}210                </button>211                <button212                  class="peach-btn peach-btn--ghost peach-btn--sm"213                  onClick={() => setShowCsvWarning(false)}214                >215                  cancel216                </button>217              </div>218            }219          >220            <button221              class="peach-btn peach-btn--ghost peach-btn--sm"222              onClick={() => setShowCsvWarning(true)}223            >224              Export as CSV225            </button>226          </Show>227        </div>228229        <Show when={error()}>230          <div class="peach-settings-group">231            <div class="peach-restore-error">{error()}</div>232          </div>233        </Show>234235        <Show when={result()}>236          <div class="peach-settings-group">237            <div class="peach-export-success">238              <span class="peach-export-success-check">✓</span>239              <span>{result()}</span>240            </div>241          </div>242        </Show>243      </div>244    </div>245  );246}247248function dateStamp(): string {249  return new Date().toISOString().split("T")[0];250}251252function downloadBlob(content: string, mimeType: string, filename: string): void {253  try {254    const url = URL.createObjectURL(new Blob([content], { type: mimeType }));255    const a = document.createElement("a");256    a.href = url;257    a.download = filename;258    document.body.appendChild(a);259    a.click();260    document.body.removeChild(a);261    URL.revokeObjectURL(url);262  } catch (err) {263    console.error("[Peach] Download failed:", err);264    throw err;265  }266}