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 232425 onBackToOnboarding?: () => void;26}27282930type RestoreSurface = "restore" | "snapshots" | "peachscript" | "file" | "mayan" | null;3132export function LockView(props: LockProps) {33 const [password, setPassword] = createSignal("");34 const [confirmPassword, setConfirmPassword] = createSignal("");35 const [error, setError] = createSignal("");36 const [loading, setLoading] = createSignal(false);37 const [restoreSurface, setRestoreSurface] = createSignal<RestoreSurface>(null);38 const [showPassword, setShowPassword] = createSignal(false);3940 const MIN_LENGTH = 12;41 const MIN_ZXCVBN_SCORE = 2;4243 const [strengthScore, setStrengthScore] = createSignal<number | null>(null);4445 const canCreate = createMemo(46 () =>47 password().length >= MIN_LENGTH &&48 confirmPassword().length >= MIN_LENGTH &&49 password() === confirmPassword() &&50 strengthScore() !== null &&51 strengthScore()! >= MIN_ZXCVBN_SCORE52 );5354 const passwordStrength = createMemo(() => {55 const score = strengthScore();56 if (score === null) return null;57 if (score <= 1) return "weak";58 if (score === 2) return "fair";59 if (score === 3) return "good";60 return "strong";61 });6263 const createBlockerMessage = createMemo(() => {64 if (!password()) return null;65 if (password().length < MIN_LENGTH) return `need ${MIN_LENGTH - password().length} more characters`;66 if (!confirmPassword()) return "confirm your password";67 if (password() !== confirmPassword()) return "passwords don't match";68 if (strengthScore() === null) return "checking strength…";69 if (strengthScore()! < MIN_ZXCVBN_SCORE) return "password too weak — add words or length";70 return null;71 });7273 74 createMemo(() => {75 const value = password();76 if (!value) { setStrengthScore(null); return; }77 getZxcvbn().then((zxcvbn) => {78 if (password() === value) setStrengthScore(zxcvbn(value).score);79 }).catch(() => {80 81 if (password() === value) setStrengthScore(value.length >= 16 ? 3 : value.length >= 12 ? 2 : 1);82 });83 });8485 async function handleUnlock() {86 if (!password().trim()) return;87 setLoading(true);88 setError("");89 const response = await sendMessage({90 type: "UNLOCK",91 payload: { password: password() },92 });93 const result = response as { ok: boolean; error?: string };94 if (result.ok) {95 await props.onUnlock();96 setLoading(false);97 return;98 }99 setError(getDisplayError(result.error, "incorrect password"));100 setPassword("");101 const passwordField = document.querySelector(".lock-password-input") as HTMLInputElement | undefined;102 passwordField?.classList.add("shake");103 setTimeout(() => passwordField?.classList.remove("shake"), 300);104 setLoading(false);105 }106107 async function handleCreate() {108 if (!password().trim()) return;109 if (password().length < MIN_LENGTH) {110 setError(`password must be at least ${MIN_LENGTH} characters`);111 return;112 }113 if (password() !== confirmPassword()) {114 setError("passwords do not match");115 return;116 }117 const score = strengthScore();118 if (score === null || score < MIN_ZXCVBN_SCORE) {119 setError("password is too weak — try a longer or more varied passphrase");120 return;121 }122 setLoading(true);123 setError("");124 const response = await sendMessage({125 type: "CREATE_VAULT",126 payload: { password: password() },127 });128 const result = response as { ok: boolean; error?: string };129 if (result.ok) {130 await props.onUnlock();131 setLoading(false);132 return;133 }134 setError(getDisplayError(result.error, "failed to create vault"));135 setLoading(false);136 }137138 function openRestore(surface: Exclude<RestoreSurface, null>) {139 if (!props.ready || loading()) return;140 setPassword("");141 setConfirmPassword("");142 setError("");143 setRestoreSurface(surface);144 }145146 147 return (148 <Switch>149 {}150 <Match when={restoreSurface() === "restore"}>151 <RestorePickerView152 onBack={() => setRestoreSurface(null)}153 onPickPeachScript={() => setRestoreSurface("peachscript")}154 onPickMayan={() => setRestoreSurface("mayan")}155 onPickCSV={() => setRestoreSurface("file")}156 onPickSnapshots={() => setRestoreSurface("snapshots")}157 />158 </Match>159160 {}161 <Match when={restoreSurface() === "mayan"}>162 <MayanComingSoonView onBack={() => setRestoreSurface("restore")} />163 </Match>164165 {}166 <Match when={restoreSurface() === "snapshots"}>167 <SnapshotRestoreView168 onBack={() => setRestoreSurface("restore")}169 onRestored={props.onUnlock}170 />171 </Match>172173 {}174 <Match when={restoreSurface() === "peachscript"}>175 <ImportView176 mode="onboarding"177 replacesExistingVault={props.hasVault}178 initialView="peachscript"179 onBack={() => setRestoreSurface("restore")}180 onImport={async () => {181 setRestoreSurface(null);182 await props.onUnlock();183 }}184 />185 </Match>186187 {}188 <Match when={restoreSurface() === "file"}>189 <ImportView190 mode="onboarding"191 replacesExistingVault={props.hasVault}192 initialView="file"193 onBack={() => setRestoreSurface("restore")}194 onImport={async () => {195 setRestoreSurface(null);196 await props.onUnlock();197 }}198 />199 </Match>200201 {}202 <Match when={props.hasVault}>203 <section class="peach-screen peach-theme lock-screen lock-screen--vault-stage" data-motion-phase="enter">204 <header class="peach-screen-header lock-screen-header">205 <div class="peach-wordmark-stack">206 <PeachWordmark class="peach-wordmark-wrap" />207 <Show when={props.isPro}>208 <span class="peach-pro-mini-badge">pro</span>209 </Show>210 </div>211 </header>212213 <div class="peach-lock-hero peach-lock-hero--unlock">214 <div class="peach-lock-hero-art" aria-hidden="true">215 <PeachMark class="peach-lock-hero-mark" />216 </div>217 <span class="peach-lock-eyebrow">vault locked</span>218 <h1 class="peach-onboarding-headline peach-lock-headline--unlock">219 unlock220 <br />221 your vault.222 </h1>223 </div>224225 <LockRuneCanvas />226227 <section class="peach-lock-panel peach-lock-panel--unlock peach-glass">228 <label class="peach-label" for="unlock-password">229 master password230 </label>231 <input232 id="unlock-password"233 class="peach-underline-input peach-unlock-input lock-password-input"234 type="password"235 placeholder="••••••••••••"236 autocomplete="current-password"237 value={password()}238 disabled={!props.ready}239 ref={(el) => { if (props.ready) setTimeout(() => el.focus(), 80); }}240 onInput={(event) => setPassword((event.target as HTMLInputElement).value)}241 onKeyDown={(event) => {242 if (event.key !== "Enter" || !props.ready) return;243 void handleUnlock();244 }}245 />246247 <Show when={error()}>248 <p class="peach-hint peach-hint-error">{error()}</p>249 </Show>250251 <button252 class="peach-btn peach-btn--accent peach-btn--md peach-lock-submit"253 onClick={() => void handleUnlock()}254 disabled={!props.ready || loading()}255 >256 {loading() ? "unlocking..." : "unlock"}257 </button>258 </section>259 </section>260 </Match>261262 {}263 <Match when={true}>264 <section265 class="peach-screen peach-theme lock-screen lock-screen--vault-stage peach-newvault"266 data-motion-phase="enter"267 >268 <header class="peach-screen-header peach-newvault-header">269 <Show when={props.onBackToOnboarding}>270 <button271 class="peach-newvault-back"272 type="button"273 onClick={() => props.onBackToOnboarding?.()}274 aria-label="Back to onboarding"275 >276 <svg width="11" height="11" viewBox="0 0 11 11" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">277 <path d="M7 2L3 5.5L7 9" />278 </svg>279 back280 </button>281 </Show>282 <div class="peach-wordmark-stack">283 <PeachWordmark class="peach-wordmark-wrap" />284 <Show when={props.isPro}>285 <span class="peach-pro-mini-badge">pro</span>286 </Show>287 </div>288 </header>289290 <div class="peach-newvault-mark" aria-hidden="true">291 <PeachMark class="peach-newvault-mark-art" />292 </div>293294 <div class="peach-newvault-card">295 <h1 class="peach-newvault-headline">{"keep your\nsecrets."}</h1>296 <hr class="peach-newvault-divider" />297298 <div class="peach-newvault-field">299 <div class="peach-newvault-label-row">300 <label class="peach-label" for="master-password">301 master password302 </label>303 <button304 class="peach-lock-reveal-btn"305 type="button"306 onClick={() => setShowPassword((value) => !value)}307 aria-label={showPassword() ? "Hide password" : "Show password"}308 >309 <Show310 when={showPassword()}311 fallback={312 <svg width="15" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">313 <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" />314 <circle cx="7" cy="5" r="1.75" />315 </svg>316 }317 >318 <svg width="15" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">319 <path d="M1 2C3 4.5 5 6 7 6C9 6 11 4.5 13 2" />320 <path d="M3.5 5.5L2.5 7.5" />321 <path d="M7 6.5V8.5" />322 <path d="M10.5 5.5L11.5 7.5" />323 </svg>324 </Show>325 {showPassword() ? "hide" : "show"}326 </button>327 </div>328 <input329 id="master-password"330 class="peach-underline-input peach-newvault-input lock-password-input"331 type={showPassword() ? "text" : "password"}332 placeholder="••••••••••••"333 autocomplete="new-password"334 value={password()}335 disabled={!props.ready}336 onInput={(event) => setPassword((event.target as HTMLInputElement).value)}337 onKeyDown={(event) => {338 if (event.key !== "Enter" || !props.ready) return;339 void handleCreate();340 }}341 />342 <Show343 when={password().length > 0 && passwordStrength() !== null}344 fallback={345 <p class="peach-newvault-hint">at least 12 characters</p>346 }347 >348 <div class="peach-password-strength">349 <div class={`peach-strength-bar peach-strength-bar--${passwordStrength()}`}>350 <div class="peach-strength-fill"></div>351 </div>352 <span class="peach-strength-label">{passwordStrength()}</span>353 </div>354 </Show>355 </div>356357 <div class="peach-newvault-field">358 <label class="peach-label" for="confirm-password">359 confirm password360 </label>361 <input362 id="confirm-password"363 class="peach-underline-input peach-newvault-input"364 type={showPassword() ? "text" : "password"}365 placeholder="••••••••••••"366 autocomplete="new-password"367 value={confirmPassword()}368 disabled={!props.ready}369 onInput={(event) => setConfirmPassword((event.target as HTMLInputElement).value)}370 onKeyDown={(event) => {371 if (event.key === "Enter" && props.ready) void handleCreate();372 }}373 />374 </div>375376 <Show when={error()}>377 <p class="peach-hint peach-hint-error">{error()}</p>378 </Show>379380 <button381 class="peach-btn peach-btn--accent peach-btn--md peach-lock-submit peach-newvault-submit"382 onClick={() => void handleCreate()}383 disabled={!props.ready || loading() || !canCreate()}384 aria-live="polite"385 >386 <Show387 when={!canCreate() && createBlockerMessage()}388 fallback={loading() ? "creating..." : "create vault"}389 >390 {createBlockerMessage()}391 </Show>392 </button>393394 <p class="peach-newvault-warning">395 <span class="peach-newvault-warning-icon" aria-hidden="true">⚠</span>396 <span>If you forget it, your vault cannot be opened. There is no reset.</span>397 </p>398 </div>399400 <div class="peach-newvault-footer">401 <p class="peach-hint">Already have a backup?</p>402 <button403 class="peach-lock-restore-link"404 onClick={() => openRestore("restore")}405 disabled={!props.ready}406 >407 › restore from backup408 </button>409 </div>410 </section>411 </Match>412 </Switch>413 );414}415416417418function RestorePickerView(props: {419 onBack: () => void;420 onPickPeachScript: () => void;421 onPickMayan: () => void;422 onPickCSV: () => void;423 onPickSnapshots: () => void;424}) {425 return (426 <section427 class="peach-screen peach-theme lock-screen lock-screen--vault-stage lock-screen--onboarding peach-restore-screen"428 data-motion-phase="enter"429 >430 <header class="peach-screen-header lock-screen-header">431 <PeachWordmark class="peach-wordmark-wrap" />432 <button class="peach-lock-restore-link" onClick={props.onBack}>433 ‹ back434 </button>435 </header>436437 <div class="peach-lock-hero">438 <span class="peach-lock-eyebrow">restore</span>439 <h1 class="peach-onboarding-headline">440 bring back441 <br />442 your vault.443 </h1>444 </div>445446 <div class="peach-restore-stack" role="list">447 <button448 class="peach-restore-card"449 role="listitem"450 onClick={props.onPickPeachScript}451 >452 <h2 class="peach-restore-card-headline">PeachScript</h2>453 <hr class="peach-restore-card-divider" />454 <p class="peach-restore-card-body">455 Paste a string of Peach symbols, or open a <code>.peach</code> file456 you saved from another device.457 </p>458 <p class="peach-restore-card-meta">backup password required</p>459 </button>460461 <button462 class="peach-restore-card"463 role="listitem"464 onClick={props.onPickMayan}465 >466 <span class="peach-restore-card-soon" aria-label="coming soon">soon</span>467 <h2 class="peach-restore-card-headline">Mayan code</h2>468 <hr class="peach-restore-card-divider" />469 <p class="peach-restore-card-body">470 Restore from a printed Mayan code by uploading a photo of it.471 </p>472 <p class="peach-restore-card-meta">decoder shipping soon</p>473 </button>474475 <button476 class="peach-restore-card"477 role="listitem"478 onClick={props.onPickCSV}479 >480 <h2 class="peach-restore-card-headline">CSV</h2>481 <hr class="peach-restore-card-divider" />482 <p class="peach-restore-card-body">483 Move in from Bitwarden, 1Password, Proton Pass, or a browser484 (Chrome, Safari, Edge) CSV export.485 </p>486 <p class="peach-restore-card-meta">plain-text — open the file once and delete it</p>487 </button>488 </div>489490 <div class="peach-restore-footer">491 <button492 class="peach-restore-footer-link"493 onClick={props.onPickSnapshots}494 >495 or revert to a local snapshot →496 </button>497 </div>498 </section>499 );500}501502503504function MayanComingSoonView(props: { onBack: () => void }) {505 return (506 <section507 class="peach-screen peach-theme lock-screen lock-screen--vault-stage lock-screen--onboarding peach-restore-screen"508 data-motion-phase="enter"509 >510 <header class="peach-screen-header lock-screen-header">511 <PeachWordmark class="peach-wordmark-wrap" />512 <button class="peach-lock-restore-link" onClick={props.onBack}>513 ‹ back514 </button>515 </header>516517 <div class="peach-lock-hero">518 <span class="peach-lock-eyebrow">mayan code</span>519 <h1 class="peach-onboarding-headline">520 decoder521 <br />522 on its way.523 </h1>524 </div>525526 <div class="peach-restore-stack">527 <div class="peach-restore-card peach-restore-card--static">528 <h2 class="peach-restore-card-headline">Not yet — but soon.</h2>529 <hr class="peach-restore-card-divider" />530 <p class="peach-restore-card-body">531 Mayan codes encode the same backup as PeachScript, but printable on532 a single page. The phone-camera decoder is the next thing to land533 for this format.534 </p>535 <p class="peach-restore-card-body">536 Until then, restore your vault using the PeachScript that came537 with this Mayan code, or open a <code>.peach</code> file backup.538 </p>539 </div>540 </div>541542 <div class="peach-restore-footer">543 <button class="peach-restore-footer-link" onClick={props.onBack}>544 ‹ back to restore545 </button>546 </div>547 </section>548 );549}550551552553interface SnapshotSummary {554 index: number;555 timestamp: number;556 entryCount: number;557}558559function SnapshotRestoreView(props: {560 onBack: () => void;561 onRestored: () => void | Promise<void>;562}) {563 const [snapshots, setSnapshots] = createSignal<SnapshotSummary[]>([]);564 const [loading, setLoading] = createSignal(true);565 const [selected, setSelected] = createSignal<number | null>(null);566 const [password, setPassword] = createSignal("");567 const [confirming, setConfirming] = createSignal(false);568 const [restoring, setRestoring] = createSignal(false);569 const [error, setError] = createSignal("");570571 onMount(async () => {572 const res = await sendMessage({ type: "LIST_VAULT_SNAPSHOTS" });573 setSnapshots((res as { ok: boolean; data?: SnapshotSummary[] }).data ?? []);574 setLoading(false);575 });576577 function formatAge(ts: number): string {578 const diff = Date.now() - ts;579 const mins = Math.floor(diff / 60_000);580 if (mins < 2) return "just now";581 if (mins < 60) return `${mins} minutes ago`;582 const hours = Math.floor(mins / 60);583 if (hours < 24) return `${hours} hour${hours === 1 ? "" : "s"} ago`;584 const days = Math.floor(hours / 24);585 return `${days} day${days === 1 ? "" : "s"} ago`;586 }587588 function selectSnapshot(index: number) {589 setSelected(index);590 setConfirming(false);591 setPassword("");592 setError("");593 }594595 async function handleRestore() {596 const idx = selected();597 if (idx === null || !password()) return;598 setRestoring(true);599 setError("");600 const res = await sendMessage({601 type: "RESTORE_VAULT_SNAPSHOT",602 payload: { index: idx, password: password() },603 });604 if ((res as { ok: boolean }).ok) {605 await props.onRestored();606 } else {607 setError("Incorrect password, or this snapshot cannot be decrypted.");608 setRestoring(false);609 }610 }611612 const selectedSnap = () => {613 const idx = selected();614 return idx !== null ? (snapshots()[idx] ?? null) : null;615 };616617 return (618 <section619 class="peach-screen peach-theme lock-screen lock-screen--vault-stage lock-screen--onboarding"620 data-motion-phase="enter"621 >622 <header class="peach-screen-header lock-screen-header">623 <PeachWordmark class="peach-wordmark-wrap" />624 <button class="peach-lock-restore-link" onClick={props.onBack} disabled={restoring()}>625 ‹ back626 </button>627 </header>628629 <div class="peach-lock-hero">630 <span class="peach-lock-eyebrow">vault history</span>631 <h1 class="peach-onboarding-headline">632 revert to633 <br />634 a snapshot.635 </h1>636 </div>637638 <section class="peach-lock-panel peach-snapshot-panel">639 <Show when={loading()}>640 <p class="peach-hint" style="text-align:center;padding:8px 0;">Loading…</p>641 </Show>642643 <Show when={!loading() && snapshots().length === 0}>644 <p class="peach-hint" style="line-height:1.6;">645 No snapshots yet. Peach automatically saves up to 6 encrypted snapshots after each vault change.646 </p>647 </Show>648649 <Show when={!loading() && snapshots().length > 0}>650 <p class="peach-hint" style="margin-bottom:4px;">Select a version to restore:</p>651 <div class="peach-snapshot-list">652 <For each={snapshots()}>653 {(snap) => (654 <button655 class="peach-snapshot-row"656 classList={{ "is-selected": selected() === snap.index }}657 onClick={() => selectSnapshot(snap.index)}658 disabled={restoring()}659 >660 <span class="peach-snapshot-time">{formatAge(snap.timestamp)}</span>661 <span class="peach-snapshot-count">662 {snap.entryCount} {snap.entryCount === 1 ? "entry" : "entries"}663 </span>664 </button>665 )}666 </For>667 </div>668669 <Show when={selected() !== null}>670 <div class="peach-snapshot-confirm">671 <label class="peach-label" for="snapshot-pw">master password</label>672 <input673 id="snapshot-pw"674 class="peach-underline-input peach-unlock-input"675 type="password"676 placeholder="••••••••••••"677 autocomplete="current-password"678 value={password()}679 disabled={restoring()}680 onInput={(e) => setPassword((e.target as HTMLInputElement).value)}681 onKeyDown={(e) => {682 if (e.key === "Enter" && !restoring() && confirming()) void handleRestore();683 }}684 />685686 <Show when={error()}>687 <p class="peach-hint peach-hint-error">{error()}</p>688 </Show>689690 <Show691 when={confirming()}692 fallback={693 <button694 class="peach-btn peach-btn--subtle peach-btn--sm peach-snapshot-restore-btn"695 onClick={() => setConfirming(true)}696 disabled={!password().trim() || restoring()}697 >698 restore this version →699 </button>700 }701 >702 <div class="peach-snapshot-warning">703 <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">704 <path d="M6 1L11 10H1L6 1z" /><line x1="6" y1="5" x2="6" y2="7.5" />705 <circle cx="6" cy="9" r="0.5" fill="rgba(220,80,60,0.9)" stroke="none" />706 </svg>707 This replaces your current vault with the {selectedSnap() ? formatAge(selectedSnap()!.timestamp) : ""} version ({selectedSnap()?.entryCount} entries). It cannot be undone.708 </div>709 <div class="peach-snapshot-confirm-row">710 <button711 class="peach-btn peach-btn--accent peach-btn--sm"712 style="flex:1"713 onClick={() => void handleRestore()}714 disabled={restoring() || !password().trim()}715 >716 {restoring() ? "restoring…" : "yes, restore"}717 </button>718 <button719 class="peach-btn peach-btn--ghost peach-btn--sm"720 onClick={() => setConfirming(false)}721 disabled={restoring()}722 >723 cancel724 </button>725 </div>726 </Show>727 </div>728 </Show>729 </Show>730 </section>731 </section>732 );733}734735736737function getDisplayError(error: string | undefined, fallback: string): string {738 if (!error) return fallback;739 if (740 error === "SERVICE_WORKER_UNAVAILABLE" ||741 error.includes("Receiving end does not exist") ||742 error.includes("message port closed")743 ) {744 return "background service unavailable";745 }746 return error;747}