1import { For, Match, Show, Switch, createMemo, createSignal, onMount } from "solid-js";23import { sendMessage } from "../App";4import { LockRuneCanvas } from "../components/LockRuneCanvas";5import { PeachMark, PeachWordmark } from "../components/PeachMark";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" | "peachscript" | "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 onPickPeachScript={() => setRestoreSurface("peachscript")}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() === "peachscript"}>154 <ImportView155 mode="onboarding"156 replacesExistingVault={props.hasVault}157 initialView="peachscript"158 onBack={() => setRestoreSurface("restore")}159 onImport={async () => {160 setRestoreSurface(null);161 await props.onUnlock();162 }}163 />164 </Match>165166 {}167 <Match when={restoreSurface() === "file"}>168 <ImportView169 mode="onboarding"170 replacesExistingVault={props.hasVault}171 initialView="file"172 onBack={() => setRestoreSurface("restore")}173 onImport={async () => {174 setRestoreSurface(null);175 await props.onUnlock();176 }}177 />178 </Match>179180 {}181 <Match when={props.hasVault}>182 <section class="peach-screen peach-theme lock-screen lock-screen--vault-stage" data-motion-phase="enter">183 <header class="peach-screen-header lock-screen-header">184 <div class="peach-wordmark-stack">185 <PeachWordmark class="peach-wordmark-wrap" />186 <Show when={props.isPro}>187 <span class="peach-pro-mini-badge">pro</span>188 </Show>189 </div>190 </header>191192 <div class="peach-lock-hero peach-lock-hero--unlock">193 <div class="peach-lock-hero-art" aria-hidden="true">194 <PeachMark class="peach-lock-hero-mark" />195 </div>196 <span class="peach-lock-eyebrow">vault locked</span>197 <h1 class="peach-onboarding-headline peach-lock-headline--unlock">198 unlock199 <br />200 your vault.201 </h1>202 </div>203204 <LockRuneCanvas />205206 <section class="peach-lock-panel peach-lock-panel--unlock peach-glass">207 <label class="peach-label" for="unlock-password">208 master password209 </label>210 <input211 id="unlock-password"212 class="peach-underline-input peach-unlock-input lock-password-input"213 type="password"214 placeholder="••••••••••••"215 autocomplete="current-password"216 value={password()}217 disabled={!props.ready}218 ref={(el) => { if (props.ready) setTimeout(() => el.focus(), 80); }}219 onInput={(event) => setPassword((event.target as HTMLInputElement).value)}220 onKeyDown={(event) => {221 if (event.key !== "Enter" || !props.ready) return;222 void handleUnlock();223 }}224 />225226 <Show when={error()}>227 <p class="peach-hint peach-hint-error">{error()}</p>228 </Show>229230 <button231 class="peach-btn peach-btn--accent peach-btn--md peach-lock-submit"232 onClick={() => void handleUnlock()}233 disabled={!props.ready || loading()}234 >235 {loading() ? "unlocking..." : "unlock"}236 </button>237 </section>238 </section>239 </Match>240241 {}242 <Match when={true}>243 <section244 class="peach-screen peach-theme lock-screen lock-screen--vault-stage peach-newvault"245 data-motion-phase="enter"246 >247 <header class="peach-screen-header peach-newvault-header">248 <div class="peach-wordmark-stack">249 <PeachWordmark class="peach-wordmark-wrap" />250 <Show when={props.isPro}>251 <span class="peach-pro-mini-badge">pro</span>252 </Show>253 </div>254 </header>255256 <div class="peach-newvault-mark" aria-hidden="true">257 <PeachMark class="peach-newvault-mark-art" />258 </div>259260 <div class="peach-newvault-card">261 <h1 class="peach-newvault-headline">{"keep your\nsecrets."}</h1>262 <hr class="peach-newvault-divider" />263264 <div class="peach-newvault-field">265 <div class="peach-newvault-label-row">266 <label class="peach-label" for="master-password">267 master password268 </label>269 <button270 class="peach-lock-reveal-btn"271 type="button"272 onClick={() => setShowPassword((value) => !value)}273 aria-label={showPassword() ? "Hide password" : "Show password"}274 >275 <Show276 when={showPassword()}277 fallback={278 <svg width="15" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">279 <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" />280 <circle cx="7" cy="5" r="1.75" />281 </svg>282 }283 >284 <svg width="15" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">285 <path d="M1 2C3 4.5 5 6 7 6C9 6 11 4.5 13 2" />286 <path d="M3.5 5.5L2.5 7.5" />287 <path d="M7 6.5V8.5" />288 <path d="M10.5 5.5L11.5 7.5" />289 </svg>290 </Show>291 {showPassword() ? "hide" : "show"}292 </button>293 </div>294 <input295 id="master-password"296 class="peach-underline-input peach-newvault-input lock-password-input"297 type={showPassword() ? "text" : "password"}298 placeholder="••••••••••••"299 autocomplete="new-password"300 value={password()}301 disabled={!props.ready}302 onInput={(event) => setPassword((event.target as HTMLInputElement).value)}303 onKeyDown={(event) => {304 if (event.key !== "Enter" || !props.ready) return;305 void handleCreate();306 }}307 />308 <Show309 when={password().length > 0 && passwordStrength() !== null}310 fallback={311 <p class="peach-newvault-hint">at least 12 characters</p>312 }313 >314 <div class="peach-password-strength">315 <div class={`peach-strength-bar peach-strength-bar--${passwordStrength()}`}>316 <div class="peach-strength-fill"></div>317 </div>318 <span class="peach-strength-label">{passwordStrength()}</span>319 </div>320 </Show>321 </div>322323 <div class="peach-newvault-field">324 <label class="peach-label" for="confirm-password">325 confirm password326 </label>327 <input328 id="confirm-password"329 class="peach-underline-input peach-newvault-input"330 type={showPassword() ? "text" : "password"}331 placeholder="••••••••••••"332 autocomplete="new-password"333 value={confirmPassword()}334 disabled={!props.ready}335 onInput={(event) => setConfirmPassword((event.target as HTMLInputElement).value)}336 onKeyDown={(event) => {337 if (event.key === "Enter" && props.ready) void handleCreate();338 }}339 />340 </div>341342 <Show when={error()}>343 <p class="peach-hint peach-hint-error">{error()}</p>344 </Show>345346 <button347 class="peach-btn peach-btn--accent peach-btn--md peach-lock-submit peach-newvault-submit"348 onClick={() => void handleCreate()}349 disabled={!props.ready || loading() || !canCreate()}350 >351 {loading() ? "creating..." : "create vault"}352 </button>353354 <p class="peach-newvault-warning">355 <span class="peach-newvault-warning-icon" aria-hidden="true">⚠</span>356 <span>If you forget it, your vault cannot be opened. There is no reset.</span>357 </p>358 </div>359360 <div class="peach-newvault-footer">361 <p class="peach-hint">Already have a backup?</p>362 <button363 class="peach-lock-restore-link"364 onClick={() => openRestore("restore")}365 disabled={!props.ready}366 >367 › restore from backup368 </button>369 </div>370 </section>371 </Match>372 </Switch>373 );374}375376377378function RestorePickerView(props: {379 onBack: () => void;380 onPickPeachScript: () => void;381 onPickFile: () => void;382 onPickSnapshots: () => void;383}) {384 return (385 <section386 class="peach-screen peach-theme lock-screen lock-screen--vault-stage lock-screen--onboarding"387 data-motion-phase="enter"388 >389 <header class="peach-screen-header lock-screen-header">390 <PeachWordmark class="peach-wordmark-wrap" />391 <button class="peach-lock-restore-link" onClick={props.onBack}>392 ‹ back393 </button>394 </header>395396 <div class="peach-lock-hero">397 <span class="peach-lock-eyebrow">restore</span>398 <h1 class="peach-onboarding-headline">399 bring back400 <br />401 your vault.402 </h1>403 </div>404405 <section class="peach-lock-panel peach-lock-panel--restore">406 <p class="peach-hint" style="margin-bottom:2px;">How did you back up your data?</p>407408 <button class="peach-lock-option" onClick={props.onPickPeachScript}>409 <span class="peach-lock-option-title">peachscript</span>410 <span class="peach-lock-option-copy">411 A string of Peach symbols you exported from another Peach device. You'll need the backup password you set at the time.412 </span>413 </button>414415 <button class="peach-lock-option" onClick={props.onPickFile}>416 <span class="peach-lock-option-title">backup file</span>417 <span class="peach-lock-option-copy">418 A Peach JSON export, or a file from Bitwarden, Proton Pass, 1Password, or a browser (Chrome, Safari, Edge CSV).419 </span>420 </button>421422 <button class="peach-lock-option peach-lock-option--history" onClick={props.onPickSnapshots}>423 <span class="peach-lock-option-title">vault history</span>424 <span class="peach-lock-option-copy">425 Peach saves encrypted snapshots locally after each change. Revert to an earlier version if something went wrong.426 </span>427 </button>428 </section>429 </section>430 );431}432433434435interface SnapshotSummary {436 index: number;437 timestamp: number;438 entryCount: number;439}440441function SnapshotRestoreView(props: {442 onBack: () => void;443 onRestored: () => void | Promise<void>;444}) {445 const [snapshots, setSnapshots] = createSignal<SnapshotSummary[]>([]);446 const [loading, setLoading] = createSignal(true);447 const [selected, setSelected] = createSignal<number | null>(null);448 const [password, setPassword] = createSignal("");449 const [confirming, setConfirming] = createSignal(false);450 const [restoring, setRestoring] = createSignal(false);451 const [error, setError] = createSignal("");452453 onMount(async () => {454 const res = await sendMessage({ type: "LIST_VAULT_SNAPSHOTS" });455 setSnapshots((res as { ok: boolean; data?: SnapshotSummary[] }).data ?? []);456 setLoading(false);457 });458459 function formatAge(ts: number): string {460 const diff = Date.now() - ts;461 const mins = Math.floor(diff / 60_000);462 if (mins < 2) return "just now";463 if (mins < 60) return `${mins} minutes ago`;464 const hours = Math.floor(mins / 60);465 if (hours < 24) return `${hours} hour${hours === 1 ? "" : "s"} ago`;466 const days = Math.floor(hours / 24);467 return `${days} day${days === 1 ? "" : "s"} ago`;468 }469470 function selectSnapshot(index: number) {471 setSelected(index);472 setConfirming(false);473 setPassword("");474 setError("");475 }476477 async function handleRestore() {478 const idx = selected();479 if (idx === null || !password()) return;480 setRestoring(true);481 setError("");482 const res = await sendMessage({483 type: "RESTORE_VAULT_SNAPSHOT",484 payload: { index: idx, password: password() },485 });486 if ((res as { ok: boolean }).ok) {487 await props.onRestored();488 } else {489 setError("Incorrect password, or this snapshot cannot be decrypted.");490 setRestoring(false);491 }492 }493494 const selectedSnap = () => {495 const idx = selected();496 return idx !== null ? (snapshots()[idx] ?? null) : null;497 };498499 return (500 <section501 class="peach-screen peach-theme lock-screen lock-screen--vault-stage lock-screen--onboarding"502 data-motion-phase="enter"503 >504 <header class="peach-screen-header lock-screen-header">505 <PeachWordmark class="peach-wordmark-wrap" />506 <button class="peach-lock-restore-link" onClick={props.onBack} disabled={restoring()}>507 ‹ back508 </button>509 </header>510511 <div class="peach-lock-hero">512 <span class="peach-lock-eyebrow">vault history</span>513 <h1 class="peach-onboarding-headline">514 revert to515 <br />516 a snapshot.517 </h1>518 </div>519520 <section class="peach-lock-panel peach-snapshot-panel">521 <Show when={loading()}>522 <p class="peach-hint" style="text-align:center;padding:8px 0;">Loading…</p>523 </Show>524525 <Show when={!loading() && snapshots().length === 0}>526 <p class="peach-hint" style="line-height:1.6;">527 No snapshots yet. Peach automatically saves up to 6 encrypted snapshots after each vault change.528 </p>529 </Show>530531 <Show when={!loading() && snapshots().length > 0}>532 <p class="peach-hint" style="margin-bottom:4px;">Select a version to restore:</p>533 <div class="peach-snapshot-list">534 <For each={snapshots()}>535 {(snap) => (536 <button537 class="peach-snapshot-row"538 classList={{ "is-selected": selected() === snap.index }}539 onClick={() => selectSnapshot(snap.index)}540 disabled={restoring()}541 >542 <span class="peach-snapshot-time">{formatAge(snap.timestamp)}</span>543 <span class="peach-snapshot-count">544 {snap.entryCount} {snap.entryCount === 1 ? "entry" : "entries"}545 </span>546 </button>547 )}548 </For>549 </div>550551 <Show when={selected() !== null}>552 <div class="peach-snapshot-confirm">553 <label class="peach-label" for="snapshot-pw">master password</label>554 <input555 id="snapshot-pw"556 class="peach-underline-input peach-unlock-input"557 type="password"558 placeholder="••••••••••••"559 autocomplete="current-password"560 value={password()}561 disabled={restoring()}562 onInput={(e) => setPassword((e.target as HTMLInputElement).value)}563 onKeyDown={(e) => {564 if (e.key === "Enter" && !restoring() && confirming()) void handleRestore();565 }}566 />567568 <Show when={error()}>569 <p class="peach-hint peach-hint-error">{error()}</p>570 </Show>571572 <Show573 when={confirming()}574 fallback={575 <button576 class="peach-btn peach-btn--subtle peach-btn--sm peach-snapshot-restore-btn"577 onClick={() => setConfirming(true)}578 disabled={!password().trim() || restoring()}579 >580 restore this version →581 </button>582 }583 >584 <div class="peach-snapshot-warning">585 <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">586 <path d="M6 1L11 10H1L6 1z" /><line x1="6" y1="5" x2="6" y2="7.5" />587 <circle cx="6" cy="9" r="0.5" fill="rgba(220,80,60,0.9)" stroke="none" />588 </svg>589 This replaces your current vault with the {selectedSnap() ? formatAge(selectedSnap()!.timestamp) : ""} version ({selectedSnap()?.entryCount} entries). It cannot be undone.590 </div>591 <div class="peach-snapshot-confirm-row">592 <button593 class="peach-btn peach-btn--accent peach-btn--sm"594 style="flex:1"595 onClick={() => void handleRestore()}596 disabled={restoring() || !password().trim()}597 >598 {restoring() ? "restoring…" : "yes, restore"}599 </button>600 <button601 class="peach-btn peach-btn--ghost peach-btn--sm"602 onClick={() => setConfirming(false)}603 disabled={restoring()}604 >605 cancel606 </button>607 </div>608 </Show>609 </div>610 </Show>611 </Show>612 </section>613 </section>614 );615}616617618619function getDisplayError(error: string | undefined, fallback: string): string {620 if (!error) return fallback;621 if (622 error === "SERVICE_WORKER_UNAVAILABLE" ||623 error.includes("Receiving end does not exist") ||624 error.includes("message port closed")625 ) {626 return "background service unavailable";627 }628 return error;629}