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}232425type RestoreSurface = "restore" | "snapshots" | "peachscript" | "file" | null;2627export function LockView(props: LockProps) {28 const [password, setPassword] = createSignal("");29 const [confirmPassword, setConfirmPassword] = createSignal("");30 const [error, setError] = createSignal("");31 const [loading, setLoading] = createSignal(false);32 const [restoreSurface, setRestoreSurface] = createSignal<RestoreSurface>(null);33 const [showPassword, setShowPassword] = createSignal(false);3435 const MIN_LENGTH = 12;36 const MIN_ZXCVBN_SCORE = 2;3738 const [strengthScore, setStrengthScore] = createSignal<number | null>(null);3940 const canCreate = createMemo(41 () =>42 password().length >= MIN_LENGTH &&43 confirmPassword().length >= MIN_LENGTH &&44 password() === confirmPassword() &&45 strengthScore() !== null &&46 strengthScore()! >= MIN_ZXCVBN_SCORE47 );4849 const passwordStrength = createMemo(() => {50 const score = strengthScore();51 if (score === null) return null;52 if (score <= 1) return "weak";53 if (score === 2) return "fair";54 if (score === 3) return "good";55 return "strong";56 });5758 59 createMemo(() => {60 const value = password();61 if (!value) { setStrengthScore(null); return; }62 getZxcvbn().then((zxcvbn) => {63 if (password() === value) setStrengthScore(zxcvbn(value).score);64 }).catch(() => {65 66 if (password() === value) setStrengthScore(value.length >= 16 ? 3 : value.length >= 12 ? 2 : 1);67 });68 });6970 async function handleUnlock() {71 if (!password().trim()) return;72 setLoading(true);73 setError("");74 const response = await sendMessage({75 type: "UNLOCK",76 payload: { password: password() },77 });78 const result = response as { ok: boolean; error?: string };79 if (result.ok) {80 await props.onUnlock();81 setLoading(false);82 return;83 }84 setError(getDisplayError(result.error, "incorrect password"));85 setPassword("");86 const passwordField = document.querySelector(".lock-password-input") as HTMLInputElement | undefined;87 passwordField?.classList.add("shake");88 setTimeout(() => passwordField?.classList.remove("shake"), 300);89 setLoading(false);90 }9192 async function handleCreate() {93 if (!password().trim()) return;94 if (password().length < MIN_LENGTH) {95 setError(`password must be at least ${MIN_LENGTH} characters`);96 return;97 }98 if (password() !== confirmPassword()) {99 setError("passwords do not match");100 return;101 }102 const score = strengthScore();103 if (score === null || score < MIN_ZXCVBN_SCORE) {104 setError("password is too weak — try a longer or more varied passphrase");105 return;106 }107 setLoading(true);108 setError("");109 const response = await sendMessage({110 type: "CREATE_VAULT",111 payload: { password: password() },112 });113 const result = response as { ok: boolean; error?: string };114 if (result.ok) {115 await props.onUnlock();116 setLoading(false);117 return;118 }119 setError(getDisplayError(result.error, "failed to create vault"));120 setLoading(false);121 }122123 function openRestore(surface: Exclude<RestoreSurface, null>) {124 if (!props.ready || loading()) return;125 setPassword("");126 setConfirmPassword("");127 setError("");128 setRestoreSurface(surface);129 }130131 132 return (133 <Switch>134 {}135 <Match when={restoreSurface() === "restore"}>136 <RestorePickerView137 onBack={() => setRestoreSurface(null)}138 onPickPeachScript={() => setRestoreSurface("peachscript")}139 onPickFile={() => setRestoreSurface("file")}140 onPickSnapshots={() => setRestoreSurface("snapshots")}141 />142 </Match>143144 {}145 <Match when={restoreSurface() === "snapshots"}>146 <SnapshotRestoreView147 onBack={() => setRestoreSurface("restore")}148 onRestored={props.onUnlock}149 />150 </Match>151152 {}153 <Match when={restoreSurface() === "peachscript"}>154 <ImportView155 mode="onboarding"156 replacesExistingVault={props.hasVault}157 initialView="peachscript"158 onBack={() => setRestoreSurface("restore")}159 onImport={async () => {160 setRestoreSurface(null);161 await props.onUnlock();162 }}163 />164 </Match>165166 {}167 <Match when={restoreSurface() === "file"}>168 <ImportView169 mode="onboarding"170 replacesExistingVault={props.hasVault}171 initialView="file"172 onBack={() => setRestoreSurface("restore")}173 onImport={async () => {174 setRestoreSurface(null);175 await props.onUnlock();176 }}177 />178 </Match>179180 {}181 <Match when={props.hasVault}>182 <section class="peach-screen peach-theme lock-screen lock-screen--vault-stage" data-motion-phase="enter">183 <LockRuneCanvas />184 <header class="peach-screen-header lock-screen-header">185 <div class="peach-wordmark-stack">186 <PeachWordmark class="peach-wordmark-wrap" />187 <Show when={props.isPro}>188 <span class="peach-pro-mini-badge">pro</span>189 </Show>190 </div>191 </header>192193 <div class="peach-lock-hero peach-lock-hero--unlock">194 <div class="peach-lock-hero-art" aria-hidden="true">195 <PeachMark class="peach-lock-hero-mark" />196 </div>197 <span class="peach-lock-eyebrow">vault locked</span>198 <h1 class="peach-onboarding-headline peach-lock-headline--unlock">199 unlock200 <br />201 your vault.202 </h1>203 </div>204205 <section class="peach-lock-panel peach-lock-panel--unlock peach-glass">206 <label class="peach-label" for="unlock-password">207 master password208 </label>209 <input210 id="unlock-password"211 class="peach-underline-input peach-unlock-input lock-password-input"212 type="password"213 placeholder="••••••••••••"214 autocomplete="current-password"215 value={password()}216 disabled={!props.ready}217 ref={(el) => { if (props.ready) setTimeout(() => el.focus(), 80); }}218 onInput={(event) => setPassword((event.target as HTMLInputElement).value)}219 onKeyDown={(event) => {220 if (event.key !== "Enter" || !props.ready) return;221 void handleUnlock();222 }}223 />224225 <Show when={error()}>226 <p class="peach-hint peach-hint-error">{error()}</p>227 </Show>228229 <button230 class="peach-btn peach-btn--accent peach-btn--md peach-lock-submit"231 onClick={() => void handleUnlock()}232 disabled={!props.ready || loading()}233 >234 {loading() ? "unlocking..." : "unlock"}235 </button>236 </section>237 </section>238 </Match>239240 {}241 <Match when={true}>242 <section243 class="peach-screen peach-theme lock-screen lock-screen--vault-stage peach-newvault"244 data-motion-phase="enter"245 >246 <header class="peach-screen-header peach-newvault-header">247 <div class="peach-wordmark-stack">248 <PeachWordmark class="peach-wordmark-wrap" />249 <Show when={props.isPro}>250 <span class="peach-pro-mini-badge">pro</span>251 </Show>252 </div>253 </header>254255 <div class="peach-newvault-mark" aria-hidden="true">256 <PeachMark class="peach-newvault-mark-art" />257 </div>258259 <div class="peach-newvault-card">260 <h1 class="peach-newvault-headline">{"keep your\nsecrets."}</h1>261 <hr class="peach-newvault-divider" />262263 <div class="peach-newvault-field">264 <div class="peach-newvault-label-row">265 <label class="peach-label" for="master-password">266 master password267 </label>268 <button269 class="peach-lock-reveal-btn"270 type="button"271 onClick={() => setShowPassword((value) => !value)}272 aria-label={showPassword() ? "Hide password" : "Show password"}273 >274 <Show275 when={showPassword()}276 fallback={277 <svg width="15" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">278 <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" />279 <circle cx="7" cy="5" r="1.75" />280 </svg>281 }282 >283 <svg width="15" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">284 <path d="M1 2C3 4.5 5 6 7 6C9 6 11 4.5 13 2" />285 <path d="M3.5 5.5L2.5 7.5" />286 <path d="M7 6.5V8.5" />287 <path d="M10.5 5.5L11.5 7.5" />288 </svg>289 </Show>290 {showPassword() ? "hide" : "show"}291 </button>292 </div>293 <input294 id="master-password"295 class="peach-underline-input peach-newvault-input lock-password-input"296 type={showPassword() ? "text" : "password"}297 placeholder="••••••••••••"298 autocomplete="new-password"299 value={password()}300 disabled={!props.ready}301 onInput={(event) => setPassword((event.target as HTMLInputElement).value)}302 onKeyDown={(event) => {303 if (event.key !== "Enter" || !props.ready) return;304 void handleCreate();305 }}306 />307 <Show308 when={password().length > 0 && passwordStrength() !== null}309 fallback={310 <p class="peach-newvault-hint">at least 12 characters</p>311 }312 >313 <div class="peach-password-strength">314 <div class={`peach-strength-bar peach-strength-bar--${passwordStrength()}`}>315 <div class="peach-strength-fill"></div>316 </div>317 <span class="peach-strength-label">{passwordStrength()}</span>318 </div>319 </Show>320 </div>321322 <div class="peach-newvault-field">323 <label class="peach-label" for="confirm-password">324 confirm password325 </label>326 <input327 id="confirm-password"328 class="peach-underline-input peach-newvault-input"329 type={showPassword() ? "text" : "password"}330 placeholder="••••••••••••"331 autocomplete="new-password"332 value={confirmPassword()}333 disabled={!props.ready}334 onInput={(event) => setConfirmPassword((event.target as HTMLInputElement).value)}335 onKeyDown={(event) => {336 if (event.key === "Enter" && props.ready) void handleCreate();337 }}338 />339 </div>340341 <Show when={error()}>342 <p class="peach-hint peach-hint-error">{error()}</p>343 </Show>344345 <button346 class="peach-btn peach-btn--accent peach-btn--md peach-lock-submit peach-newvault-submit"347 onClick={() => void handleCreate()}348 disabled={!props.ready || loading() || !canCreate()}349 >350 {loading() ? "creating..." : "create vault"}351 </button>352353 <p class="peach-newvault-warning">354 <span class="peach-newvault-warning-icon" aria-hidden="true">⚠</span>355 <span>If you forget it, your vault cannot be opened. There is no reset.</span>356 </p>357 </div>358359 <div class="peach-newvault-footer">360 <p class="peach-hint">Already have a backup?</p>361 <button362 class="peach-lock-restore-link"363 onClick={() => openRestore("restore")}364 disabled={!props.ready}365 >366 › restore from backup367 </button>368 </div>369 </section>370 </Match>371 </Switch>372 );373}374375376377function RestorePickerView(props: {378 onBack: () => void;379 onPickPeachScript: () => void;380 onPickFile: () => void;381 onPickSnapshots: () => void;382}) {383 return (384 <section385 class="peach-screen peach-theme lock-screen lock-screen--vault-stage lock-screen--onboarding"386 data-motion-phase="enter"387 >388 <header class="peach-screen-header lock-screen-header">389 <PeachWordmark class="peach-wordmark-wrap" />390 <button class="peach-lock-restore-link" onClick={props.onBack}>391 ‹ back392 </button>393 </header>394395 <div class="peach-lock-hero">396 <span class="peach-lock-eyebrow">restore</span>397 <h1 class="peach-onboarding-headline">398 bring back399 <br />400 your vault.401 </h1>402 </div>403404 <section class="peach-lock-panel peach-lock-panel--restore">405 <p class="peach-hint" style="margin-bottom:2px;">How did you back up your data?</p>406407 <button class="peach-lock-option" onClick={props.onPickPeachScript}>408 <span class="peach-lock-option-title">peachscript</span>409 <span class="peach-lock-option-copy">410 A string of Peach symbols you exported from another Peach device. You'll need the backup password you set at the time.411 </span>412 </button>413414 <button class="peach-lock-option" onClick={props.onPickFile}>415 <span class="peach-lock-option-title">backup file</span>416 <span class="peach-lock-option-copy">417 A Peach JSON export, or a file from Bitwarden, Proton Pass, 1Password, or a browser (Chrome, Safari, Edge CSV).418 </span>419 </button>420421 <button class="peach-lock-option peach-lock-option--history" onClick={props.onPickSnapshots}>422 <span class="peach-lock-option-title">vault history</span>423 <span class="peach-lock-option-copy">424 Peach saves encrypted snapshots locally after each change. Revert to an earlier version if something went wrong.425 </span>426 </button>427 </section>428 </section>429 );430}431432433434interface SnapshotSummary {435 index: number;436 timestamp: number;437 entryCount: number;438}439440function SnapshotRestoreView(props: {441 onBack: () => void;442 onRestored: () => void | Promise<void>;443}) {444 const [snapshots, setSnapshots] = createSignal<SnapshotSummary[]>([]);445 const [loading, setLoading] = createSignal(true);446 const [selected, setSelected] = createSignal<number | null>(null);447 const [password, setPassword] = createSignal("");448 const [confirming, setConfirming] = createSignal(false);449 const [restoring, setRestoring] = createSignal(false);450 const [error, setError] = createSignal("");451452 onMount(async () => {453 const res = await sendMessage({ type: "LIST_VAULT_SNAPSHOTS" });454 setSnapshots((res as { ok: boolean; data?: SnapshotSummary[] }).data ?? []);455 setLoading(false);456 });457458 function formatAge(ts: number): string {459 const diff = Date.now() - ts;460 const mins = Math.floor(diff / 60_000);461 if (mins < 2) return "just now";462 if (mins < 60) return `${mins} minutes ago`;463 const hours = Math.floor(mins / 60);464 if (hours < 24) return `${hours} hour${hours === 1 ? "" : "s"} ago`;465 const days = Math.floor(hours / 24);466 return `${days} day${days === 1 ? "" : "s"} ago`;467 }468469 function selectSnapshot(index: number) {470 setSelected(index);471 setConfirming(false);472 setPassword("");473 setError("");474 }475476 async function handleRestore() {477 const idx = selected();478 if (idx === null || !password()) return;479 setRestoring(true);480 setError("");481 const res = await sendMessage({482 type: "RESTORE_VAULT_SNAPSHOT",483 payload: { index: idx, password: password() },484 });485 if ((res as { ok: boolean }).ok) {486 await props.onRestored();487 } else {488 setError("Incorrect password, or this snapshot cannot be decrypted.");489 setRestoring(false);490 }491 }492493 const selectedSnap = () => {494 const idx = selected();495 return idx !== null ? (snapshots()[idx] ?? null) : null;496 };497498 return (499 <section500 class="peach-screen peach-theme lock-screen lock-screen--vault-stage lock-screen--onboarding"501 data-motion-phase="enter"502 >503 <header class="peach-screen-header lock-screen-header">504 <PeachWordmark class="peach-wordmark-wrap" />505 <button class="peach-lock-restore-link" onClick={props.onBack} disabled={restoring()}>506 ‹ back507 </button>508 </header>509510 <div class="peach-lock-hero">511 <span class="peach-lock-eyebrow">vault history</span>512 <h1 class="peach-onboarding-headline">513 revert to514 <br />515 a snapshot.516 </h1>517 </div>518519 <section class="peach-lock-panel peach-snapshot-panel">520 <Show when={loading()}>521 <p class="peach-hint" style="text-align:center;padding:8px 0;">Loading…</p>522 </Show>523524 <Show when={!loading() && snapshots().length === 0}>525 <p class="peach-hint" style="line-height:1.6;">526 No snapshots yet. Peach automatically saves up to 6 encrypted snapshots after each vault change.527 </p>528 </Show>529530 <Show when={!loading() && snapshots().length > 0}>531 <p class="peach-hint" style="margin-bottom:4px;">Select a version to restore:</p>532 <div class="peach-snapshot-list">533 <For each={snapshots()}>534 {(snap) => (535 <button536 class="peach-snapshot-row"537 classList={{ "is-selected": selected() === snap.index }}538 onClick={() => selectSnapshot(snap.index)}539 disabled={restoring()}540 >541 <span class="peach-snapshot-time">{formatAge(snap.timestamp)}</span>542 <span class="peach-snapshot-count">543 {snap.entryCount} {snap.entryCount === 1 ? "entry" : "entries"}544 </span>545 </button>546 )}547 </For>548 </div>549550 <Show when={selected() !== null}>551 <div class="peach-snapshot-confirm">552 <label class="peach-label" for="snapshot-pw">master password</label>553 <input554 id="snapshot-pw"555 class="peach-underline-input peach-unlock-input"556 type="password"557 placeholder="••••••••••••"558 autocomplete="current-password"559 value={password()}560 disabled={restoring()}561 onInput={(e) => setPassword((e.target as HTMLInputElement).value)}562 onKeyDown={(e) => {563 if (e.key === "Enter" && !restoring() && confirming()) void handleRestore();564 }}565 />566567 <Show when={error()}>568 <p class="peach-hint peach-hint-error">{error()}</p>569 </Show>570571 <Show572 when={confirming()}573 fallback={574 <button575 class="peach-btn peach-btn--subtle peach-btn--sm peach-snapshot-restore-btn"576 onClick={() => setConfirming(true)}577 disabled={!password().trim() || restoring()}578 >579 restore this version →580 </button>581 }582 >583 <div class="peach-snapshot-warning">584 <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">585 <path d="M6 1L11 10H1L6 1z" /><line x1="6" y1="5" x2="6" y2="7.5" />586 <circle cx="6" cy="9" r="0.5" fill="rgba(220,80,60,0.9)" stroke="none" />587 </svg>588 This replaces your current vault with the {selectedSnap() ? formatAge(selectedSnap()!.timestamp) : ""} version ({selectedSnap()?.entryCount} entries). It cannot be undone.589 </div>590 <div class="peach-snapshot-confirm-row">591 <button592 class="peach-btn peach-btn--accent peach-btn--sm"593 style="flex:1"594 onClick={() => void handleRestore()}595 disabled={restoring() || !password().trim()}596 >597 {restoring() ? "restoring…" : "yes, restore"}598 </button>599 <button600 class="peach-btn peach-btn--ghost peach-btn--sm"601 onClick={() => setConfirming(false)}602 disabled={restoring()}603 >604 cancel605 </button>606 </div>607 </Show>608 </div>609 </Show>610 </Show>611 </section>612 </section>613 );614}615616617618function getDisplayError(error: string | undefined, fallback: string): string {619 if (!error) return fallback;620 if (621 error === "SERVICE_WORKER_UNAVAILABLE" ||622 error.includes("Receiving end does not exist") ||623 error.includes("message port closed")624 ) {625 return "background service unavailable";626 }627 return error;628}