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 [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] = await Promise.all([225 sendMessage({ type: "GET_ENTRIES" }),226 sendMessage({ type: "GET_SETTINGS" }),227 ]);228 const entriesResponse = entriesRes as {229 ok: boolean;230 data?: VaultEntry[];231 error?: string;232 };233 const settingsResponse = settingsRes as {234 ok: boolean;235 data?: Record<string, unknown>;236 error?: string;237 };238 if (!entriesResponse.ok) {239 throw new Error(entriesResponse.error ?? "Couldn't read vault entries.");240 }241 if (!settingsResponse.ok) {242 throw new Error(settingsResponse.error ?? "Couldn't read vault settings.");243 }244245 const entries = entriesResponse.data ?? [];246 if (entries.length === 0) {247 throw new Error("EMPTY_VAULT_EXPORT");248 }249250 const settings = settingsResponse.data ?? {};251252 setProgress("deriving-key");253 await new Promise((r) => setTimeout(r, 0));254255 const result = await exportToCJK({256 entries,257 settings: settings as any,258 passphrase: passphrase(),259 argon2: ARGON2_DEFAULT,260 });261262 setProgress("encoding");263 await new Promise((r) => setTimeout(r, 0));264265 setPhraseText(result.phrase);266 setEntryCount(result.entryCount);267 setCjkCharCount(result.cjkCharCount);268 setLoginCount(result.loginCount);269 setNoteCount(result.noteCount);270 setPasskeyCount(result.passkeyCount);271 setTrashedCount(result.trashedCount);272 setProgress("done");273 setScreen("phrase");274 } catch (err) {275 const message = err instanceof Error ? err.message : String(err);276 if (isLockRecoveryError(message)) {277 props.onRequiresUnlock?.();278 return;279 }280 setPipelineError(getPipelineErrorCopy(message));281 setScreen("passphrase");282 setProgress("idle");283 }284 }285286 287 288 289290 async function handleCopy() {291 try {292 await navigator.clipboard.writeText(phraseText());293 setCopied(true);294 setTimeout(() => setCopied(false), 2000);295 } catch {296 297 setCopied(false);298 }299 }300301 function handleSaveFile() {302 const dateStamp = new Date().toISOString().split("T")[0];303 const filename = `peach-backup-${dateStamp}.txt`;304 const blob = new Blob([phraseText()], { type: "text/plain;charset=utf-8" });305 const url = URL.createObjectURL(blob);306 const a = document.createElement("a");307 a.href = url;308 a.download = filename;309 a.click();310 URL.revokeObjectURL(url);311 }312313 async function handleShowQr() {314 const byteLen = new TextEncoder().encode(phraseText()).length;315 if (byteLen > 2800) return;316 setShowQrOverlay(true);317 const { default: QRCode } = await import("qrcode");318 const dataUrl = await QRCode.toDataURL(phraseText(), {319 errorCorrectionLevel: "L",320 width: 320,321 margin: 2,322 color: { dark: "#E8A87C", light: "#00000000" },323 });324 setQrDataUrl(dataUrl);325 }326327 function handleCloseQr() {328 setShowQrOverlay(false);329 setQrDataUrl(null);330 }331332 function handleDone() {333 setPassphrase("");334 setConfirmPhrase("");335 setPhraseText("");336 setShowQrOverlay(false);337 setQrDataUrl(null);338 props.onBack();339 }340341 342 343 344345 return (346 <div class="peach-overlay-view ov-enter-right">347 <div class="peach-overlay-header">348 <button349 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"350 onClick={screen() === "phrase" ? handleDone : props.onBack}351 aria-label="Back"352 disabled={screen() === "generating"}353 >354 <svg355 width="7"356 height="12"357 viewBox="0 0 7 12"358 fill="none"359 stroke="currentColor"360 stroke-width="1.1"361 stroke-linecap="round"362 stroke-linejoin="round"363 >364 <path d="M6 1L1 6L6 11" />365 </svg>366 </button>367 <span class="peach-overlay-title">368 {screen() === "phrase" ? "backup phrase" : "export"}369 </span>370 <div class="peach-overlay-spacer"></div>371 </div>372373 <div class="peach-settings-body">374 <Show when={screen() === "passphrase"}>375 <PassphraseScreen376 passphrase={passphrase()}377 onPassphraseInput={handlePassphraseInput}378 confirm={confirmPhrase()}379 onConfirmInput={setConfirmPhrase}380 strengthTier={strength().tier}381 strengthScore={strength().score}382 strengthLabel={strength().feedback}383 hibp={hibp()}384 hibpChecking={hibpChecking()}385 error={liveValidation().error}386 canExport={canExport()}387 onGenerate={handleGenerate}388 onExport={() => void runExport()}389 pipelineError={pipelineError()}390 />391 </Show>392393 <Show when={screen() === "generating"}>394 <GeneratingScreen stage={progress()} />395 </Show>396397 <Show when={screen() === "phrase"}>398 <PhraseScreen399 phrase={phraseText()}400 entryCount={entryCount()}401 cjkCharCount={cjkCharCount()}402 loginCount={loginCount()}403 noteCount={noteCount()}404 passkeyCount={passkeyCount()}405 trashedCount={trashedCount()}406 copied={copied()}407 onCopy={() => void handleCopy()}408 onSaveFile={handleSaveFile}409 onDone={handleDone}410 onShowQr={() => void handleShowQr()}411 showQrOverlay={showQrOverlay()}412 qrDataUrl={qrDataUrl()}413 onCloseQr={handleCloseQr}414 />415 </Show>416 </div>417 </div>418 );419}420421422423424425interface PassphraseScreenProps {426 passphrase: string;427 onPassphraseInput: (value: string) => void;428 confirm: string;429 onConfirmInput: (value: string) => void;430 strengthTier: StrengthTier;431 strengthScore: number;432 strengthLabel: string;433 hibp: HibpResult | null;434 hibpChecking: boolean;435 error?: ExportPassphraseErrorCode;436 canExport: boolean;437 onGenerate: () => void;438 onExport: () => void;439 pipelineError: string | null;440}441442function PassphraseScreen(props: PassphraseScreenProps) {443 const barFillPercent = () => {444 if (props.passphrase.length === 0) return 0;445 return [10, 25, 50, 75, 100][props.strengthScore] ?? 10;446 };447448 const errorMessage = () =>449 props.error && props.passphrase.length > 0450 ? EXPORT_ERROR_COPY[props.error]451 : null;452453 const confirmMatches = () =>454 props.confirm.length > 0 && props.confirm === props.passphrase;455456 return (457 <>458 <div class="peach-settings-group">459 <p class="peach-settings-label">choose a passphrase</p>460 <p class="peach-settings-copy peach-settings-copy--spacious">461 Your backup will be encrypted with this. You'll need it when you restore — keep it somewhere you won't lose it.462 </p>463464 <div class="peach-settings-actions" style="margin-bottom:8px">465 <button466 class="peach-btn peach-btn--ghost peach-btn--sm"467 onClick={props.onGenerate}468 >469 ↻ generate one for me470 </button>471 </div>472473 <input474 class="peach-settings-input"475 type="text"476 placeholder="passphrase"477 value={props.passphrase}478 onInput={(e) =>479 props.onPassphraseInput((e.target as HTMLInputElement).value)480 }481 />482483 <Show when={props.passphrase.length > 0}>484 <div class="peach-strength-row">485 <div class="peach-strength-track">486 <div487 class="peach-strength-fill"488 style={{489 width: `${barFillPercent()}%`,490 background: STRENGTH_COLOUR[props.strengthTier],491 }}492 />493 </div>494 <span class="peach-strength-label" style={{ color: STRENGTH_COLOUR[props.strengthTier] }}>495 {STRENGTH_LABEL[props.strengthTier]}496 </span>497 </div>498 </Show>499500 <Show when={props.passphrase.length > 0 && !confirmMatches()}>501 <input502 class="peach-settings-input"503 type="text"504 placeholder="confirm passphrase"505 value={props.confirm}506 onInput={(e) =>507 props.onConfirmInput((e.target as HTMLInputElement).value)508 }509 />510 </Show>511512 <Show when={confirmMatches()}>513 <div class="peach-inline-feedback peach-inline-feedback--ok">514 ✓ passphrases match515 </div>516 </Show>517518 <Show when={errorMessage()}>519 <div class="peach-inline-feedback peach-inline-feedback--error">520 {errorMessage()}521 </div>522 </Show>523524 <Show when={props.hibpChecking}>525 <div class="peach-inline-feedback peach-inline-feedback--muted">526 Checking passphrase…527 </div>528 </Show>529 <Show when={props.hibp && !props.hibp.apiReachable}>530 <div class="peach-inline-feedback peach-inline-feedback--muted">531 Couldn't reach breach database — proceed with care.532 </div>533 </Show>534535 <Show when={props.pipelineError}>536 <div class="peach-inline-feedback peach-inline-feedback--error">537 {props.pipelineError}538 </div>539 </Show>540541 <div class="peach-settings-actions" style="margin-top:10px">542 <button543 class="peach-btn peach-btn--subtle peach-btn--sm"544 onClick={props.onExport}545 disabled={!props.canExport}546 >547 Create backup phrase →548 </button>549 </div>550 </div>551 </>552 );553}554555556557558559interface GeneratingScreenProps {560 stage: ProgressStage;561}562563function GeneratingScreen(props: GeneratingScreenProps) {564 return (565 <div class="peach-settings-group peach-progress-screen">566 <div class="peach-progress-screen-title">Creating your backup phrase…</div>567 <div class="peach-spinner" aria-label="Working" />568 <div class="peach-progress-screen-stage">{PROGRESS_COPY[props.stage]}</div>569 </div>570 );571}572573574575576577interface PhraseScreenProps {578 phrase: string;579 entryCount: number;580 cjkCharCount: number;581 loginCount: number;582 noteCount: number;583 passkeyCount: number;584 trashedCount: number;585 copied: boolean;586 onCopy: () => void;587 onSaveFile: () => void;588 onDone: () => void;589 onShowQr: () => void;590 showQrOverlay: boolean;591 qrDataUrl: string | null;592 onCloseQr: () => void;593}594595function PhraseScreen(props: PhraseScreenProps) {596 const activeCount = () => props.loginCount + props.noteCount + props.passkeyCount;597 const phraseTooLongForQr = () => new TextEncoder().encode(props.phrase).length > 2800;598599 return (600 <>601 <div class="peach-settings-group">602 <p class="peach-settings-label">your backup phrase</p>603 <p class="peach-settings-copy peach-settings-copy--spacious">604 Copy this phrase and store it somewhere safe. You need this phrase605 and your passphrase to restore your vault.606 </p>607608 <div class="peach-cjk-phrase-box">{props.phrase}</div>609610 <div class="peach-inline-feedback peach-inline-feedback--muted">611 {activeCount()} entr{activeCount() === 1 ? "y" : "ies"}612 {props.trashedCount > 0 ? ` · ${props.trashedCount} in trash` : ""}613 {" · "}{props.cjkCharCount.toLocaleString()} characters614 </div>615 <div class="peach-inline-feedback peach-inline-feedback--muted">616 {props.loginCount} login{props.loginCount === 1 ? "" : "s"}617 {props.noteCount > 0618 ? ` · ${props.noteCount} note${props.noteCount === 1 ? "" : "s"}`619 : ""}620 {props.passkeyCount > 0621 ? ` · ${props.passkeyCount} passke${props.passkeyCount === 1 ? "y" : "ys"}`622 : ""}623 {" · settings included"}624 </div>625626 <div class="peach-settings-actions">627 <button628 class="peach-btn peach-btn--subtle peach-btn--sm"629 onClick={props.onCopy}630 >631 {props.copied ? "Copied ✓" : "Copy phrase"}632 </button>633 <button634 class="peach-btn peach-btn--ghost peach-btn--sm"635 onClick={props.onSaveFile}636 >637 Save as file638 </button>639 </div>640641 <Show when={phraseTooLongForQr()}>642 <div class="peach-settings-copy" style="margin-top:12px">643 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.644 </div>645 </Show>646 <Show when={!phraseTooLongForQr()}>647 <div class="peach-settings-stack" style="margin-top:12px">648 <button649 class="peach-btn peach-btn--ghost peach-btn--sm"650 onClick={props.onShowQr}651 >652 Show as QR653 </button>654 </div>655 </Show>656657 <div class="peach-settings-actions--row peach-settings-actions--end" style="margin-top:16px">658 <button659 class="peach-btn peach-btn--ghost peach-btn--sm"660 onClick={props.onDone}661 >662 Done663 </button>664 </div>665 </div>666667 <Show when={props.showQrOverlay && props.qrDataUrl}>668 <div class="peach-overlay-view" role="dialog" aria-modal="true" onClick={props.onCloseQr}>669 <div class="peach-overlay-card peach-cjk-qr-card" onClick={(e) => e.stopPropagation()}>670 <div class="peach-overlay-header">671 <span class="peach-overlay-title">scan with Peach</span>672 <div class="peach-overlay-spacer" />673 <button class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn" onClick={props.onCloseQr} aria-label="Close">674 <CloseGlyph />675 </button>676 </div>677 <div class="peach-cjk-qr-body">678 <p class="peach-cjk-qr-hint">Open Peach → Import → Restore from CJK phrase</p>679 <img src={props.qrDataUrl!} alt="Vault QR code" class="peach-cjk-qr-image" width="320" height="320" />680 </div>681 </div>682 </div>683 </Show>684 </>685 );686}687688function CloseGlyph() {689 return (690 <svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round">691 <line x1="2" y1="2" x2="10" y2="10" />692 <line x1="10" y1="2" x2="2" y2="10" />693 </svg>694 );695}