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" | "peachscript" | "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 onPickPeachScript={() => setRestoreSurface("peachscript")}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() === "peachscript"}>153 <ImportView154 mode="onboarding"155 replacesExistingVault={props.hasVault}156 initialView="peachscript"157 onBack={() => setRestoreSurface("restore")}158 onImport={async () => {159 setRestoreSurface(null);160 await props.onUnlock();161 }}162 />163 </Match>164165 {}166 <Match when={restoreSurface() === "file"}>167 <ImportView168 mode="onboarding"169 replacesExistingVault={props.hasVault}170 initialView="file"171 onBack={() => setRestoreSurface("restore")}172 onImport={async () => {173 setRestoreSurface(null);174 await props.onUnlock();175 }}176 />177 </Match>178179 {}180 <Match when={props.hasVault}>181 <section class="peach-screen peach-theme lock-screen lock-screen--vault-stage" data-motion-phase="enter">182 <header class="peach-screen-header lock-screen-header">183 <div class="peach-wordmark-stack">184 <PeachWordmark class="peach-wordmark-wrap" />185 <Show when={props.isPro}>186 <span class="peach-pro-mini-badge">pro</span>187 </Show>188 </div>189 </header>190191 <div class="peach-lock-hero peach-lock-hero--unlock">192 <div class="peach-lock-hero-art" aria-hidden="true">193 <PeachMark class="peach-lock-hero-mark" />194 </div>195 <span class="peach-lock-eyebrow">vault locked</span>196 <h1 class="peach-onboarding-headline peach-lock-headline--unlock">197 unlock198 <br />199 your vault.200 </h1>201 </div>202203 <section class="peach-lock-panel peach-lock-panel--unlock">204 <label class="peach-label" for="unlock-password">205 master password206 </label>207 <input208 id="unlock-password"209 class="peach-underline-input peach-unlock-input lock-password-input"210 type="password"211 placeholder="••••••••••••"212 autocomplete="current-password"213 value={password()}214 disabled={!props.ready}215 ref={(el) => { if (props.ready) setTimeout(() => el.focus(), 80); }}216 onInput={(event) => setPassword((event.target as HTMLInputElement).value)}217 onKeyDown={(event) => {218 if (event.key !== "Enter" || !props.ready) return;219 void handleUnlock();220 }}221 />222223 <Show when={error()}>224 <p class="peach-hint peach-hint-error">{error()}</p>225 </Show>226227 <button228 class="peach-btn peach-btn--accent peach-btn--md peach-lock-submit"229 onClick={() => void handleUnlock()}230 disabled={!props.ready || loading()}231 >232 {loading() ? "unlocking..." : "unlock"}233 </button>234 </section>235 </section>236 </Match>237238 {}239 <Match when={true}>240 <section241 class="peach-screen peach-theme lock-screen lock-screen--vault-stage lock-screen--onboarding"242 data-motion-phase="enter"243 >244 <header class="peach-screen-header lock-screen-header">245 <div class="peach-wordmark-stack">246 <PeachWordmark class="peach-wordmark-wrap" />247 <Show when={props.isPro}>248 <span class="peach-pro-mini-badge">pro</span>249 </Show>250 </div>251 </header>252253 <div class="peach-lock-hero">254 <div class="peach-lock-hero-art" aria-hidden="true">255 <PeachMark class="peach-lock-hero-mark" />256 </div>257 <span class="peach-lock-eyebrow">new vault</span>258 <h1 class="peach-onboarding-headline">259 keep your260 <br />261 secrets.262 </h1>263 </div>264265 <section class="peach-lock-panel peach-lock-panel--primary">266 <div class="peach-lock-label-row">267 <label class="peach-label" for="master-password">268 master password269 </label>270 <button271 class="peach-lock-reveal-btn"272 type="button"273 onClick={() => setShowPassword((value) => !value)}274 aria-label={showPassword() ? "Hide password" : "Show password"}275 >276 <Show277 when={showPassword()}278 fallback={279 <svg width="15" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">280 <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" />281 <circle cx="7" cy="5" r="1.75" />282 </svg>283 }284 >285 <svg width="15" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">286 <path d="M1 2C3 4.5 5 6 7 6C9 6 11 4.5 13 2" />287 <path d="M3.5 5.5L2.5 7.5" />288 <path d="M7 6.5V8.5" />289 <path d="M10.5 5.5L11.5 7.5" />290 </svg>291 </Show>292 {showPassword() ? "hide" : "show"}293 </button>294 </div>295 <input296 id="master-password"297 class="peach-underline-input peach-unlock-input lock-password-input"298 type={showPassword() ? "text" : "password"}299 placeholder="at least 12 characters"300 autocomplete="new-password"301 value={password()}302 disabled={!props.ready}303 onInput={(event) => setPassword((event.target as HTMLInputElement).value)}304 onKeyDown={(event) => {305 if (event.key !== "Enter" || !props.ready) return;306 void handleCreate();307 }}308 />309310 <Show when={password().length > 0 && passwordStrength() !== null}>311 <div class="peach-password-strength">312 <div class={`peach-strength-bar peach-strength-bar--${passwordStrength()}`}>313 <div class="peach-strength-fill"></div>314 </div>315 <span class="peach-strength-label">{passwordStrength()}</span>316 </div>317 </Show>318319 <label class="peach-label" for="confirm-password">320 confirm password321 </label>322 <input323 id="confirm-password"324 class="peach-underline-input peach-unlock-input"325 type={showPassword() ? "text" : "password"}326 placeholder="••••••••••••"327 autocomplete="new-password"328 value={confirmPassword()}329 disabled={!props.ready}330 onInput={(event) => setConfirmPassword((event.target as HTMLInputElement).value)}331 onKeyDown={(event) => {332 if (event.key === "Enter" && props.ready) void handleCreate();333 }}334 />335336 <Show when={error()}>337 <p class="peach-hint peach-hint-error">{error()}</p>338 </Show>339340 <button341 class="peach-btn peach-btn--accent peach-btn--md peach-lock-submit"342 onClick={() => void handleCreate()}343 disabled={!props.ready || loading() || !canCreate()}344 >345 {loading() ? "creating..." : "create vault"}346 </button>347348 <p class="peach-hint peach-hint-error peach-lock-warning">349 If you lose this password, Peach cannot recover your vault. There is no reset.350 </p>351352 <div class="peach-lock-panel-footer">353 <p class="peach-hint">Already have a backup?</p>354 <button355 class="peach-lock-restore-link"356 onClick={() => openRestore("restore")}357 disabled={!props.ready}358 >359 › restore from backup360 </button>361 </div>362 </section>363 </section>364 </Match>365 </Switch>366 );367}368369370371function RestorePickerView(props: {372 onBack: () => void;373 onPickPeachScript: () => void;374 onPickFile: () => void;375 onPickSnapshots: () => void;376}) {377 return (378 <section379 class="peach-screen peach-theme lock-screen lock-screen--vault-stage lock-screen--onboarding"380 data-motion-phase="enter"381 >382 <header class="peach-screen-header lock-screen-header">383 <PeachWordmark class="peach-wordmark-wrap" />384 <button class="peach-lock-restore-link" onClick={props.onBack}>385 ‹ back386 </button>387 </header>388389 <div class="peach-lock-hero">390 <span class="peach-lock-eyebrow">restore</span>391 <h1 class="peach-onboarding-headline">392 bring back393 <br />394 your vault.395 </h1>396 </div>397398 <section class="peach-lock-panel peach-lock-panel--restore">399 <p class="peach-hint" style="margin-bottom:2px;">How did you back up your data?</p>400401 <button class="peach-lock-option" onClick={props.onPickPeachScript}>402 <span class="peach-lock-option-title">peachscript</span>403 <span class="peach-lock-option-copy">404 A string of Peach symbols you exported from another Peach device. You'll need the backup password you set at the time.405 </span>406 </button>407408 <button class="peach-lock-option" onClick={props.onPickFile}>409 <span class="peach-lock-option-title">backup file</span>410 <span class="peach-lock-option-copy">411 A Peach JSON export, or a file from Bitwarden, Proton Pass, 1Password, or a browser (Chrome, Safari, Edge CSV).412 </span>413 </button>414415 <button class="peach-lock-option peach-lock-option--history" onClick={props.onPickSnapshots}>416 <span class="peach-lock-option-title">vault history</span>417 <span class="peach-lock-option-copy">418 Peach saves encrypted snapshots locally after each change. Revert to an earlier version if something went wrong.419 </span>420 </button>421 </section>422 </section>423 );424}425426427428interface SnapshotSummary {429 index: number;430 timestamp: number;431 entryCount: number;432}433434function SnapshotRestoreView(props: {435 onBack: () => void;436 onRestored: () => void | Promise<void>;437}) {438 const [snapshots, setSnapshots] = createSignal<SnapshotSummary[]>([]);439 const [loading, setLoading] = createSignal(true);440 const [selected, setSelected] = createSignal<number | null>(null);441 const [password, setPassword] = createSignal("");442 const [confirming, setConfirming] = createSignal(false);443 const [restoring, setRestoring] = createSignal(false);444 const [error, setError] = createSignal("");445446 onMount(async () => {447 const res = await sendMessage({ type: "LIST_VAULT_SNAPSHOTS" });448 setSnapshots((res as { ok: boolean; data?: SnapshotSummary[] }).data ?? []);449 setLoading(false);450 });451452 function formatAge(ts: number): string {453 const diff = Date.now() - ts;454 const mins = Math.floor(diff / 60_000);455 if (mins < 2) return "just now";456 if (mins < 60) return `${mins} minutes ago`;457 const hours = Math.floor(mins / 60);458 if (hours < 24) return `${hours} hour${hours === 1 ? "" : "s"} ago`;459 const days = Math.floor(hours / 24);460 return `${days} day${days === 1 ? "" : "s"} ago`;461 }462463 function selectSnapshot(index: number) {464 setSelected(index);465 setConfirming(false);466 setPassword("");467 setError("");468 }469470 async function handleRestore() {471 const idx = selected();472 if (idx === null || !password()) return;473 setRestoring(true);474 setError("");475 const res = await sendMessage({476 type: "RESTORE_VAULT_SNAPSHOT",477 payload: { index: idx, password: password() },478 });479 if ((res as { ok: boolean }).ok) {480 await props.onRestored();481 } else {482 setError("Incorrect password, or this snapshot cannot be decrypted.");483 setRestoring(false);484 }485 }486487 const selectedSnap = () => {488 const idx = selected();489 return idx !== null ? (snapshots()[idx] ?? null) : null;490 };491492 return (493 <section494 class="peach-screen peach-theme lock-screen lock-screen--vault-stage lock-screen--onboarding"495 data-motion-phase="enter"496 >497 <header class="peach-screen-header lock-screen-header">498 <PeachWordmark class="peach-wordmark-wrap" />499 <button class="peach-lock-restore-link" onClick={props.onBack} disabled={restoring()}>500 ‹ back501 </button>502 </header>503504 <div class="peach-lock-hero">505 <span class="peach-lock-eyebrow">vault history</span>506 <h1 class="peach-onboarding-headline">507 revert to508 <br />509 a snapshot.510 </h1>511 </div>512513 <section class="peach-lock-panel peach-snapshot-panel">514 <Show when={loading()}>515 <p class="peach-hint" style="text-align:center;padding:8px 0;">Loading…</p>516 </Show>517518 <Show when={!loading() && snapshots().length === 0}>519 <p class="peach-hint" style="line-height:1.6;">520 No snapshots yet. Peach automatically saves up to 6 encrypted snapshots after each vault change.521 </p>522 </Show>523524 <Show when={!loading() && snapshots().length > 0}>525 <p class="peach-hint" style="margin-bottom:4px;">Select a version to restore:</p>526 <div class="peach-snapshot-list">527 <For each={snapshots()}>528 {(snap) => (529 <button530 class="peach-snapshot-row"531 classList={{ "is-selected": selected() === snap.index }}532 onClick={() => selectSnapshot(snap.index)}533 disabled={restoring()}534 >535 <span class="peach-snapshot-time">{formatAge(snap.timestamp)}</span>536 <span class="peach-snapshot-count">537 {snap.entryCount} {snap.entryCount === 1 ? "entry" : "entries"}538 </span>539 </button>540 )}541 </For>542 </div>543544 <Show when={selected() !== null}>545 <div class="peach-snapshot-confirm">546 <label class="peach-label" for="snapshot-pw">master password</label>547 <input548 id="snapshot-pw"549 class="peach-underline-input peach-unlock-input"550 type="password"551 placeholder="••••••••••••"552 autocomplete="current-password"553 value={password()}554 disabled={restoring()}555 onInput={(e) => setPassword((e.target as HTMLInputElement).value)}556 onKeyDown={(e) => {557 if (e.key === "Enter" && !restoring() && confirming()) void handleRestore();558 }}559 />560561 <Show when={error()}>562 <p class="peach-hint peach-hint-error">{error()}</p>563 </Show>564565 <Show566 when={confirming()}567 fallback={568 <button569 class="peach-btn peach-btn--subtle peach-btn--sm peach-snapshot-restore-btn"570 onClick={() => setConfirming(true)}571 disabled={!password().trim() || restoring()}572 >573 restore this version →574 </button>575 }576 >577 <div class="peach-snapshot-warning">578 <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">579 <path d="M6 1L11 10H1L6 1z" /><line x1="6" y1="5" x2="6" y2="7.5" />580 <circle cx="6" cy="9" r="0.5" fill="rgba(220,80,60,0.9)" stroke="none" />581 </svg>582 This replaces your current vault with the {selectedSnap() ? formatAge(selectedSnap()!.timestamp) : ""} version ({selectedSnap()?.entryCount} entries). It cannot be undone.583 </div>584 <div class="peach-snapshot-confirm-row">585 <button586 class="peach-btn peach-btn--accent peach-btn--sm"587 style="flex:1"588 onClick={() => void handleRestore()}589 disabled={restoring() || !password().trim()}590 >591 {restoring() ? "restoring…" : "yes, restore"}592 </button>593 <button594 class="peach-btn peach-btn--ghost peach-btn--sm"595 onClick={() => setConfirming(false)}596 disabled={restoring()}597 >598 cancel599 </button>600 </div>601 </Show>602 </div>603 </Show>604 </Show>605 </section>606 </section>607 );608}609610611612function getDisplayError(error: string | undefined, fallback: string): string {613 if (!error) return fallback;614 if (615 error === "SERVICE_WORKER_UNAVAILABLE" ||616 error.includes("Receiving end does not exist") ||617 error.includes("message port closed")618 ) {619 return "background service unavailable";620 }621 return error;622}