12345678910111213141516171819202122232425262728import { createMemo, createSignal, onMount, Show } from "solid-js";2930import {31 decodePhraseToBytes,32 decryptAndDeserialise,33 parseHeaderLine,34 splitPhrase,35 validatePhraseStructure,36 verifyIntegrity,37} from "../../crypto/cjkImport";38import type { VaultBackupPayload } from "../../crypto/cjkSerialise";39import type { ExtractedFields } from "../../crypto/cjkPayload";40import type { VaultEntry } from "../../shared/types";4142import { sendMessage } from "../App";43import { PeachWordmark } from "../components/PeachMark";444546474849const IMPORT_ERROR_COPY: Record<string, string> = {50 INVALID_HEADER:51 "This doesn't look like a Peach backup phrase. Check that you copied the whole phrase including the first line.",52 INVALID_HEADER_FORMAT:53 "This doesn't look like a Peach backup phrase. Check that you copied the whole phrase including the first line.",54 INVALID_CHARACTER:55 "The phrase contains characters that aren't part of a Peach backup. It may have been modified or partially corrupted.",56 CHECKSUM_FAILED:57 "The phrase appears to be incomplete or corrupted. A few missing or changed characters will cause this. Try copying it again exactly as exported.",58 DECRYPT_FAILED:59 "Wrong passphrase. This is the passphrase you used when you exported, not your vault password. Try again.",60 UNSUPPORTED_VERSION:61 "This phrase was created by a newer version of Peach. Update Peach and try again.",62 UNSUPPORTED_FLAGS:63 "This phrase uses an encoding format not supported by this version of Peach.",64 PAYLOAD_TOO_SHORT:65 "The phrase is too short to be a complete backup. It may have been truncated.",66 ENTRY_COUNT_MISMATCH:67 "The phrase appears to be incomplete — the number of entries found doesn't match the header.",68 DESERIALISE_OVERFLOW:69 "The phrase contains more data than expected. It may have been modified.",70 DESERIALISE_UNDERFLOW:71 "The phrase is incomplete — not all entries could be read.",72 HEADER_COUNT_MISMATCH:73 "The phrase header is inconsistent. It may have been modified.",74 PHRASE_TOO_SHORT:75 "The phrase is too short to be a complete backup. It may have been truncated.",76 NO_CJK_CONTENT:77 "The phrase is missing its body. Copy the whole phrase, including all lines of Chinese characters.",78};7980function messageForError(err: unknown): string {81 const raw = err instanceof Error ? err.message : String(err);82 83 84 const code = raw.split(":")[0].trim();85 return IMPORT_ERROR_COPY[code] ?? `Import failed: ${raw}`;86}878889909192type Screen =93 | "paste"94 | "passphrase"95 | "onboarding-master"96 | "restoring"97 | "merge"98 | "success";99100type ProgressStage =101 | "idle"102 | "decoding"103 | "verifying"104 | "deriving"105 | "decrypting"106 | "importing";107108const PROGRESS_COPY: Record<ProgressStage, string> = {109 idle: "",110 decoding: "Decoding phrase…",111 verifying: "Verifying integrity…",112 deriving: "Deriving key…",113 decrypting: "Decrypting…",114 importing: "Importing entries…",115};116117export type CJKImportMode = "existing" | "onboarding";118119interface CJKImportProps {120 mode: CJKImportMode;121 onBack: () => void;122 123124 onDone: () => void | Promise<void>;125}126127export function CJKImportView(props: CJKImportProps) {128 const [screen, setScreen] = createSignal<Screen>("paste");129 const [existingEntryCount, setExistingEntryCount] = createSignal(0);130131 132 133 onMount(async () => {134 if (props.mode !== "existing") return;135 const response = await sendMessage({ type: "GET_ENTRIES" });136 const entries =137 (response as { ok: boolean; data?: VaultEntry[] }).data ?? [];138 setExistingEntryCount(entries.filter((e) => !e.deletedAt).length);139 });140141 const [phrase, setPhrase] = createSignal("");142 const [passphrase, setPassphrase] = createSignal("");143 const [masterPassword, setMasterPassword] = createSignal("");144 const [masterConfirm, setMasterConfirm] = createSignal("");145146 147 const [verifiedFields, setVerifiedFields] = createSignal<ExtractedFields | null>(null);148 const [announcedEntryCount, setAnnouncedEntryCount] = createSignal(0);149150 151 152 const [restoredPayload, setRestoredPayload] = createSignal<VaultBackupPayload | null>(null);153154 const [progress, setProgress] = createSignal<ProgressStage>("idle");155 const [pasteError, setPasteError] = createSignal<string | null>(null);156 const [passphraseError, setPassphraseError] = createSignal<string | null>(null);157 const [masterError, setMasterError] = createSignal<string | null>(null);158 const [importedCount, setImportedCount] = createSignal(0);159160 161 162 163164 const structureOk = createMemo(() =>165 phrase().trim().length > 0 ? validatePhraseStructure(phrase()) : null166 );167168 const pasteSummary = createMemo(() => {169 if (!structureOk()) return null;170 try {171 const { headerLine } = splitPhrase(phrase());172 const header = parseHeaderLine(headerLine);173 return { entryCount: header.entryCount };174 } catch {175 return null;176 }177 });178179 async function handlePhraseInput(value: string) {180 setPhrase(value);181 setPasteError(null);182 }183184 async function handlePasteFromClipboard() {185 try {186 const text = await navigator.clipboard.readText();187 setPhrase(text);188 setPasteError(null);189 } catch {190 setPasteError(191 "Couldn't read the clipboard. Paste into the box above instead."192 );193 }194 }195196 function handleFileSelect(e: Event) {197 const file = (e.target as HTMLInputElement).files?.[0];198 if (!file) return;199 const reader = new FileReader();200 reader.onload = (ev) => {201 const text = ev.target?.result as string;202 setPhrase(text);203 setPasteError(null);204 };205 reader.readAsText(file);206 }207208 209210211212213 async function handleContinueFromPaste() {214 setPasteError(null);215 if (!structureOk()) {216 setPasteError(IMPORT_ERROR_COPY.INVALID_HEADER);217 return;218 }219220 try {221 setProgress("decoding");222 const { headerLine, cjkContent } = splitPhrase(phrase());223 const header = parseHeaderLine(headerLine);224 setAnnouncedEntryCount(header.entryCount);225226 const binary = decodePhraseToBytes(cjkContent);227228 setProgress("verifying");229 const fields = await verifyIntegrity(binary);230231 if (header.entryCount !== fields.entryCount) {232 throw new Error("HEADER_COUNT_MISMATCH");233 }234235 setVerifiedFields(fields);236 setProgress("idle");237 238 239 setScreen(240 props.mode === "onboarding" ? "onboarding-master" : "passphrase"241 );242 } catch (err) {243 setProgress("idle");244 setPasteError(messageForError(err));245 }246 }247248 249 250 251252 const masterOk = createMemo(253 () =>254 masterPassword().length >= 8 && masterPassword() === masterConfirm()255 );256257 function handleContinueFromMaster() {258 setMasterError(null);259 if (masterPassword().length < 8) {260 setMasterError("Master password must be at least 8 characters.");261 return;262 }263 if (masterPassword() !== masterConfirm()) {264 setMasterError("Passwords don't match.");265 return;266 }267 setScreen("passphrase");268 }269270 271 272 273274 async function handleRestore() {275 setPassphraseError(null);276 const fields = verifiedFields();277 if (!fields) {278 setPassphraseError(IMPORT_ERROR_COPY.INVALID_HEADER);279 setScreen("paste");280 return;281 }282 if (!passphrase()) {283 setPassphraseError("Enter your export passphrase.");284 return;285 }286287 setScreen("restoring");288289 try {290 setProgress("deriving");291 292 await new Promise((r) => setTimeout(r, 0));293294 const payload = await decryptAndDeserialise(fields, passphrase());295 setProgress("decrypting");296 await new Promise((r) => setTimeout(r, 0));297298 if (payload.entries.length !== fields.entryCount) {299 throw new Error("ENTRY_COUNT_MISMATCH");300 }301302 setRestoredPayload(payload);303 setProgress("importing");304305 if (props.mode === "onboarding") {306 307 308 const create = await sendMessage({309 type: "CREATE_VAULT",310 payload: { password: masterPassword() },311 });312 if (!(create as { ok: boolean }).ok) {313 throw new Error("Could not create vault — please try again.");314 }315 await restorePayload(payload, "replace");316 await finishSuccess(payload.entries.length);317 return;318 }319320 321 322 if (existingEntryCount() > 0) {323 setProgress("idle");324 setScreen("merge");325 } else {326 await restorePayload(payload, "replace");327 await finishSuccess(payload.entries.length);328 }329 } catch (err) {330 setProgress("idle");331 const message = messageForError(err);332 333 334 const raw = err instanceof Error ? err.message : String(err);335 const code = raw.split(":")[0].trim();336 if (337 code === "DECRYPT_FAILED"338 ) {339 setPassphraseError(message);340 setScreen("passphrase");341 } else {342 setPasteError(message);343 setScreen("paste");344 }345 }346 }347348 349 350 351352 async function handleReplace() {353 const payload = restoredPayload();354 if (!payload) return;355 setScreen("restoring");356 setProgress("importing");357 await restorePayload(payload, "replace");358 await finishSuccess(payload.entries.length);359 }360361 async function handleMerge() {362 const payload = restoredPayload();363 if (!payload) return;364 setScreen("restoring");365 setProgress("importing");366 await restorePayload(payload, "merge");367 await finishSuccess(payload.entries.length);368 }369370 function handleCancel() {371 setRestoredPayload(null);372 setPassphrase("");373 props.onBack();374 }375376 377 378 379380 async function restorePayload(381 payload: VaultBackupPayload,382 mode: "replace" | "merge"383 ): Promise<void> {384 const res = await sendMessage({385 type: "RESTORE_ENTRIES",386 payload: { entries: payload.entries, settings: payload.settings, mode },387 });388 const summary = (res as { ok: boolean; data?: { added: number; updated: number; skipped: number } }).data;389 const total = (summary?.added ?? 0) + (summary?.updated ?? 0);390 setImportedCount(total || payload.entries.length);391392 393 394 if (payload.license?.key) {395 await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key: payload.license.key } }).catch(() => {});396 }397 }398399 async function finishSuccess(count: number) {400 setImportedCount(count);401 setRestoredPayload(null);402 setPassphrase("");403 setMasterPassword("");404 setMasterConfirm("");405 setProgress("idle");406 setScreen("success");407 }408409 async function handleOpenVault() {410 await props.onDone();411 }412413 414 415 416417 return (418 <div class={props.mode === "onboarding" ? "peach-lock-view" : "peach-overlay-view ov-enter-right"}>419 <Show420 when={props.mode === "onboarding"}421 fallback={422 <div class="peach-overlay-header">423 <button424 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"425 onClick={props.onBack}426 aria-label="Back"427 disabled={screen() === "restoring"}428 >429 <svg430 width="7"431 height="12"432 viewBox="0 0 7 12"433 fill="none"434 stroke="currentColor"435 stroke-width="1.1"436 stroke-linecap="round"437 stroke-linejoin="round"438 >439 <path d="M6 1L1 6L6 11" />440 </svg>441 </button>442 <span class="peach-overlay-title">import</span>443 <div class="peach-overlay-spacer"></div>444 </div>445 }446 >447 <div class="peach-lock-view-header">448 <PeachWordmark class="peach-wordmark-wrap" />449 <button450 class="peach-lock-restore-link"451 onClick={props.onBack}452 disabled={screen() === "restoring"}453 >454 ‹ back455 </button>456 </div>457 </Show>458459 <div class="peach-settings-body">460 <Show when={screen() === "paste"}>461 <PasteScreen462 phrase={phrase()}463 onPhraseInput={handlePhraseInput}464 structureOk={Boolean(structureOk())}465 summary={pasteSummary()}466 error={pasteError()}467 onPasteClipboard={() => void handlePasteFromClipboard()}468 onFileSelect={handleFileSelect}469 onContinue={() => void handleContinueFromPaste()}470 />471 </Show>472473 <Show when={screen() === "onboarding-master"}>474 <OnboardingMasterScreen475 password={masterPassword()}476 confirm={masterConfirm()}477 onPasswordInput={setMasterPassword}478 onConfirmInput={setMasterConfirm}479 error={masterError()}480 canContinue={masterOk()}481 onContinue={handleContinueFromMaster}482 />483 </Show>484485 <Show when={screen() === "passphrase"}>486 <PassphraseScreen487 passphrase={passphrase()}488 onPassphraseInput={setPassphrase}489 error={passphraseError()}490 entryCount={verifiedFields()?.entryCount ?? announcedEntryCount()}491 onRestore={() => void handleRestore()}492 />493 </Show>494495 <Show when={screen() === "restoring"}>496 <RestoringScreen stage={progress()} />497 </Show>498499 <Show when={screen() === "merge"}>500 <MergeScreen501 currentCount={existingEntryCount()}502 restoredCount={restoredPayload()?.entries.length ?? 0}503 onReplace={() => void handleReplace()}504 onMerge={() => void handleMerge()}505 onCancel={handleCancel}506 />507 </Show>508509 <Show when={screen() === "success"}>510 <SuccessScreen511 count={importedCount()}512 onOpen={() => void handleOpenVault()}513 />514 </Show>515 </div>516 </div>517 );518}519520521522523524interface PasteScreenProps {525 phrase: string;526 onPhraseInput: (value: string) => void;527 structureOk: boolean;528 summary: { entryCount: number } | null;529 error: string | null;530 onPasteClipboard: () => void;531 onFileSelect: (e: Event) => void;532 onContinue: () => void;533}534535function PasteScreen(props: PasteScreenProps) {536 return (537 <div class="peach-settings-group">538 <p class="peach-settings-label">restore from backup phrase</p>539 <p class="peach-settings-copy peach-settings-copy--spacious">540 Paste your Peach backup phrase below, then enter the passphrase you used when you exported it.541 </p>542543 <textarea544 placeholder={545 'Paste your phrase here.\nIt begins with "peach:v2:entries=..." and consists of Chinese characters.'546 }547 class="peach-phrase-textarea"548 value={props.phrase}549 onInput={(e) => props.onPhraseInput((e.target as HTMLTextAreaElement).value)}550 />551552 <div class="peach-settings-actions" style="margin-top:10px">553 <button554 class="peach-btn peach-btn--ghost peach-btn--sm"555 onClick={props.onPasteClipboard}556 >557 Paste from clipboard558 </button>559 <label class="peach-btn peach-btn--ghost peach-btn--sm">560 Load from file561 <input562 type="file"563 accept=".txt"564 class="peach-hidden-file-input"565 onChange={props.onFileSelect}566 />567 </label>568 </div>569570 <Show when={props.phrase.trim().length > 0 && props.structureOk && props.summary}>571 <div class="peach-inline-feedback peach-inline-feedback--ok">572 ✓ Phrase recognised — {props.summary!.entryCount} entr{props.summary!.entryCount === 1 ? "y" : "ies"}573 </div>574 </Show>575576 <Show when={props.error}>577 <div class="peach-inline-feedback peach-inline-feedback--error">578 {props.error}579 </div>580 </Show>581 <Show when={props.phrase.trim().length > 0 && !props.structureOk && !props.error}>582 <div class="peach-inline-feedback peach-inline-feedback--error">583 {IMPORT_ERROR_COPY.INVALID_HEADER}584 </div>585 </Show>586587 <div class="peach-settings-actions--row peach-settings-actions--end">588 <button589 class="peach-btn peach-btn--subtle peach-btn--sm"590 onClick={props.onContinue}591 disabled={!props.structureOk}592 >593 Continue →594 </button>595 </div>596 </div>597 );598}599600601602603604interface OnboardingMasterScreenProps {605 password: string;606 confirm: string;607 onPasswordInput: (value: string) => void;608 onConfirmInput: (value: string) => void;609 error: string | null;610 canContinue: boolean;611 onContinue: () => void;612}613614function OnboardingMasterScreen(props: OnboardingMasterScreenProps) {615 return (616 <div class="peach-settings-group">617 <p class="peach-settings-label">set a master password</p>618 <p class="peach-settings-copy peach-settings-copy--spacious">619 This locks your local vault. It's separate from the export passphrase620 on your backup phrase.621 </p>622623 <input624 class="peach-settings-input"625 type="password"626 placeholder="master password"627 value={props.password}628 onInput={(e) => props.onPasswordInput((e.target as HTMLInputElement).value)}629 />630 <input631 class="peach-settings-input"632 type="password"633 placeholder="confirm"634 value={props.confirm}635 onInput={(e) => props.onConfirmInput((e.target as HTMLInputElement).value)}636 />637638 <Show when={props.error}>639 <div class="peach-inline-feedback peach-inline-feedback--error">640 {props.error}641 </div>642 </Show>643644 <div class="peach-settings-actions--row peach-settings-actions--end">645 <button646 class="peach-btn peach-btn--subtle peach-btn--sm"647 onClick={props.onContinue}648 disabled={!props.canContinue}649 >650 Continue →651 </button>652 </div>653 </div>654 );655}656657658659660661interface PassphraseScreenProps {662 passphrase: string;663 onPassphraseInput: (value: string) => void;664 error: string | null;665 entryCount: number;666 onRestore: () => void;667}668669function PassphraseScreen(props: PassphraseScreenProps) {670 let inputRef: HTMLInputElement | undefined;671 onMount(() => { inputRef?.focus(); });672 return (673 <div class="peach-settings-group">674 <p class="peach-settings-label">enter your export passphrase</p>675 <p class="peach-settings-copy peach-settings-copy--spacious">676 This is the passphrase you chose when you exported, not your vault password.677 </p>678679 <input680 ref={inputRef}681 class="peach-settings-input"682 type="password"683 placeholder="export passphrase"684 value={props.passphrase}685 onInput={(e) => props.onPassphraseInput((e.target as HTMLInputElement).value)}686 onKeyDown={(e) => {687 if (e.key === "Enter") props.onRestore();688 }}689 />690691 <div class="peach-inline-feedback peach-inline-feedback--muted peach-inline-feedback--spaced">692 {props.entryCount} entr{props.entryCount === 1 ? "y" : "ies"} will be restored.693 </div>694695 <Show when={props.error}>696 <div class="peach-inline-feedback peach-inline-feedback--error">697 {props.error}698 </div>699 </Show>700701 <div class="peach-settings-actions--row peach-settings-actions--end">702 <button703 class="peach-btn peach-btn--subtle peach-btn--sm"704 onClick={props.onRestore}705 disabled={props.passphrase.length === 0}706 >707 Restore →708 </button>709 </div>710 </div>711 );712}713714715716717718interface RestoringScreenProps {719 stage: ProgressStage;720}721722function RestoringScreen(props: RestoringScreenProps) {723 return (724 <div class="peach-settings-group peach-progress-screen">725 <div class="peach-progress-screen-title">Restoring your vault…</div>726 <div class="peach-spinner" aria-label="Working" />727 <div class="peach-progress-screen-stage">{PROGRESS_COPY[props.stage]}</div>728 </div>729 );730}731732733734735736interface MergeScreenProps {737 currentCount: number;738 restoredCount: number;739 onReplace: () => void;740 onMerge: () => void;741 onCancel: () => void;742}743744function MergeScreen(props: MergeScreenProps) {745 return (746 <div class="peach-settings-group">747 <p class="peach-settings-label">you already have a vault</p>748 <p class="peach-settings-copy" style="margin:0 0 16px">749 Your current vault has <strong>{props.currentCount}</strong> entr750 {props.currentCount === 1 ? "y" : "ies"}. The backup has{" "}751 <strong>{props.restoredCount}</strong> entr752 {props.restoredCount === 1 ? "y" : "ies"}.753 </p>754755 <div class="peach-settings-stack peach-settings-stack--spacious">756 <button757 class="peach-btn peach-btn--subtle peach-btn--sm"758 onClick={props.onMerge}759 >760 Merge — combine both, keep newer on conflicts761 </button>762 <button763 class="peach-btn peach-btn--subtle peach-btn--sm"764 style="border-color:rgba(220,80,60,0.4)"765 onClick={props.onReplace}766 >767 Replace my vault — discards current {props.currentCount}768 </button>769 <button770 class="peach-btn peach-btn--ghost peach-btn--sm"771 onClick={props.onCancel}772 >773 Cancel — nothing changed774 </button>775 </div>776 </div>777 );778}779780781782783784interface SuccessScreenProps {785 count: number;786 onOpen: () => void;787}788789function SuccessScreen(props: SuccessScreenProps) {790 return (791 <div class="peach-settings-group peach-progress-screen">792 <div class="peach-progress-screen-title">Vault restored</div>793 <div class="peach-settings-copy">{props.count} entr{props.count === 1 ? "y" : "ies"} imported successfully.</div>794 <button795 class="peach-btn peach-btn--subtle peach-btn--sm"796 onClick={props.onOpen}797 >798 Open vault →799 </button>800 </div>801 );802}