1import { For, Match, Show, Switch, createMemo, createSignal, onMount } from "solid-js";23import { sendMessage } from "../App";4import { PeachMark, PeachWordmark } from "../components/PeachMark";5import { ImportView } from "./Import";678let _zxcvbn: ((password: string) => { score: number }) | null = null;9async function getZxcvbn() {10 if (_zxcvbn) return _zxcvbn;11 const mod = await import("zxcvbn");12 _zxcvbn = mod.default;13 return _zxcvbn;14}1516interface LockProps {17 hasVault: boolean;18 ready: boolean;19 isPro?: boolean;20 onUnlock: () => void | Promise<void>;21}222324type RestoreSurface = "restore" | "snapshots" | "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 onPickFile={() => setRestoreSurface("file")}138 onPickSnapshots={() => setRestoreSurface("snapshots")}139 />140 </Match>141142 {}143 <Match when={restoreSurface() === "snapshots"}>144 <SnapshotRestoreView145 onBack={() => setRestoreSurface("restore")}146 onRestored={props.onUnlock}147 />148 </Match>149150 {}151 <Match when={restoreSurface() === "file"}>152 <ImportView153 mode="onboarding"154 replacesExistingVault={props.hasVault}155 onBack={() => setRestoreSurface("restore")}156 onImport={async () => {157 setRestoreSurface(null);158 await props.onUnlock();159 }}160 />161 </Match>162163 {}164 <Match when={props.hasVault}>165 <section class="peach-screen peach-theme lock-screen lock-screen--vault-stage" data-motion-phase="enter">166 <header class="peach-screen-header lock-screen-header">167 <div class="peach-wordmark-stack">168 <PeachWordmark class="peach-wordmark-wrap" />169 <Show when={props.isPro}>170 <span class="peach-pro-mini-badge">pro</span>171 </Show>172 </div>173 </header>174175 <div class="peach-lock-hero peach-lock-hero--unlock">176 <div class="peach-lock-hero-art" aria-hidden="true">177 <PeachMark class="peach-lock-hero-mark" />178 </div>179 <span class="peach-lock-eyebrow">vault locked</span>180 <h1 class="peach-onboarding-headline peach-lock-headline--unlock">181 unlock182 <br />183 your vault.184 </h1>185 </div>186187 <section class="peach-lock-panel peach-lock-panel--unlock">188 <label class="peach-label" for="unlock-password">189 master password190 </label>191 <input192 id="unlock-password"193 class="peach-underline-input peach-unlock-input lock-password-input"194 type="password"195 placeholder="••••••••••••"196 autocomplete="current-password"197 value={password()}198 disabled={!props.ready}199 ref={(el) => { if (props.ready) setTimeout(() => el.focus(), 80); }}200 onInput={(event) => setPassword((event.target as HTMLInputElement).value)}201 onKeyDown={(event) => {202 if (event.key !== "Enter" || !props.ready) return;203 void handleUnlock();204 }}205 />206207 <Show when={error()}>208 <p class="peach-hint peach-hint-error">{error()}</p>209 </Show>210211 <button212 class="peach-btn peach-btn--accent peach-btn--md peach-lock-submit"213 onClick={() => void handleUnlock()}214 disabled={!props.ready || loading()}215 >216 {loading() ? "unlocking..." : "unlock"}217 </button>218 </section>219 </section>220 </Match>221222 {}223 <Match when={true}>224 <section225 class="peach-screen peach-theme lock-screen lock-screen--vault-stage lock-screen--onboarding"226 data-motion-phase="enter"227 >228 <header class="peach-screen-header lock-screen-header">229 <div class="peach-wordmark-stack">230 <PeachWordmark class="peach-wordmark-wrap" />231 <Show when={props.isPro}>232 <span class="peach-pro-mini-badge">pro</span>233 </Show>234 </div>235 </header>236237 <div class="peach-lock-hero">238 <div class="peach-lock-hero-art" aria-hidden="true">239 <PeachMark class="peach-lock-hero-mark" />240 </div>241 <span class="peach-lock-eyebrow">new vault</span>242 <h1 class="peach-onboarding-headline">243 keep your244 <br />245 secrets.246 </h1>247 </div>248249 <section class="peach-lock-panel peach-lock-panel--primary">250 <div class="peach-lock-label-row">251 <label class="peach-label" for="master-password">252 master password253 </label>254 <button255 class="peach-lock-reveal-btn"256 type="button"257 onClick={() => setShowPassword((value) => !value)}258 aria-label={showPassword() ? "Hide password" : "Show password"}259 >260 <Show261 when={showPassword()}262 fallback={263 <svg width="15" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">264 <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" />265 <circle cx="7" cy="5" r="1.75" />266 </svg>267 }268 >269 <svg width="15" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">270 <path d="M1 2C3 4.5 5 6 7 6C9 6 11 4.5 13 2" />271 <path d="M3.5 5.5L2.5 7.5" />272 <path d="M7 6.5V8.5" />273 <path d="M10.5 5.5L11.5 7.5" />274 </svg>275 </Show>276 {showPassword() ? "hide" : "show"}277 </button>278 </div>279 <input280 id="master-password"281 class="peach-underline-input peach-unlock-input lock-password-input"282 type={showPassword() ? "text" : "password"}283 placeholder="at least 12 characters"284 autocomplete="new-password"285 value={password()}286 disabled={!props.ready}287 onInput={(event) => setPassword((event.target as HTMLInputElement).value)}288 onKeyDown={(event) => {289 if (event.key !== "Enter" || !props.ready) return;290 void handleCreate();291 }}292 />293294 <Show when={password().length > 0 && passwordStrength() !== null}>295 <div class="peach-password-strength">296 <div class={`peach-strength-bar peach-strength-bar--${passwordStrength()}`}>297 <div class="peach-strength-fill"></div>298 </div>299 <span class="peach-strength-label">{passwordStrength()}</span>300 </div>301 </Show>302303 <label class="peach-label" for="confirm-password">304 confirm password305 </label>306 <input307 id="confirm-password"308 class="peach-underline-input peach-unlock-input"309 type={showPassword() ? "text" : "password"}310 placeholder="••••••••••••"311 autocomplete="new-password"312 value={confirmPassword()}313 disabled={!props.ready}314 onInput={(event) => setConfirmPassword((event.target as HTMLInputElement).value)}315 onKeyDown={(event) => {316 if (event.key === "Enter" && props.ready) void handleCreate();317 }}318 />319320 <Show when={error()}>321 <p class="peach-hint peach-hint-error">{error()}</p>322 </Show>323324 <button325 class="peach-btn peach-btn--accent peach-btn--md peach-lock-submit"326 onClick={() => void handleCreate()}327 disabled={!props.ready || loading() || !canCreate()}328 >329 {loading() ? "creating..." : "create vault"}330 </button>331332 <p class="peach-hint peach-hint-error peach-lock-warning">333 If you lose this password, Peach cannot recover your vault. There is no reset.334 </p>335336 <div class="peach-lock-panel-footer">337 <p class="peach-hint">Already have a backup?</p>338 <button339 class="peach-lock-restore-link"340 onClick={() => openRestore("restore")}341 disabled={!props.ready}342 >343 › restore from backup344 </button>345 </div>346 </section>347 </section>348 </Match>349 </Switch>350 );351}352353354355function RestorePickerView(props: {356 onBack: () => void;357 onPickFile: () => void;358 onPickSnapshots: () => void;359}) {360 return (361 <section362 class="peach-screen peach-theme lock-screen lock-screen--vault-stage lock-screen--onboarding"363 data-motion-phase="enter"364 >365 <header class="peach-screen-header lock-screen-header">366 <PeachWordmark class="peach-wordmark-wrap" />367 <button class="peach-lock-restore-link" onClick={props.onBack}>368 ‹ back369 </button>370 </header>371372 <div class="peach-lock-hero">373 <span class="peach-lock-eyebrow">restore</span>374 <h1 class="peach-onboarding-headline">375 bring back376 <br />377 your vault.378 </h1>379 </div>380381 <section class="peach-lock-panel peach-lock-panel--restore">382 <p class="peach-hint" style="margin-bottom:2px;">How did you back up your data?</p>383384 <button class="peach-lock-option" onClick={props.onPickFile}>385 <span class="peach-lock-option-title">backup file</span>386 <span class="peach-lock-option-copy">387 A Peach JSON export, or a file from Bitwarden, Proton Pass, 1Password, or a browser (Chrome, Safari, Edge CSV).388 </span>389 </button>390391 <button class="peach-lock-option peach-lock-option--history" onClick={props.onPickSnapshots}>392 <span class="peach-lock-option-title">vault history</span>393 <span class="peach-lock-option-copy">394 Peach saves encrypted snapshots locally after each change. Revert to an earlier version if something went wrong.395 </span>396 </button>397 </section>398 </section>399 );400}401402403404interface SnapshotSummary {405 index: number;406 timestamp: number;407 entryCount: number;408}409410function SnapshotRestoreView(props: {411 onBack: () => void;412 onRestored: () => void | Promise<void>;413}) {414 const [snapshots, setSnapshots] = createSignal<SnapshotSummary[]>([]);415 const [loading, setLoading] = createSignal(true);416 const [selected, setSelected] = createSignal<number | null>(null);417 const [password, setPassword] = createSignal("");418 const [confirming, setConfirming] = createSignal(false);419 const [restoring, setRestoring] = createSignal(false);420 const [error, setError] = createSignal("");421422 onMount(async () => {423 const res = await sendMessage({ type: "LIST_VAULT_SNAPSHOTS" });424 setSnapshots((res as { ok: boolean; data?: SnapshotSummary[] }).data ?? []);425 setLoading(false);426 });427428 function formatAge(ts: number): string {429 const diff = Date.now() - ts;430 const mins = Math.floor(diff / 60_000);431 if (mins < 2) return "just now";432 if (mins < 60) return `${mins} minutes ago`;433 const hours = Math.floor(mins / 60);434 if (hours < 24) return `${hours} hour${hours === 1 ? "" : "s"} ago`;435 const days = Math.floor(hours / 24);436 return `${days} day${days === 1 ? "" : "s"} ago`;437 }438439 function selectSnapshot(index: number) {440 setSelected(index);441 setConfirming(false);442 setPassword("");443 setError("");444 }445446 async function handleRestore() {447 const idx = selected();448 if (idx === null || !password()) return;449 setRestoring(true);450 setError("");451 const res = await sendMessage({452 type: "RESTORE_VAULT_SNAPSHOT",453 payload: { index: idx, password: password() },454 });455 if ((res as { ok: boolean }).ok) {456 await props.onRestored();457 } else {458 setError("Incorrect password, or this snapshot cannot be decrypted.");459 setRestoring(false);460 }461 }462463 const selectedSnap = () => {464 const idx = selected();465 return idx !== null ? (snapshots()[idx] ?? null) : null;466 };467468 return (469 <section470 class="peach-screen peach-theme lock-screen lock-screen--vault-stage lock-screen--onboarding"471 data-motion-phase="enter"472 >473 <header class="peach-screen-header lock-screen-header">474 <PeachWordmark class="peach-wordmark-wrap" />475 <button class="peach-lock-restore-link" onClick={props.onBack} disabled={restoring()}>476 ‹ back477 </button>478 </header>479480 <div class="peach-lock-hero">481 <span class="peach-lock-eyebrow">vault history</span>482 <h1 class="peach-onboarding-headline">483 revert to484 <br />485 a snapshot.486 </h1>487 </div>488489 <section class="peach-lock-panel peach-snapshot-panel">490 <Show when={loading()}>491 <p class="peach-hint" style="text-align:center;padding:8px 0;">Loading…</p>492 </Show>493494 <Show when={!loading() && snapshots().length === 0}>495 <p class="peach-hint" style="line-height:1.6;">496 No snapshots yet. Peach automatically saves up to 6 encrypted snapshots after each vault change.497 </p>498 </Show>499500 <Show when={!loading() && snapshots().length > 0}>501 <p class="peach-hint" style="margin-bottom:4px;">Select a version to restore:</p>502 <div class="peach-snapshot-list">503 <For each={snapshots()}>504 {(snap) => (505 <button506 class="peach-snapshot-row"507 classList={{ "is-selected": selected() === snap.index }}508 onClick={() => selectSnapshot(snap.index)}509 disabled={restoring()}510 >511 <span class="peach-snapshot-time">{formatAge(snap.timestamp)}</span>512 <span class="peach-snapshot-count">513 {snap.entryCount} {snap.entryCount === 1 ? "entry" : "entries"}514 </span>515 </button>516 )}517 </For>518 </div>519520 <Show when={selected() !== null}>521 <div class="peach-snapshot-confirm">522 <label class="peach-label" for="snapshot-pw">master password</label>523 <input524 id="snapshot-pw"525 class="peach-underline-input peach-unlock-input"526 type="password"527 placeholder="••••••••••••"528 autocomplete="current-password"529 value={password()}530 disabled={restoring()}531 onInput={(e) => setPassword((e.target as HTMLInputElement).value)}532 onKeyDown={(e) => {533 if (e.key === "Enter" && !restoring() && confirming()) void handleRestore();534 }}535 />536537 <Show when={error()}>538 <p class="peach-hint peach-hint-error">{error()}</p>539 </Show>540541 <Show542 when={confirming()}543 fallback={544 <button545 class="peach-btn peach-btn--subtle peach-btn--sm peach-snapshot-restore-btn"546 onClick={() => setConfirming(true)}547 disabled={!password().trim() || restoring()}548 >549 restore this version →550 </button>551 }552 >553 <div class="peach-snapshot-warning">554 <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">555 <path d="M6 1L11 10H1L6 1z" /><line x1="6" y1="5" x2="6" y2="7.5" />556 <circle cx="6" cy="9" r="0.5" fill="rgba(220,80,60,0.9)" stroke="none" />557 </svg>558 This replaces your current vault with the {selectedSnap() ? formatAge(selectedSnap()!.timestamp) : ""} version ({selectedSnap()?.entryCount} entries). It cannot be undone.559 </div>560 <div class="peach-snapshot-confirm-row">561 <button562 class="peach-btn peach-btn--accent peach-btn--sm"563 style="flex:1"564 onClick={() => void handleRestore()}565 disabled={restoring() || !password().trim()}566 >567 {restoring() ? "restoring…" : "yes, restore"}568 </button>569 <button570 class="peach-btn peach-btn--ghost peach-btn--sm"571 onClick={() => setConfirming(false)}572 disabled={restoring()}573 >574 cancel575 </button>576 </div>577 </Show>578 </div>579 </Show>580 </Show>581 </section>582 </section>583 );584}585586587588function getDisplayError(error: string | undefined, fallback: string): string {589 if (!error) return fallback;590 if (591 error === "SERVICE_WORKER_UNAVAILABLE" ||592 error.includes("Receiving end does not exist") ||593 error.includes("message port closed")594 ) {595 return "background service unavailable";596 }597 return error;598}