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 onUnlock: () => void | Promise<void>;21}222324type RestoreSurface = "restore" | "snapshots" | "cjk" | "file" | null;2526export function LockView(props: LockProps) {27 const [password, setPassword] = createSignal("");28 const [confirmPassword, setConfirmPassword] = createSignal("");29 const [error, setError] = createSignal("");30 const [loading, setLoading] = createSignal(false);31 const [restoreSurface, setRestoreSurface] = createSignal<RestoreSurface>(null);32 const [showPassword, setShowPassword] = createSignal(false);3334 const MIN_LENGTH = 12;35 const MIN_ZXCVBN_SCORE = 2;3637 const [strengthScore, setStrengthScore] = createSignal<number | null>(null);3839 const canCreate = createMemo(40 () =>41 password().length >= MIN_LENGTH &&42 confirmPassword().length >= MIN_LENGTH &&43 password() === confirmPassword() &&44 strengthScore() !== null &&45 strengthScore()! >= MIN_ZXCVBN_SCORE46 );4748 const passwordStrength = createMemo(() => {49 const score = strengthScore();50 if (score === null) return null;51 if (score <= 1) return "weak";52 if (score === 2) return "fair";53 if (score === 3) return "good";54 return "strong";55 });5657 58 createMemo(() => {59 const value = password();60 if (!value) { setStrengthScore(null); return; }61 getZxcvbn().then((zxcvbn) => {62 if (password() === value) setStrengthScore(zxcvbn(value).score);63 }).catch(() => {64 65 if (password() === value) setStrengthScore(value.length >= 16 ? 3 : value.length >= 12 ? 2 : 1);66 });67 });6869 async function handleUnlock() {70 if (!password().trim()) return;71 setLoading(true);72 setError("");73 const response = await sendMessage({74 type: "UNLOCK",75 payload: { password: password() },76 });77 const result = response as { ok: boolean; error?: string };78 if (result.ok) {79 await props.onUnlock();80 setLoading(false);81 return;82 }83 setError(getDisplayError(result.error, "incorrect password"));84 setPassword("");85 const passwordField = document.querySelector(".lock-password-input") as HTMLInputElement | undefined;86 passwordField?.classList.add("shake");87 setTimeout(() => passwordField?.classList.remove("shake"), 300);88 setLoading(false);89 }9091 async function handleCreate() {92 if (!password().trim()) return;93 if (password().length < MIN_LENGTH) {94 setError(`password must be at least ${MIN_LENGTH} characters`);95 return;96 }97 if (password() !== confirmPassword()) {98 setError("passwords do not match");99 return;100 }101 const score = strengthScore();102 if (score === null || score < MIN_ZXCVBN_SCORE) {103 setError("password is too weak — try a longer or more varied passphrase");104 return;105 }106 setLoading(true);107 setError("");108 const response = await sendMessage({109 type: "CREATE_VAULT",110 payload: { password: password() },111 });112 const result = response as { ok: boolean; error?: string };113 if (result.ok) {114 await props.onUnlock();115 setLoading(false);116 return;117 }118 setError(getDisplayError(result.error, "failed to create vault"));119 setLoading(false);120 }121122 function openRestore(surface: Exclude<RestoreSurface, null>) {123 if (!props.ready || loading()) return;124 setPassword("");125 setConfirmPassword("");126 setError("");127 setRestoreSurface(surface);128 }129130 131 return (132 <Switch>133 {}134 <Match when={restoreSurface() === "restore"}>135 <RestorePickerView136 onBack={() => setRestoreSurface(null)}137 onPickCjk={() => setRestoreSurface("cjk")}138 onPickFile={() => setRestoreSurface("file")}139 onPickSnapshots={() => setRestoreSurface("snapshots")}140 />141 </Match>142143 {}144 <Match when={restoreSurface() === "snapshots"}>145 <SnapshotRestoreView146 onBack={() => setRestoreSurface("restore")}147 onRestored={props.onUnlock}148 />149 </Match>150151 {}152 <Match when={restoreSurface() === "cjk"}>153 <CJKImportView154 mode="onboarding"155 onBack={() => setRestoreSurface("restore")}156 onDone={async () => {157 setRestoreSurface(null);158 await props.onUnlock();159 }}160 />161 </Match>162163 {}164 <Match when={restoreSurface() === "file"}>165 <ImportView166 mode="onboarding"167 replacesExistingVault={props.hasVault}168 onBack={() => setRestoreSurface("restore")}169 onImport={async () => {170 setRestoreSurface(null);171 await props.onUnlock();172 }}173 />174 </Match>175176 {}177 <Match when={props.hasVault}>178 <section class="peach-screen peach-theme lock-screen lock-screen--vault-stage" data-motion-phase="enter">179 <header class="peach-screen-header lock-screen-header">180 <PeachWordmark class="peach-wordmark-wrap" />181 </header>182183 <div class="peach-lock-hero peach-lock-hero--unlock">184 <div class="peach-lock-hero-art" aria-hidden="true">185 <PeachMark class="peach-lock-hero-mark" />186 </div>187 <span class="peach-lock-eyebrow">vault locked</span>188 <h1 class="peach-onboarding-headline peach-lock-headline--unlock">189 unlock190 <br />191 your vault.192 </h1>193 </div>194195 <section class="peach-lock-panel peach-lock-panel--unlock">196 <label class="peach-label" for="unlock-password">197 master password198 </label>199 <input200 id="unlock-password"201 class="peach-underline-input peach-unlock-input lock-password-input"202 type="password"203 placeholder="••••••••••••"204 autocomplete="current-password"205 value={password()}206 disabled={!props.ready}207 ref={(el) => { if (props.ready) setTimeout(() => el.focus(), 80); }}208 onInput={(event) => setPassword((event.target as HTMLInputElement).value)}209 onKeyDown={(event) => {210 if (event.key !== "Enter" || !props.ready) return;211 void handleUnlock();212 }}213 />214215 <Show when={error()}>216 <p class="peach-hint peach-hint-error">{error()}</p>217 </Show>218219 <button220 class="peach-btn peach-btn--accent peach-btn--md peach-lock-submit"221 onClick={() => void handleUnlock()}222 disabled={!props.ready || loading() || !password().trim()}223 >224 {loading() ? "unlocking..." : "unlock"}225 </button>226 </section>227 </section>228 </Match>229230 {}231 <Match when={true}>232 <section233 class="peach-screen peach-theme lock-screen lock-screen--vault-stage lock-screen--onboarding"234 data-motion-phase="enter"235 >236 <header class="peach-screen-header lock-screen-header">237 <PeachWordmark class="peach-wordmark-wrap" />238 </header>239240 <div class="peach-lock-hero">241 <div class="peach-lock-hero-art" aria-hidden="true">242 <PeachMark class="peach-lock-hero-mark" />243 </div>244 <span class="peach-lock-eyebrow">new vault</span>245 <h1 class="peach-onboarding-headline">246 keep your247 <br />248 secrets.249 </h1>250 </div>251252 <section class="peach-lock-panel peach-lock-panel--primary">253 <div class="peach-lock-label-row">254 <label class="peach-label" for="master-password">255 master password256 </label>257 <button258 class="peach-lock-reveal-btn"259 type="button"260 onClick={() => setShowPassword((value) => !value)}261 aria-label={showPassword() ? "Hide password" : "Show password"}262 >263 <Show264 when={showPassword()}265 fallback={266 <svg width="15" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">267 <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" />268 <circle cx="7" cy="5" r="1.75" />269 </svg>270 }271 >272 <svg width="15" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">273 <path d="M1 2C3 4.5 5 6 7 6C9 6 11 4.5 13 2" />274 <path d="M3.5 5.5L2.5 7.5" />275 <path d="M7 6.5V8.5" />276 <path d="M10.5 5.5L11.5 7.5" />277 </svg>278 </Show>279 {showPassword() ? "hide" : "show"}280 </button>281 </div>282 <input283 id="master-password"284 class="peach-underline-input peach-unlock-input lock-password-input"285 type={showPassword() ? "text" : "password"}286 placeholder="at least 12 characters"287 autocomplete="new-password"288 value={password()}289 disabled={!props.ready}290 onInput={(event) => setPassword((event.target as HTMLInputElement).value)}291 onKeyDown={(event) => {292 if (event.key !== "Enter" || !props.ready) return;293 void handleCreate();294 }}295 />296297 <Show when={password().length > 0 && passwordStrength() !== null}>298 <div class="peach-password-strength">299 <div class={`peach-strength-bar peach-strength-bar--${passwordStrength()}`}>300 <div class="peach-strength-fill"></div>301 </div>302 <span class="peach-strength-label">{passwordStrength()}</span>303 </div>304 </Show>305306 <label class="peach-label" for="confirm-password">307 confirm password308 </label>309 <input310 id="confirm-password"311 class="peach-underline-input peach-unlock-input"312 type={showPassword() ? "text" : "password"}313 placeholder="••••••••••••"314 autocomplete="new-password"315 value={confirmPassword()}316 disabled={!props.ready}317 onInput={(event) => setConfirmPassword((event.target as HTMLInputElement).value)}318 onKeyDown={(event) => {319 if (event.key === "Enter" && props.ready) void handleCreate();320 }}321 />322323 <Show when={error()}>324 <p class="peach-hint peach-hint-error">{error()}</p>325 </Show>326327 <button328 class="peach-btn peach-btn--accent peach-btn--md peach-lock-submit"329 onClick={() => void handleCreate()}330 disabled={!props.ready || loading() || !canCreate()}331 >332 {loading() ? "creating..." : "create vault"}333 </button>334335 <p class="peach-hint peach-hint-error peach-lock-warning">336 If you lose this password, Peach cannot recover your vault. There is no reset.337 </p>338339 <div class="peach-lock-panel-footer">340 <p class="peach-hint">Already have a backup?</p>341 <button342 class="peach-lock-restore-link"343 onClick={() => openRestore("restore")}344 disabled={!props.ready}345 >346 › restore from backup347 </button>348 </div>349 </section>350 </section>351 </Match>352 </Switch>353 );354}355356357358function RestorePickerView(props: {359 onBack: () => void;360 onPickCjk: () => void;361 onPickFile: () => void;362 onPickSnapshots: () => void;363}) {364 return (365 <section366 class="peach-screen peach-theme lock-screen lock-screen--vault-stage lock-screen--onboarding"367 data-motion-phase="enter"368 >369 <header class="peach-screen-header lock-screen-header">370 <PeachWordmark class="peach-wordmark-wrap" />371 <button class="peach-lock-restore-link" onClick={props.onBack}>372 ‹ back373 </button>374 </header>375376 <div class="peach-lock-hero">377 <span class="peach-lock-eyebrow">restore</span>378 <h1 class="peach-onboarding-headline">379 bring back380 <br />381 your vault.382 </h1>383 </div>384385 <section class="peach-lock-panel peach-lock-panel--restore">386 <p class="peach-hint" style="margin-bottom:2px;">How did you back up your data?</p>387388 <button class="peach-lock-option" onClick={props.onPickCjk}>389 <span class="peach-lock-option-title">backup phrase</span>390 <span class="peach-lock-option-copy">391 A block of Chinese characters exported directly from Peach. You'll need the export passphrase you set at the time.392 </span>393 </button>394395 <button class="peach-lock-option" onClick={props.onPickFile}>396 <span class="peach-lock-option-title">backup file</span>397 <span class="peach-lock-option-copy">398 A Peach JSON export, or a file from Bitwarden, Proton Pass, 1Password, or a browser (Chrome, Safari, Edge CSV).399 </span>400 </button>401402 <button class="peach-lock-option peach-lock-option--history" onClick={props.onPickSnapshots}>403 <span class="peach-lock-option-title">vault history</span>404 <span class="peach-lock-option-copy">405 Peach saves encrypted snapshots locally after each change. Revert to an earlier version if something went wrong.406 </span>407 </button>408 </section>409 </section>410 );411}412413414415interface SnapshotSummary {416 index: number;417 timestamp: number;418 entryCount: number;419}420421function SnapshotRestoreView(props: {422 onBack: () => void;423 onRestored: () => void | Promise<void>;424}) {425 const [snapshots, setSnapshots] = createSignal<SnapshotSummary[]>([]);426 const [loading, setLoading] = createSignal(true);427 const [selected, setSelected] = createSignal<number | null>(null);428 const [password, setPassword] = createSignal("");429 const [confirming, setConfirming] = createSignal(false);430 const [restoring, setRestoring] = createSignal(false);431 const [error, setError] = createSignal("");432433 onMount(async () => {434 const res = await sendMessage({ type: "LIST_VAULT_SNAPSHOTS" });435 setSnapshots((res as { ok: boolean; data?: SnapshotSummary[] }).data ?? []);436 setLoading(false);437 });438439 function formatAge(ts: number): string {440 const diff = Date.now() - ts;441 const mins = Math.floor(diff / 60_000);442 if (mins < 2) return "just now";443 if (mins < 60) return `${mins} minutes ago`;444 const hours = Math.floor(mins / 60);445 if (hours < 24) return `${hours} hour${hours === 1 ? "" : "s"} ago`;446 const days = Math.floor(hours / 24);447 return `${days} day${days === 1 ? "" : "s"} ago`;448 }449450 function selectSnapshot(index: number) {451 setSelected(index);452 setConfirming(false);453 setPassword("");454 setError("");455 }456457 async function handleRestore() {458 const idx = selected();459 if (idx === null || !password()) return;460 setRestoring(true);461 setError("");462 const res = await sendMessage({463 type: "RESTORE_VAULT_SNAPSHOT",464 payload: { index: idx, password: password() },465 });466 if ((res as { ok: boolean }).ok) {467 await props.onRestored();468 } else {469 setError("Incorrect password, or this snapshot cannot be decrypted.");470 setRestoring(false);471 }472 }473474 const selectedSnap = () => {475 const idx = selected();476 return idx !== null ? (snapshots()[idx] ?? null) : null;477 };478479 return (480 <section481 class="peach-screen peach-theme lock-screen lock-screen--vault-stage lock-screen--onboarding"482 data-motion-phase="enter"483 >484 <header class="peach-screen-header lock-screen-header">485 <PeachWordmark class="peach-wordmark-wrap" />486 <button class="peach-lock-restore-link" onClick={props.onBack} disabled={restoring()}>487 ‹ back488 </button>489 </header>490491 <div class="peach-lock-hero">492 <span class="peach-lock-eyebrow">vault history</span>493 <h1 class="peach-onboarding-headline">494 revert to495 <br />496 a snapshot.497 </h1>498 </div>499500 <section class="peach-lock-panel peach-snapshot-panel">501 <Show when={loading()}>502 <p class="peach-hint" style="text-align:center;padding:8px 0;">Loading…</p>503 </Show>504505 <Show when={!loading() && snapshots().length === 0}>506 <p class="peach-hint" style="line-height:1.6;">507 No snapshots yet. Peach automatically saves up to 6 encrypted snapshots after each vault change.508 </p>509 </Show>510511 <Show when={!loading() && snapshots().length > 0}>512 <p class="peach-hint" style="margin-bottom:4px;">Select a version to restore:</p>513 <div class="peach-snapshot-list">514 <For each={snapshots()}>515 {(snap) => (516 <button517 class="peach-snapshot-row"518 classList={{ "is-selected": selected() === snap.index }}519 onClick={() => selectSnapshot(snap.index)}520 disabled={restoring()}521 >522 <span class="peach-snapshot-time">{formatAge(snap.timestamp)}</span>523 <span class="peach-snapshot-count">524 {snap.entryCount} {snap.entryCount === 1 ? "entry" : "entries"}525 </span>526 </button>527 )}528 </For>529 </div>530531 <Show when={selected() !== null}>532 <div class="peach-snapshot-confirm">533 <label class="peach-label" for="snapshot-pw">master password</label>534 <input535 id="snapshot-pw"536 class="peach-underline-input peach-unlock-input"537 type="password"538 placeholder="••••••••••••"539 autocomplete="current-password"540 value={password()}541 disabled={restoring()}542 onInput={(e) => setPassword((e.target as HTMLInputElement).value)}543 onKeyDown={(e) => {544 if (e.key === "Enter" && !restoring() && confirming()) void handleRestore();545 }}546 />547548 <Show when={error()}>549 <p class="peach-hint peach-hint-error">{error()}</p>550 </Show>551552 <Show553 when={confirming()}554 fallback={555 <button556 class="peach-btn peach-btn--subtle peach-btn--sm peach-snapshot-restore-btn"557 onClick={() => setConfirming(true)}558 disabled={!password().trim() || restoring()}559 >560 restore this version →561 </button>562 }563 >564 <div class="peach-snapshot-warning">565 <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">566 <path d="M6 1L11 10H1L6 1z" /><line x1="6" y1="5" x2="6" y2="7.5" />567 <circle cx="6" cy="9" r="0.5" fill="rgba(220,80,60,0.9)" stroke="none" />568 </svg>569 This replaces your current vault with the {selectedSnap() ? formatAge(selectedSnap()!.timestamp) : ""} version ({selectedSnap()?.entryCount} entries). It cannot be undone.570 </div>571 <div class="peach-snapshot-confirm-row">572 <button573 class="peach-btn peach-btn--accent peach-btn--sm"574 style="flex:1"575 onClick={() => void handleRestore()}576 disabled={restoring() || !password().trim()}577 >578 {restoring() ? "restoring…" : "yes, restore"}579 </button>580 <button581 class="peach-btn peach-btn--ghost peach-btn--sm"582 onClick={() => setConfirming(false)}583 disabled={restoring()}584 >585 cancel586 </button>587 </div>588 </Show>589 </div>590 </Show>591 </Show>592 </section>593 </section>594 );595}596597598599function getDisplayError(error: string | undefined, fallback: string): string {600 if (!error) return fallback;601 if (602 error === "SERVICE_WORKER_UNAVAILABLE" ||603 error.includes("Receiving end does not exist") ||604 error.includes("message port closed")605 ) {606 return "background service unavailable";607 }608 return error;609}