1import { For, Match, Show, Switch, createMemo, createSignal, onMount } from "solid-js";23import { sendMessage } from "../App";4import { PeachMark, PeachWordmark } from "../components/PeachMark";5import { CJKImportView } from "./CJKImport";6import { ImportView } from "./Import";789let _zxcvbn: ((password: string) => { score: number }) | null = null;10async function getZxcvbn() {11 if (_zxcvbn) return _zxcvbn;12 const mod = await import("zxcvbn");13 _zxcvbn = mod.default;14 return _zxcvbn;15}1617interface LockProps {18 hasVault: boolean;19 ready: boolean;20 isPro?: boolean;21 onUnlock: () => void | Promise<void>;22}232425type RestoreSurface = "restore" | "snapshots" | "cjk" | "file" | null;2627export function LockView(props: LockProps) {28 const [password, setPassword] = createSignal("");29 const [confirmPassword, setConfirmPassword] = createSignal("");30 const [error, setError] = createSignal("");31 const [loading, setLoading] = createSignal(false);32 const [restoreSurface, setRestoreSurface] = createSignal<RestoreSurface>(null);33 const [showPassword, setShowPassword] = createSignal(false);3435 const MIN_LENGTH = 12;36 const MIN_ZXCVBN_SCORE = 2;3738 const [strengthScore, setStrengthScore] = createSignal<number | null>(null);3940 const canCreate = createMemo(41 () =>42 password().length >= MIN_LENGTH &&43 confirmPassword().length >= MIN_LENGTH &&44 password() === confirmPassword() &&45 strengthScore() !== null &&46 strengthScore()! >= MIN_ZXCVBN_SCORE47 );4849 const passwordStrength = createMemo(() => {50 const score = strengthScore();51 if (score === null) return null;52 if (score <= 1) return "weak";53 if (score === 2) return "fair";54 if (score === 3) return "good";55 return "strong";56 });5758 59 createMemo(() => {60 const value = password();61 if (!value) { setStrengthScore(null); return; }62 getZxcvbn().then((zxcvbn) => {63 if (password() === value) setStrengthScore(zxcvbn(value).score);64 }).catch(() => {65 66 if (password() === value) setStrengthScore(value.length >= 16 ? 3 : value.length >= 12 ? 2 : 1);67 });68 });6970 async function handleUnlock() {71 if (!password().trim()) return;72 setLoading(true);73 setError("");74 const response = await sendMessage({75 type: "UNLOCK",76 payload: { password: password() },77 });78 const result = response as { ok: boolean; error?: string };79 if (result.ok) {80 await props.onUnlock();81 setLoading(false);82 return;83 }84 setError(getDisplayError(result.error, "incorrect password"));85 setPassword("");86 const passwordField = document.querySelector(".lock-password-input") as HTMLInputElement | undefined;87 passwordField?.classList.add("shake");88 setTimeout(() => passwordField?.classList.remove("shake"), 300);89 setLoading(false);90 }9192 async function handleCreate() {93 if (!password().trim()) return;94 if (password().length < MIN_LENGTH) {95 setError(`password must be at least ${MIN_LENGTH} characters`);96 return;97 }98 if (password() !== confirmPassword()) {99 setError("passwords do not match");100 return;101 }102 const score = strengthScore();103 if (score === null || score < MIN_ZXCVBN_SCORE) {104 setError("password is too weak — try a longer or more varied passphrase");105 return;106 }107 setLoading(true);108 setError("");109 const response = await sendMessage({110 type: "CREATE_VAULT",111 payload: { password: password() },112 });113 const result = response as { ok: boolean; error?: string };114 if (result.ok) {115 await props.onUnlock();116 setLoading(false);117 return;118 }119 setError(getDisplayError(result.error, "failed to create vault"));120 setLoading(false);121 }122123 function openRestore(surface: Exclude<RestoreSurface, null>) {124 if (!props.ready || loading()) return;125 setPassword("");126 setConfirmPassword("");127 setError("");128 setRestoreSurface(surface);129 }130131 132 return (133 <Switch>134 {}135 <Match when={restoreSurface() === "restore"}>136 <RestorePickerView137 onBack={() => setRestoreSurface(null)}138 onPickCjk={() => setRestoreSurface("cjk")}139 onPickFile={() => setRestoreSurface("file")}140 onPickSnapshots={() => setRestoreSurface("snapshots")}141 />142 </Match>143144 {}145 <Match when={restoreSurface() === "snapshots"}>146 <SnapshotRestoreView147 onBack={() => setRestoreSurface("restore")}148 onRestored={props.onUnlock}149 />150 </Match>151152 {}153 <Match when={restoreSurface() === "cjk"}>154 <CJKImportView155 mode="onboarding"156 onBack={() => setRestoreSurface("restore")}157 onDone={async () => {158 setRestoreSurface(null);159 await props.onUnlock();160 }}161 />162 </Match>163164 {}165 <Match when={restoreSurface() === "file"}>166 <ImportView167 mode="onboarding"168 replacesExistingVault={props.hasVault}169 onBack={() => setRestoreSurface("restore")}170 onImport={async () => {171 setRestoreSurface(null);172 await props.onUnlock();173 }}174 />175 </Match>176177 {}178 <Match when={props.hasVault}>179 <section class="peach-screen peach-theme lock-screen lock-screen--vault-stage" data-motion-phase="enter">180 <header class="peach-screen-header lock-screen-header">181 <div class="peach-wordmark-stack">182 <PeachWordmark class="peach-wordmark-wrap" />183 <Show when={props.isPro}>184 <span class="peach-pro-mini-badge">pro</span>185 </Show>186 </div>187 </header>188189 <div class="peach-lock-hero peach-lock-hero--unlock">190 <div class="peach-lock-hero-art" aria-hidden="true">191 <PeachMark class="peach-lock-hero-mark" />192 </div>193 <span class="peach-lock-eyebrow">vault locked</span>194 <h1 class="peach-onboarding-headline peach-lock-headline--unlock">195 unlock196 <br />197 your vault.198 </h1>199 </div>200201 <section class="peach-lock-panel peach-lock-panel--unlock">202 <label class="peach-label" for="unlock-password">203 master password204 </label>205 <input206 id="unlock-password"207 class="peach-underline-input peach-unlock-input lock-password-input"208 type="password"209 placeholder="••••••••••••"210 autocomplete="current-password"211 value={password()}212 disabled={!props.ready}213 ref={(el) => { if (props.ready) setTimeout(() => el.focus(), 80); }}214 onInput={(event) => setPassword((event.target as HTMLInputElement).value)}215 onKeyDown={(event) => {216 if (event.key !== "Enter" || !props.ready) return;217 void handleUnlock();218 }}219 />220221 <Show when={error()}>222 <p class="peach-hint peach-hint-error">{error()}</p>223 </Show>224225 <button226 class="peach-btn peach-btn--accent peach-btn--md peach-lock-submit"227 onClick={() => void handleUnlock()}228 disabled={!props.ready || loading()}229 >230 {loading() ? "unlocking..." : "unlock"}231 </button>232 </section>233 </section>234 </Match>235236 {}237 <Match when={true}>238 <section239 class="peach-screen peach-theme lock-screen lock-screen--vault-stage lock-screen--onboarding"240 data-motion-phase="enter"241 >242 <header class="peach-screen-header lock-screen-header">243 <div class="peach-wordmark-stack">244 <PeachWordmark class="peach-wordmark-wrap" />245 <Show when={props.isPro}>246 <span class="peach-pro-mini-badge">pro</span>247 </Show>248 </div>249 </header>250251 <div class="peach-lock-hero">252 <div class="peach-lock-hero-art" aria-hidden="true">253 <PeachMark class="peach-lock-hero-mark" />254 </div>255 <span class="peach-lock-eyebrow">new vault</span>256 <h1 class="peach-onboarding-headline">257 keep your258 <br />259 secrets.260 </h1>261 </div>262263 <section class="peach-lock-panel peach-lock-panel--primary">264 <div class="peach-lock-label-row">265 <label class="peach-label" for="master-password">266 master password267 </label>268 <button269 class="peach-lock-reveal-btn"270 type="button"271 onClick={() => setShowPassword((value) => !value)}272 aria-label={showPassword() ? "Hide password" : "Show password"}273 >274 <Show275 when={showPassword()}276 fallback={277 <svg width="15" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">278 <path d="M1 5C1 5 2.8 1 7 1C11.2 1 13 5 13 5C13 5 11.2 9 7 9C2.8 9 1 5 1 5Z" />279 <circle cx="7" cy="5" r="1.75" />280 </svg>281 }282 >283 <svg width="15" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">284 <path d="M1 2C3 4.5 5 6 7 6C9 6 11 4.5 13 2" />285 <path d="M3.5 5.5L2.5 7.5" />286 <path d="M7 6.5V8.5" />287 <path d="M10.5 5.5L11.5 7.5" />288 </svg>289 </Show>290 {showPassword() ? "hide" : "show"}291 </button>292 </div>293 <input294 id="master-password"295 class="peach-underline-input peach-unlock-input lock-password-input"296 type={showPassword() ? "text" : "password"}297 placeholder="at least 12 characters"298 autocomplete="new-password"299 value={password()}300 disabled={!props.ready}301 onInput={(event) => setPassword((event.target as HTMLInputElement).value)}302 onKeyDown={(event) => {303 if (event.key !== "Enter" || !props.ready) return;304 void handleCreate();305 }}306 />307308 <Show when={password().length > 0 && passwordStrength() !== null}>309 <div class="peach-password-strength">310 <div class={`peach-strength-bar peach-strength-bar--${passwordStrength()}`}>311 <div class="peach-strength-fill"></div>312 </div>313 <span class="peach-strength-label">{passwordStrength()}</span>314 </div>315 </Show>316317 <label class="peach-label" for="confirm-password">318 confirm password319 </label>320 <input321 id="confirm-password"322 class="peach-underline-input peach-unlock-input"323 type={showPassword() ? "text" : "password"}324 placeholder="••••••••••••"325 autocomplete="new-password"326 value={confirmPassword()}327 disabled={!props.ready}328 onInput={(event) => setConfirmPassword((event.target as HTMLInputElement).value)}329 onKeyDown={(event) => {330 if (event.key === "Enter" && props.ready) void handleCreate();331 }}332 />333334 <Show when={error()}>335 <p class="peach-hint peach-hint-error">{error()}</p>336 </Show>337338 <button339 class="peach-btn peach-btn--accent peach-btn--md peach-lock-submit"340 onClick={() => void handleCreate()}341 disabled={!props.ready || loading() || !canCreate()}342 >343 {loading() ? "creating..." : "create vault"}344 </button>345346 <p class="peach-hint peach-hint-error peach-lock-warning">347 If you lose this password, Peach cannot recover your vault. There is no reset.348 </p>349350 <div class="peach-lock-panel-footer">351 <p class="peach-hint">Already have a backup?</p>352 <button353 class="peach-lock-restore-link"354 onClick={() => openRestore("restore")}355 disabled={!props.ready}356 >357 › restore from backup358 </button>359 </div>360 </section>361 </section>362 </Match>363 </Switch>364 );365}366367368369function RestorePickerView(props: {370 onBack: () => void;371 onPickCjk: () => void;372 onPickFile: () => void;373 onPickSnapshots: () => void;374}) {375 return (376 <section377 class="peach-screen peach-theme lock-screen lock-screen--vault-stage lock-screen--onboarding"378 data-motion-phase="enter"379 >380 <header class="peach-screen-header lock-screen-header">381 <PeachWordmark class="peach-wordmark-wrap" />382 <button class="peach-lock-restore-link" onClick={props.onBack}>383 ‹ back384 </button>385 </header>386387 <div class="peach-lock-hero">388 <span class="peach-lock-eyebrow">restore</span>389 <h1 class="peach-onboarding-headline">390 bring back391 <br />392 your vault.393 </h1>394 </div>395396 <section class="peach-lock-panel peach-lock-panel--restore">397 <p class="peach-hint" style="margin-bottom:2px;">How did you back up your data?</p>398399 <button class="peach-lock-option" onClick={props.onPickCjk}>400 <span class="peach-lock-option-title">backup phrase</span>401 <span class="peach-lock-option-copy">402 A block of Chinese characters exported directly from Peach. You'll need the export passphrase you set at the time.403 </span>404 </button>405406 <button class="peach-lock-option" onClick={props.onPickFile}>407 <span class="peach-lock-option-title">backup file</span>408 <span class="peach-lock-option-copy">409 A Peach JSON export, or a file from Bitwarden, Proton Pass, 1Password, or a browser (Chrome, Safari, Edge CSV).410 </span>411 </button>412413 <button class="peach-lock-option peach-lock-option--history" onClick={props.onPickSnapshots}>414 <span class="peach-lock-option-title">vault history</span>415 <span class="peach-lock-option-copy">416 Peach saves encrypted snapshots locally after each change. Revert to an earlier version if something went wrong.417 </span>418 </button>419 </section>420 </section>421 );422}423424425426interface SnapshotSummary {427 index: number;428 timestamp: number;429 entryCount: number;430}431432function SnapshotRestoreView(props: {433 onBack: () => void;434 onRestored: () => void | Promise<void>;435}) {436 const [snapshots, setSnapshots] = createSignal<SnapshotSummary[]>([]);437 const [loading, setLoading] = createSignal(true);438 const [selected, setSelected] = createSignal<number | null>(null);439 const [password, setPassword] = createSignal("");440 const [confirming, setConfirming] = createSignal(false);441 const [restoring, setRestoring] = createSignal(false);442 const [error, setError] = createSignal("");443444 onMount(async () => {445 const res = await sendMessage({ type: "LIST_VAULT_SNAPSHOTS" });446 setSnapshots((res as { ok: boolean; data?: SnapshotSummary[] }).data ?? []);447 setLoading(false);448 });449450 function formatAge(ts: number): string {451 const diff = Date.now() - ts;452 const mins = Math.floor(diff / 60_000);453 if (mins < 2) return "just now";454 if (mins < 60) return `${mins} minutes ago`;455 const hours = Math.floor(mins / 60);456 if (hours < 24) return `${hours} hour${hours === 1 ? "" : "s"} ago`;457 const days = Math.floor(hours / 24);458 return `${days} day${days === 1 ? "" : "s"} ago`;459 }460461 function selectSnapshot(index: number) {462 setSelected(index);463 setConfirming(false);464 setPassword("");465 setError("");466 }467468 async function handleRestore() {469 const idx = selected();470 if (idx === null || !password()) return;471 setRestoring(true);472 setError("");473 const res = await sendMessage({474 type: "RESTORE_VAULT_SNAPSHOT",475 payload: { index: idx, password: password() },476 });477 if ((res as { ok: boolean }).ok) {478 await props.onRestored();479 } else {480 setError("Incorrect password, or this snapshot cannot be decrypted.");481 setRestoring(false);482 }483 }484485 const selectedSnap = () => {486 const idx = selected();487 return idx !== null ? (snapshots()[idx] ?? null) : null;488 };489490 return (491 <section492 class="peach-screen peach-theme lock-screen lock-screen--vault-stage lock-screen--onboarding"493 data-motion-phase="enter"494 >495 <header class="peach-screen-header lock-screen-header">496 <PeachWordmark class="peach-wordmark-wrap" />497 <button class="peach-lock-restore-link" onClick={props.onBack} disabled={restoring()}>498 ‹ back499 </button>500 </header>501502 <div class="peach-lock-hero">503 <span class="peach-lock-eyebrow">vault history</span>504 <h1 class="peach-onboarding-headline">505 revert to506 <br />507 a snapshot.508 </h1>509 </div>510511 <section class="peach-lock-panel peach-snapshot-panel">512 <Show when={loading()}>513 <p class="peach-hint" style="text-align:center;padding:8px 0;">Loading…</p>514 </Show>515516 <Show when={!loading() && snapshots().length === 0}>517 <p class="peach-hint" style="line-height:1.6;">518 No snapshots yet. Peach automatically saves up to 6 encrypted snapshots after each vault change.519 </p>520 </Show>521522 <Show when={!loading() && snapshots().length > 0}>523 <p class="peach-hint" style="margin-bottom:4px;">Select a version to restore:</p>524 <div class="peach-snapshot-list">525 <For each={snapshots()}>526 {(snap) => (527 <button528 class="peach-snapshot-row"529 classList={{ "is-selected": selected() === snap.index }}530 onClick={() => selectSnapshot(snap.index)}531 disabled={restoring()}532 >533 <span class="peach-snapshot-time">{formatAge(snap.timestamp)}</span>534 <span class="peach-snapshot-count">535 {snap.entryCount} {snap.entryCount === 1 ? "entry" : "entries"}536 </span>537 </button>538 )}539 </For>540 </div>541542 <Show when={selected() !== null}>543 <div class="peach-snapshot-confirm">544 <label class="peach-label" for="snapshot-pw">master password</label>545 <input546 id="snapshot-pw"547 class="peach-underline-input peach-unlock-input"548 type="password"549 placeholder="••••••••••••"550 autocomplete="current-password"551 value={password()}552 disabled={restoring()}553 onInput={(e) => setPassword((e.target as HTMLInputElement).value)}554 onKeyDown={(e) => {555 if (e.key === "Enter" && !restoring() && confirming()) void handleRestore();556 }}557 />558559 <Show when={error()}>560 <p class="peach-hint peach-hint-error">{error()}</p>561 </Show>562563 <Show564 when={confirming()}565 fallback={566 <button567 class="peach-btn peach-btn--subtle peach-btn--sm peach-snapshot-restore-btn"568 onClick={() => setConfirming(true)}569 disabled={!password().trim() || restoring()}570 >571 restore this version →572 </button>573 }574 >575 <div class="peach-snapshot-warning">576 <svg width="11" height="11" viewBox="0 0 12 12" fill="none" stroke="rgba(220,80,60,0.9)" stroke-width="1.4" stroke-linecap="round">577 <path d="M6 1L11 10H1L6 1z" /><line x1="6" y1="5" x2="6" y2="7.5" />578 <circle cx="6" cy="9" r="0.5" fill="rgba(220,80,60,0.9)" stroke="none" />579 </svg>580 This replaces your current vault with the {selectedSnap() ? formatAge(selectedSnap()!.timestamp) : ""} version ({selectedSnap()?.entryCount} entries). It cannot be undone.581 </div>582 <div class="peach-snapshot-confirm-row">583 <button584 class="peach-btn peach-btn--accent peach-btn--sm"585 style="flex:1"586 onClick={() => void handleRestore()}587 disabled={restoring() || !password().trim()}588 >589 {restoring() ? "restoring…" : "yes, restore"}590 </button>591 <button592 class="peach-btn peach-btn--ghost peach-btn--sm"593 onClick={() => setConfirming(false)}594 disabled={restoring()}595 >596 cancel597 </button>598 </div>599 </Show>600 </div>601 </Show>602 </Show>603 </section>604 </section>605 );606}607608609610function getDisplayError(error: string | undefined, fallback: string): string {611 if (!error) return fallback;612 if (613 error === "SERVICE_WORKER_UNAVAILABLE" ||614 error.includes("Receiving end does not exist") ||615 error.includes("message port closed")616 ) {617 return "background service unavailable";618 }619 return error;620}