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 64 createMemo(() => {65 const value = password();66 if (!value) { setStrengthScore(null); return; }67 getZxcvbn().then((zxcvbn) => {68 if (password() === value) setStrengthScore(zxcvbn(value).score);69 }).catch(() => {70 71 if (password() === value) setStrengthScore(value.length >= 16 ? 3 : value.length >= 12 ? 2 : 1);72 });73 });7475 async function handleUnlock() {76 if (!password().trim()) return;77 setLoading(true);78 setError("");79 const response = await sendMessage({80 type: "UNLOCK",81 payload: { password: password() },82 });83 const result = response as { ok: boolean; error?: string };84 if (result.ok) {85 await props.onUnlock();86 setLoading(false);87 return;88 }89 setError(getDisplayError(result.error, "incorrect password"));90 setPassword("");91 const passwordField = document.querySelector(".lock-password-input") as HTMLInputElement | undefined;92 passwordField?.classList.add("shake");93 setTimeout(() => passwordField?.classList.remove("shake"), 300);94 setLoading(false);95 }9697 async function handleCreate() {98 if (!password().trim()) return;99 if (password().length < MIN_LENGTH) {100 setError(`password must be at least ${MIN_LENGTH} characters`);101 return;102 }103 if (password() !== confirmPassword()) {104 setError("passwords do not match");105 return;106 }107 const score = strengthScore();108 if (score === null || score < MIN_ZXCVBN_SCORE) {109 setError("password is too weak — try a longer or more varied passphrase");110 return;111 }112 setLoading(true);113 setError("");114 const response = await sendMessage({115 type: "CREATE_VAULT",116 payload: { password: password() },117 });118 const result = response as { ok: boolean; error?: string };119 if (result.ok) {120 await props.onUnlock();121 setLoading(false);122 return;123 }124 setError(getDisplayError(result.error, "failed to create vault"));125 setLoading(false);126 }127128 function openRestore(surface: Exclude<RestoreSurface, null>) {129 if (!props.ready || loading()) return;130 setPassword("");131 setConfirmPassword("");132 setError("");133 setRestoreSurface(surface);134 }135136 137 return (138 <Switch>139 {}140 <Match when={restoreSurface() === "restore"}>141 <RestorePickerView142 onBack={() => setRestoreSurface(null)}143 onPickPeachScript={() => setRestoreSurface("peachscript")}144 onPickMayan={() => setRestoreSurface("mayan")}145 onPickCSV={() => setRestoreSurface("file")}146 onPickSnapshots={() => setRestoreSurface("snapshots")}147 />148 </Match>149150 {}151 <Match when={restoreSurface() === "mayan"}>152 <MayanComingSoonView onBack={() => setRestoreSurface("restore")} />153 </Match>154155 {}156 <Match when={restoreSurface() === "snapshots"}>157 <SnapshotRestoreView158 onBack={() => setRestoreSurface("restore")}159 onRestored={props.onUnlock}160 />161 </Match>162163 {}164 <Match when={restoreSurface() === "peachscript"}>165 <ImportView166 mode="onboarding"167 replacesExistingVault={props.hasVault}168 initialView="peachscript"169 onBack={() => setRestoreSurface("restore")}170 onImport={async () => {171 setRestoreSurface(null);172 await props.onUnlock();173 }}174 />175 </Match>176177 {}178 <Match when={restoreSurface() === "file"}>179 <ImportView180 mode="onboarding"181 replacesExistingVault={props.hasVault}182 initialView="file"183 onBack={() => setRestoreSurface("restore")}184 onImport={async () => {185 setRestoreSurface(null);186 await props.onUnlock();187 }}188 />189 </Match>190191 {}192 <Match when={props.hasVault}>193 <section class="peach-screen peach-theme lock-screen lock-screen--vault-stage" data-motion-phase="enter">194 <header class="peach-screen-header lock-screen-header">195 <div class="peach-wordmark-stack">196 <PeachWordmark class="peach-wordmark-wrap" />197 <Show when={props.isPro}>198 <span class="peach-pro-mini-badge">pro</span>199 </Show>200 </div>201 </header>202203 <div class="peach-lock-hero peach-lock-hero--unlock">204 <div class="peach-lock-hero-art" aria-hidden="true">205 <PeachMark class="peach-lock-hero-mark" />206 </div>207 <span class="peach-lock-eyebrow">vault locked</span>208 <h1 class="peach-onboarding-headline peach-lock-headline--unlock">209 unlock210 <br />211 your vault.212 </h1>213 </div>214215 <LockRuneCanvas />216217 <section class="peach-lock-panel peach-lock-panel--unlock peach-glass">218 <label class="peach-label" for="unlock-password">219 master password220 </label>221 <input222 id="unlock-password"223 class="peach-underline-input peach-unlock-input lock-password-input"224 type="password"225 placeholder="••••••••••••"226 autocomplete="current-password"227 value={password()}228 disabled={!props.ready}229 ref={(el) => { if (props.ready) setTimeout(() => el.focus(), 80); }}230 onInput={(event) => setPassword((event.target as HTMLInputElement).value)}231 onKeyDown={(event) => {232 if (event.key !== "Enter" || !props.ready) return;233 void handleUnlock();234 }}235 />236237 <Show when={error()}>238 <p class="peach-hint peach-hint-error">{error()}</p>239 </Show>240241 <button242 class="peach-btn peach-btn--accent peach-btn--md peach-lock-submit"243 onClick={() => void handleUnlock()}244 disabled={!props.ready || loading()}245 >246 {loading() ? "unlocking..." : "unlock"}247 </button>248 </section>249 </section>250 </Match>251252 {}253 <Match when={true}>254 <section255 class="peach-screen peach-theme lock-screen lock-screen--vault-stage peach-newvault"256 data-motion-phase="enter"257 >258 <header class="peach-screen-header peach-newvault-header">259 <Show when={props.onBackToOnboarding}>260 <button261 class="peach-newvault-back"262 type="button"263 onClick={() => props.onBackToOnboarding?.()}264 aria-label="Back to onboarding"265 >266 <svg width="11" height="11" viewBox="0 0 11 11" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">267 <path d="M7 2L3 5.5L7 9" />268 </svg>269 back270 </button>271 </Show>272 <div class="peach-wordmark-stack">273 <PeachWordmark class="peach-wordmark-wrap" />274 <Show when={props.isPro}>275 <span class="peach-pro-mini-badge">pro</span>276 </Show>277 </div>278 </header>279280 <div class="peach-newvault-mark" aria-hidden="true">281 <PeachMark class="peach-newvault-mark-art" />282 </div>283284 <div class="peach-newvault-card">285 <h1 class="peach-newvault-headline">{"keep your\nsecrets."}</h1>286 <hr class="peach-newvault-divider" />287288 <div class="peach-newvault-field">289 <div class="peach-newvault-label-row">290 <label class="peach-label" for="master-password">291 master password292 </label>293 <button294 class="peach-lock-reveal-btn"295 type="button"296 onClick={() => setShowPassword((value) => !value)}297 aria-label={showPassword() ? "Hide password" : "Show password"}298 >299 <Show300 when={showPassword()}301 fallback={302 <svg width="15" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">303 <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" />304 <circle cx="7" cy="5" r="1.75" />305 </svg>306 }307 >308 <svg width="15" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">309 <path d="M1 2C3 4.5 5 6 7 6C9 6 11 4.5 13 2" />310 <path d="M3.5 5.5L2.5 7.5" />311 <path d="M7 6.5V8.5" />312 <path d="M10.5 5.5L11.5 7.5" />313 </svg>314 </Show>315 {showPassword() ? "hide" : "show"}316 </button>317 </div>318 <input319 id="master-password"320 class="peach-underline-input peach-newvault-input lock-password-input"321 type={showPassword() ? "text" : "password"}322 placeholder="••••••••••••"323 autocomplete="new-password"324 value={password()}325 disabled={!props.ready}326 onInput={(event) => setPassword((event.target as HTMLInputElement).value)}327 onKeyDown={(event) => {328 if (event.key !== "Enter" || !props.ready) return;329 void handleCreate();330 }}331 />332 <Show333 when={password().length > 0 && passwordStrength() !== null}334 fallback={335 <p class="peach-newvault-hint">at least 12 characters</p>336 }337 >338 <div class="peach-password-strength">339 <div class={`peach-strength-bar peach-strength-bar--${passwordStrength()}`}>340 <div class="peach-strength-fill"></div>341 </div>342 <span class="peach-strength-label">{passwordStrength()}</span>343 </div>344 </Show>345 </div>346347 <div class="peach-newvault-field">348 <label class="peach-label" for="confirm-password">349 confirm password350 </label>351 <input352 id="confirm-password"353 class="peach-underline-input peach-newvault-input"354 type={showPassword() ? "text" : "password"}355 placeholder="••••••••••••"356 autocomplete="new-password"357 value={confirmPassword()}358 disabled={!props.ready}359 onInput={(event) => setConfirmPassword((event.target as HTMLInputElement).value)}360 onKeyDown={(event) => {361 if (event.key === "Enter" && props.ready) void handleCreate();362 }}363 />364 </div>365366 <Show when={error()}>367 <p class="peach-hint peach-hint-error">{error()}</p>368 </Show>369370 <button371 class="peach-btn peach-btn--accent peach-btn--md peach-lock-submit peach-newvault-submit"372 onClick={() => void handleCreate()}373 disabled={!props.ready || loading() || !canCreate()}374 >375 {loading() ? "creating..." : "create vault"}376 </button>377378 <p class="peach-newvault-warning">379 <span class="peach-newvault-warning-icon" aria-hidden="true">⚠</span>380 <span>If you forget it, your vault cannot be opened. There is no reset.</span>381 </p>382 </div>383384 <div class="peach-newvault-footer">385 <p class="peach-hint">Already have a backup?</p>386 <button387 class="peach-lock-restore-link"388 onClick={() => openRestore("restore")}389 disabled={!props.ready}390 >391 › restore from backup392 </button>393 </div>394 </section>395 </Match>396 </Switch>397 );398}399400401402function RestorePickerView(props: {403 onBack: () => void;404 onPickPeachScript: () => void;405 onPickMayan: () => void;406 onPickCSV: () => void;407 onPickSnapshots: () => void;408}) {409 return (410 <section411 class="peach-screen peach-theme lock-screen lock-screen--vault-stage lock-screen--onboarding peach-restore-screen"412 data-motion-phase="enter"413 >414 <header class="peach-screen-header lock-screen-header">415 <PeachWordmark class="peach-wordmark-wrap" />416 <button class="peach-lock-restore-link" onClick={props.onBack}>417 ‹ back418 </button>419 </header>420421 <div class="peach-lock-hero">422 <span class="peach-lock-eyebrow">restore</span>423 <h1 class="peach-onboarding-headline">424 bring back425 <br />426 your vault.427 </h1>428 </div>429430 <div class="peach-restore-stack" role="list">431 <button432 class="peach-restore-card"433 role="listitem"434 onClick={props.onPickPeachScript}435 >436 <h2 class="peach-restore-card-headline">PeachScript</h2>437 <hr class="peach-restore-card-divider" />438 <p class="peach-restore-card-body">439 Paste a string of Peach symbols, or open a <code>.peach</code> file440 you saved from another device.441 </p>442 <p class="peach-restore-card-meta">backup password required</p>443 </button>444445 <button446 class="peach-restore-card"447 role="listitem"448 onClick={props.onPickMayan}449 >450 <span class="peach-restore-card-soon" aria-label="coming soon">soon</span>451 <h2 class="peach-restore-card-headline">Mayan code</h2>452 <hr class="peach-restore-card-divider" />453 <p class="peach-restore-card-body">454 Restore from a printed Mayan code by uploading a photo of it.455 </p>456 <p class="peach-restore-card-meta">decoder shipping soon</p>457 </button>458459 <button460 class="peach-restore-card"461 role="listitem"462 onClick={props.onPickCSV}463 >464 <h2 class="peach-restore-card-headline">CSV</h2>465 <hr class="peach-restore-card-divider" />466 <p class="peach-restore-card-body">467 Move in from Bitwarden, 1Password, Proton Pass, or a browser468 (Chrome, Safari, Edge) CSV export.469 </p>470 <p class="peach-restore-card-meta">plain-text — open the file once and delete it</p>471 </button>472 </div>473474 <div class="peach-restore-footer">475 <button476 class="peach-restore-footer-link"477 onClick={props.onPickSnapshots}478 >479 or revert to a local snapshot →480 </button>481 </div>482 </section>483 );484}485486487488function MayanComingSoonView(props: { onBack: () => void }) {489 return (490 <section491 class="peach-screen peach-theme lock-screen lock-screen--vault-stage lock-screen--onboarding peach-restore-screen"492 data-motion-phase="enter"493 >494 <header class="peach-screen-header lock-screen-header">495 <PeachWordmark class="peach-wordmark-wrap" />496 <button class="peach-lock-restore-link" onClick={props.onBack}>497 ‹ back498 </button>499 </header>500501 <div class="peach-lock-hero">502 <span class="peach-lock-eyebrow">mayan code</span>503 <h1 class="peach-onboarding-headline">504 decoder505 <br />506 on its way.507 </h1>508 </div>509510 <div class="peach-restore-stack">511 <div class="peach-restore-card peach-restore-card--static">512 <h2 class="peach-restore-card-headline">Not yet — but soon.</h2>513 <hr class="peach-restore-card-divider" />514 <p class="peach-restore-card-body">515 Mayan codes encode the same backup as PeachScript, but printable on516 a single page. The phone-camera decoder is the next thing to land517 for this format.518 </p>519 <p class="peach-restore-card-body">520 Until then, restore your vault using the PeachScript that came521 with this Mayan code, or open a <code>.peach</code> file backup.522 </p>523 </div>524 </div>525526 <div class="peach-restore-footer">527 <button class="peach-restore-footer-link" onClick={props.onBack}>528 ‹ back to restore529 </button>530 </div>531 </section>532 );533}534535536537interface SnapshotSummary {538 index: number;539 timestamp: number;540 entryCount: number;541}542543function SnapshotRestoreView(props: {544 onBack: () => void;545 onRestored: () => void | Promise<void>;546}) {547 const [snapshots, setSnapshots] = createSignal<SnapshotSummary[]>([]);548 const [loading, setLoading] = createSignal(true);549 const [selected, setSelected] = createSignal<number | null>(null);550 const [password, setPassword] = createSignal("");551 const [confirming, setConfirming] = createSignal(false);552 const [restoring, setRestoring] = createSignal(false);553 const [error, setError] = createSignal("");554555 onMount(async () => {556 const res = await sendMessage({ type: "LIST_VAULT_SNAPSHOTS" });557 setSnapshots((res as { ok: boolean; data?: SnapshotSummary[] }).data ?? []);558 setLoading(false);559 });560561 function formatAge(ts: number): string {562 const diff = Date.now() - ts;563 const mins = Math.floor(diff / 60_000);564 if (mins < 2) return "just now";565 if (mins < 60) return `${mins} minutes ago`;566 const hours = Math.floor(mins / 60);567 if (hours < 24) return `${hours} hour${hours === 1 ? "" : "s"} ago`;568 const days = Math.floor(hours / 24);569 return `${days} day${days === 1 ? "" : "s"} ago`;570 }571572 function selectSnapshot(index: number) {573 setSelected(index);574 setConfirming(false);575 setPassword("");576 setError("");577 }578579 async function handleRestore() {580 const idx = selected();581 if (idx === null || !password()) return;582 setRestoring(true);583 setError("");584 const res = await sendMessage({585 type: "RESTORE_VAULT_SNAPSHOT",586 payload: { index: idx, password: password() },587 });588 if ((res as { ok: boolean }).ok) {589 await props.onRestored();590 } else {591 setError("Incorrect password, or this snapshot cannot be decrypted.");592 setRestoring(false);593 }594 }595596 const selectedSnap = () => {597 const idx = selected();598 return idx !== null ? (snapshots()[idx] ?? null) : null;599 };600601 return (602 <section603 class="peach-screen peach-theme lock-screen lock-screen--vault-stage lock-screen--onboarding"604 data-motion-phase="enter"605 >606 <header class="peach-screen-header lock-screen-header">607 <PeachWordmark class="peach-wordmark-wrap" />608 <button class="peach-lock-restore-link" onClick={props.onBack} disabled={restoring()}>609 ‹ back610 </button>611 </header>612613 <div class="peach-lock-hero">614 <span class="peach-lock-eyebrow">vault history</span>615 <h1 class="peach-onboarding-headline">616 revert to617 <br />618 a snapshot.619 </h1>620 </div>621622 <section class="peach-lock-panel peach-snapshot-panel">623 <Show when={loading()}>624 <p class="peach-hint" style="text-align:center;padding:8px 0;">Loading…</p>625 </Show>626627 <Show when={!loading() && snapshots().length === 0}>628 <p class="peach-hint" style="line-height:1.6;">629 No snapshots yet. Peach automatically saves up to 6 encrypted snapshots after each vault change.630 </p>631 </Show>632633 <Show when={!loading() && snapshots().length > 0}>634 <p class="peach-hint" style="margin-bottom:4px;">Select a version to restore:</p>635 <div class="peach-snapshot-list">636 <For each={snapshots()}>637 {(snap) => (638 <button639 class="peach-snapshot-row"640 classList={{ "is-selected": selected() === snap.index }}641 onClick={() => selectSnapshot(snap.index)}642 disabled={restoring()}643 >644 <span class="peach-snapshot-time">{formatAge(snap.timestamp)}</span>645 <span class="peach-snapshot-count">646 {snap.entryCount} {snap.entryCount === 1 ? "entry" : "entries"}647 </span>648 </button>649 )}650 </For>651 </div>652653 <Show when={selected() !== null}>654 <div class="peach-snapshot-confirm">655 <label class="peach-label" for="snapshot-pw">master password</label>656 <input657 id="snapshot-pw"658 class="peach-underline-input peach-unlock-input"659 type="password"660 placeholder="••••••••••••"661 autocomplete="current-password"662 value={password()}663 disabled={restoring()}664 onInput={(e) => setPassword((e.target as HTMLInputElement).value)}665 onKeyDown={(e) => {666 if (e.key === "Enter" && !restoring() && confirming()) void handleRestore();667 }}668 />669670 <Show when={error()}>671 <p class="peach-hint peach-hint-error">{error()}</p>672 </Show>673674 <Show675 when={confirming()}676 fallback={677 <button678 class="peach-btn peach-btn--subtle peach-btn--sm peach-snapshot-restore-btn"679 onClick={() => setConfirming(true)}680 disabled={!password().trim() || restoring()}681 >682 restore this version →683 </button>684 }685 >686 <div class="peach-snapshot-warning">687 <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">688 <path d="M6 1L11 10H1L6 1z" /><line x1="6" y1="5" x2="6" y2="7.5" />689 <circle cx="6" cy="9" r="0.5" fill="rgba(220,80,60,0.9)" stroke="none" />690 </svg>691 This replaces your current vault with the {selectedSnap() ? formatAge(selectedSnap()!.timestamp) : ""} version ({selectedSnap()?.entryCount} entries). It cannot be undone.692 </div>693 <div class="peach-snapshot-confirm-row">694 <button695 class="peach-btn peach-btn--accent peach-btn--sm"696 style="flex:1"697 onClick={() => void handleRestore()}698 disabled={restoring() || !password().trim()}699 >700 {restoring() ? "restoring…" : "yes, restore"}701 </button>702 <button703 class="peach-btn peach-btn--ghost peach-btn--sm"704 onClick={() => setConfirming(false)}705 disabled={restoring()}706 >707 cancel708 </button>709 </div>710 </Show>711 </div>712 </Show>713 </Show>714 </section>715 </section>716 );717}718719720721function getDisplayError(error: string | undefined, fallback: string): string {722 if (!error) return fallback;723 if (724 error === "SERVICE_WORKER_UNAVAILABLE" ||725 error.includes("Receiving end does not exist") ||726 error.includes("message port closed")727 ) {728 return "background service unavailable";729 }730 return error;731}