123456789101112131415import { createEffect, createMemo, createSignal, onCleanup, Show } from "solid-js";1617import {18 ARGON2_DEFAULT,19 exportToCJK,20} from "../../crypto/cjkExport";21import {22 checkPassphraseBreached,23 evaluateStrength,24 generateDicewarePassphrase,25 MIN_PASSPHRASE_LENGTH,26 validateForExport,27 type ExportPassphraseErrorCode,28 type HibpResult,29 type StrengthTier,30} from "../../crypto/cjkPassphrase";31import type { PeachLicenseExport, VaultEntry } from "../../shared/types";3233import { sendMessage } from "../App";343536373839const EXPORT_ERROR_COPY: Record<ExportPassphraseErrorCode, string> = {40 PASSPHRASE_TOO_SHORT: "Your export passphrase must be at least 16 characters.",41 PASSPHRASE_TOO_WEAK:42 "Your export passphrase is too easy to guess. Try a random phrase of 5–6 words.",43 PASSPHRASE_BREACHED:44 "This passphrase has appeared in a known data breach. Choose a different one.",45 PASSPHRASE_MISMATCH: "Passphrases don't match. Re-enter them carefully.",46};4748const STRENGTH_COLOUR: Record<StrengthTier, string> = {49 weak: "#DC503C",50 fair: "#D96830",51 good: "#64D264",52 excellent: "#5090D8",53};5455const STRENGTH_LABEL: Record<StrengthTier, string> = {56 weak: "weak",57 fair: "fair",58 good: "good",59 excellent: "excellent",60};616263646566type Screen = "passphrase" | "generating" | "phrase";6768type ProgressStage =69 | "idle"70 | "deriving-key"71 | "encrypting"72 | "encoding"73 | "done";7475const PROGRESS_COPY: Record<ProgressStage, string> = {76 idle: "",77 "deriving-key": "Deriving key…",78 encrypting: "Encrypting…",79 encoding: "Encoding…",80 done: "Done.",81};8283interface CJKExportProps {84 onBack: () => void;85 onRequiresUnlock?: () => void;86}8788function getPipelineErrorCopy(error: string | undefined): string {89 if (!error) return "Export failed.";90 if (error === "EMPTY_VAULT_EXPORT") {91 return "Your vault has no entries to export.";92 }93 if (error === "vault locked" || error === "VAULT_LOCKED") {94 return "Peach locked before the export could start. Unlock it again and retry.";95 }96 if (error === "SERVICE_WORKER_UNAVAILABLE") {97 return "Peach couldn't reach its background worker. Reopen the popup and try again.";98 }99 return `Export failed: ${error}`;100}101102function isLockRecoveryError(error: string | undefined): boolean {103 return (104 error === "vault locked" ||105 error === "VAULT_LOCKED" ||106 error === "SERVICE_WORKER_UNAVAILABLE"107 );108}109110export function CJKExportView(props: CJKExportProps) {111 const [screen, setScreen] = createSignal<Screen>("passphrase");112113 const [passphrase, setPassphrase] = createSignal("");114 const [confirmPhrase, setConfirmPhrase] = createSignal("");115116 const [hibp, setHibp] = createSignal<HibpResult | null>(null);117 const [hibpChecking, setHibpChecking] = createSignal(false);118 let hibpController: AbortController | null = null;119 let hibpDebounce: ReturnType<typeof setTimeout> | null = null;120121 const [progress, setProgress] = createSignal<ProgressStage>("idle");122 const [phraseText, setPhraseText] = createSignal("");123 const [entryCount, setEntryCount] = createSignal(0);124 const [cjkCharCount, setCjkCharCount] = createSignal(0);125 const [passkeyCount, setPasskeyCount] = createSignal(0);126 const [noteCount, setNoteCount] = createSignal(0);127 const [loginCount, setLoginCount] = createSignal(0);128 const [trashedCount, setTrashedCount] = createSignal(0);129 const [copied, setCopied] = createSignal(false);130 const [pipelineError, setPipelineError] = createSignal<string | null>(null);131132 const [showQrOverlay, setShowQrOverlay] = createSignal(false);133 const [qrDataUrl, setQrDataUrl] = createSignal<string | null>(null);134135 createEffect(() => {136 if (!showQrOverlay()) return;137 const handler = (e: KeyboardEvent) => {138 if (e.key === "Escape") handleCloseQr();139 };140 window.addEventListener("keydown", handler);141 onCleanup(() => window.removeEventListener("keydown", handler));142 });143144 145 146 147148 const strength = createMemo(() => evaluateStrength(passphrase()));149150 const liveValidation = createMemo(() =>151 validateForExport({152 passphrase: passphrase(),153 confirm: confirmPhrase(),154 hibp: hibp() ?? undefined,155 })156 );157158 const canExport = createMemo(() => liveValidation().ok);159160 161 162 163164 function schedulePwnedCheck(value: string) {165 if (hibpController) hibpController.abort();166 if (hibpDebounce) clearTimeout(hibpDebounce);167 setHibp(null);168 setHibpChecking(false);169170 if (value.length < MIN_PASSPHRASE_LENGTH) return; 171172 hibpDebounce = setTimeout(() => {173 const controller = new AbortController();174 hibpController = controller;175 setHibpChecking(true);176 checkPassphraseBreached(value, controller.signal)177 .then((result) => {178 179 if (passphrase() === value) {180 setHibp(result);181 }182 })183 .catch(() => {184 185 })186 .finally(() => {187 if (hibpController === controller) {188 setHibpChecking(false);189 }190 });191 }, 500);192 }193194 onCleanup(() => {195 if (hibpController) hibpController.abort();196 if (hibpDebounce) clearTimeout(hibpDebounce);197 });198199 function handlePassphraseInput(value: string) {200 setPassphrase(value);201 setHibp(null);202 schedulePwnedCheck(value);203 }204205 function handleGenerate() {206 const generated = generateDicewarePassphrase(6);207 setPassphrase(generated);208 setConfirmPhrase(generated); 209 schedulePwnedCheck(generated);210 }211212 213 214 215216 async function runExport() {217 if (!canExport()) return;218 setScreen("generating");219 setProgress("deriving-key");220 setPipelineError(null);221222 try {223 224 const [entriesRes, settingsRes, licenseRes] = await Promise.all([225 sendMessage({ type: "GET_ENTRIES" }),226 sendMessage({ type: "GET_SETTINGS" }),227 sendMessage({ type: "GET_LICENSE" }),228 ]);229 const entriesResponse = entriesRes as {230 ok: boolean;231 data?: VaultEntry[];232 error?: string;233 };234 const settingsResponse = settingsRes as {235 ok: boolean;236 data?: Record<string, unknown>;237 error?: string;238 };239 if (!entriesResponse.ok) {240 throw new Error(entriesResponse.error ?? "Couldn't read vault entries.");241 }242 if (!settingsResponse.ok) {243 throw new Error(settingsResponse.error ?? "Couldn't read vault settings.");244 }245246 const entries = entriesResponse.data ?? [];247 if (entries.length === 0) {248 throw new Error("EMPTY_VAULT_EXPORT");249 }250251 const settings = settingsResponse.data ?? {};252253 254 const licenseInfo = (licenseRes as { ok: boolean; data?: { valid?: boolean; plan?: string; issued?: string } }).data;255 let license: PeachLicenseExport | undefined;256 if (licenseInfo?.valid && licenseInfo.plan === "pro") {257 const keyRes = await sendMessage({ type: "GET_LICENSE_KEY" });258 const keyStr = (keyRes as { ok: boolean; data?: string }).data;259 if (keyStr) {260 license = {261 key: keyStr,262 ...(licenseInfo.issued ? { activatedAt: new Date(licenseInfo.issued).getTime() } : {}),263 };264 }265 }266267 setProgress("deriving-key");268 await new Promise((r) => setTimeout(r, 0));269270 const result = await exportToCJK({271 entries,272 settings: settings as any,273 license,274 passphrase: passphrase(),275 argon2: ARGON2_DEFAULT,276 });277278 setProgress("encoding");279 await new Promise((r) => setTimeout(r, 0));280281 setPhraseText(result.phrase);282 setEntryCount(result.entryCount);283 setCjkCharCount(result.cjkCharCount);284 setLoginCount(result.loginCount);285 setNoteCount(result.noteCount);286 setPasskeyCount(result.passkeyCount);287 setTrashedCount(result.trashedCount);288 setProgress("done");289 setScreen("phrase");290 } catch (err) {291 const message = err instanceof Error ? err.message : String(err);292 if (isLockRecoveryError(message)) {293 props.onRequiresUnlock?.();294 return;295 }296 setPipelineError(getPipelineErrorCopy(message));297 setScreen("passphrase");298 setProgress("idle");299 }300 }301302 303 304 305306 async function handleCopy() {307 try {308 await navigator.clipboard.writeText(phraseText());309 setCopied(true);310 setTimeout(() => setCopied(false), 2000);311 } catch {312 313 setCopied(false);314 }315 }316317 function handleSaveFile() {318 const dateStamp = new Date().toISOString().split("T")[0];319 const filename = `peach-backup-${dateStamp}.txt`;320 const blob = new Blob([phraseText()], { type: "text/plain;charset=utf-8" });321 const url = URL.createObjectURL(blob);322 const a = document.createElement("a");323 a.href = url;324 a.download = filename;325 a.click();326 URL.revokeObjectURL(url);327 }328329 async function handleShowQr() {330 const byteLen = new TextEncoder().encode(phraseText()).length;331 if (byteLen > 2800) return;332 setShowQrOverlay(true);333 const { default: QRCode } = await import("qrcode");334 const dataUrl = await QRCode.toDataURL(phraseText(), {335 errorCorrectionLevel: "L",336 width: 320,337 margin: 2,338 color: { dark: "#E8A87C", light: "#00000000" },339 });340 setQrDataUrl(dataUrl);341 }342343 function handleCloseQr() {344 setShowQrOverlay(false);345 setQrDataUrl(null);346 }347348 function handleDone() {349 setPassphrase("");350 setConfirmPhrase("");351 setPhraseText("");352 setShowQrOverlay(false);353 setQrDataUrl(null);354 props.onBack();355 }356357 358 359 360361 return (362 <div class="peach-overlay-view ov-enter-right">363 <div class="peach-overlay-header">364 <button365 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"366 onClick={screen() === "phrase" ? handleDone : props.onBack}367 aria-label="Back"368 disabled={screen() === "generating"}369 >370 <svg371 width="7"372 height="12"373 viewBox="0 0 7 12"374 fill="none"375 stroke="currentColor"376 stroke-width="1.1"377 stroke-linecap="round"378 stroke-linejoin="round"379 >380 <path d="M6 1L1 6L6 11" />381 </svg>382 </button>383 <span class="peach-overlay-title">384 {screen() === "phrase" ? "backup phrase" : "export"}385 </span>386 <div class="peach-overlay-spacer"></div>387 </div>388389 <div class="peach-settings-body">390 <Show when={screen() === "passphrase"}>391 <PassphraseScreen392 passphrase={passphrase()}393 onPassphraseInput={handlePassphraseInput}394 confirm={confirmPhrase()}395 onConfirmInput={setConfirmPhrase}396 strengthTier={strength().tier}397 strengthScore={strength().score}398 strengthLabel={strength().feedback}399 hibp={hibp()}400 hibpChecking={hibpChecking()}401 error={liveValidation().error}402 canExport={canExport()}403 onGenerate={handleGenerate}404 onExport={() => void runExport()}405 pipelineError={pipelineError()}406 />407 </Show>408409 <Show when={screen() === "generating"}>410 <GeneratingScreen stage={progress()} />411 </Show>412413 <Show when={screen() === "phrase"}>414 <PhraseScreen415 phrase={phraseText()}416 entryCount={entryCount()}417 cjkCharCount={cjkCharCount()}418 loginCount={loginCount()}419 noteCount={noteCount()}420 passkeyCount={passkeyCount()}421 trashedCount={trashedCount()}422 copied={copied()}423 onCopy={() => void handleCopy()}424 onSaveFile={handleSaveFile}425 onDone={handleDone}426 onShowQr={() => void handleShowQr()}427 showQrOverlay={showQrOverlay()}428 qrDataUrl={qrDataUrl()}429 onCloseQr={handleCloseQr}430 />431 </Show>432 </div>433 </div>434 );435}436437438439440441interface PassphraseScreenProps {442 passphrase: string;443 onPassphraseInput: (value: string) => void;444 confirm: string;445 onConfirmInput: (value: string) => void;446 strengthTier: StrengthTier;447 strengthScore: number;448 strengthLabel: string;449 hibp: HibpResult | null;450 hibpChecking: boolean;451 error?: ExportPassphraseErrorCode;452 canExport: boolean;453 onGenerate: () => void;454 onExport: () => void;455 pipelineError: string | null;456}457458function PassphraseScreen(props: PassphraseScreenProps) {459 const barFillPercent = () => {460 if (props.passphrase.length === 0) return 0;461 return [10, 25, 50, 75, 100][props.strengthScore] ?? 10;462 };463464 const errorMessage = () =>465 props.error && props.passphrase.length > 0466 ? EXPORT_ERROR_COPY[props.error]467 : null;468469 const confirmMatches = () =>470 props.confirm.length > 0 && props.confirm === props.passphrase;471472 return (473 <>474 <div class="peach-settings-group">475 <p class="peach-settings-label">choose a passphrase</p>476 <p class="peach-settings-copy peach-settings-copy--spacious">477 Your backup will be encrypted with this. You'll need it when you restore — keep it somewhere you won't lose it.478 </p>479480 <div class="peach-settings-actions" style="margin-bottom:8px">481 <button482 class="peach-btn peach-btn--ghost peach-btn--sm"483 onClick={props.onGenerate}484 >485 ↻ generate one for me486 </button>487 </div>488489 <input490 class="peach-settings-input"491 type="text"492 placeholder="passphrase"493 value={props.passphrase}494 onInput={(e) =>495 props.onPassphraseInput((e.target as HTMLInputElement).value)496 }497 />498499 <Show when={props.passphrase.length > 0}>500 <div class="peach-strength-row">501 <div class="peach-strength-track">502 <div503 class="peach-strength-fill"504 style={{505 width: `${barFillPercent()}%`,506 background: STRENGTH_COLOUR[props.strengthTier],507 }}508 />509 </div>510 <span class="peach-strength-label" style={{ color: STRENGTH_COLOUR[props.strengthTier] }}>511 {STRENGTH_LABEL[props.strengthTier]}512 </span>513 </div>514 </Show>515516 <Show when={props.passphrase.length > 0 && !confirmMatches()}>517 <input518 class="peach-settings-input"519 type="text"520 placeholder="confirm passphrase"521 value={props.confirm}522 onInput={(e) =>523 props.onConfirmInput((e.target as HTMLInputElement).value)524 }525 />526 </Show>527528 <Show when={confirmMatches()}>529 <div class="peach-inline-feedback peach-inline-feedback--ok">530 ✓ passphrases match531 </div>532 </Show>533534 <Show when={errorMessage()}>535 <div class="peach-inline-feedback peach-inline-feedback--error">536 {errorMessage()}537 </div>538 </Show>539540 <Show when={props.hibpChecking}>541 <div class="peach-inline-feedback peach-inline-feedback--muted">542 Checking passphrase…543 </div>544 </Show>545 <Show when={props.hibp && !props.hibp.apiReachable}>546 <div class="peach-inline-feedback peach-inline-feedback--muted">547 Couldn't reach breach database — proceed with care.548 </div>549 </Show>550551 <Show when={props.pipelineError}>552 <div class="peach-inline-feedback peach-inline-feedback--error">553 {props.pipelineError}554 </div>555 </Show>556557 <div class="peach-settings-actions" style="margin-top:10px">558 <button559 class="peach-btn peach-btn--subtle peach-btn--sm"560 onClick={props.onExport}561 disabled={!props.canExport}562 >563 Create backup phrase →564 </button>565 </div>566 </div>567 </>568 );569}570571572573574575interface GeneratingScreenProps {576 stage: ProgressStage;577}578579function GeneratingScreen(props: GeneratingScreenProps) {580 return (581 <div class="peach-settings-group peach-progress-screen">582 <div class="peach-progress-screen-title">Creating your backup phrase…</div>583 <div class="peach-spinner" aria-label="Working" />584 <div class="peach-progress-screen-stage">{PROGRESS_COPY[props.stage]}</div>585 </div>586 );587}588589590591592593interface PhraseScreenProps {594 phrase: string;595 entryCount: number;596 cjkCharCount: number;597 loginCount: number;598 noteCount: number;599 passkeyCount: number;600 trashedCount: number;601 copied: boolean;602 onCopy: () => void;603 onSaveFile: () => void;604 onDone: () => void;605 onShowQr: () => void;606 showQrOverlay: boolean;607 qrDataUrl: string | null;608 onCloseQr: () => void;609}610611function PhraseScreen(props: PhraseScreenProps) {612 const activeCount = () => props.loginCount + props.noteCount + props.passkeyCount;613 const phraseTooLongForQr = () => new TextEncoder().encode(props.phrase).length > 2800;614615 return (616 <>617 <div class="peach-settings-group">618 <p class="peach-settings-label">your backup phrase</p>619 <p class="peach-settings-copy peach-settings-copy--spacious">620 Copy this phrase and store it somewhere safe. You need this phrase621 and your passphrase to restore your vault.622 </p>623624 <div class="peach-cjk-phrase-box">{props.phrase}</div>625626 <div class="peach-inline-feedback peach-inline-feedback--muted">627 {activeCount()} entr{activeCount() === 1 ? "y" : "ies"}628 {props.trashedCount > 0 ? ` · ${props.trashedCount} in trash` : ""}629 {" · "}{props.cjkCharCount.toLocaleString()} characters630 </div>631 <div class="peach-inline-feedback peach-inline-feedback--muted">632 {props.loginCount} login{props.loginCount === 1 ? "" : "s"}633 {props.noteCount > 0634 ? ` · ${props.noteCount} note${props.noteCount === 1 ? "" : "s"}`635 : ""}636 {props.passkeyCount > 0637 ? ` · ${props.passkeyCount} passke${props.passkeyCount === 1 ? "y" : "ys"}`638 : ""}639 {" · settings included"}640 </div>641642 <div class="peach-settings-actions">643 <button644 class="peach-btn peach-btn--subtle peach-btn--sm"645 onClick={props.onCopy}646 >647 {props.copied ? "Copied ✓" : "Copy phrase"}648 </button>649 <button650 class="peach-btn peach-btn--ghost peach-btn--sm"651 onClick={props.onSaveFile}652 >653 Save as file654 </button>655 </div>656657 <Show when={phraseTooLongForQr()}>658 <div class="peach-settings-copy" style="margin-top:12px">659 Your vault is too large to fit in a single QR code. Copy the phrase above and paste it into Peach on your other device.660 </div>661 </Show>662 <Show when={!phraseTooLongForQr()}>663 <div class="peach-settings-stack" style="margin-top:12px">664 <button665 class="peach-btn peach-btn--ghost peach-btn--sm"666 onClick={props.onShowQr}667 >668 Show as QR669 </button>670 </div>671 </Show>672673 <div class="peach-settings-actions--row peach-settings-actions--end" style="margin-top:16px">674 <button675 class="peach-btn peach-btn--ghost peach-btn--sm"676 onClick={props.onDone}677 >678 Done679 </button>680 </div>681 </div>682683 <Show when={props.showQrOverlay && props.qrDataUrl}>684 <div class="peach-overlay-view" role="dialog" aria-modal="true" onClick={props.onCloseQr}>685 <div class="peach-overlay-card peach-cjk-qr-card" onClick={(e) => e.stopPropagation()}>686 <div class="peach-overlay-header">687 <span class="peach-overlay-title">scan with Peach</span>688 <div class="peach-overlay-spacer" />689 <button class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn" onClick={props.onCloseQr} aria-label="Close">690 <CloseGlyph />691 </button>692 </div>693 <div class="peach-cjk-qr-body">694 <p class="peach-cjk-qr-hint">Open Peach → Import → Restore from CJK phrase</p>695 <img src={props.qrDataUrl!} alt="Vault QR code" class="peach-cjk-qr-image" width="320" height="320" />696 </div>697 </div>698 </div>699 </Show>700 </>701 );702}703704function CloseGlyph() {705 return (706 <svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round">707 <line x1="2" y1="2" x2="10" y2="10" />708 <line x1="10" y1="2" x2="2" y2="10" />709 </svg>710 );711}