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);391 }392393 async function finishSuccess(count: number) {394 setImportedCount(count);395 setRestoredPayload(null);396 setPassphrase("");397 setMasterPassword("");398 setMasterConfirm("");399 setProgress("idle");400 setScreen("success");401 }402403 async function handleOpenVault() {404 await props.onDone();405 }406407 408 409 410411 return (412 <div class={props.mode === "onboarding" ? "peach-lock-view" : "peach-overlay-view ov-enter-right"}>413 <Show414 when={props.mode === "onboarding"}415 fallback={416 <div class="peach-overlay-header">417 <button418 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"419 onClick={props.onBack}420 aria-label="Back"421 disabled={screen() === "restoring"}422 >423 <svg424 width="7"425 height="12"426 viewBox="0 0 7 12"427 fill="none"428 stroke="currentColor"429 stroke-width="1.1"430 stroke-linecap="round"431 stroke-linejoin="round"432 >433 <path d="M6 1L1 6L6 11" />434 </svg>435 </button>436 <span class="peach-overlay-title">import</span>437 <div class="peach-overlay-spacer"></div>438 </div>439 }440 >441 <div class="peach-lock-view-header">442 <PeachWordmark class="peach-wordmark-wrap" />443 <button444 class="peach-lock-restore-link"445 onClick={props.onBack}446 disabled={screen() === "restoring"}447 >448 ‹ back449 </button>450 </div>451 </Show>452453 <div class="peach-settings-body">454 <Show when={screen() === "paste"}>455 <PasteScreen456 phrase={phrase()}457 onPhraseInput={handlePhraseInput}458 structureOk={Boolean(structureOk())}459 summary={pasteSummary()}460 error={pasteError()}461 onPasteClipboard={() => void handlePasteFromClipboard()}462 onFileSelect={handleFileSelect}463 onContinue={() => void handleContinueFromPaste()}464 />465 </Show>466467 <Show when={screen() === "onboarding-master"}>468 <OnboardingMasterScreen469 password={masterPassword()}470 confirm={masterConfirm()}471 onPasswordInput={setMasterPassword}472 onConfirmInput={setMasterConfirm}473 error={masterError()}474 canContinue={masterOk()}475 onContinue={handleContinueFromMaster}476 />477 </Show>478479 <Show when={screen() === "passphrase"}>480 <PassphraseScreen481 passphrase={passphrase()}482 onPassphraseInput={setPassphrase}483 error={passphraseError()}484 entryCount={verifiedFields()?.entryCount ?? announcedEntryCount()}485 onRestore={() => void handleRestore()}486 />487 </Show>488489 <Show when={screen() === "restoring"}>490 <RestoringScreen stage={progress()} />491 </Show>492493 <Show when={screen() === "merge"}>494 <MergeScreen495 currentCount={existingEntryCount()}496 restoredCount={restoredPayload()?.entries.length ?? 0}497 onReplace={() => void handleReplace()}498 onMerge={() => void handleMerge()}499 onCancel={handleCancel}500 />501 </Show>502503 <Show when={screen() === "success"}>504 <SuccessScreen505 count={importedCount()}506 onOpen={() => void handleOpenVault()}507 />508 </Show>509 </div>510 </div>511 );512}513514515516517518interface PasteScreenProps {519 phrase: string;520 onPhraseInput: (value: string) => void;521 structureOk: boolean;522 summary: { entryCount: number } | null;523 error: string | null;524 onPasteClipboard: () => void;525 onFileSelect: (e: Event) => void;526 onContinue: () => void;527}528529function PasteScreen(props: PasteScreenProps) {530 return (531 <div class="peach-settings-group">532 <p class="peach-settings-label">restore from backup phrase</p>533 <p class="peach-settings-copy peach-settings-copy--spacious">534 Paste your Peach backup phrase below, then enter the passphrase you used when you exported it.535 </p>536537 <textarea538 placeholder={539 'Paste your phrase here.\nIt begins with "peach:v2:entries=..." and consists of Chinese characters.'540 }541 class="peach-phrase-textarea"542 value={props.phrase}543 onInput={(e) => props.onPhraseInput((e.target as HTMLTextAreaElement).value)}544 />545546 <div class="peach-settings-actions" style="margin-top:10px">547 <button548 class="peach-btn peach-btn--ghost peach-btn--sm"549 onClick={props.onPasteClipboard}550 >551 Paste from clipboard552 </button>553 <label class="peach-btn peach-btn--ghost peach-btn--sm">554 Load from file555 <input556 type="file"557 accept=".txt"558 class="peach-hidden-file-input"559 onChange={props.onFileSelect}560 />561 </label>562 </div>563564 <Show when={props.phrase.trim().length > 0 && props.structureOk && props.summary}>565 <div class="peach-inline-feedback peach-inline-feedback--ok">566 ✓ Phrase recognised — {props.summary!.entryCount} entr{props.summary!.entryCount === 1 ? "y" : "ies"}567 </div>568 </Show>569570 <Show when={props.error}>571 <div class="peach-inline-feedback peach-inline-feedback--error">572 {props.error}573 </div>574 </Show>575 <Show when={props.phrase.trim().length > 0 && !props.structureOk && !props.error}>576 <div class="peach-inline-feedback peach-inline-feedback--error">577 {IMPORT_ERROR_COPY.INVALID_HEADER}578 </div>579 </Show>580581 <div class="peach-settings-actions--row peach-settings-actions--end">582 <button583 class="peach-btn peach-btn--subtle peach-btn--sm"584 onClick={props.onContinue}585 disabled={!props.structureOk}586 >587 Continue →588 </button>589 </div>590 </div>591 );592}593594595596597598interface OnboardingMasterScreenProps {599 password: string;600 confirm: string;601 onPasswordInput: (value: string) => void;602 onConfirmInput: (value: string) => void;603 error: string | null;604 canContinue: boolean;605 onContinue: () => void;606}607608function OnboardingMasterScreen(props: OnboardingMasterScreenProps) {609 return (610 <div class="peach-settings-group">611 <p class="peach-settings-label">set a master password</p>612 <p class="peach-settings-copy peach-settings-copy--spacious">613 This locks your local vault. It's separate from the export passphrase614 on your backup phrase.615 </p>616617 <input618 class="peach-settings-input"619 type="password"620 placeholder="master password"621 value={props.password}622 onInput={(e) => props.onPasswordInput((e.target as HTMLInputElement).value)}623 />624 <input625 class="peach-settings-input"626 type="password"627 placeholder="confirm"628 value={props.confirm}629 onInput={(e) => props.onConfirmInput((e.target as HTMLInputElement).value)}630 />631632 <Show when={props.error}>633 <div class="peach-inline-feedback peach-inline-feedback--error">634 {props.error}635 </div>636 </Show>637638 <div class="peach-settings-actions--row peach-settings-actions--end">639 <button640 class="peach-btn peach-btn--subtle peach-btn--sm"641 onClick={props.onContinue}642 disabled={!props.canContinue}643 >644 Continue →645 </button>646 </div>647 </div>648 );649}650651652653654655interface PassphraseScreenProps {656 passphrase: string;657 onPassphraseInput: (value: string) => void;658 error: string | null;659 entryCount: number;660 onRestore: () => void;661}662663function PassphraseScreen(props: PassphraseScreenProps) {664 let inputRef: HTMLInputElement | undefined;665 onMount(() => { inputRef?.focus(); });666 return (667 <div class="peach-settings-group">668 <p class="peach-settings-label">enter your export passphrase</p>669 <p class="peach-settings-copy peach-settings-copy--spacious">670 This is the passphrase you chose when you exported, not your vault password.671 </p>672673 <input674 ref={inputRef}675 class="peach-settings-input"676 type="password"677 placeholder="export passphrase"678 value={props.passphrase}679 onInput={(e) => props.onPassphraseInput((e.target as HTMLInputElement).value)}680 onKeyDown={(e) => {681 if (e.key === "Enter") props.onRestore();682 }}683 />684685 <div class="peach-inline-feedback peach-inline-feedback--muted peach-inline-feedback--spaced">686 {props.entryCount} entr{props.entryCount === 1 ? "y" : "ies"} will be restored.687 </div>688689 <Show when={props.error}>690 <div class="peach-inline-feedback peach-inline-feedback--error">691 {props.error}692 </div>693 </Show>694695 <div class="peach-settings-actions--row peach-settings-actions--end">696 <button697 class="peach-btn peach-btn--subtle peach-btn--sm"698 onClick={props.onRestore}699 disabled={props.passphrase.length === 0}700 >701 Restore →702 </button>703 </div>704 </div>705 );706}707708709710711712interface RestoringScreenProps {713 stage: ProgressStage;714}715716function RestoringScreen(props: RestoringScreenProps) {717 return (718 <div class="peach-settings-group peach-progress-screen">719 <div class="peach-progress-screen-title">Restoring your vault…</div>720 <div class="peach-spinner" aria-label="Working" />721 <div class="peach-progress-screen-stage">{PROGRESS_COPY[props.stage]}</div>722 </div>723 );724}725726727728729730interface MergeScreenProps {731 currentCount: number;732 restoredCount: number;733 onReplace: () => void;734 onMerge: () => void;735 onCancel: () => void;736}737738function MergeScreen(props: MergeScreenProps) {739 return (740 <div class="peach-settings-group">741 <p class="peach-settings-label">you already have a vault</p>742 <p class="peach-settings-copy" style="margin:0 0 16px">743 Your current vault has <strong>{props.currentCount}</strong> entr744 {props.currentCount === 1 ? "y" : "ies"}. The backup has{" "}745 <strong>{props.restoredCount}</strong> entr746 {props.restoredCount === 1 ? "y" : "ies"}.747 </p>748749 <div class="peach-settings-stack peach-settings-stack--spacious">750 <button751 class="peach-btn peach-btn--subtle peach-btn--sm"752 onClick={props.onMerge}753 >754 Merge — combine both, keep newer on conflicts755 </button>756 <button757 class="peach-btn peach-btn--subtle peach-btn--sm"758 style="border-color:rgba(220,80,60,0.4)"759 onClick={props.onReplace}760 >761 Replace my vault — discards current {props.currentCount}762 </button>763 <button764 class="peach-btn peach-btn--ghost peach-btn--sm"765 onClick={props.onCancel}766 >767 Cancel — nothing changed768 </button>769 </div>770 </div>771 );772}773774775776777778interface SuccessScreenProps {779 count: number;780 onOpen: () => void;781}782783function SuccessScreen(props: SuccessScreenProps) {784 return (785 <div class="peach-settings-group peach-progress-screen">786 <div class="peach-progress-screen-title">Vault restored</div>787 <div class="peach-settings-copy">{props.count} entr{props.count === 1 ? "y" : "ies"} imported successfully.</div>788 <button789 class="peach-btn peach-btn--subtle peach-btn--sm"790 onClick={props.onOpen}791 >792 Open vault →793 </button>794 </div>795 );796}