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 { 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 [copied, setCopied] = createSignal(false);129 const [pipelineError, setPipelineError] = createSignal<string | null>(null);130131 const [showQrOverlay, setShowQrOverlay] = createSignal(false);132 const [qrDataUrl, setQrDataUrl] = createSignal<string | null>(null);133134 createEffect(() => {135 if (!showQrOverlay()) return;136 const handler = (e: KeyboardEvent) => {137 if (e.key === "Escape") handleCloseQr();138 };139 window.addEventListener("keydown", handler);140 onCleanup(() => window.removeEventListener("keydown", handler));141 });142143 144 145 146147 const strength = createMemo(() => evaluateStrength(passphrase()));148149 const liveValidation = createMemo(() =>150 validateForExport({151 passphrase: passphrase(),152 confirm: confirmPhrase(),153 hibp: hibp() ?? undefined,154 })155 );156157 const canExport = createMemo(() => liveValidation().ok);158159 160 161 162163 function schedulePwnedCheck(value: string) {164 if (hibpController) hibpController.abort();165 if (hibpDebounce) clearTimeout(hibpDebounce);166 setHibp(null);167 setHibpChecking(false);168169 if (value.length < MIN_PASSPHRASE_LENGTH) return; 170171 hibpDebounce = setTimeout(() => {172 const controller = new AbortController();173 hibpController = controller;174 setHibpChecking(true);175 checkPassphraseBreached(value, controller.signal)176 .then((result) => {177 178 if (passphrase() === value) {179 setHibp(result);180 }181 })182 .catch(() => {183 184 })185 .finally(() => {186 if (hibpController === controller) {187 setHibpChecking(false);188 }189 });190 }, 500);191 }192193 onCleanup(() => {194 if (hibpController) hibpController.abort();195 if (hibpDebounce) clearTimeout(hibpDebounce);196 });197198 function handlePassphraseInput(value: string) {199 setPassphrase(value);200 setHibp(null);201 schedulePwnedCheck(value);202 }203204 function handleGenerate() {205 const generated = generateDicewarePassphrase(6);206 setPassphrase(generated);207 setConfirmPhrase(generated); 208 schedulePwnedCheck(generated);209 }210211 212 213 214215 async function runExport() {216 if (!canExport()) return;217 setScreen("generating");218 setProgress("deriving-key");219 setPipelineError(null);220221 try {222 223 const [entriesRes, settingsRes] = await Promise.all([224 sendMessage({ type: "GET_ENTRIES" }),225 sendMessage({ type: "GET_SETTINGS" }),226 ]);227 const entriesResponse = entriesRes as {228 ok: boolean;229 data?: VaultEntry[];230 error?: string;231 };232 const settingsResponse = settingsRes as {233 ok: boolean;234 data?: Record<string, unknown>;235 error?: string;236 };237 if (!entriesResponse.ok) {238 throw new Error(entriesResponse.error ?? "Couldn't read vault entries.");239 }240 if (!settingsResponse.ok) {241 throw new Error(settingsResponse.error ?? "Couldn't read vault settings.");242 }243244 const entries = (entriesResponse.data ?? []).filter((entry) => !entry.deletedAt);245 if (entries.length === 0) {246 throw new Error("EMPTY_VAULT_EXPORT");247 }248249 const settings = settingsResponse.data ?? {};250251 setProgress("deriving-key");252 await new Promise((r) => setTimeout(r, 0));253254 const result = await exportToCJK({255 entries,256 settings: settings as any,257 passphrase: passphrase(),258 argon2: ARGON2_DEFAULT,259 });260261 setProgress("encoding");262 await new Promise((r) => setTimeout(r, 0));263264 setPhraseText(result.phrase);265 setEntryCount(result.entryCount);266 setCjkCharCount(result.cjkCharCount);267 setLoginCount(result.loginCount);268 setNoteCount(result.noteCount);269 setPasskeyCount(result.passkeyCount);270 setProgress("done");271 setScreen("phrase");272 } catch (err) {273 const message = err instanceof Error ? err.message : String(err);274 if (isLockRecoveryError(message)) {275 props.onRequiresUnlock?.();276 return;277 }278 setPipelineError(getPipelineErrorCopy(message));279 setScreen("passphrase");280 setProgress("idle");281 }282 }283284 285 286 287288 async function handleCopy() {289 try {290 await navigator.clipboard.writeText(phraseText());291 setCopied(true);292 setTimeout(() => setCopied(false), 2000);293 } catch {294 295 setCopied(false);296 }297 }298299 function handleSaveFile() {300 const dateStamp = new Date().toISOString().split("T")[0];301 const filename = `peach-backup-${dateStamp}.txt`;302 const blob = new Blob([phraseText()], { type: "text/plain;charset=utf-8" });303 const url = URL.createObjectURL(blob);304 const a = document.createElement("a");305 a.href = url;306 a.download = filename;307 a.click();308 URL.revokeObjectURL(url);309 }310311 async function handleShowQr() {312 const byteLen = new TextEncoder().encode(phraseText()).length;313 if (byteLen > 2800) return;314 setShowQrOverlay(true);315 const { default: QRCode } = await import("qrcode");316 const dataUrl = await QRCode.toDataURL(phraseText(), {317 errorCorrectionLevel: "L",318 width: 320,319 margin: 2,320 color: { dark: "#E8A87C", light: "#00000000" },321 });322 setQrDataUrl(dataUrl);323 }324325 function handleCloseQr() {326 setShowQrOverlay(false);327 setQrDataUrl(null);328 }329330 function handleDone() {331 setPassphrase("");332 setConfirmPhrase("");333 setPhraseText("");334 setShowQrOverlay(false);335 setQrDataUrl(null);336 props.onBack();337 }338339 340 341 342343 return (344 <div class="peach-overlay-view ov-enter-right">345 <div class="peach-overlay-header">346 <button347 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"348 onClick={screen() === "phrase" ? handleDone : props.onBack}349 aria-label="Back"350 disabled={screen() === "generating"}351 >352 <svg353 width="7"354 height="12"355 viewBox="0 0 7 12"356 fill="none"357 stroke="currentColor"358 stroke-width="1.1"359 stroke-linecap="round"360 stroke-linejoin="round"361 >362 <path d="M6 1L1 6L6 11" />363 </svg>364 </button>365 <span class="peach-overlay-title">366 {screen() === "phrase" ? "backup phrase" : "export"}367 </span>368 <div class="peach-overlay-spacer"></div>369 </div>370371 <div class="peach-settings-body">372 <Show when={screen() === "passphrase"}>373 <PassphraseScreen374 passphrase={passphrase()}375 onPassphraseInput={handlePassphraseInput}376 confirm={confirmPhrase()}377 onConfirmInput={setConfirmPhrase}378 strengthTier={strength().tier}379 strengthScore={strength().score}380 strengthLabel={strength().feedback}381 hibp={hibp()}382 hibpChecking={hibpChecking()}383 error={liveValidation().error}384 canExport={canExport()}385 onGenerate={handleGenerate}386 onExport={() => void runExport()}387 pipelineError={pipelineError()}388 />389 </Show>390391 <Show when={screen() === "generating"}>392 <GeneratingScreen stage={progress()} />393 </Show>394395 <Show when={screen() === "phrase"}>396 <PhraseScreen397 phrase={phraseText()}398 entryCount={entryCount()}399 cjkCharCount={cjkCharCount()}400 loginCount={loginCount()}401 noteCount={noteCount()}402 passkeyCount={passkeyCount()}403 copied={copied()}404 onCopy={() => void handleCopy()}405 onSaveFile={handleSaveFile}406 onDone={handleDone}407 onShowQr={() => void handleShowQr()}408 showQrOverlay={showQrOverlay()}409 qrDataUrl={qrDataUrl()}410 onCloseQr={handleCloseQr}411 />412 </Show>413 </div>414 </div>415 );416}417418419420421422interface PassphraseScreenProps {423 passphrase: string;424 onPassphraseInput: (value: string) => void;425 confirm: string;426 onConfirmInput: (value: string) => void;427 strengthTier: StrengthTier;428 strengthScore: number;429 strengthLabel: string;430 hibp: HibpResult | null;431 hibpChecking: boolean;432 error?: ExportPassphraseErrorCode;433 canExport: boolean;434 onGenerate: () => void;435 onExport: () => void;436 pipelineError: string | null;437}438439function PassphraseScreen(props: PassphraseScreenProps) {440 const barFillPercent = () => {441 if (props.passphrase.length === 0) return 0;442 return [10, 25, 50, 75, 100][props.strengthScore] ?? 10;443 };444445 const errorMessage = () =>446 props.error && props.passphrase.length > 0447 ? EXPORT_ERROR_COPY[props.error]448 : null;449450 const confirmMatches = () =>451 props.confirm.length > 0 && props.confirm === props.passphrase;452453 return (454 <>455 <div class="peach-settings-group">456 <p class="peach-settings-label">choose a passphrase</p>457 <p class="peach-settings-copy peach-settings-copy--spacious">458 Your backup will be encrypted with this. You'll need it when you restore — keep it somewhere you won't lose it.459 </p>460461 <div class="peach-settings-actions" style="margin-bottom:8px">462 <button463 class="peach-btn peach-btn--ghost peach-btn--sm"464 onClick={props.onGenerate}465 >466 ↻ generate one for me467 </button>468 </div>469470 <input471 class="peach-settings-input"472 type="text"473 placeholder="passphrase"474 value={props.passphrase}475 onInput={(e) =>476 props.onPassphraseInput((e.target as HTMLInputElement).value)477 }478 />479480 <Show when={props.passphrase.length > 0}>481 <div class="peach-strength-row">482 <div class="peach-strength-track">483 <div484 class="peach-strength-fill"485 style={{486 width: `${barFillPercent()}%`,487 background: STRENGTH_COLOUR[props.strengthTier],488 }}489 />490 </div>491 <span class="peach-strength-label" style={{ color: STRENGTH_COLOUR[props.strengthTier] }}>492 {STRENGTH_LABEL[props.strengthTier]}493 </span>494 </div>495 </Show>496497 <Show when={props.passphrase.length > 0 && !confirmMatches()}>498 <input499 class="peach-settings-input"500 type="text"501 placeholder="confirm passphrase"502 value={props.confirm}503 onInput={(e) =>504 props.onConfirmInput((e.target as HTMLInputElement).value)505 }506 />507 </Show>508509 <Show when={confirmMatches()}>510 <div class="peach-inline-feedback peach-inline-feedback--ok">511 ✓ passphrases match512 </div>513 </Show>514515 <Show when={errorMessage()}>516 <div class="peach-inline-feedback peach-inline-feedback--error">517 {errorMessage()}518 </div>519 </Show>520521 <Show when={props.hibpChecking}>522 <div class="peach-inline-feedback peach-inline-feedback--muted">523 Checking passphrase…524 </div>525 </Show>526 <Show when={props.hibp && !props.hibp.apiReachable}>527 <div class="peach-inline-feedback peach-inline-feedback--muted">528 Couldn't reach breach database — proceed with care.529 </div>530 </Show>531532 <Show when={props.pipelineError}>533 <div class="peach-inline-feedback peach-inline-feedback--error">534 {props.pipelineError}535 </div>536 </Show>537538 <div class="peach-settings-actions" style="margin-top:10px">539 <button540 class="peach-btn peach-btn--subtle peach-btn--sm"541 onClick={props.onExport}542 disabled={!props.canExport}543 >544 Create backup phrase →545 </button>546 </div>547 </div>548 </>549 );550}551552553554555556interface GeneratingScreenProps {557 stage: ProgressStage;558}559560function GeneratingScreen(props: GeneratingScreenProps) {561 return (562 <div class="peach-settings-group peach-progress-screen">563 <div class="peach-progress-screen-title">Creating your backup phrase…</div>564 <div class="peach-spinner" aria-label="Working" />565 <div class="peach-progress-screen-stage">{PROGRESS_COPY[props.stage]}</div>566 </div>567 );568}569570571572573574interface PhraseScreenProps {575 phrase: string;576 entryCount: number;577 cjkCharCount: number;578 loginCount: number;579 noteCount: number;580 passkeyCount: number;581 copied: boolean;582 onCopy: () => void;583 onSaveFile: () => void;584 onDone: () => void;585 onShowQr: () => void;586 showQrOverlay: boolean;587 qrDataUrl: string | null;588 onCloseQr: () => void;589}590591function PhraseScreen(props: PhraseScreenProps) {592 const phraseTooLongForQr = () => new TextEncoder().encode(props.phrase).length > 2800;593594 return (595 <>596 <div class="peach-settings-group">597 <p class="peach-settings-label">your backup phrase</p>598 <p class="peach-settings-copy peach-settings-copy--spacious">599 Copy this phrase and store it somewhere safe. You need this phrase600 and your passphrase to restore your vault.601 </p>602603 <div class="peach-cjk-phrase-box">{props.phrase}</div>604605 <div class="peach-inline-feedback peach-inline-feedback--muted">606 {props.entryCount} entr{props.entryCount === 1 ? "y" : "ies"} ·{" "}607 {props.cjkCharCount.toLocaleString()} characters608 </div>609 <div class="peach-inline-feedback peach-inline-feedback--muted">610 {props.loginCount} login{props.loginCount === 1 ? "" : "s"}611 {props.noteCount > 0612 ? ` · ${props.noteCount} note${props.noteCount === 1 ? "" : "s"}`613 : ""}614 {props.passkeyCount > 0615 ? ` · ${props.passkeyCount} passke${props.passkeyCount === 1 ? "y" : "ys"}`616 : ""}617 {" · settings included"}618 </div>619620 <div class="peach-settings-actions">621 <button622 class="peach-btn peach-btn--subtle peach-btn--sm"623 onClick={props.onCopy}624 >625 {props.copied ? "Copied ✓" : "Copy phrase"}626 </button>627 <button628 class="peach-btn peach-btn--ghost peach-btn--sm"629 onClick={props.onSaveFile}630 >631 Save as file632 </button>633 </div>634635 <Show when={phraseTooLongForQr()}>636 <div class="peach-settings-copy" style="margin-top:12px">637 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.638 </div>639 </Show>640 <Show when={!phraseTooLongForQr()}>641 <div class="peach-settings-stack" style="margin-top:12px">642 <button643 class="peach-btn peach-btn--ghost peach-btn--sm"644 onClick={props.onShowQr}645 >646 Show as QR647 </button>648 </div>649 </Show>650651 <div class="peach-settings-actions--row peach-settings-actions--end" style="margin-top:16px">652 <button653 class="peach-btn peach-btn--ghost peach-btn--sm"654 onClick={props.onDone}655 >656 Done657 </button>658 </div>659 </div>660661 <Show when={props.showQrOverlay && props.qrDataUrl}>662 <div class="peach-overlay-view" role="dialog" aria-modal="true" onClick={props.onCloseQr}>663 <div class="peach-overlay-card peach-cjk-qr-card" onClick={(e) => e.stopPropagation()}>664 <div class="peach-overlay-header">665 <span class="peach-overlay-title">scan with Peach</span>666 <div class="peach-overlay-spacer" />667 <button class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn" onClick={props.onCloseQr} aria-label="Close">668 <CloseGlyph />669 </button>670 </div>671 <div class="peach-cjk-qr-body">672 <p class="peach-cjk-qr-hint">Open Peach → Import → Restore from CJK phrase</p>673 <img src={props.qrDataUrl!} alt="Vault QR code" class="peach-cjk-qr-image" width="320" height="320" />674 </div>675 </div>676 </div>677 </Show>678 </>679 );680}681682function CloseGlyph() {683 return (684 <svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round">685 <line x1="2" y1="2" x2="10" y2="10" />686 <line x1="10" y1="2" x2="2" y2="10" />687 </svg>688 );689}