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}272829type RestoreSurface = "restore" | "snapshots" | "peachscript" | "file" | null;3031export function LockView(props: LockProps) {32 const [password, setPassword] = createSignal("");33 const [confirmPassword, setConfirmPassword] = createSignal("");34 const [error, setError] = createSignal("");35 const [loading, setLoading] = createSignal(false);36 const [restoreSurface, setRestoreSurface] = createSignal<RestoreSurface>(null);37 const [showPassword, setShowPassword] = createSignal(false);3839 const MIN_LENGTH = 12;40 const MIN_ZXCVBN_SCORE = 2;4142 const [strengthScore, setStrengthScore] = createSignal<number | null>(null);4344 const canCreate = createMemo(45 () =>46 password().length >= MIN_LENGTH &&47 confirmPassword().length >= MIN_LENGTH &&48 password() === confirmPassword() &&49 strengthScore() !== null &&50 strengthScore()! >= MIN_ZXCVBN_SCORE51 );5253 const passwordStrength = createMemo(() => {54 const score = strengthScore();55 if (score === null) return null;56 if (score <= 1) return "weak";57 if (score === 2) return "fair";58 if (score === 3) return "good";59 return "strong";60 });6162 63 createMemo(() => {64 const value = password();65 if (!value) { setStrengthScore(null); return; }66 getZxcvbn().then((zxcvbn) => {67 if (password() === value) setStrengthScore(zxcvbn(value).score);68 }).catch(() => {69 70 if (password() === value) setStrengthScore(value.length >= 16 ? 3 : value.length >= 12 ? 2 : 1);71 });72 });7374 async function handleUnlock() {75 if (!password().trim()) return;76 setLoading(true);77 setError("");78 const response = await sendMessage({79 type: "UNLOCK",80 payload: { password: password() },81 });82 const result = response as { ok: boolean; error?: string };83 if (result.ok) {84 await props.onUnlock();85 setLoading(false);86 return;87 }88 setError(getDisplayError(result.error, "incorrect password"));89 setPassword("");90 const passwordField = document.querySelector(".lock-password-input") as HTMLInputElement | undefined;91 passwordField?.classList.add("shake");92 setTimeout(() => passwordField?.classList.remove("shake"), 300);93 setLoading(false);94 }9596 async function handleCreate() {97 if (!password().trim()) return;98 if (password().length < MIN_LENGTH) {99 setError(`password must be at least ${MIN_LENGTH} characters`);100 return;101 }102 if (password() !== confirmPassword()) {103 setError("passwords do not match");104 return;105 }106 const score = strengthScore();107 if (score === null || score < MIN_ZXCVBN_SCORE) {108 setError("password is too weak — try a longer or more varied passphrase");109 return;110 }111 setLoading(true);112 setError("");113 const response = await sendMessage({114 type: "CREATE_VAULT",115 payload: { password: password() },116 });117 const result = response as { ok: boolean; error?: string };118 if (result.ok) {119 await props.onUnlock();120 setLoading(false);121 return;122 }123 setError(getDisplayError(result.error, "failed to create vault"));124 setLoading(false);125 }126127 function openRestore(surface: Exclude<RestoreSurface, null>) {128 if (!props.ready || loading()) return;129 setPassword("");130 setConfirmPassword("");131 setError("");132 setRestoreSurface(surface);133 }134135 136 return (137 <Switch>138 {}139 <Match when={restoreSurface() === "restore"}>140 <RestorePickerView141 onBack={() => setRestoreSurface(null)}142 onPickPeachScript={() => setRestoreSurface("peachscript")}143 onPickFile={() => setRestoreSurface("file")}144 onPickSnapshots={() => setRestoreSurface("snapshots")}145 />146 </Match>147148 {}149 <Match when={restoreSurface() === "snapshots"}>150 <SnapshotRestoreView151 onBack={() => setRestoreSurface("restore")}152 onRestored={props.onUnlock}153 />154 </Match>155156 {}157 <Match when={restoreSurface() === "peachscript"}>158 <ImportView159 mode="onboarding"160 replacesExistingVault={props.hasVault}161 initialView="peachscript"162 onBack={() => setRestoreSurface("restore")}163 onImport={async () => {164 setRestoreSurface(null);165 await props.onUnlock();166 }}167 />168 </Match>169170 {}171 <Match when={restoreSurface() === "file"}>172 <ImportView173 mode="onboarding"174 replacesExistingVault={props.hasVault}175 initialView="file"176 onBack={() => setRestoreSurface("restore")}177 onImport={async () => {178 setRestoreSurface(null);179 await props.onUnlock();180 }}181 />182 </Match>183184 {}185 <Match when={props.hasVault}>186 <section class="peach-screen peach-theme lock-screen lock-screen--vault-stage" data-motion-phase="enter">187 <header class="peach-screen-header lock-screen-header">188 <div class="peach-wordmark-stack">189 <PeachWordmark class="peach-wordmark-wrap" />190 <Show when={props.isPro}>191 <span class="peach-pro-mini-badge">pro</span>192 </Show>193 </div>194 </header>195196 <div class="peach-lock-hero peach-lock-hero--unlock">197 <div class="peach-lock-hero-art" aria-hidden="true">198 <PeachMark class="peach-lock-hero-mark" />199 </div>200 <span class="peach-lock-eyebrow">vault locked</span>201 <h1 class="peach-onboarding-headline peach-lock-headline--unlock">202 unlock203 <br />204 your vault.205 </h1>206 </div>207208 <LockRuneCanvas />209210 <section class="peach-lock-panel peach-lock-panel--unlock peach-glass">211 <label class="peach-label" for="unlock-password">212 master password213 </label>214 <input215 id="unlock-password"216 class="peach-underline-input peach-unlock-input lock-password-input"217 type="password"218 placeholder="••••••••••••"219 autocomplete="current-password"220 value={password()}221 disabled={!props.ready}222 ref={(el) => { if (props.ready) setTimeout(() => el.focus(), 80); }}223 onInput={(event) => setPassword((event.target as HTMLInputElement).value)}224 onKeyDown={(event) => {225 if (event.key !== "Enter" || !props.ready) return;226 void handleUnlock();227 }}228 />229230 <Show when={error()}>231 <p class="peach-hint peach-hint-error">{error()}</p>232 </Show>233234 <button235 class="peach-btn peach-btn--accent peach-btn--md peach-lock-submit"236 onClick={() => void handleUnlock()}237 disabled={!props.ready || loading()}238 >239 {loading() ? "unlocking..." : "unlock"}240 </button>241 </section>242 </section>243 </Match>244245 {}246 <Match when={true}>247 <section248 class="peach-screen peach-theme lock-screen lock-screen--vault-stage peach-newvault"249 data-motion-phase="enter"250 >251 <header class="peach-screen-header peach-newvault-header">252 <Show when={props.onBackToOnboarding}>253 <button254 class="peach-newvault-back"255 type="button"256 onClick={() => props.onBackToOnboarding?.()}257 aria-label="Back to onboarding"258 >259 <svg width="11" height="11" viewBox="0 0 11 11" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">260 <path d="M7 2L3 5.5L7 9" />261 </svg>262 back263 </button>264 </Show>265 <div class="peach-wordmark-stack">266 <PeachWordmark class="peach-wordmark-wrap" />267 <Show when={props.isPro}>268 <span class="peach-pro-mini-badge">pro</span>269 </Show>270 </div>271 </header>272273 <div class="peach-newvault-mark" aria-hidden="true">274 <PeachMark class="peach-newvault-mark-art" />275 </div>276277 <div class="peach-newvault-card">278 <h1 class="peach-newvault-headline">{"keep your\nsecrets."}</h1>279 <hr class="peach-newvault-divider" />280281 <div class="peach-newvault-field">282 <div class="peach-newvault-label-row">283 <label class="peach-label" for="master-password">284 master password285 </label>286 <button287 class="peach-lock-reveal-btn"288 type="button"289 onClick={() => setShowPassword((value) => !value)}290 aria-label={showPassword() ? "Hide password" : "Show password"}291 >292 <Show293 when={showPassword()}294 fallback={295 <svg width="15" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">296 <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" />297 <circle cx="7" cy="5" r="1.75" />298 </svg>299 }300 >301 <svg width="15" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">302 <path d="M1 2C3 4.5 5 6 7 6C9 6 11 4.5 13 2" />303 <path d="M3.5 5.5L2.5 7.5" />304 <path d="M7 6.5V8.5" />305 <path d="M10.5 5.5L11.5 7.5" />306 </svg>307 </Show>308 {showPassword() ? "hide" : "show"}309 </button>310 </div>311 <input312 id="master-password"313 class="peach-underline-input peach-newvault-input lock-password-input"314 type={showPassword() ? "text" : "password"}315 placeholder="••••••••••••"316 autocomplete="new-password"317 value={password()}318 disabled={!props.ready}319 onInput={(event) => setPassword((event.target as HTMLInputElement).value)}320 onKeyDown={(event) => {321 if (event.key !== "Enter" || !props.ready) return;322 void handleCreate();323 }}324 />325 <Show326 when={password().length > 0 && passwordStrength() !== null}327 fallback={328 <p class="peach-newvault-hint">at least 12 characters</p>329 }330 >331 <div class="peach-password-strength">332 <div class={`peach-strength-bar peach-strength-bar--${passwordStrength()}`}>333 <div class="peach-strength-fill"></div>334 </div>335 <span class="peach-strength-label">{passwordStrength()}</span>336 </div>337 </Show>338 </div>339340 <div class="peach-newvault-field">341 <label class="peach-label" for="confirm-password">342 confirm password343 </label>344 <input345 id="confirm-password"346 class="peach-underline-input peach-newvault-input"347 type={showPassword() ? "text" : "password"}348 placeholder="••••••••••••"349 autocomplete="new-password"350 value={confirmPassword()}351 disabled={!props.ready}352 onInput={(event) => setConfirmPassword((event.target as HTMLInputElement).value)}353 onKeyDown={(event) => {354 if (event.key === "Enter" && props.ready) void handleCreate();355 }}356 />357 </div>358359 <Show when={error()}>360 <p class="peach-hint peach-hint-error">{error()}</p>361 </Show>362363 <button364 class="peach-btn peach-btn--accent peach-btn--md peach-lock-submit peach-newvault-submit"365 onClick={() => void handleCreate()}366 disabled={!props.ready || loading() || !canCreate()}367 >368 {loading() ? "creating..." : "create vault"}369 </button>370371 <p class="peach-newvault-warning">372 <span class="peach-newvault-warning-icon" aria-hidden="true">⚠</span>373 <span>If you forget it, your vault cannot be opened. There is no reset.</span>374 </p>375 </div>376377 <div class="peach-newvault-footer">378 <p class="peach-hint">Already have a backup?</p>379 <button380 class="peach-lock-restore-link"381 onClick={() => openRestore("restore")}382 disabled={!props.ready}383 >384 › restore from backup385 </button>386 </div>387 </section>388 </Match>389 </Switch>390 );391}392393394395function RestorePickerView(props: {396 onBack: () => void;397 onPickPeachScript: () => void;398 onPickFile: () => void;399 onPickSnapshots: () => void;400}) {401 return (402 <section403 class="peach-screen peach-theme lock-screen lock-screen--vault-stage lock-screen--onboarding"404 data-motion-phase="enter"405 >406 <header class="peach-screen-header lock-screen-header">407 <PeachWordmark class="peach-wordmark-wrap" />408 <button class="peach-lock-restore-link" onClick={props.onBack}>409 ‹ back410 </button>411 </header>412413 <div class="peach-lock-hero">414 <span class="peach-lock-eyebrow">restore</span>415 <h1 class="peach-onboarding-headline">416 bring back417 <br />418 your vault.419 </h1>420 </div>421422 <section class="peach-lock-panel peach-lock-panel--restore">423 <p class="peach-hint" style="margin-bottom:2px;">How did you back up your data?</p>424425 <button class="peach-lock-option" onClick={props.onPickPeachScript}>426 <span class="peach-lock-option-title">peachscript</span>427 <span class="peach-lock-option-copy">428 A string of Peach symbols you exported from another Peach device. You'll need the backup password you set at the time.429 </span>430 </button>431432 <button class="peach-lock-option" onClick={props.onPickFile}>433 <span class="peach-lock-option-title">backup file</span>434 <span class="peach-lock-option-copy">435 A Peach JSON export, or a file from Bitwarden, Proton Pass, 1Password, or a browser (Chrome, Safari, Edge CSV).436 </span>437 </button>438439 <button class="peach-lock-option peach-lock-option--history" onClick={props.onPickSnapshots}>440 <span class="peach-lock-option-title">vault history</span>441 <span class="peach-lock-option-copy">442 Peach saves encrypted snapshots locally after each change. Revert to an earlier version if something went wrong.443 </span>444 </button>445 </section>446 </section>447 );448}449450451452interface SnapshotSummary {453 index: number;454 timestamp: number;455 entryCount: number;456}457458function SnapshotRestoreView(props: {459 onBack: () => void;460 onRestored: () => void | Promise<void>;461}) {462 const [snapshots, setSnapshots] = createSignal<SnapshotSummary[]>([]);463 const [loading, setLoading] = createSignal(true);464 const [selected, setSelected] = createSignal<number | null>(null);465 const [password, setPassword] = createSignal("");466 const [confirming, setConfirming] = createSignal(false);467 const [restoring, setRestoring] = createSignal(false);468 const [error, setError] = createSignal("");469470 onMount(async () => {471 const res = await sendMessage({ type: "LIST_VAULT_SNAPSHOTS" });472 setSnapshots((res as { ok: boolean; data?: SnapshotSummary[] }).data ?? []);473 setLoading(false);474 });475476 function formatAge(ts: number): string {477 const diff = Date.now() - ts;478 const mins = Math.floor(diff / 60_000);479 if (mins < 2) return "just now";480 if (mins < 60) return `${mins} minutes ago`;481 const hours = Math.floor(mins / 60);482 if (hours < 24) return `${hours} hour${hours === 1 ? "" : "s"} ago`;483 const days = Math.floor(hours / 24);484 return `${days} day${days === 1 ? "" : "s"} ago`;485 }486487 function selectSnapshot(index: number) {488 setSelected(index);489 setConfirming(false);490 setPassword("");491 setError("");492 }493494 async function handleRestore() {495 const idx = selected();496 if (idx === null || !password()) return;497 setRestoring(true);498 setError("");499 const res = await sendMessage({500 type: "RESTORE_VAULT_SNAPSHOT",501 payload: { index: idx, password: password() },502 });503 if ((res as { ok: boolean }).ok) {504 await props.onRestored();505 } else {506 setError("Incorrect password, or this snapshot cannot be decrypted.");507 setRestoring(false);508 }509 }510511 const selectedSnap = () => {512 const idx = selected();513 return idx !== null ? (snapshots()[idx] ?? null) : null;514 };515516 return (517 <section518 class="peach-screen peach-theme lock-screen lock-screen--vault-stage lock-screen--onboarding"519 data-motion-phase="enter"520 >521 <header class="peach-screen-header lock-screen-header">522 <PeachWordmark class="peach-wordmark-wrap" />523 <button class="peach-lock-restore-link" onClick={props.onBack} disabled={restoring()}>524 ‹ back525 </button>526 </header>527528 <div class="peach-lock-hero">529 <span class="peach-lock-eyebrow">vault history</span>530 <h1 class="peach-onboarding-headline">531 revert to532 <br />533 a snapshot.534 </h1>535 </div>536537 <section class="peach-lock-panel peach-snapshot-panel">538 <Show when={loading()}>539 <p class="peach-hint" style="text-align:center;padding:8px 0;">Loading…</p>540 </Show>541542 <Show when={!loading() && snapshots().length === 0}>543 <p class="peach-hint" style="line-height:1.6;">544 No snapshots yet. Peach automatically saves up to 6 encrypted snapshots after each vault change.545 </p>546 </Show>547548 <Show when={!loading() && snapshots().length > 0}>549 <p class="peach-hint" style="margin-bottom:4px;">Select a version to restore:</p>550 <div class="peach-snapshot-list">551 <For each={snapshots()}>552 {(snap) => (553 <button554 class="peach-snapshot-row"555 classList={{ "is-selected": selected() === snap.index }}556 onClick={() => selectSnapshot(snap.index)}557 disabled={restoring()}558 >559 <span class="peach-snapshot-time">{formatAge(snap.timestamp)}</span>560 <span class="peach-snapshot-count">561 {snap.entryCount} {snap.entryCount === 1 ? "entry" : "entries"}562 </span>563 </button>564 )}565 </For>566 </div>567568 <Show when={selected() !== null}>569 <div class="peach-snapshot-confirm">570 <label class="peach-label" for="snapshot-pw">master password</label>571 <input572 id="snapshot-pw"573 class="peach-underline-input peach-unlock-input"574 type="password"575 placeholder="••••••••••••"576 autocomplete="current-password"577 value={password()}578 disabled={restoring()}579 onInput={(e) => setPassword((e.target as HTMLInputElement).value)}580 onKeyDown={(e) => {581 if (e.key === "Enter" && !restoring() && confirming()) void handleRestore();582 }}583 />584585 <Show when={error()}>586 <p class="peach-hint peach-hint-error">{error()}</p>587 </Show>588589 <Show590 when={confirming()}591 fallback={592 <button593 class="peach-btn peach-btn--subtle peach-btn--sm peach-snapshot-restore-btn"594 onClick={() => setConfirming(true)}595 disabled={!password().trim() || restoring()}596 >597 restore this version →598 </button>599 }600 >601 <div class="peach-snapshot-warning">602 <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">603 <path d="M6 1L11 10H1L6 1z" /><line x1="6" y1="5" x2="6" y2="7.5" />604 <circle cx="6" cy="9" r="0.5" fill="rgba(220,80,60,0.9)" stroke="none" />605 </svg>606 This replaces your current vault with the {selectedSnap() ? formatAge(selectedSnap()!.timestamp) : ""} version ({selectedSnap()?.entryCount} entries). It cannot be undone.607 </div>608 <div class="peach-snapshot-confirm-row">609 <button610 class="peach-btn peach-btn--accent peach-btn--sm"611 style="flex:1"612 onClick={() => void handleRestore()}613 disabled={restoring() || !password().trim()}614 >615 {restoring() ? "restoring…" : "yes, restore"}616 </button>617 <button618 class="peach-btn peach-btn--ghost peach-btn--sm"619 onClick={() => setConfirming(false)}620 disabled={restoring()}621 >622 cancel623 </button>624 </div>625 </Show>626 </div>627 </Show>628 </Show>629 </section>630 </section>631 );632}633634635636function getDisplayError(error: string | undefined, fallback: string): string {637 if (!error) return fallback;638 if (639 error === "SERVICE_WORKER_UNAVAILABLE" ||640 error.includes("Receiving end does not exist") ||641 error.includes("message port closed")642 ) {643 return "background service unavailable";644 }645 return error;646}