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}8687function getPipelineErrorCopy(error: string | undefined): string {88 if (!error) return "Export failed.";89 if (error === "EMPTY_VAULT_EXPORT") {90 return "Your vault has no entries to export.";91 }92 if (error === "vault locked" || error === "VAULT_LOCKED") {93 return "Peach locked before the export could start. Unlock it again and retry.";94 }95 if (error === "SERVICE_WORKER_UNAVAILABLE") {96 return "Peach couldn't reach its background worker. Reopen the popup and try again.";97 }98 return `Export failed: ${error}`;99}100101export function CJKExportView(props: CJKExportProps) {102 const [screen, setScreen] = createSignal<Screen>("passphrase");103104 const [passphrase, setPassphrase] = createSignal("");105 const [confirmPhrase, setConfirmPhrase] = createSignal("");106107 const [hibp, setHibp] = createSignal<HibpResult | null>(null);108 const [hibpChecking, setHibpChecking] = createSignal(false);109 let hibpController: AbortController | null = null;110 let hibpDebounce: ReturnType<typeof setTimeout> | null = null;111112 const [progress, setProgress] = createSignal<ProgressStage>("idle");113 const [phraseText, setPhraseText] = createSignal("");114 const [entryCount, setEntryCount] = createSignal(0);115 const [cjkCharCount, setCjkCharCount] = createSignal(0);116 const [passkeyCount, setPasskeyCount] = createSignal(0);117 const [noteCount, setNoteCount] = createSignal(0);118 const [loginCount, setLoginCount] = createSignal(0);119 const [copied, setCopied] = createSignal(false);120 const [pipelineError, setPipelineError] = createSignal<string | null>(null);121122 const [showQrOverlay, setShowQrOverlay] = createSignal(false);123 const [qrDataUrl, setQrDataUrl] = createSignal<string | null>(null);124125 createEffect(() => {126 if (!showQrOverlay()) return;127 const handler = (e: KeyboardEvent) => {128 if (e.key === "Escape") handleCloseQr();129 };130 window.addEventListener("keydown", handler);131 onCleanup(() => window.removeEventListener("keydown", handler));132 });133134 135 136 137138 const strength = createMemo(() => evaluateStrength(passphrase()));139140 const liveValidation = createMemo(() =>141 validateForExport({142 passphrase: passphrase(),143 confirm: confirmPhrase(),144 hibp: hibp() ?? undefined,145 })146 );147148 const canExport = createMemo(() => liveValidation().ok);149150 151 152 153154 function schedulePwnedCheck(value: string) {155 if (hibpController) hibpController.abort();156 if (hibpDebounce) clearTimeout(hibpDebounce);157 setHibp(null);158 setHibpChecking(false);159160 if (value.length < MIN_PASSPHRASE_LENGTH) return; 161162 hibpDebounce = setTimeout(() => {163 const controller = new AbortController();164 hibpController = controller;165 setHibpChecking(true);166 checkPassphraseBreached(value, controller.signal)167 .then((result) => {168 169 if (passphrase() === value) {170 setHibp(result);171 }172 })173 .catch(() => {174 175 })176 .finally(() => {177 if (hibpController === controller) {178 setHibpChecking(false);179 }180 });181 }, 500);182 }183184 onCleanup(() => {185 if (hibpController) hibpController.abort();186 if (hibpDebounce) clearTimeout(hibpDebounce);187 });188189 function handlePassphraseInput(value: string) {190 setPassphrase(value);191 setHibp(null);192 schedulePwnedCheck(value);193 }194195 function handleGenerate() {196 const generated = generateDicewarePassphrase(6);197 setPassphrase(generated);198 setConfirmPhrase(generated); 199 schedulePwnedCheck(generated);200 }201202 203 204 205206 async function runExport() {207 if (!canExport()) return;208 setScreen("generating");209 setProgress("deriving-key");210 setPipelineError(null);211212 try {213 214 const [entriesRes, settingsRes] = await Promise.all([215 sendMessage({ type: "GET_ENTRIES" }),216 sendMessage({ type: "GET_SETTINGS" }),217 ]);218 const entriesResponse = entriesRes as {219 ok: boolean;220 data?: VaultEntry[];221 error?: string;222 };223 const settingsResponse = settingsRes as {224 ok: boolean;225 data?: Record<string, unknown>;226 error?: string;227 };228 if (!entriesResponse.ok) {229 throw new Error(entriesResponse.error ?? "Couldn't read vault entries.");230 }231 if (!settingsResponse.ok) {232 throw new Error(settingsResponse.error ?? "Couldn't read vault settings.");233 }234235 const entries = (entriesResponse.data ?? []).filter((entry) => !entry.deletedAt);236 if (entries.length === 0) {237 throw new Error("EMPTY_VAULT_EXPORT");238 }239240 const settings = settingsResponse.data ?? {};241242 setProgress("deriving-key");243 await new Promise((r) => setTimeout(r, 0));244245 const result = await exportToCJK({246 entries,247 settings: settings as any,248 passphrase: passphrase(),249 argon2: ARGON2_DEFAULT,250 });251252 setProgress("encoding");253 await new Promise((r) => setTimeout(r, 0));254255 setPhraseText(result.phrase);256 setEntryCount(result.entryCount);257 setCjkCharCount(result.cjkCharCount);258 setLoginCount(result.loginCount);259 setNoteCount(result.noteCount);260 setPasskeyCount(result.passkeyCount);261 setProgress("done");262 setScreen("phrase");263 } catch (err) {264 const message = err instanceof Error ? err.message : String(err);265 setPipelineError(getPipelineErrorCopy(message));266 setScreen("passphrase");267 setProgress("idle");268 }269 }270271 272 273 274275 async function handleCopy() {276 try {277 await navigator.clipboard.writeText(phraseText());278 setCopied(true);279 setTimeout(() => setCopied(false), 2000);280 } catch {281 282 setCopied(false);283 }284 }285286 function handleSaveFile() {287 const dateStamp = new Date().toISOString().split("T")[0];288 const filename = `peach-backup-${dateStamp}.txt`;289 const blob = new Blob([phraseText()], { type: "text/plain;charset=utf-8" });290 const url = URL.createObjectURL(blob);291 const a = document.createElement("a");292 a.href = url;293 a.download = filename;294 a.click();295 URL.revokeObjectURL(url);296 }297298 async function handleShowQr() {299 const byteLen = new TextEncoder().encode(phraseText()).length;300 if (byteLen > 2800) return;301 setShowQrOverlay(true);302 const { default: QRCode } = await import("qrcode");303 const dataUrl = await QRCode.toDataURL(phraseText(), {304 errorCorrectionLevel: "L",305 width: 320,306 margin: 2,307 color: { dark: "#E8A87C", light: "#00000000" },308 });309 setQrDataUrl(dataUrl);310 }311312 function handleCloseQr() {313 setShowQrOverlay(false);314 setQrDataUrl(null);315 }316317 function handleDone() {318 setPassphrase("");319 setConfirmPhrase("");320 setPhraseText("");321 setShowQrOverlay(false);322 setQrDataUrl(null);323 props.onBack();324 }325326 327 328 329330 return (331 <div class="peach-overlay-view ov-enter-right">332 <div class="peach-overlay-header">333 <button334 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"335 onClick={screen() === "phrase" ? handleDone : props.onBack}336 aria-label="Back"337 disabled={screen() === "generating"}338 >339 <svg340 width="7"341 height="12"342 viewBox="0 0 7 12"343 fill="none"344 stroke="currentColor"345 stroke-width="1.1"346 stroke-linecap="round"347 stroke-linejoin="round"348 >349 <path d="M6 1L1 6L6 11" />350 </svg>351 </button>352 <span class="peach-overlay-title">353 {screen() === "phrase" ? "backup phrase" : "export"}354 </span>355 <div class="peach-overlay-spacer"></div>356 </div>357358 <div class="peach-settings-body">359 <Show when={screen() === "passphrase"}>360 <PassphraseScreen361 passphrase={passphrase()}362 onPassphraseInput={handlePassphraseInput}363 confirm={confirmPhrase()}364 onConfirmInput={setConfirmPhrase}365 strengthTier={strength().tier}366 strengthScore={strength().score}367 strengthLabel={strength().feedback}368 hibp={hibp()}369 hibpChecking={hibpChecking()}370 error={liveValidation().error}371 canExport={canExport()}372 onGenerate={handleGenerate}373 onExport={() => void runExport()}374 pipelineError={pipelineError()}375 />376 </Show>377378 <Show when={screen() === "generating"}>379 <GeneratingScreen stage={progress()} />380 </Show>381382 <Show when={screen() === "phrase"}>383 <PhraseScreen384 phrase={phraseText()}385 entryCount={entryCount()}386 cjkCharCount={cjkCharCount()}387 loginCount={loginCount()}388 noteCount={noteCount()}389 passkeyCount={passkeyCount()}390 copied={copied()}391 onCopy={() => void handleCopy()}392 onSaveFile={handleSaveFile}393 onDone={handleDone}394 onShowQr={() => void handleShowQr()}395 showQrOverlay={showQrOverlay()}396 qrDataUrl={qrDataUrl()}397 onCloseQr={handleCloseQr}398 />399 </Show>400 </div>401 </div>402 );403}404405406407408409interface PassphraseScreenProps {410 passphrase: string;411 onPassphraseInput: (value: string) => void;412 confirm: string;413 onConfirmInput: (value: string) => void;414 strengthTier: StrengthTier;415 strengthScore: number;416 strengthLabel: string;417 hibp: HibpResult | null;418 hibpChecking: boolean;419 error?: ExportPassphraseErrorCode;420 canExport: boolean;421 onGenerate: () => void;422 onExport: () => void;423 pipelineError: string | null;424}425426function PassphraseScreen(props: PassphraseScreenProps) {427 const barFillPercent = () => {428 if (props.passphrase.length === 0) return 0;429 return [10, 25, 50, 75, 100][props.strengthScore] ?? 10;430 };431432 const errorMessage = () =>433 props.error && props.passphrase.length > 0434 ? EXPORT_ERROR_COPY[props.error]435 : null;436437 const confirmMatches = () =>438 props.confirm.length > 0 && props.confirm === props.passphrase;439440 return (441 <>442 <div class="peach-settings-group">443 <p class="peach-settings-label">choose a passphrase</p>444 <p class="peach-settings-copy peach-settings-copy--spacious">445 Your backup will be encrypted with this. You'll need it when you restore — keep it somewhere you won't lose it.446 </p>447448 <div class="peach-settings-actions" style="margin-bottom:8px">449 <button450 class="peach-btn peach-btn--ghost peach-btn--sm"451 onClick={props.onGenerate}452 >453 ↻ generate one for me454 </button>455 </div>456457 <input458 class="peach-settings-input"459 type="text"460 placeholder="passphrase"461 value={props.passphrase}462 onInput={(e) =>463 props.onPassphraseInput((e.target as HTMLInputElement).value)464 }465 />466467 <Show when={props.passphrase.length > 0}>468 <div class="peach-strength-row">469 <div class="peach-strength-track">470 <div471 class="peach-strength-fill"472 style={{473 width: `${barFillPercent()}%`,474 background: STRENGTH_COLOUR[props.strengthTier],475 }}476 />477 </div>478 <span class="peach-strength-label" style={{ color: STRENGTH_COLOUR[props.strengthTier] }}>479 {STRENGTH_LABEL[props.strengthTier]}480 </span>481 </div>482 </Show>483484 <Show when={props.passphrase.length > 0 && !confirmMatches()}>485 <input486 class="peach-settings-input"487 type="text"488 placeholder="confirm passphrase"489 value={props.confirm}490 onInput={(e) =>491 props.onConfirmInput((e.target as HTMLInputElement).value)492 }493 />494 </Show>495496 <Show when={confirmMatches()}>497 <div class="peach-inline-feedback peach-inline-feedback--ok">498 ✓ passphrases match499 </div>500 </Show>501502 <Show when={errorMessage()}>503 <div class="peach-inline-feedback peach-inline-feedback--error">504 {errorMessage()}505 </div>506 </Show>507508 <Show when={props.hibpChecking}>509 <div class="peach-inline-feedback peach-inline-feedback--muted">510 Checking passphrase…511 </div>512 </Show>513 <Show when={props.hibp && !props.hibp.apiReachable}>514 <div class="peach-inline-feedback peach-inline-feedback--muted">515 Couldn't reach breach database — proceed with care.516 </div>517 </Show>518519 <Show when={props.pipelineError}>520 <div class="peach-inline-feedback peach-inline-feedback--error">521 {props.pipelineError}522 </div>523 </Show>524525 <div class="peach-settings-actions" style="margin-top:10px">526 <button527 class="peach-btn peach-btn--subtle peach-btn--sm"528 onClick={props.onExport}529 disabled={!props.canExport}530 >531 Create backup phrase →532 </button>533 </div>534 </div>535 </>536 );537}538539540541542543interface GeneratingScreenProps {544 stage: ProgressStage;545}546547function GeneratingScreen(props: GeneratingScreenProps) {548 return (549 <div class="peach-settings-group peach-progress-screen">550 <div class="peach-progress-screen-title">Creating your backup phrase…</div>551 <div class="peach-spinner" aria-label="Working" />552 <div class="peach-progress-screen-stage">{PROGRESS_COPY[props.stage]}</div>553 </div>554 );555}556557558559560561interface PhraseScreenProps {562 phrase: string;563 entryCount: number;564 cjkCharCount: number;565 loginCount: number;566 noteCount: number;567 passkeyCount: number;568 copied: boolean;569 onCopy: () => void;570 onSaveFile: () => void;571 onDone: () => void;572 onShowQr: () => void;573 showQrOverlay: boolean;574 qrDataUrl: string | null;575 onCloseQr: () => void;576}577578function PhraseScreen(props: PhraseScreenProps) {579 const phraseTooLongForQr = () => new TextEncoder().encode(props.phrase).length > 2800;580581 return (582 <>583 <div class="peach-settings-group">584 <p class="peach-settings-label">your backup phrase</p>585 <p class="peach-settings-copy peach-settings-copy--spacious">586 Copy this phrase and store it somewhere safe. You need this phrase587 and your passphrase to restore your vault.588 </p>589590 <div class="peach-cjk-phrase-box">{props.phrase}</div>591592 <div class="peach-inline-feedback peach-inline-feedback--muted">593 {props.entryCount} entr{props.entryCount === 1 ? "y" : "ies"} ·{" "}594 {props.cjkCharCount.toLocaleString()} characters595 </div>596 <div class="peach-inline-feedback peach-inline-feedback--muted">597 {props.loginCount} login{props.loginCount === 1 ? "" : "s"}598 {props.noteCount > 0599 ? ` · ${props.noteCount} note${props.noteCount === 1 ? "" : "s"}`600 : ""}601 {props.passkeyCount > 0602 ? ` · ${props.passkeyCount} passke${props.passkeyCount === 1 ? "y" : "ys"}`603 : ""}604 {" · settings included"}605 </div>606607 <div class="peach-settings-actions">608 <button609 class="peach-btn peach-btn--subtle peach-btn--sm"610 onClick={props.onCopy}611 >612 {props.copied ? "Copied ✓" : "Copy phrase"}613 </button>614 <button615 class="peach-btn peach-btn--ghost peach-btn--sm"616 onClick={props.onSaveFile}617 >618 Save as file619 </button>620 </div>621622 <Show when={phraseTooLongForQr()}>623 <div class="peach-settings-copy" style="margin-top:12px">624 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.625 </div>626 </Show>627 <Show when={!phraseTooLongForQr()}>628 <div class="peach-settings-stack" style="margin-top:12px">629 <button630 class="peach-btn peach-btn--ghost peach-btn--sm"631 onClick={props.onShowQr}632 >633 Show as QR634 </button>635 </div>636 </Show>637638 <div class="peach-settings-actions--row peach-settings-actions--end" style="margin-top:16px">639 <button640 class="peach-btn peach-btn--ghost peach-btn--sm"641 onClick={props.onDone}642 >643 Done644 </button>645 </div>646 </div>647648 <Show when={props.showQrOverlay && props.qrDataUrl}>649 <div class="peach-overlay-view" role="dialog" aria-modal="true" onClick={props.onCloseQr}>650 <div class="peach-overlay-card peach-cjk-qr-card" onClick={(e) => e.stopPropagation()}>651 <div class="peach-overlay-header">652 <span class="peach-overlay-title">scan with Peach</span>653 <div class="peach-overlay-spacer" />654 <button class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn" onClick={props.onCloseQr} aria-label="Close">655 <CloseGlyph />656 </button>657 </div>658 <div class="peach-cjk-qr-body">659 <p class="peach-cjk-qr-hint">Open Peach → Import → Restore from CJK phrase</p>660 <img src={props.qrDataUrl!} alt="Vault QR code" class="peach-cjk-qr-image" width="320" height="320" />661 </div>662 </div>663 </div>664 </Show>665 </>666 );667}668669function CloseGlyph() {670 return (671 <svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round">672 <line x1="2" y1="2" x2="10" y2="10" />673 <line x1="10" y1="2" x2="2" y2="10" />674 </svg>675 );676}